diff --git a/PARITY.md b/PARITY.md index bce5dc3..a8fff12 100644 --- a/PARITY.md +++ b/PARITY.md @@ -59,15 +59,18 @@ Evidence: ### Rust exists Evidence: - Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`. -- Hook config can be inspected via Rust config reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`. +- Shell-command `PreToolUse` / `PostToolUse` hooks execute via `rust/crates/runtime/src/hooks.rs`. +- Conversation runtime runs pre/post hooks around tool execution in `rust/crates/runtime/src/conversation.rs`. +- Hook config can now be inspected through a dedicated Rust `/hooks` report in `rust/crates/commands/src/lib.rs` and `rust/crates/claw-cli/src/main.rs`. - Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`. ### Missing or broken in Rust -- No actual hook execution pipeline in `rust/crates/runtime/src/conversation.rs`. -- No PreToolUse/PostToolUse mutation/deny/rewrite/result-hook behavior. -- No Rust `/hooks` parity command. +- No TS-style matcher-based hook config model; Rust only supports merged string command lists under `settings.hooks.PreToolUse` and `PostToolUse`. +- No TS-style prompt/agent/http hook types, `PostToolUseFailure`, `PermissionDenied`, or richer hook lifecycle surfaces. +- No TS-equivalent interactive `/hooks` browser/editor; Rust currently provides inspection/reporting only. +- No PreToolUse/PostToolUse input rewrite, MCP-output mutation, or continuation-stop behavior beyond allow/deny plus feedback text. -**Status:** config-only; runtime behavior missing. +**Status:** basic shell hook runtime plus `/hooks` inspection; richer TS hook model still missing. --- diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs index 50ed476..1b359b1 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -22,9 +22,9 @@ use api::{ }; use commands::{ - handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, - render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - suggest_slash_commands, SlashCommand, + handle_agents_slash_command, handle_hooks_slash_command, handle_plugins_slash_command, + handle_skills_slash_command, render_slash_command_help, resume_supported_slash_commands, + slash_command_specs, suggest_slash_commands, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -86,6 +86,7 @@ fn run() -> Result<(), Box> { CliAction::DumpManifests => dump_manifests(), CliAction::BootstrapPlan => print_bootstrap_plan(), CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?, + CliAction::Hooks { args } => LiveCli::print_hooks(args.as_deref())?, CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?, CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), CliAction::Version => print_version(), @@ -121,6 +122,9 @@ enum CliAction { Agents { args: Option, }, + Hooks { + args: Option, + }, Skills { args: Option, }, @@ -290,6 +294,9 @@ fn parse_args(args: &[String]) -> Result { "agents" => Ok(CliAction::Agents { args: join_optional_args(&rest[1..]), }), + "hooks" => Ok(CliAction::Hooks { + args: join_optional_args(&rest[1..]), + }), "skills" => Ok(CliAction::Skills { args: join_optional_args(&rest[1..]), }), @@ -332,6 +339,7 @@ fn parse_direct_slash_cli_action(rest: &[String]) -> Result { match SlashCommand::parse(&raw) { Some(SlashCommand::Help) => Ok(CliAction::Help), Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }), + Some(SlashCommand::Hooks { args }) => Ok(CliAction::Hooks { args }), Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }), Some(command) => Err(format_direct_slash_command_error( match &command { @@ -943,6 +951,13 @@ fn run_resume_command( session: session.clone(), message: Some(render_config_report(section.as_deref())?), }), + SlashCommand::Hooks { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_hooks_slash_command(args.as_deref(), &cwd)?), + }) + } SlashCommand::Memory => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_memory_report()?), @@ -1295,6 +1310,10 @@ impl LiveCli { Self::print_config(section.as_deref())?; false } + SlashCommand::Hooks { args } => { + Self::print_hooks(args.as_deref())?; + false + } SlashCommand::Memory => { Self::print_memory()?; false @@ -1556,6 +1575,12 @@ impl LiveCli { Ok(()) } + fn print_hooks(args: Option<&str>) -> Result<(), Box> { + let cwd = env::current_dir()?; + println!("{}", handle_hooks_slash_command(args, &cwd)?); + Ok(()) + } + fn print_skills(args: Option<&str>) -> Result<(), Box> { let cwd = env::current_dir()?; println!("{}", handle_skills_slash_command(args, &cwd)?); @@ -4057,6 +4082,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " claw agents List configured agents" )?; + writeln!( + out, + " claw hooks Inspect configured tool hooks" + )?; writeln!( out, " claw skills List discoverable local skills" @@ -4128,6 +4157,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { " claw --resume session.json /status /diff /export notes.txt" )?; writeln!(out, " claw agents")?; + writeln!(out, " claw hooks")?; writeln!(out, " claw /skills")?; writeln!(out, " claw login")?; writeln!(out, " claw init")?; @@ -4355,6 +4385,10 @@ mod tests { parse_args(&["agents".to_string()]).expect("agents should parse"), CliAction::Agents { args: None } ); + assert_eq!( + parse_args(&["hooks".to_string()]).expect("hooks should parse"), + CliAction::Hooks { args: None } + ); assert_eq!( parse_args(&["skills".to_string()]).expect("skills should parse"), CliAction::Skills { args: None } @@ -4374,6 +4408,10 @@ mod tests { parse_args(&["/agents".to_string()]).expect("/agents should parse"), CliAction::Agents { args: None } ); + assert_eq!( + parse_args(&["/hooks".to_string()]).expect("/hooks should parse"), + CliAction::Hooks { args: None } + ); assert_eq!( parse_args(&["/skills".to_string()]).expect("/skills should parse"), CliAction::Skills { args: None } @@ -4482,6 +4520,7 @@ mod tests { assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config [env|hooks|model|plugins]")); + assert!(help.contains("/hooks")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/diff")); @@ -4546,8 +4585,8 @@ mod tests { assert_eq!( names, vec![ - "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", - "version", "export", "agents", "skills", + "help", "status", "compact", "clear", "cost", "config", "hooks", "memory", "init", + "diff", "version", "export", "agents", "skills", ] ); } @@ -4618,6 +4657,7 @@ mod tests { assert!(help.contains("claw init")); assert!(help.contains("Open slash suggestions in the REPL")); assert!(help.contains("claw agents")); + assert!(help.contains("claw hooks")); assert!(help.contains("claw skills")); assert!(help.contains("claw /skills")); } diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 6ea577e..e28c889 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -8,8 +8,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use plugins::{PluginError, PluginManager, PluginSummary}; use runtime::{ - compact_session, discover_skill_roots, CompactionConfig, Session, SkillDiscoveryRoot, - SkillDiscoverySource, SkillRootKind, + compact_session, discover_skill_roots, CompactionConfig, ConfigLoader, ConfigSource, + RuntimeConfig, Session, SkillDiscoveryRoot, SkillDiscoverySource, SkillRootKind, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -146,6 +146,14 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ resume_supported: true, category: SlashCommandCategory::Workspace, }, + SlashCommandSpec { + name: "hooks", + aliases: &[], + summary: "Inspect configured tool hooks", + argument_hint: None, + resume_supported: true, + category: SlashCommandCategory::Workspace, + }, SlashCommandSpec { name: "memory", aliases: &[], @@ -352,6 +360,9 @@ pub enum SlashCommand { Config { section: Option, }, + Hooks { + args: Option, + }, Memory, Init, Diff, @@ -435,6 +446,9 @@ impl SlashCommand { "config" => Self::Config { section: parts.next().map(ToOwned::to_owned), }, + "hooks" => Self::Hooks { + args: remainder_after_command(trimmed, command), + }, "memory" => Self::Memory, "init" => Self::Init, "diff" => Self::Diff, @@ -803,6 +817,23 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R } } +pub fn handle_hooks_slash_command( + args: Option<&str>, + cwd: &Path, +) -> Result { + let args = normalize_optional_args(args); + if matches!(args, Some("-h" | "--help" | "help")) { + return Ok(render_hooks_usage(None)); + } + if let Some(unexpected) = args { + return Ok(render_hooks_usage(Some(unexpected))); + } + + let loader = ConfigLoader::default_for(cwd); + let runtime_config = loader.load()?; + Ok(render_hooks_report(cwd, &runtime_config)) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitPushPrRequest { pub commit_message: Option, @@ -1648,6 +1679,77 @@ fn render_skills_usage(unexpected: Option<&str>) -> String { lines.join("\n") } +fn render_hooks_usage(unexpected: Option<&str>) -> String { + let mut lines = vec![ + "Hooks".to_string(), + " Usage /hooks".to_string(), + " Direct CLI claw hooks".to_string(), + " Runtime support PreToolUse, PostToolUse".to_string(), + ]; + if let Some(args) = unexpected { + lines.push(format!(" Unexpected {args}")); + } + lines.join("\n") +} + +fn render_hooks_report(cwd: &Path, runtime_config: &RuntimeConfig) -> String { + let pre_tool_use = runtime_config.hooks().pre_tool_use(); + let post_tool_use = runtime_config.hooks().post_tool_use(); + let configured_events = + usize::from(!pre_tool_use.is_empty()) + usize::from(!post_tool_use.is_empty()); + let total_hooks = pre_tool_use.len() + post_tool_use.len(); + + let mut lines = vec![ + "Hooks".to_string(), + format!(" Working directory {}", cwd.display()), + format!( + " Loaded files {}", + runtime_config.loaded_entries().len() + ), + format!(" Configured hooks {total_hooks}"), + format!(" Events {configured_events}"), + " Runtime support PreToolUse, PostToolUse shell commands".to_string(), + String::new(), + "Loaded config files".to_string(), + ]; + + if runtime_config.loaded_entries().is_empty() { + lines.push(" (none)".to_string()); + } else { + for entry in runtime_config.loaded_entries() { + let source = match entry.source { + ConfigSource::User => "user", + ConfigSource::Project => "project", + ConfigSource::Local => "local", + }; + lines.push(format!(" {source:<7} {}", entry.path.display())); + } + } + + if total_hooks == 0 { + lines.push(String::new()); + lines.push("No hooks configured.".to_string()); + return lines.join("\n"); + } + + render_hook_event_section(&mut lines, "PreToolUse", pre_tool_use); + render_hook_event_section(&mut lines, "PostToolUse", post_tool_use); + lines.join("\n") +} + +fn render_hook_event_section(lines: &mut Vec, event_name: &str, commands: &[String]) { + if commands.is_empty() { + return; + } + + lines.push(String::new()); + lines.push(event_name.to_string()); + lines.push(format!(" Count {}", commands.len())); + for (index, command) in commands.iter().enumerate() { + lines.push(format!(" {}. {}", index + 1, command)); + } +} + #[must_use] pub fn handle_slash_command( input: &str, @@ -1691,6 +1793,7 @@ pub fn handle_slash_command( | SlashCommand::Cost | SlashCommand::Resume { .. } | SlashCommand::Config { .. } + | SlashCommand::Hooks { .. } | SlashCommand::Memory | SlashCommand::Init | SlashCommand::Diff @@ -1708,9 +1811,9 @@ pub fn handle_slash_command( mod tests { use super::{ handle_branch_slash_command, handle_commit_push_pr_slash_command, - handle_commit_slash_command, handle_plugins_slash_command, handle_slash_command, - handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots, - render_agents_report, render_plugins_report, render_skills_report, + handle_commit_slash_command, handle_hooks_slash_command, handle_plugins_slash_command, + handle_slash_command, handle_worktree_slash_command, load_agents_from_roots, + load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SlashCommand, }; @@ -1977,6 +2080,16 @@ mod tests { section: Some("env".to_string()) }) ); + assert_eq!( + SlashCommand::parse("/hooks"), + Some(SlashCommand::Hooks { args: None }) + ); + assert_eq!( + SlashCommand::parse("/hooks help"), + Some(SlashCommand::Hooks { + args: Some("help".to_string()) + }) + ); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff)); @@ -2052,6 +2165,7 @@ mod tests { assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config [env|hooks|model|plugins]")); + assert!(help.contains("/hooks")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/diff")); @@ -2064,8 +2178,8 @@ mod tests { assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents")); assert!(help.contains("/skills")); - assert_eq!(slash_command_specs().len(), 28); - assert_eq!(resume_supported_slash_commands().len(), 13); + assert_eq!(slash_command_specs().len(), 29); + assert_eq!(resume_supported_slash_commands().len(), 14); } #[test] @@ -2172,6 +2286,7 @@ mod tests { assert!( handle_slash_command("/config env", &session, CompactionConfig::default()).is_none() ); + assert!(handle_slash_command("/hooks", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none()); assert!( @@ -2329,7 +2444,7 @@ mod tests { } #[test] - fn agents_and_skills_usage_support_help_and_unexpected_args() { + fn agents_skills_and_hooks_usage_support_help_and_unexpected_args() { let cwd = temp_dir("slash-usage"); let agents_help = @@ -2350,9 +2465,51 @@ mod tests { super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage"); assert!(skills_unexpected.contains("Unexpected show help")); + let hooks_help = handle_hooks_slash_command(Some("help"), &cwd).expect("hooks help"); + assert!(hooks_help.contains("Usage /hooks")); + assert!(hooks_help.contains("Direct CLI claw hooks")); + + let hooks_unexpected = handle_hooks_slash_command(Some("show"), &cwd).expect("hooks usage"); + assert!(hooks_unexpected.contains("Unexpected show")); + let _ = fs::remove_dir_all(cwd); } + #[test] + fn hooks_report_lists_configured_commands() { + let _guard = env_lock(); + let workspace = temp_dir("hooks-report-workspace"); + let home = temp_dir("hooks-report-home"); + fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir"); + fs::create_dir_all(&home).expect("home config dir"); + env::set_var("CLAW_CONFIG_HOME", &home); + + fs::write( + home.join("settings.json"), + r#"{"hooks":{"PreToolUse":["echo pre"],"PostToolUse":["echo post"]}}"#, + ) + .expect("write home hooks"); + fs::write( + workspace.join(".claw").join("settings.local.json"), + r#"{"hooks":{"PostToolUse":["echo local post"]}}"#, + ) + .expect("write local hooks"); + + let report = handle_hooks_slash_command(None, &workspace).expect("hooks report"); + assert!(report.contains("Hooks")); + assert!(report.contains("Configured hooks 2")); + assert!(report.contains("Runtime support PreToolUse, PostToolUse shell commands")); + assert!(report.contains("PreToolUse")); + assert!(report.contains("1. echo pre")); + assert!(report.contains("PostToolUse")); + assert!(report.contains("1. echo local post")); + assert!(report.contains("Loaded config files")); + + env::remove_var("CLAW_CONFIG_HOME"); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(home); + } + #[test] fn empty_skills_report_lists_checked_directories() { let workspace = temp_dir("skills-empty");