diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index dc61ba7..8cfd744 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -622,11 +622,11 @@ fn parse_optional_hooks_config(root: &JsonValue) -> Result, + key: &str, + context: &str, +) -> Result>, ConfigError> { + match object.get(key) { + Some(value) => { + let Some(array) = value.as_array() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} must be an array" + ))); + }; + let mut commands = Vec::new(); + for item in array { + append_hook_commands(item, &mut commands, key, context)?; + } + Ok(Some(commands)) + } + None => Ok(None), + } +} + +fn append_hook_commands( + value: &JsonValue, + commands: &mut Vec, + key: &str, + context: &str, +) -> Result<(), ConfigError> { + if let Some(command) = value.as_str() { + commands.push(command.to_string()); + return Ok(()); + } + + let Some(object) = value.as_object() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} must contain only strings or simple command hook objects" + ))); + }; + + if let Some(command) = object.get("command").and_then(JsonValue::as_str) { + commands.push(command.to_string()); + return Ok(()); + } + + let Some(hooks) = object.get("hooks").and_then(JsonValue::as_array) else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} must contain only strings or simple command hook objects" + ))); + }; + + for hook in hooks { + let Some(hook_object) = hook.as_object() else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} must contain only command hook objects" + ))); + }; + let Some(hook_type) = hook_object.get("type").and_then(JsonValue::as_str) else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} command hook objects must include a string type" + ))); + }; + if hook_type != "command" { + return Err(ConfigError::Parse(format!( + "{context}: field {key} supports only command hook objects" + ))); + } + let Some(command) = hook_object.get("command").and_then(JsonValue::as_str) else { + return Err(ConfigError::Parse(format!( + "{context}: field {key} command hook objects must include a string command" + ))); + }; + commands.push(command.to_string()); + } + + Ok(()) +} + fn optional_string_map( object: &BTreeMap, key: &str, @@ -1168,6 +1245,52 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn loads_command_hooks_from_structured_hook_entries() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(cwd.join(".claw")).expect("project config dir"); + fs::create_dir_all(&home).expect("home config dir"); + + fs::write( + home.join("settings.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/hooks/pre-tool-use.mjs" + } + ] + } + ], + "PostToolUse": [ + "./hooks/post-tool-use.sh" + ] + } +}"#, + ) + .expect("write user settings"); + + let loaded = ConfigLoader::new(&cwd, &home) + .load() + .expect("config should load"); + + assert_eq!( + loaded.hooks().pre_tool_use(), + &["node ~/.claude/hooks/pre-tool-use.mjs".to_string()] + ); + assert_eq!( + loaded.hooks().post_tool_use(), + &["./hooks/post-tool-use.sh".to_string()] + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn parses_sandbox_config() { let root = temp_dir(); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 02ca704..e06d63d 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -7295,6 +7295,55 @@ UU conflicted.rs", let _ = fs::remove_dir_all(source_root); } + #[test] + fn build_runtime_plugin_state_keeps_structured_config_hooks_and_plugin_hooks() { + let config_home = temp_dir(); + let workspace = temp_dir(); + let source_root = temp_dir(); + fs::create_dir_all(&config_home).expect("config home"); + fs::create_dir_all(workspace.join(".claw")).expect("workspace settings dir"); + fs::create_dir_all(&source_root).expect("source root"); + fs::write( + workspace.join(".claw").join("settings.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/hooks/pre-tool-use.mjs" + } + ] + } + ] + } +}"#, + ) + .expect("write workspace settings"); + write_plugin_fixture(&source_root, "hook-runtime-demo", true, false); + + let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); + manager + .install(source_root.to_str().expect("utf8 source path")) + .expect("plugin install should succeed"); + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) + .expect("plugin state should load"); + let pre_hooks = state.feature_config.hooks().pre_tool_use(); + assert_eq!(pre_hooks.len(), 2); + assert_eq!(pre_hooks[0], "node ~/.claude/hooks/pre-tool-use.mjs"); + assert!( + pre_hooks[1].ends_with("hooks/pre.sh"), + "expected installed plugin hook path, got {pre_hooks:?}" + ); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + } + #[test] fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() { let config_home = temp_dir();