mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 16:39:04 +08:00
feat: add hooks inspection report
This adds a narrow, shippable /hooks surface that reports the merged\nPreToolUse and PostToolUse shell hook configuration from the Rust\nruntime. The CLI now exposes hooks consistently in direct, REPL, and\nresume-safe slash-command flows, with focused tests covering parsing,\nhelp text, and report rendering.\n\nConstraint: Keep the increment inspection-only instead of introducing a broader TS-style hook model\nRejected: Build matcher-based or interactive hook editing now | too broad for the next parity slice\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Extend /hooks from the runtime's current string-list model unless config parsing grows first\nTested: cargo fmt --all; cargo test -p commands; cargo test -p claw-cli; cargo test --workspace\nNot-tested: cargo clippy --workspace --all-targets -- -D warnings (blocked by unrelated existing lsp warnings in rust/crates/lsp/src/client.rs and rust/crates/lsp/src/lib.rs)
This commit is contained in:
13
PARITY.md
13
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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<String>,
|
||||
},
|
||||
Hooks {
|
||||
args: Option<String>,
|
||||
},
|
||||
Skills {
|
||||
args: Option<String>,
|
||||
},
|
||||
@@ -290,6 +294,9 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
||||
"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<CliAction, String> {
|
||||
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<dyn std::error::Error>> {
|
||||
let cwd = env::current_dir()?;
|
||||
println!("{}", handle_hooks_slash_command(args, &cwd)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_skills(args: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 <session-path>"));
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
Hooks {
|
||||
args: Option<String>,
|
||||
},
|
||||
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<String, runtime::ConfigError> {
|
||||
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<String>,
|
||||
@@ -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<String>, 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 <session-path>"));
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user