feat(cli): add claw hook list command

Expose registered runtime and plugin hook entries through a direct claw hook list command and cover the new surface with CLI, help, and report tests.

Constraint: Reuse the existing runtime/plugin configuration plumbing without introducing a separate hook registry
Rejected: Hide the feature behind the REPL-only /hooks slash command | the request requires a direct top-level CLI command
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the hook list output aligned with config-loaded hooks plus plugin enabled state when hook sources change
Tested: cd rust && cargo build --workspace
Tested: cd rust && cargo test --workspace
Tested: cd rust && cargo run -q -p rusty-claude-cli --bin claw -- hook list
Not-tested: Remote push/CI after publishing the branch
This commit is contained in:
Yeachan-Heo
2026-04-04 16:40:13 +00:00
parent 7db400c54e
commit 184188e986
+248 -43
View File
@@ -123,6 +123,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
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<CliAction, String> {
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<CliAction, String> {
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<PermissionMode>,
) -> Option<Result<CliAction, String>> {
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<CliAction, String> {
}
}
fn parse_hook_args(args: &[String]) -> Result<CliAction, String> {
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<CliAction, String> {
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::<Vec<_>>();
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::<Vec<_>>();
})
.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::<Vec<_>>();
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<String, Box<dyn std::error::Err
Ok(serde_json::to_string_pretty(&parsed)?)
}
fn print_hook_list() -> Result<(), Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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<Vec<HookListEntry>, Box<dyn std::error::Error>> {
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<HookListEntry>,
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<HookListEntry>,
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<String, Box<dyn std::error::Error>> {
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}"#)