feat(TASK-038): add hooks builtin-asset category with example scripts and roles mixin scanning

- AssetCategory::Hooks + InstallFilter::Hooks (NAMES/parse/clap), reserving
  the 'hooks' bundle name via the existing AssetCategory::parse check
- HookAssets embed + install_builtin_hooks(force): installs the bundled
  example scripts 0755 into paths::hooks_dir() via write_file_atomic, wired
  into install_builtins() and install_assets(); warns once when
  COYOTE_HOOKS_DIR diverges from the config-dir default
- assets/hooks/notify.sh: notify-send -> osascript -> terminal fallback,
  with AppleScript-literal sanitization of model-influenced values
- assets/hooks/log-events.sh: appends a COYOTE_* env snapshot (secrets
  excluded, 0600 log, symlink-refusing) to ${COYOTE_HOOK_LOG:-/tmp/coyote-hooks.log}
- sbx-mixin discovery now scans roles/*.sbx-mixin.yaml and
  roles/hooks/*.sbx-mixin.yaml (mirroring the role-hook cwd)
- tests: install+0755+force, install_assets(Hooks) end-to-end, discover()
  roles scanning, reserved bundle name, no-ARGC-BUILD embed content, script
  content order + real execution (secret exclusion, tty-less fallback),
  CLI parser round-trips; hooks_dir env test made #[serial]
This commit is contained in:
Dark-Alex-17 committed 2026-09-16 13:44:35 -06:00
1 parent 0c6b7c8594
commit fc3bb259cb
9 files changed
+439 -4

No files matched your search

+7
View File
@@ -1183,6 +1183,13 @@ mod tests {
assert_eq!(resolved.name, "y/mcp_config");
assert_eq!(resolved.qualified_from.as_deref(), Some("mcp_config"));
let resolved = store
.resolve_bundle_name("https://github.com/z/hooks", None)
.unwrap();
assert_eq!(resolved.name, "z/hooks");
assert_eq!(resolved.qualified_from.as_deref(), Some("hooks"));
}
#[test]
+42
View File
@@ -1540,6 +1540,10 @@ fn apply_filter(mut layout: RemoteLayout, filter: Option<InstallFilter>) -> Remo
functions_tools: layout.functions_tools.take(),
..base
},
// Bundles cannot ship hooks yet: RemoteLayout has no hooks field, so
// this filter always yields an empty layout. Bundle-side hooks support
// (layout field, scan, is_empty) lands together with TopCategory::Hooks.
InstallFilter::Hooks => base,
InstallFilter::McpConfig => RemoteLayout {
mcp_json: layout.mcp_json.take(),
..base
@@ -3662,6 +3666,44 @@ mod tests {
assert!(!paths::installed_bundles_file().exists());
}
#[test]
#[serial]
fn builtin_hooks_category_installs_example_scripts() {
if *IS_STDOUT_TERMINAL {
eprintln!(
"Skipping builtin_hooks_category_installs_example_scripts: requires non-TTY stdout"
);
return;
}
let _guard = TestVaultConfigGuard::new("prov-hooks");
let hooks_key = get_env_name("hooks_dir");
let previous_hooks = env::var_os(&hooks_key);
let hooks_dir = fresh_temp_dir("prov-hooks-dir-");
unsafe {
env::set_var(&hooks_key, &hooks_dir);
}
// Capture outcomes before restoring the env var so a failed assertion
// cannot leave the hooks-dir override pointing at a deleted temp dir.
let result = crate::config::install_assets(crate::config::AssetCategory::Hooks);
let installed = (
hooks_dir.join("notify.sh").is_file(),
hooks_dir.join("log-events.sh").is_file(),
);
unsafe {
match previous_hooks {
Some(v) => env::set_var(&hooks_key, v),
None => env::remove_var(&hooks_key),
}
}
let _ = fs::remove_dir_all(&hooks_dir);
result.unwrap();
assert!(installed.0, "notify.sh must be installed");
assert!(installed.1, "log-events.sh must be installed");
}
#[test]
#[serial]
fn update_silently_refreshes_owned_unmodified_files() {
+17 -2
View File
@@ -419,6 +419,7 @@ pub fn install_builtins() -> Result<()> {
Agent::install_builtin_agents(false)?;
Macro::install_macros(false)?;
Skill::install_builtin_skills(false)?;
crate::hooks::install_builtin_hooks(false)?;
Ok(())
}
@@ -428,12 +429,20 @@ pub enum AssetCategory {
Macros,
Functions,
Skills,
Hooks,
#[value(name = "mcp-config", alias = "mcp_config")]
McpConfig,
}
impl AssetCategory {
pub const NAMES: [&'static str; 5] = ["agents", "macros", "functions", "skills", "mcp-config"];
pub const NAMES: [&'static str; 6] = [
"agents",
"macros",
"functions",
"skills",
"hooks",
"mcp-config",
];
pub fn parse(name: &str) -> Option<Self> {
match name {
@@ -441,6 +450,7 @@ impl AssetCategory {
"macros" => Some(Self::Macros),
"functions" => Some(Self::Functions),
"skills" => Some(Self::Skills),
"hooks" => Some(Self::Hooks),
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
_ => None,
}
@@ -460,17 +470,19 @@ pub enum InstallFilter {
Skills,
Macros,
Functions,
Hooks,
#[value(name = "mcp-config", alias = "mcp_config")]
McpConfig,
}
impl InstallFilter {
pub const NAMES: [&'static str; 6] = [
pub const NAMES: [&'static str; 7] = [
"agents",
"roles",
"skills",
"macros",
"functions",
"hooks",
"mcp-config",
];
@@ -481,6 +493,7 @@ impl InstallFilter {
"skills" => Some(Self::Skills),
"macros" => Some(Self::Macros),
"functions" => Some(Self::Functions),
"hooks" => Some(Self::Hooks),
"mcp-config" | "mcp_config" => Some(Self::McpConfig),
_ => None,
}
@@ -493,6 +506,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
AssetCategory::Macros => ("macros", paths::macros_dir()),
AssetCategory::Functions => ("functions", paths::functions_dir()),
AssetCategory::Skills => ("skills", paths::skills_dir()),
AssetCategory::Hooks => ("hooks", paths::hooks_dir()),
AssetCategory::McpConfig => ("MCP config", paths::mcp_config_file()),
};
@@ -506,6 +520,7 @@ pub fn install_assets(category: AssetCategory) -> Result<()> {
AssetCategory::Macros => Macro::install_macros(true)?,
AssetCategory::Functions => Functions::install_builtin_global_tools(true)?,
AssetCategory::Skills => Skill::install_builtin_skills(true)?,
AssetCategory::Hooks => crate::hooks::install_builtin_hooks(true)?,
AssetCategory::McpConfig => Functions::install_mcp_config()?,
}
+1 -1
View File
@@ -184,7 +184,6 @@ pub fn role_file(name: &str) -> PathBuf {
roles_dir().join(format!("{name}.md"))
}
#[allow(dead_code)]
pub fn hooks_dir() -> PathBuf {
match env::var(get_env_name("hooks_dir")) {
Ok(value) => PathBuf::from(value),
@@ -956,6 +955,7 @@ mod tests {
}
#[test]
#[serial_test::serial]
fn hooks_dir_reflects_env_var_state() {
let env_name = get_env_name("hooks_dir");
let prev = env::var_os(&env_name);