diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index ff7adae..d8020ee 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -123,6 +123,7 @@ fn run() -> Result<(), Box> { permission_mode, } => print_status_snapshot(&model, permission_mode)?, CliAction::ConfigShow => print_config_json()?, + CliAction::HookList => print_hook_list()?, CliAction::Sandbox => print_sandbox_status_snapshot()?, CliAction::Prompt { prompt, @@ -173,6 +174,7 @@ enum CliAction { permission_mode: PermissionMode, }, ConfigShow, + HookList, Sandbox, Prompt { prompt: String, @@ -345,8 +347,7 @@ fn parse_args(args: &[String]) -> Result { if rest.first().map(String::as_str) == Some("--resume") { return parse_resume_args(&rest[1..]); } - if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override) - { + if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override) { return action; } @@ -366,6 +367,7 @@ fn parse_args(args: &[String]) -> Result { args: join_optional_args(&rest[1..]), }), "system-prompt" => parse_system_prompt_args(&rest[1..]), + "hook" => parse_hook_args(&rest[1..]), "login" => Ok(CliAction::Login), "logout" => Ok(CliAction::Logout), "init" => Ok(CliAction::Init), @@ -399,7 +401,7 @@ fn parse_single_word_command_alias( model: &str, permission_mode_override: Option, ) -> Option> { - if rest.len() != 1 || matches!(rest[0].as_str(), "branch" | "config") { + if rest.len() != 1 || matches!(rest[0].as_str(), "branch" | "config" | "hook") { return None; } @@ -715,6 +717,16 @@ fn parse_config_args(args: &[String]) -> Result { } } +fn parse_hook_args(args: &[String]) -> Result { + match args { + [] => Err("Usage: claw hook list".to_string()), + [action] if action == "list" => Ok(CliAction::HookList), + [action, ..] => Err(format!( + "unknown hook action: {action}. Usage: claw hook list" + )), + } +} + fn parse_branch_args(args: &[String]) -> Result { match args { [] => Err("Usage: claw branch delete".to_string()), @@ -1956,37 +1968,38 @@ impl RuntimeMcpState { .into_iter() .filter(|server_name| !failed_server_names.contains(server_name)) .collect::>(); - let failed_servers = discovery - .failed_servers - .iter() - .map(|failure| runtime::McpFailedServer { - server_name: failure.server_name.clone(), - phase: runtime::McpLifecyclePhase::ToolDiscovery, - error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ToolDiscovery, - Some(failure.server_name.clone()), - failure.error.clone(), - std::collections::BTreeMap::new(), - true, - ), - }) - .chain(discovery.unsupported_servers.iter().map(|server| { - runtime::McpFailedServer { - server_name: server.server_name.clone(), - phase: runtime::McpLifecyclePhase::ServerRegistration, + let failed_servers = + discovery + .failed_servers + .iter() + .map(|failure| runtime::McpFailedServer { + server_name: failure.server_name.clone(), + phase: runtime::McpLifecyclePhase::ToolDiscovery, error: runtime::McpErrorSurface::new( - runtime::McpLifecyclePhase::ServerRegistration, - Some(server.server_name.clone()), - server.reason.clone(), - std::collections::BTreeMap::from([( - "transport".to_string(), - format!("{:?}", server.transport).to_ascii_lowercase(), - )]), - false, + runtime::McpLifecyclePhase::ToolDiscovery, + Some(failure.server_name.clone()), + failure.error.clone(), + std::collections::BTreeMap::new(), + true, ), - } - })) - .collect::>(); + }) + .chain(discovery.unsupported_servers.iter().map(|server| { + runtime::McpFailedServer { + server_name: server.server_name.clone(), + phase: runtime::McpLifecyclePhase::ServerRegistration, + error: runtime::McpErrorSurface::new( + runtime::McpLifecyclePhase::ServerRegistration, + Some(server.server_name.clone()), + server.reason.clone(), + std::collections::BTreeMap::from([( + "transport".to_string(), + format!("{:?}", server.transport).to_ascii_lowercase(), + )]), + false, + ), + } + })) + .collect::>(); let degraded_report = (!failed_servers.is_empty()).then(|| { runtime::McpDegradedReport::new( working_servers, @@ -3448,10 +3461,10 @@ fn format_status_report( Worktrees {} Entries {} Recent commits {}", - context - .git_freshness - .as_ref() - .map_or_else(|| "origin/main unavailable".to_string(), GitBranchFreshness::headline), + context.git_freshness.as_ref().map_or_else( + || "origin/main unavailable".to_string(), + GitBranchFreshness::headline + ), if context.git_worktrees.is_empty() { "unavailable".to_string() } else { @@ -3647,6 +3660,127 @@ fn render_merged_runtime_config_json() -> Result Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + println!( + "{}", + render_hook_list_report_for(&cwd, &loader, &runtime_config)? + ); + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct HookListEntry { + source: String, + event: &'static str, + command: String, + enabled: bool, +} + +fn render_hook_list_report_for( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> Result> { + let entries = collect_hook_list_entries(cwd, loader, runtime_config)?; + let enabled_count = entries.iter().filter(|entry| entry.enabled).count(); + let mut lines = vec![format!( + "Hooks\n Registered {}\n Enabled {}", + entries.len(), + enabled_count + )]; + + if entries.is_empty() { + lines.push(" No hooks registered.".to_string()); + return Ok(lines.join("\n")); + } + + lines.push("Entries".to_string()); + lines.push(format!( + " {:<7} {:<32} {:<19} {}", + "Enabled", "Source", "Event", "Command" + )); + + for entry in entries { + lines.push(format!( + " {:<7} {:<32} {:<19} {}", + if entry.enabled { "yes" } else { "no" }, + entry.source, + entry.event, + entry.command + )); + } + + Ok(lines.join("\n")) +} + +fn collect_hook_list_entries( + cwd: &Path, + loader: &ConfigLoader, + runtime_config: &runtime::RuntimeConfig, +) -> Result, Box> { + let mut entries = Vec::new(); + extend_hook_list_entries( + &mut entries, + "config".to_string(), + true, + runtime_config.hooks().pre_tool_use(), + runtime_config.hooks().post_tool_use(), + runtime_config.hooks().post_tool_use_failure(), + ); + + let plugin_manager = build_plugin_manager(cwd, loader, runtime_config); + let plugin_registry = plugin_manager.plugin_registry()?; + for plugin in plugin_registry.plugins() { + extend_hook_list_entries( + &mut entries, + format!("plugin:{}", plugin.metadata().id), + plugin.is_enabled(), + &plugin.hooks().pre_tool_use, + &plugin.hooks().post_tool_use, + &plugin.hooks().post_tool_use_failure, + ); + } + + Ok(entries) +} + +fn extend_hook_list_entries( + entries: &mut Vec, + source: String, + enabled: bool, + pre_tool_use: &[String], + post_tool_use: &[String], + post_tool_use_failure: &[String], +) { + append_hook_list_entries(entries, &source, enabled, "PreToolUse", pre_tool_use); + append_hook_list_entries(entries, &source, enabled, "PostToolUse", post_tool_use); + append_hook_list_entries( + entries, + &source, + enabled, + "PostToolUseFailure", + post_tool_use_failure, + ); +} + +fn append_hook_list_entries( + entries: &mut Vec, + source: &str, + enabled: bool, + event: &'static str, + commands: &[String], +) { + entries.extend(commands.iter().cloned().map(|command| HookListEntry { + source: source.to_string(), + event, + command, + enabled, + })); +} + fn render_config_report(section: Option<&str>) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); @@ -5943,6 +6077,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!(out, " claw config show")?; writeln!(out, " Print the merged runtime config as JSON")?; + writeln!(out, " claw hook list")?; + writeln!( + out, + " Show registered hooks and whether they are enabled" + )?; writeln!(out, " claw sandbox")?; writeln!(out, " Show the current sandbox isolation snapshot")?; writeln!(out, " claw dump-manifests")?; @@ -6025,6 +6164,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { " claw --resume {LATEST_SESSION_REFERENCE} /status /diff /export notes.txt" )?; writeln!(out, " claw config show")?; + writeln!(out, " claw hook list")?; writeln!(out, " claw branch delete")?; writeln!(out, " claw agents")?; writeln!(out, " claw mcp show my-server")?; @@ -6051,11 +6191,12 @@ mod tests { format_tool_result, format_ultraplan_report, format_unknown_slash_command, format_unknown_slash_command_message, git_ref_exists_in, normalize_permission_mode, parse_args, parse_git_status_branch, parse_git_status_metadata_for, - parse_git_workspace_summary, parse_git_worktrees, parse_recent_commits, permission_policy, - print_help_to, push_output_block, render_config_report, render_diff_report, - render_diff_report_for, render_memory_report, render_merged_runtime_config_json, - render_repl_help, render_resume_usage, resolve_model_alias, resolve_session_reference, - response_to_events, resume_supported_slash_commands, run_resume_command, + parse_git_workspace_summary, parse_git_worktrees, parse_hook_args, parse_recent_commits, + permission_policy, print_help_to, push_output_block, render_config_report, + render_diff_report, render_diff_report_for, render_hook_list_report_for, + render_memory_report, render_merged_runtime_config_json, render_repl_help, + render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events, + resume_supported_slash_commands, run_resume_command, slash_command_completion_candidates_with_sessions, status_context, validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitBranchFreshness, GitCommitEntry, GitWorkspaceSummary, GitWorktreeEntry, InternalPromptProgressEvent, @@ -6483,6 +6624,21 @@ mod tests { assert!(error.contains("Usage: claw config show")); } + #[test] + fn parses_hook_list_subcommand() { + assert_eq!( + parse_args(&["hook".to_string(), "list".to_string()]).expect("hook list should parse"), + CliAction::HookList + ); + + let error = parse_args(&["hook".to_string()]).expect_err("missing action should fail"); + assert!(error.contains("Usage: claw hook list")); + + let error = parse_hook_args(&["run".to_string()]).expect_err("unknown action should fail"); + assert!(error.contains("unknown hook action: run")); + assert!(error.contains("Usage: claw hook list")); + } + #[test] fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() { let _guard = env_lock(); @@ -6905,6 +7061,7 @@ mod tests { assert!(help.contains("claw help")); assert!(help.contains("claw version")); assert!(help.contains("claw status")); + assert!(help.contains("claw hook list")); assert!(help.contains("claw sandbox")); assert!(help.contains("claw init")); assert!(help.contains("claw agents")); @@ -7180,6 +7337,49 @@ mod tests { assert!(report.contains("Merged JSON")); } + #[test] + fn hook_list_report_shows_config_and_plugin_hooks_with_enabled_state() { + 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 config dir"); + fs::create_dir_all(&source_root).expect("source root"); + fs::write( + workspace.join(".claw").join("settings.json"), + r#"{"hooks":{"PostToolUse":["printf 'config post'"]}}"#, + ) + .expect("workspace settings should write"); + write_plugin_fixture(&source_root, "hook-report-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"); + manager + .disable("hook-report-demo@external") + .expect("plugin disable should succeed"); + + let loader = ConfigLoader::new(&workspace, &config_home); + let runtime_config = loader.load().expect("runtime config should load"); + let report = render_hook_list_report_for(&workspace, &loader, &runtime_config) + .expect("hook list report should render"); + + assert!(report.contains("Hooks")); + assert!(report.contains("Registered ")); + assert!(report.contains("Enabled ")); + assert!(report.contains("yes config")); + assert!(report.contains("PostToolUse")); + assert!(report.contains("printf 'config post'")); + assert!(report.contains("no plugin:hook-report-demo@external")); + assert!(report.contains("PreToolUse")); + assert!(report.contains("hooks/pre.sh")); + + let _ = fs::remove_dir_all(config_home); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(source_root); + } + #[test] fn parses_git_status_metadata() { let _guard = env_lock(); @@ -7494,6 +7694,7 @@ UU conflicted.rs", print_help_to(&mut help).expect("help should render"); let help = String::from_utf8(help).expect("help should be utf8"); assert!(help.contains("claw config show")); + assert!(help.contains("claw hook list")); assert!(help.contains("claw branch delete")); assert!(help.contains("claw --resume [SESSION.jsonl|session-id|latest]")); assert!(help.contains("Use `latest` with --resume, /resume, or /session switch")); @@ -8137,8 +8338,12 @@ UU conflicted.rs", let runtime_config = loader.load().expect("runtime config should load"); let state = build_runtime_plugin_state_with_loader(&workspace, &loader, &runtime_config) .expect("runtime plugin state should load"); - let mut executor = - CliToolExecutor::new(None, false, state.tool_registry.clone(), state.mcp_state.clone()); + let mut executor = CliToolExecutor::new( + None, + false, + state.tool_registry.clone(), + state.mcp_state.clone(), + ); let search_output = executor .execute("ToolSearch", r#"{"query":"remote","max_results":5}"#)