mirror of
https://github.com/ultraworkers/claw-code-parity.git
synced 2026-04-23 05:06:12 +02:00
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:
@@ -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}"#)
|
||||
|
||||
Reference in New Issue
Block a user