From 6a55b2d66e5f6802998bafbb065239f8fe204172 Mon Sep 17 00:00:00 2001 From: crsei Date: Tue, 21 Apr 2026 05:30:49 -0400 Subject: [PATCH] refactor(workspace): P5 break hub cycles (tools<->engine, engine<->commands) - Move tools::agent -> engine::agent (5a) - Extract HookRunner trait into cc-types; engine no longer imports tools::hooks (5b) - Extract CommandDispatcher trait into cc-types; engine no longer imports commands (5c) Closes #74 --- Cargo.lock | 2 + crates/cc-types/Cargo.toml | 2 + crates/cc-types/src/commands.rs | 72 +++++ crates/cc-types/src/hooks.rs | 263 ++++++++++++++++++ crates/cc-types/src/lib.rs | 2 + crates/claude-code-rs/src/commands/mod.rs | 34 +++ .../src/{tools => engine}/agent/dispatch.rs | 18 +- .../src/{tools => engine}/agent/mod.rs | 0 .../src/{tools => engine}/agent/tests.rs | 0 .../src/{tools => engine}/agent/tool_impl.rs | 28 +- .../src/{tools => engine}/agent/worktree.rs | 4 +- .../src/engine/input_processing.rs | 80 +++++- .../src/engine/lifecycle/deps.rs | 116 ++++---- .../src/engine/lifecycle/mod.rs | 44 +++ .../src/engine/lifecycle/submit_message.rs | 20 +- .../src/engine/lifecycle/tests.rs | 5 +- crates/claude-code-rs/src/engine/mod.rs | 1 + crates/claude-code-rs/src/main.rs | 7 +- crates/claude-code-rs/src/plugins/tools.rs | 2 + crates/claude-code-rs/src/teams/runner.rs | 8 +- crates/claude-code-rs/src/tools/ask_user.rs | 4 + crates/claude-code-rs/src/tools/brief.rs | 2 + crates/claude-code-rs/src/tools/exec/sleep.rs | 2 + .../src/tools/execution/pipeline.rs | 2 + .../src/tools/execution/tests.rs | 2 + crates/claude-code-rs/src/tools/hooks/mod.rs | 204 ++++++-------- .../src/tools/hooks/post_tool.rs | 42 ++- crates/claude-code-rs/src/tools/mod.rs | 2 - crates/claude-code-rs/src/tools/plan_mode.rs | 2 + crates/claude-code-rs/src/tools/registry.rs | 3 +- .../claude-code-rs/src/tools/send_message.rs | 2 + crates/claude-code-rs/src/tools/skill.rs | 8 + .../src/tools/web_search/tests.rs | 2 + .../src/tools/web_search/tool.rs | 2 + crates/claude-code-rs/src/tools/worktree.rs | 2 + crates/claude-code-rs/src/types/tool.rs | 7 + crates/claude-code-rs/src/web/handlers.rs | 2 + 37 files changed, 774 insertions(+), 224 deletions(-) create mode 100644 crates/cc-types/src/commands.rs create mode 100644 crates/cc-types/src/hooks.rs rename crates/claude-code-rs/src/{tools => engine}/agent/dispatch.rs (95%) rename crates/claude-code-rs/src/{tools => engine}/agent/mod.rs (100%) rename crates/claude-code-rs/src/{tools => engine}/agent/tests.rs (100%) rename crates/claude-code-rs/src/{tools => engine}/agent/tool_impl.rs (96%) rename crates/claude-code-rs/src/{tools => engine}/agent/worktree.rs (99%) diff --git a/Cargo.lock b/Cargo.lock index 2d8f0070..93dc174f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -695,6 +695,8 @@ dependencies = [ name = "cc-types" version = "0.1.0" dependencies = [ + "anyhow", + "async-trait", "chrono", "serde", "serde_json", diff --git a/crates/cc-types/Cargo.toml b/crates/cc-types/Cargo.toml index 2d2f54b2..bac57b54 100644 --- a/crates/cc-types/Cargo.toml +++ b/crates/cc-types/Cargo.toml @@ -9,3 +9,5 @@ serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } +async-trait = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/cc-types/src/commands.rs b/crates/cc-types/src/commands.rs new file mode 100644 index 00000000..79728022 --- /dev/null +++ b/crates/cc-types/src/commands.rs @@ -0,0 +1,72 @@ +//! Trait used by the engine to dispatch slash commands without importing the +//! main crate's `commands::` module. +//! +//! The engine only needs two operations: parse an input string and find out +//! the canonical command name for a parsed index. The concrete dispatcher +//! lives in the main crate's `commands::` module. +//! +//! See issue #74 (`[workspace-split] Phase 5`, sub-task 5c). + +/// Minimal view of a parsed slash command. +/// +/// Mirrors the `(usize, String)` tuple historically returned by the main +/// crate's `parse_command_input`: the command-registry index and the raw +/// argument string (everything after the command token). +#[derive(Debug, Clone)] +pub struct ParsedCommand { + /// Zero-based index of the command in the registry's `get_all_commands()` + /// list. Opaque to the engine — it only passes it back to the dispatcher. + pub index: usize, + /// Arguments: the trimmed text after the command token (may be empty). + pub args: String, +} + +/// Trait for parsing and looking up slash commands. +/// +/// Object-safe: call sites store this as `Arc`. +pub trait CommandDispatcher: Send + Sync { + /// Parse a raw user input string. + /// + /// Returns `Some(ParsedCommand)` iff the input starts with `/` and the + /// token after the slash resolves to a registered command name or alias. + /// Otherwise returns `None` (including for non-slash input). + fn parse_command_input(&self, input: &str) -> Option; + + /// Canonical name of the command at the given registry index. + /// + /// Returns `None` if the index is out of range. + fn command_name(&self, index: usize) -> Option; +} + +// --------------------------------------------------------------------------- +// NoopCommandDispatcher — default that never matches any input +// --------------------------------------------------------------------------- + +/// A `CommandDispatcher` that never recognises any slash commands. +/// +/// Used as the default dispatcher for engines constructed without an explicit +/// one (e.g. in unit tests). Real call sites override with the concrete +/// `DefaultCommandDispatcher` from the main crate. +pub struct NoopCommandDispatcher; + +impl NoopCommandDispatcher { + pub fn new() -> Self { + Self + } +} + +impl Default for NoopCommandDispatcher { + fn default() -> Self { + Self + } +} + +impl CommandDispatcher for NoopCommandDispatcher { + fn parse_command_input(&self, _input: &str) -> Option { + None + } + + fn command_name(&self, _index: usize) -> Option { + None + } +} diff --git a/crates/cc-types/src/hooks.rs b/crates/cc-types/src/hooks.rs new file mode 100644 index 00000000..61115a18 --- /dev/null +++ b/crates/cc-types/src/hooks.rs @@ -0,0 +1,263 @@ +//! Hook runner trait and plain data types for the tool-execution hook system. +//! +//! The engine uses hooks at several lifecycle points (PreToolUse, PostToolUse, +//! PostToolUseFailure, SubagentStart, SubagentStop, UserPromptSubmit, +//! InstructionsLoaded, PermissionRequest, PermissionDenied, …). The concrete +//! runner that spawns shell commands lives in the main crate's `tools::hooks` +//! module; the engine depends only on this trait so it has no direct edge to +//! `tools::hooks`. +//! +//! See issue #74 (`[workspace-split] Phase 5`). +use std::collections::HashMap; + +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Hook result types +// --------------------------------------------------------------------------- + +/// Result of running pre-tool hooks. +#[derive(Debug, Clone)] +pub enum PreToolHookResult { + /// Continue with execution (possibly with modified input). + Continue { + /// Modified input (None = use original). + updated_input: Option, + /// Permission override from hook. + permission_override: Option, + }, + /// Stop tool execution (hook explicitly blocked it). + Stop { + /// Message explaining why the hook stopped execution. + message: String, + }, +} + +/// Permission override from a hook. +#[derive(Debug, Clone)] +pub enum PermissionOverride { + /// Force allow. + Allow, + /// Force deny. + Deny { reason: String }, +} + +/// Result of running post-tool hooks. +#[derive(Debug, Clone)] +pub enum PostToolHookResult { + /// Continue normally. + Continue, + /// Hook wants to stop the continuation chain. + StopContinuation { message: String }, +} + +// --------------------------------------------------------------------------- +// Hook configuration types (deserialized from settings.json) +// --------------------------------------------------------------------------- + +/// Hook configuration from settings.json. +/// +/// Each event (e.g. "PreToolUse") contains a list of these, each optionally +/// matching a tool name and containing a list of hook entries to run. +#[derive(Debug, Clone, Deserialize)] +pub struct HookEventConfig { + /// Tool name matcher (e.g., "Bash", "Read", "*"). + /// None or "*" matches all tools. + pub matcher: Option, + /// List of hook entries to run when this config matches. + pub hooks: Vec, +} + +/// A single hook entry — currently only "command" type is supported. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum HookEntry { + #[serde(rename = "command")] + Command { + command: String, + #[serde(default = "default_timeout")] + timeout: u64, // seconds + }, +} + +fn default_timeout() -> u64 { + 60 +} + +/// JSON output from a hook subprocess. +/// +/// The subprocess writes a single JSON line to stdout. All fields are +/// optional; the default is to continue execution without changes. +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct HookOutput { + /// If false, stop tool execution. + #[serde(rename = "continue")] + pub should_continue: bool, + /// Reason for stopping (post-tool hooks). + pub stop_reason: Option, + /// Decision string (e.g., "allow", "deny", "block"). + pub decision: Option, + /// Reason for the decision. + pub reason: Option, + /// Permission decision for pre-tool hooks ("allow" or "deny"). + pub permission_decision: Option, + /// Modified tool input (pre-tool hooks). + pub updated_input: Option, + /// Additional context to include in messages. + pub additional_context: Option, +} + +impl Default for HookOutput { + fn default() -> Self { + Self { + should_continue: true, + stop_reason: None, + decision: None, + reason: None, + permission_decision: None, + updated_input: None, + additional_context: None, + } + } +} + +// --------------------------------------------------------------------------- +// HookRunner trait +// --------------------------------------------------------------------------- + +/// Type alias for the hooks map loaded from `settings.json`. +pub type HooksMap = HashMap; + +/// Trait for running hook subprocess commands. +/// +/// Decouples the engine from the concrete shell-execution implementation that +/// lives in `tools::hooks`. Object-safe: callers store this as +/// `Arc`. +#[async_trait] +pub trait HookRunner: Send + Sync { + /// Load hook configurations for a specific event from the hooks settings. + /// + /// `event_name` is one of "PreToolUse", "PostToolUse", "Stop", etc. + fn load_hook_configs( + &self, + hooks_value: &HooksMap, + event_name: &str, + ) -> Vec; + + /// Run pre-tool hooks for a tool invocation. + async fn run_pre_tool_hooks( + &self, + tool_name: &str, + input: &Value, + hook_configs: &[HookEventConfig], + ) -> anyhow::Result; + + /// Run post-tool hooks after a successful tool call. + /// + /// `tool_result_data` is the serialized tool result payload (typically + /// `ToolResult::data`). + async fn run_post_tool_hooks( + &self, + tool_name: &str, + input: &Value, + tool_result_data: &Value, + hook_configs: &[HookEventConfig], + ) -> anyhow::Result; + + /// Run post-tool failure hooks after a failed tool call. + async fn run_post_tool_failure_hooks( + &self, + tool_name: &str, + input: &Value, + error: &str, + hook_configs: &[HookEventConfig], + ) -> anyhow::Result<()>; + + /// Generic event hook runner for non-tool lifecycle events + /// (UserPromptSubmit, InstructionsLoaded, SubagentStart, …). + async fn run_event_hooks( + &self, + event_name: &str, + payload: &Value, + hook_configs: &[HookEventConfig], + ) -> anyhow::Result; +} + +// --------------------------------------------------------------------------- +// NoopHookRunner — a safe default that never fires any hooks +// --------------------------------------------------------------------------- + +/// A `HookRunner` that runs no hooks, regardless of settings. +/// +/// Used as the default runner for engines constructed without an explicit +/// runner (e.g. in unit tests where hook semantics are irrelevant). Real call +/// sites (main binary, web handlers, IPC, teams) should override with the +/// concrete `ShellHookRunner` from `tools::hooks`. +pub struct NoopHookRunner; + +impl NoopHookRunner { + pub fn new() -> Self { + Self + } +} + +impl Default for NoopHookRunner { + fn default() -> Self { + Self + } +} + +#[async_trait] +impl HookRunner for NoopHookRunner { + fn load_hook_configs( + &self, + _hooks_value: &HooksMap, + _event_name: &str, + ) -> Vec { + Vec::new() + } + + async fn run_pre_tool_hooks( + &self, + _tool_name: &str, + _input: &Value, + _hook_configs: &[HookEventConfig], + ) -> anyhow::Result { + Ok(PreToolHookResult::Continue { + updated_input: None, + permission_override: None, + }) + } + + async fn run_post_tool_hooks( + &self, + _tool_name: &str, + _input: &Value, + _tool_result_data: &Value, + _hook_configs: &[HookEventConfig], + ) -> anyhow::Result { + Ok(PostToolHookResult::Continue) + } + + async fn run_post_tool_failure_hooks( + &self, + _tool_name: &str, + _input: &Value, + _error: &str, + _hook_configs: &[HookEventConfig], + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn run_event_hooks( + &self, + _event_name: &str, + _payload: &Value, + _hook_configs: &[HookEventConfig], + ) -> anyhow::Result { + Ok(HookOutput::default()) + } +} diff --git a/crates/cc-types/src/lib.rs b/crates/cc-types/src/lib.rs index e67e9324..e6624283 100644 --- a/crates/cc-types/src/lib.rs +++ b/crates/cc-types/src/lib.rs @@ -7,6 +7,8 @@ //! //! See issue #70 (`[workspace-split] Phase 1`) for the rationale behind this //! partial split. +pub mod commands; +pub mod hooks; pub mod message; pub mod permissions; pub mod state; diff --git a/crates/claude-code-rs/src/commands/mod.rs b/crates/claude-code-rs/src/commands/mod.rs index e21e7493..02734bfb 100644 --- a/crates/claude-code-rs/src/commands/mod.rs +++ b/crates/claude-code-rs/src/commands/mod.rs @@ -554,6 +554,40 @@ pub fn parse_command_input(input: &str) -> Option<(usize, String)> { find_command(without_slash).map(|idx| (idx, args)) } +// --------------------------------------------------------------------------- +// CommandDispatcher trait implementation +// --------------------------------------------------------------------------- + +/// Concrete [`cc_types::commands::CommandDispatcher`] for the full command +/// registry. Used to inject command parsing into the engine without the +/// engine importing `commands::` directly (see issue #74, Phase 5c). +pub struct DefaultCommandDispatcher; + +impl DefaultCommandDispatcher { + pub fn new() -> Self { + Self + } +} + +impl Default for DefaultCommandDispatcher { + fn default() -> Self { + Self + } +} + +impl cc_types::commands::CommandDispatcher for DefaultCommandDispatcher { + fn parse_command_input(&self, input: &str) -> Option { + parse_command_input(input).map(|(index, args)| cc_types::commands::ParsedCommand { + index, + args, + }) + } + + fn command_name(&self, index: usize) -> Option { + get_all_commands().get(index).map(|cmd| cmd.name.clone()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/claude-code-rs/src/tools/agent/dispatch.rs b/crates/claude-code-rs/src/engine/agent/dispatch.rs similarity index 95% rename from crates/claude-code-rs/src/tools/agent/dispatch.rs rename to crates/claude-code-rs/src/engine/agent/dispatch.rs index 84c364ea..f3eda393 100644 --- a/crates/claude-code-rs/src/tools/agent/dispatch.rs +++ b/crates/claude-code-rs/src/engine/agent/dispatch.rs @@ -91,7 +91,9 @@ impl AgentTool { } } - let child_engine = QueryEngine::new(child_config); + let mut child_engine = QueryEngine::new(child_config); + child_engine.set_hook_runner(ctx.hook_runner.clone()); + child_engine.set_command_dispatcher(ctx.command_dispatcher.clone()); let stream = child_engine.submit_message(¶ms.prompt, QuerySource::Agent(agent_id.to_string())); @@ -175,8 +177,8 @@ impl AgentTool { parent_model: &str, current_depth: usize, description: &str, - start_configs: &[crate::tools::hooks::HookEventConfig], - stop_configs: &[crate::tools::hooks::HookEventConfig], + start_configs: &[cc_types::hooks::HookEventConfig], + stop_configs: &[cc_types::hooks::HookEventConfig], background: bool, ) -> Result { // Fire SubagentStart hook @@ -189,7 +191,9 @@ impl AgentTool { "model": agent_model, "depth": current_depth + 1, }); - let _ = crate::tools::hooks::run_event_hooks("SubagentStart", &payload, start_configs) + let _ = ctx + .hook_runner + .run_event_hooks("SubagentStart", &payload, start_configs) .await; } @@ -227,8 +231,10 @@ impl AgentTool { "description": description, "is_error": is_error, }); - let _ = - crate::tools::hooks::run_event_hooks("SubagentStop", &payload, stop_configs).await; + let _ = ctx + .hook_runner + .run_event_hooks("SubagentStop", &payload, stop_configs) + .await; } result diff --git a/crates/claude-code-rs/src/tools/agent/mod.rs b/crates/claude-code-rs/src/engine/agent/mod.rs similarity index 100% rename from crates/claude-code-rs/src/tools/agent/mod.rs rename to crates/claude-code-rs/src/engine/agent/mod.rs diff --git a/crates/claude-code-rs/src/tools/agent/tests.rs b/crates/claude-code-rs/src/engine/agent/tests.rs similarity index 100% rename from crates/claude-code-rs/src/tools/agent/tests.rs rename to crates/claude-code-rs/src/engine/agent/tests.rs diff --git a/crates/claude-code-rs/src/tools/agent/tool_impl.rs b/crates/claude-code-rs/src/engine/agent/tool_impl.rs similarity index 96% rename from crates/claude-code-rs/src/tools/agent/tool_impl.rs rename to crates/claude-code-rs/src/engine/agent/tool_impl.rs index 27a61713..938fbc67 100644 --- a/crates/claude-code-rs/src/tools/agent/tool_impl.rs +++ b/crates/claude-code-rs/src/engine/agent/tool_impl.rs @@ -135,11 +135,13 @@ impl Tool for AgentTool { // Load hook configs once (used by both background and synchronous paths) let start_configs = { let app_state = (ctx.get_app_state)(); - crate::tools::hooks::load_hook_configs(&app_state.hooks, "SubagentStart") + ctx.hook_runner + .load_hook_configs(&app_state.hooks, "SubagentStart") }; let stop_configs = { let app_state = (ctx.get_app_state)(); - crate::tools::hooks::load_hook_configs(&app_state.hooks, "SubagentStop") + ctx.hook_runner + .load_hook_configs(&app_state.hooks, "SubagentStop") }; // -- Background path @@ -190,9 +192,10 @@ impl Tool for AgentTool { "depth": current_depth + 1, "background": true, }); - let _ = - crate::tools::hooks::run_event_hooks("SubagentStart", &payload, &start_configs) - .await; + let _ = ctx + .hook_runner + .run_event_hooks("SubagentStart", &payload, &start_configs) + .await; } // Build child config now (before move into spawn) @@ -281,12 +284,16 @@ impl Tool for AgentTool { let spawn_prompt = params.prompt.clone(); let spawn_agent_model = agent_model.clone(); let spawn_stop_configs = stop_configs.clone(); + let spawn_hook_runner = ctx.hook_runner.clone(); + let spawn_command_dispatcher = ctx.command_dispatcher.clone(); tokio::spawn(async move { let started = std::time::Instant::now(); info!(agent_id = %spawn_agent_id, description = %spawn_description, "background agent started"); - let child_engine = QueryEngine::new(child_config); + let mut child_engine = QueryEngine::new(child_config); + child_engine.set_hook_runner(spawn_hook_runner.clone()); + child_engine.set_command_dispatcher(spawn_command_dispatcher); let stream = child_engine .submit_message(&spawn_prompt, QuerySource::Agent(spawn_agent_id.clone())); let mut stream = std::pin::pin!(stream); @@ -363,12 +370,9 @@ impl Tool for AgentTool { "is_error": had_error, "background": true, }); - let _ = crate::tools::hooks::run_event_hooks( - "SubagentStop", - &payload, - &spawn_stop_configs, - ) - .await; + let _ = spawn_hook_runner + .run_event_hooks("SubagentStop", &payload, &spawn_stop_configs) + .await; } let result_preview = if result_text.len() > 200 { diff --git a/crates/claude-code-rs/src/tools/agent/worktree.rs b/crates/claude-code-rs/src/engine/agent/worktree.rs similarity index 99% rename from crates/claude-code-rs/src/tools/agent/worktree.rs rename to crates/claude-code-rs/src/engine/agent/worktree.rs index 8a742a34..ed34b7b6 100644 --- a/crates/claude-code-rs/src/tools/agent/worktree.rs +++ b/crates/claude-code-rs/src/engine/agent/worktree.rs @@ -261,7 +261,9 @@ impl AgentTool { } } - let child_engine = QueryEngine::new(child_config); + let mut child_engine = QueryEngine::new(child_config); + child_engine.set_hook_runner(ctx.hook_runner.clone()); + child_engine.set_command_dispatcher(ctx.command_dispatcher.clone()); let stream = child_engine.submit_message(¶ms.prompt, QuerySource::Agent(agent_id.to_string())); diff --git a/crates/claude-code-rs/src/engine/input_processing.rs b/crates/claude-code-rs/src/engine/input_processing.rs index 6bebd423..3545bc67 100644 --- a/crates/claude-code-rs/src/engine/input_processing.rs +++ b/crates/claude-code-rs/src/engine/input_processing.rs @@ -5,7 +5,8 @@ use uuid::Uuid; -use crate::commands; +use cc_types::commands::CommandDispatcher; + use crate::types::message::{Message, MessageContent, UserMessage}; // --------------------------------------------------------------------------- @@ -44,27 +45,39 @@ pub struct ProcessedInput { /// in `messages`. /// 2. Otherwise, wrap the input in a plain `UserMessage` with /// `should_query = true`. -pub fn process_user_input(input: &str, _messages: &[Message], _cwd: &str) -> ProcessedInput { +/// +/// `dispatcher` is the command dispatcher used to parse slash commands. The +/// engine no longer imports `crate::commands` directly (see issue #74 / 5c). +pub fn process_user_input( + input: &str, + _messages: &[Message], + _cwd: &str, + dispatcher: &dyn CommandDispatcher, +) -> ProcessedInput { let trimmed = input.trim(); // -- Slash-command path --------------------------------------------------- if trimmed.starts_with('/') { - if let Some((cmd_idx, args)) = commands::parse_command_input(trimmed) { + if let Some(parsed) = dispatcher.parse_command_input(trimmed) { // We matched a registered command. For now we treat all // commands as local (should_query = false) and return the // command name + args as result_text. Full command execution // (which requires async) will be wired later; this gives the // engine the information it needs to route. - let all_commands = commands::get_all_commands(); - let cmd = &all_commands[cmd_idx]; - let cmd_name = cmd.name.clone(); + let cmd_name = dispatcher + .command_name(parsed.index) + .unwrap_or_else(|| String::from("unknown")); return ProcessedInput { messages: Vec::new(), should_query: false, allowed_tools: None, model: None, - result_text: Some(format!("/{cmd_name} {args}").trim().to_string()), + result_text: Some( + format!("/{cmd_name} {args}", args = parsed.args) + .trim() + .to_string(), + ), }; } @@ -99,10 +112,46 @@ pub fn process_user_input(input: &str, _messages: &[Message], _cwd: &str) -> Pro #[cfg(test)] mod tests { use super::*; + use cc_types::commands::{CommandDispatcher, ParsedCommand}; + + /// Minimal dispatcher used only in tests. Recognises `/help` and + /// `/config`; everything else is treated as regular text. + struct TestDispatcher; + + impl CommandDispatcher for TestDispatcher { + fn parse_command_input(&self, input: &str) -> Option { + let trimmed = input.trim(); + if !trimmed.starts_with('/') { + return None; + } + let without_slash = &trimmed[1..]; + let name = without_slash.split_whitespace().next().unwrap_or(""); + let args = without_slash + .strip_prefix(name) + .unwrap_or("") + .trim() + .to_string(); + let index = match name { + "help" => 0, + "config" => 1, + _ => return None, + }; + Some(ParsedCommand { index, args }) + } + + fn command_name(&self, index: usize) -> Option { + match index { + 0 => Some("help".to_string()), + 1 => Some("config".to_string()), + _ => None, + } + } + } #[test] fn test_regular_text() { - let result = process_user_input("Hello, Claude!", &[], "/tmp"); + let d = TestDispatcher; + let result = process_user_input("Hello, Claude!", &[], "/tmp", &d); assert!(result.should_query); assert_eq!(result.messages.len(), 1); assert!(result.result_text.is_none()); @@ -110,7 +159,8 @@ mod tests { #[test] fn test_slash_command_known() { - let result = process_user_input("/help", &[], "/tmp"); + let d = TestDispatcher; + let result = process_user_input("/help", &[], "/tmp", &d); assert!(!result.should_query); assert!(result.messages.is_empty()); assert!(result.result_text.is_some()); @@ -119,7 +169,8 @@ mod tests { #[test] fn test_slash_command_with_args() { - let result = process_user_input("/config set model opus", &[], "/tmp"); + let d = TestDispatcher; + let result = process_user_input("/config set model opus", &[], "/tmp", &d); assert!(!result.should_query); assert!(result.result_text.is_some()); let text = result.result_text.unwrap(); @@ -129,7 +180,8 @@ mod tests { #[test] fn test_unknown_slash_command() { - let result = process_user_input("/nonexistent_command", &[], "/tmp"); + let d = TestDispatcher; + let result = process_user_input("/nonexistent_command", &[], "/tmp", &d); // Unknown commands are treated as regular text. assert!(result.should_query); assert_eq!(result.messages.len(), 1); @@ -137,14 +189,16 @@ mod tests { #[test] fn test_empty_input() { - let result = process_user_input("", &[], "/tmp"); + let d = TestDispatcher; + let result = process_user_input("", &[], "/tmp", &d); assert!(result.should_query); assert_eq!(result.messages.len(), 1); } #[test] fn test_whitespace_only() { - let result = process_user_input(" ", &[], "/tmp"); + let d = TestDispatcher; + let result = process_user_input(" ", &[], "/tmp", &d); assert!(result.should_query); assert_eq!(result.messages.len(), 1); } diff --git a/crates/claude-code-rs/src/engine/lifecycle/deps.rs b/crates/claude-code-rs/src/engine/lifecycle/deps.rs index 8eebe888..75e1c831 100644 --- a/crates/claude-code-rs/src/engine/lifecycle/deps.rs +++ b/crates/claude-code-rs/src/engine/lifecycle/deps.rs @@ -47,6 +47,12 @@ pub(crate) struct QueryEngineDeps { pub(crate) bg_agent_tx: Option, /// Shared buffer of completed background agents. pub(crate) pending_bg_results: crate::tools::background_agents::PendingBackgroundResults, + /// Hook runner — used via the `HookRunner` trait from `cc-types::hooks` so + /// the engine has no direct dependency on `crate::tools::hooks`. + pub(crate) hook_runner: Arc, + /// Command dispatcher — forwarded into `ToolUseContext` for tools that + /// spawn child engines (e.g. Agent). + pub(crate) command_dispatcher: Arc, } #[async_trait::async_trait] @@ -310,11 +316,13 @@ impl QueryDeps for QueryEngineDeps { parent_message: &crate::types::message::AssistantMessage, _on_progress: Option>, ) -> Result { - use crate::tools::hooks::{ - self, PermissionOverride, PostToolHookResult, PreToolHookResult, - }; + use cc_types::hooks::{PermissionOverride, PostToolHookResult, PreToolHookResult}; use crate::types::tool::PermissionResult; + // Hook dispatcher trait object — decouples the engine from the concrete + // `crate::tools::hooks` impl (see issue #74, Phase 5b). + let hooks = self.hook_runner.as_ref(); + let tool = tools .iter() .find(|t| t.name() == request.tool_name) @@ -369,17 +377,21 @@ impl QueryDeps for QueryEngineDeps { permission_callback: self.permission_callback.clone(), ask_user_callback: self.state.read().ask_user_callback.clone(), bg_agent_tx: self.bg_agent_tx.clone(), + hook_runner: self.hook_runner.clone(), + command_dispatcher: self.command_dispatcher.clone(), }; // ── Load hook configs from AppState ──────────────────────── let hooks_map = self.state.read().app_state.hooks.clone(); - let pre_configs = hooks::load_hook_configs(&hooks_map, "PreToolUse"); - let post_configs = hooks::load_hook_configs(&hooks_map, "PostToolUse"); - let failure_configs = hooks::load_hook_configs(&hooks_map, "PostToolUseFailure"); + let pre_configs = hooks.load_hook_configs(&hooks_map, "PreToolUse"); + let post_configs = hooks.load_hook_configs(&hooks_map, "PostToolUse"); + let failure_configs = hooks.load_hook_configs(&hooks_map, "PostToolUseFailure"); // ── Pre-tool hooks ───────────────────────────────────────── let (effective_input, permission_override) = - match hooks::run_pre_tool_hooks(&request.tool_name, &request.input, &pre_configs).await + match hooks + .run_pre_tool_hooks(&request.tool_name, &request.input, &pre_configs) + .await { Ok(PreToolHookResult::Continue { updated_input, @@ -411,14 +423,15 @@ impl QueryDeps for QueryEngineDeps { match override_decision { PermissionOverride::Deny { reason } => { // Fire PermissionDenied hook - let deny_configs = hooks::load_hook_configs(&hooks_map, "PermissionDenied"); + let deny_configs = hooks.load_hook_configs(&hooks_map, "PermissionDenied"); if !deny_configs.is_empty() { let payload = serde_json::json!({ "tool_name": request.tool_name, "tool_input": request.input, "reason": format!("Permission denied by hook: {}", reason), }); - let _ = hooks::run_event_hooks("PermissionDenied", &payload, &deny_configs) + let _ = hooks + .run_event_hooks("PermissionDenied", &payload, &deny_configs) .await; } @@ -467,14 +480,15 @@ impl QueryDeps for QueryEngineDeps { ); } // Fire PermissionDenied hook - let deny_configs = hooks::load_hook_configs(&hooks_map, "PermissionDenied"); + let deny_configs = hooks.load_hook_configs(&hooks_map, "PermissionDenied"); if !deny_configs.is_empty() { let payload = serde_json::json!({ "tool_name": request.tool_name, "tool_input": request.input, "reason": format!("Permission denied: {}", message), }); - let _ = hooks::run_event_hooks("PermissionDenied", &payload, &deny_configs) + let _ = hooks + .run_event_hooks("PermissionDenied", &payload, &deny_configs) .await; } @@ -509,16 +523,16 @@ impl QueryDeps for QueryEngineDeps { // Fire PermissionRequest hook before interactive prompt let mut hook_allowed = false; let perm_req_configs = - hooks::load_hook_configs(&hooks_map, "PermissionRequest"); + hooks.load_hook_configs(&hooks_map, "PermissionRequest"); if !perm_req_configs.is_empty() { let payload = serde_json::json!({ "tool_name": request.tool_name, "tool_input": request.input, "message": message, }); - if let Ok(output) = - hooks::run_event_hooks("PermissionRequest", &payload, &perm_req_configs) - .await + if let Ok(output) = hooks + .run_event_hooks("PermissionRequest", &payload, &perm_req_configs) + .await { // If hook provides a permission decision, use it if let Some(ref decision) = output.permission_decision { @@ -533,22 +547,21 @@ impl QueryDeps for QueryEngineDeps { } "deny" => { // Fire PermissionDenied hook - let deny_configs = hooks::load_hook_configs( - &hooks_map, - "PermissionDenied", - ); + let deny_configs = hooks + .load_hook_configs(&hooks_map, "PermissionDenied"); if !deny_configs.is_empty() { let deny_payload = serde_json::json!({ "tool_name": request.tool_name, "tool_input": request.input, "reason": "Permission denied by PermissionRequest hook", }); - let _ = hooks::run_event_hooks( - "PermissionDenied", - &deny_payload, - &deny_configs, - ) - .await; + let _ = hooks + .run_event_hooks( + "PermissionDenied", + &deny_payload, + &deny_configs, + ) + .await; } return Ok(ToolExecResult { @@ -638,20 +651,21 @@ impl QueryDeps for QueryEngineDeps { } // Fire PermissionDenied hook (user chose deny) - let deny_configs = - hooks::load_hook_configs(&hooks_map, "PermissionDenied"); + let deny_configs = hooks + .load_hook_configs(&hooks_map, "PermissionDenied"); if !deny_configs.is_empty() { let payload = serde_json::json!({ "tool_name": request.tool_name, "tool_input": request.input, "reason": "Permission denied by user", }); - let _ = hooks::run_event_hooks( - "PermissionDenied", - &payload, - &deny_configs, - ) - .await; + let _ = hooks + .run_event_hooks( + "PermissionDenied", + &payload, + &deny_configs, + ) + .await; } return Ok(ToolExecResult { @@ -669,19 +683,20 @@ impl QueryDeps for QueryEngineDeps { } else { // Fire PermissionDenied hook (no callback available) let deny_configs = - hooks::load_hook_configs(&hooks_map, "PermissionDenied"); + hooks.load_hook_configs(&hooks_map, "PermissionDenied"); if !deny_configs.is_empty() { let payload = serde_json::json!({ "tool_name": request.tool_name, "tool_input": request.input, "reason": format!("Permission required (no callback): {}", message), }); - let _ = hooks::run_event_hooks( - "PermissionDenied", - &payload, - &deny_configs, - ) - .await; + let _ = hooks + .run_event_hooks( + "PermissionDenied", + &payload, + &deny_configs, + ) + .await; } return Ok(ToolExecResult { @@ -769,11 +784,11 @@ impl QueryDeps for QueryEngineDeps { // Run post-tool hooks on success if !post_configs.is_empty() { - if let Ok(PostToolHookResult::StopContinuation { message }) = - hooks::run_post_tool_hooks( + if let Ok(PostToolHookResult::StopContinuation { message }) = hooks + .run_post_tool_hooks( &request.tool_name, &effective_input, - &result, + &result.data, &post_configs, ) .await @@ -817,13 +832,14 @@ impl QueryDeps for QueryEngineDeps { // Run post-failure hooks on error if !failure_configs.is_empty() { - let _ = hooks::run_post_tool_failure_hooks( - &request.tool_name, - &request.input, - &e.to_string(), - &failure_configs, - ) - .await; + let _ = hooks + .run_post_tool_failure_hooks( + &request.tool_name, + &request.input, + &e.to_string(), + &failure_configs, + ) + .await; } Ok(ToolExecResult { diff --git a/crates/claude-code-rs/src/engine/lifecycle/mod.rs b/crates/claude-code-rs/src/engine/lifecycle/mod.rs index e3888f56..81db6d7c 100644 --- a/crates/claude-code-rs/src/engine/lifecycle/mod.rs +++ b/crates/claude-code-rs/src/engine/lifecycle/mod.rs @@ -110,6 +110,18 @@ pub struct QueryEngine { /// Shared buffer of completed background agents. /// Event loop pushes; query loop drains. pub(crate) pending_bg_results: crate::tools::background_agents::PendingBackgroundResults, + /// Hook runner for the tool-execution hook system. + /// + /// Defaults to [`cc_types::hooks::NoopHookRunner`]. Call sites that want + /// real shell-command hooks wire in the concrete `ShellHookRunner` via + /// [`QueryEngine::set_hook_runner`] at construction time. + pub(crate) hook_runner: Arc, + /// Slash-command dispatcher used by input processing. + /// + /// Defaults to [`cc_types::commands::NoopCommandDispatcher`]. Call sites + /// wire in `DefaultCommandDispatcher` from the main crate's `commands::` + /// module via [`QueryEngine::set_command_dispatcher`]. + pub(crate) command_dispatcher: Arc, } impl QueryEngine { @@ -156,9 +168,41 @@ impl QueryEngine { aborted: Arc::new(AtomicBool::new(false)), has_handled_orphaned_permission: Arc::new(AtomicBool::new(false)), pending_bg_results: crate::tools::background_agents::PendingBackgroundResults::new(), + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } + /// Install a concrete hook runner (normally `ShellHookRunner` from the + /// main crate's `tools::hooks` module). + /// + /// Must be called before `submit_message` if runtime hook firing is + /// desired; otherwise the [`cc_types::hooks::NoopHookRunner`] default is + /// used and no hooks execute. + pub fn set_hook_runner(&mut self, runner: Arc) { + self.hook_runner = runner; + } + + /// Clone of the current hook runner. Useful when rebuilding a sibling + /// engine that should share the same runner as an existing one. + pub fn hook_runner(&self) -> Arc { + self.hook_runner.clone() + } + + /// Install a concrete command dispatcher (normally + /// `DefaultCommandDispatcher` from `crate::commands`). + pub fn set_command_dispatcher( + &mut self, + dispatcher: Arc, + ) { + self.command_dispatcher = dispatcher; + } + + /// Clone of the current command dispatcher. + pub fn command_dispatcher(&self) -> Arc { + self.command_dispatcher.clone() + } + // -- Permission callback -------------------------------------------------- /// Set the async permission callback used by headless/TUI mode. diff --git a/crates/claude-code-rs/src/engine/lifecycle/submit_message.rs b/crates/claude-code-rs/src/engine/lifecycle/submit_message.rs index 2bfe0045..cf07501c 100644 --- a/crates/claude-code-rs/src/engine/lifecycle/submit_message.rs +++ b/crates/claude-code-rs/src/engine/lifecycle/submit_message.rs @@ -21,7 +21,6 @@ use crate::engine::sdk_types::*; use crate::engine::system_prompt; use crate::query::loop_impl; use crate::session::transcript; -use crate::tools::hooks; use crate::types::config::{QueryParams, QuerySource}; use crate::types::message::{ Attachment, Message, MessageContent, QueryYield, StreamEvent, SystemSubtype, @@ -57,6 +56,8 @@ impl QueryEngine { let state_ref = self.state.clone(); let aborted_ref = self.aborted.clone(); let pending_bg_results = self.pending_bg_results.clone(); + let hook_runner = self.hook_runner.clone(); + let command_dispatcher = self.command_dispatcher.clone(); let stream = async_stream::stream! { let started_at = Instant::now(); @@ -87,12 +88,15 @@ impl QueryEngine { // ================================================================ { let hooks_map = state_ref.read().app_state.hooks.clone(); - let configs = hooks::load_hook_configs(&hooks_map, "UserPromptSubmit"); + let configs = hook_runner.load_hook_configs(&hooks_map, "UserPromptSubmit"); if !configs.is_empty() { let payload = serde_json::json!({ "prompt": &prompt, }); - match hooks::run_event_hooks("UserPromptSubmit", &payload, &configs).await { + match hook_runner + .run_event_hooks("UserPromptSubmit", &payload, &configs) + .await + { Ok(output) => { if !output.should_continue { info!("UserPromptSubmit hook blocked prompt"); @@ -138,6 +142,7 @@ impl QueryEngine { &prompt, ¤t_msgs_snapshot, &config.cwd, + command_dispatcher.as_ref(), ); // A.3: Push processed messages into mutable_messages @@ -246,14 +251,17 @@ impl QueryEngine { let content_length: usize = system_prompt_parts.iter().map(|p| p.len()).sum(); if content_length > 0 { let hooks_map = state_ref.read().app_state.hooks.clone(); - let configs = hooks::load_hook_configs(&hooks_map, "InstructionsLoaded"); + let configs = + hook_runner.load_hook_configs(&hooks_map, "InstructionsLoaded"); if !configs.is_empty() { let payload = serde_json::json!({ "source": "system_prompt", "content_length": content_length, "cwd": &config.cwd, }); - let _ = hooks::run_event_hooks("InstructionsLoaded", &payload, &configs).await; + let _ = hook_runner + .run_event_hooks("InstructionsLoaded", &payload, &configs) + .await; } } } @@ -352,6 +360,8 @@ impl QueryEngine { permission_callback, bg_agent_tx, pending_bg_results: pending_bg_results.clone(), + hook_runner: hook_runner.clone(), + command_dispatcher: command_dispatcher.clone(), }); // Run the query loop diff --git a/crates/claude-code-rs/src/engine/lifecycle/tests.rs b/crates/claude-code-rs/src/engine/lifecycle/tests.rs index 0aa78825..fa1cda1b 100644 --- a/crates/claude-code-rs/src/engine/lifecycle/tests.rs +++ b/crates/claude-code-rs/src/engine/lifecycle/tests.rs @@ -138,7 +138,10 @@ mod tests { async fn test_submit_local_command() { use futures::StreamExt; - let engine = QueryEngine::new(make_config()); + let mut engine = QueryEngine::new(make_config()); + engine.set_command_dispatcher(std::sync::Arc::new( + crate::commands::DefaultCommandDispatcher::new(), + )); let stream = engine.submit_message("/clear", QuerySource::Sdk); let mut stream = std::pin::pin!(stream); diff --git a/crates/claude-code-rs/src/engine/mod.rs b/crates/claude-code-rs/src/engine/mod.rs index 1a716189..e760911b 100644 --- a/crates/claude-code-rs/src/engine/mod.rs +++ b/crates/claude-code-rs/src/engine/mod.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod codex_exec; pub mod effort; pub mod input_processing; diff --git a/crates/claude-code-rs/src/main.rs b/crates/claude-code-rs/src/main.rs index 93fbc5a1..06958fb2 100644 --- a/crates/claude-code-rs/src/main.rs +++ b/crates/claude-code-rs/src/main.rs @@ -630,7 +630,12 @@ async fn run_full_init(cli: Cli) -> anyhow::Result { }; // B.8: Create QueryEngine - let engine = Arc::new(QueryEngine::new(engine_config)); + let engine = Arc::new({ + let mut e = QueryEngine::new(engine_config); + e.set_hook_runner(Arc::new(crate::tools::hooks::ShellHookRunner::new())); + e.set_command_dispatcher(Arc::new(crate::commands::DefaultCommandDispatcher::new())); + e + }); info!(session = %engine.session_id, "QueryEngine created"); crate::dashboard::init_session_id(engine.session_id.as_str()); diff --git a/crates/claude-code-rs/src/plugins/tools.rs b/crates/claude-code-rs/src/plugins/tools.rs index a390d277..c16071c1 100644 --- a/crates/claude-code-rs/src/plugins/tools.rs +++ b/crates/claude-code-rs/src/plugins/tools.rs @@ -367,6 +367,8 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } diff --git a/crates/claude-code-rs/src/teams/runner.rs b/crates/claude-code-rs/src/teams/runner.rs index a14e77f0..b013763e 100644 --- a/crates/claude-code-rs/src/teams/runner.rs +++ b/crates/claude-code-rs/src/teams/runner.rs @@ -107,7 +107,13 @@ async fn run_teammate(config: InProcessRunnerConfig) -> Result<()> { agent_context: None, }; - let engine = QueryEngine::new(engine_config); + let mut engine = QueryEngine::new(engine_config); + engine.set_hook_runner(std::sync::Arc::new( + crate::tools::hooks::ShellHookRunner::new(), + )); + engine.set_command_dispatcher(std::sync::Arc::new( + crate::commands::DefaultCommandDispatcher::new(), + )); // Submit the initial prompt { diff --git a/crates/claude-code-rs/src/tools/ask_user.rs b/crates/claude-code-rs/src/tools/ask_user.rs index ff414016..eca49ebb 100644 --- a/crates/claude-code-rs/src/tools/ask_user.rs +++ b/crates/claude-code-rs/src/tools/ask_user.rs @@ -240,6 +240,10 @@ mod tests { permission_callback: None, ask_user_callback: Some(callback), bg_agent_tx: None, + hook_runner: std::sync::Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: std::sync::Arc::new( + cc_types::commands::NoopCommandDispatcher::new(), + ), }; let parent = AssistantMessage { diff --git a/crates/claude-code-rs/src/tools/brief.rs b/crates/claude-code-rs/src/tools/brief.rs index d66cabe8..dde0aae3 100644 --- a/crates/claude-code-rs/src/tools/brief.rs +++ b/crates/claude-code-rs/src/tools/brief.rs @@ -177,6 +177,8 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } diff --git a/crates/claude-code-rs/src/tools/exec/sleep.rs b/crates/claude-code-rs/src/tools/exec/sleep.rs index 81e77c0b..a98b9ea3 100644 --- a/crates/claude-code-rs/src/tools/exec/sleep.rs +++ b/crates/claude-code-rs/src/tools/exec/sleep.rs @@ -247,6 +247,8 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } } diff --git a/crates/claude-code-rs/src/tools/execution/pipeline.rs b/crates/claude-code-rs/src/tools/execution/pipeline.rs index d2c1dc63..c381eca3 100644 --- a/crates/claude-code-rs/src/tools/execution/pipeline.rs +++ b/crates/claude-code-rs/src/tools/execution/pipeline.rs @@ -488,6 +488,8 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } diff --git a/crates/claude-code-rs/src/tools/execution/tests.rs b/crates/claude-code-rs/src/tools/execution/tests.rs index d283ce96..1f6033ac 100644 --- a/crates/claude-code-rs/src/tools/execution/tests.rs +++ b/crates/claude-code-rs/src/tools/execution/tests.rs @@ -45,6 +45,8 @@ fn make_ctx_with_mode(mode: PermissionMode) -> ToolUseContext { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } diff --git a/crates/claude-code-rs/src/tools/hooks/mod.rs b/crates/claude-code-rs/src/tools/hooks/mod.rs index a848cd3d..7469b79e 100644 --- a/crates/claude-code-rs/src/tools/hooks/mod.rs +++ b/crates/claude-code-rs/src/tools/hooks/mod.rs @@ -10,6 +10,10 @@ //! the `hooks` key. Each hook event (PreToolUse, PostToolUse, Stop) contains //! a list of HookEventConfig entries, each with an optional matcher and a //! list of HookEntry commands to execute as subprocesses. +//! +//! The plain data types and the `HookRunner` trait live in `cc-types::hooks`. +//! This module provides the concrete shell-command runner (`ShellHookRunner`) +//! together with free functions that the rest of the crate uses directly. mod execution; mod post_tool; @@ -21,124 +25,20 @@ pub use post_tool::{ }; pub use pre_tool::run_pre_tool_hooks; -use std::collections::HashMap; +// Re-export the plain data types from cc-types so existing +// `crate::tools::hooks::{HookEventConfig, HookOutput, ...}` import paths keep +// working without changes. +pub use cc_types::hooks::{ + HookEntry, HookEventConfig, HookOutput, HookRunner, HooksMap, PermissionOverride, + PostToolHookResult, PreToolHookResult, +}; -use serde::Deserialize; +use async_trait::async_trait; use serde_json::Value; use tracing::warn; // --------------------------------------------------------------------------- -// Hook types (public API) -// --------------------------------------------------------------------------- - -/// Result of running pre-tool hooks. -#[derive(Debug, Clone)] -pub enum PreToolHookResult { - /// Continue with execution (possibly with modified input). - Continue { - /// Modified input (None = use original). - updated_input: Option, - /// Permission override from hook. - permission_override: Option, - }, - /// Stop tool execution (hook explicitly blocked it). - Stop { - /// Message explaining why the hook stopped execution. - message: String, - }, -} - -/// Permission override from a hook. -#[derive(Debug, Clone)] -pub enum PermissionOverride { - /// Force allow. - Allow, - /// Force deny. - Deny { reason: String }, -} - -/// Result of running post-tool hooks. -#[derive(Debug, Clone)] -pub enum PostToolHookResult { - /// Continue normally. - Continue, - /// Hook wants to stop the continuation chain. - StopContinuation { message: String }, -} - -// --------------------------------------------------------------------------- -// Hook configuration types (deserialized from settings.json) -// --------------------------------------------------------------------------- - -/// Hook configuration from settings.json. -/// -/// Each event (e.g. "PreToolUse") contains a list of these, each optionally -/// matching a tool name and containing a list of hook entries to run. -#[derive(Debug, Clone, Deserialize)] -pub struct HookEventConfig { - /// Tool name matcher (e.g., "Bash", "Read", "*"). - /// None or "*" matches all tools. - pub matcher: Option, - /// List of hook entries to run when this config matches. - pub hooks: Vec, -} - -/// A single hook entry — currently only "command" type is supported. -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type")] -pub enum HookEntry { - #[serde(rename = "command")] - Command { - command: String, - #[serde(default = "default_timeout")] - timeout: u64, // seconds - }, -} - -fn default_timeout() -> u64 { - 60 -} - -/// JSON output from a hook subprocess. -/// -/// The subprocess writes a single JSON line to stdout. All fields are -/// optional; the default is to continue execution without changes. -#[derive(Debug, Deserialize)] -#[serde(default)] -pub struct HookOutput { - /// If false, stop tool execution. - #[serde(rename = "continue")] - pub should_continue: bool, - /// Reason for stopping (post-tool hooks). - pub stop_reason: Option, - /// Decision string (e.g., "allow", "deny", "block"). - pub decision: Option, - /// Reason for the decision. - pub reason: Option, - /// Permission decision for pre-tool hooks ("allow" or "deny"). - pub permission_decision: Option, - /// Modified tool input (pre-tool hooks). - pub updated_input: Option, - /// Additional context to include in messages. - pub additional_context: Option, -} - -impl Default for HookOutput { - fn default() -> Self { - Self { - should_continue: true, - stop_reason: None, - decision: None, - reason: None, - permission_decision: None, - updated_input: None, - additional_context: None, - } - } -} - -// --------------------------------------------------------------------------- -// Matcher logic +// Matcher logic (still needed by the free-function implementations) // --------------------------------------------------------------------------- /// Check if a matcher pattern matches a tool name. @@ -162,10 +62,7 @@ fn matches_tool(matcher: Option<&str>, tool_name: &str) -> bool { /// /// `hooks_value` is the deserialized `hooks` map from GlobalConfig. /// `event_name` is one of "PreToolUse", "PostToolUse", "Stop". -pub fn load_hook_configs( - hooks_value: &HashMap, - event_name: &str, -) -> Vec { +pub fn load_hook_configs(hooks_value: &HooksMap, event_name: &str) -> Vec { let Some(event_value) = hooks_value.get(event_name) else { return vec![]; }; @@ -183,6 +80,78 @@ pub fn load_hook_configs( } } +// --------------------------------------------------------------------------- +// ShellHookRunner — concrete HookRunner backed by the shell-command impl +// --------------------------------------------------------------------------- + +/// Shell-command-backed `HookRunner`. +/// +/// Delegates every trait method to the free functions in this module so the +/// implementations (`execute_command_hook`, subprocess spawning, etc.) remain +/// in one place. +pub struct ShellHookRunner; + +impl ShellHookRunner { + pub fn new() -> Self { + Self + } +} + +impl Default for ShellHookRunner { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl HookRunner for ShellHookRunner { + fn load_hook_configs( + &self, + hooks_value: &HooksMap, + event_name: &str, + ) -> Vec { + load_hook_configs(hooks_value, event_name) + } + + async fn run_pre_tool_hooks( + &self, + tool_name: &str, + input: &Value, + hook_configs: &[HookEventConfig], + ) -> anyhow::Result { + run_pre_tool_hooks(tool_name, input, hook_configs).await + } + + async fn run_post_tool_hooks( + &self, + tool_name: &str, + input: &Value, + tool_result_data: &Value, + hook_configs: &[HookEventConfig], + ) -> anyhow::Result { + post_tool::run_post_tool_hooks_data(tool_name, input, tool_result_data, hook_configs).await + } + + async fn run_post_tool_failure_hooks( + &self, + tool_name: &str, + input: &Value, + error: &str, + hook_configs: &[HookEventConfig], + ) -> anyhow::Result<()> { + run_post_tool_failure_hooks(tool_name, input, error, hook_configs).await + } + + async fn run_event_hooks( + &self, + event_name: &str, + payload: &Value, + hook_configs: &[HookEventConfig], + ) -> anyhow::Result { + run_event_hooks(event_name, payload, hook_configs).await + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -191,6 +160,7 @@ pub fn load_hook_configs( mod tests { use super::*; use serde_json::json; + use std::collections::HashMap; // -- matches_tool tests -- diff --git a/crates/claude-code-rs/src/tools/hooks/post_tool.rs b/crates/claude-code-rs/src/tools/hooks/post_tool.rs index 39d663e5..5e53b29e 100644 --- a/crates/claude-code-rs/src/tools/hooks/post_tool.rs +++ b/crates/claude-code-rs/src/tools/hooks/post_tool.rs @@ -10,20 +10,15 @@ use super::{ }; use crate::types::tool::ToolResult; -// --------------------------------------------------------------------------- -// Post-tool hooks -// --------------------------------------------------------------------------- - -/// Run post-tool hooks after successful tool execution. -/// -/// Corresponds to TypeScript: `runPostToolUseHooks()` in toolExecution.ts +/// Value-only variant of [`run_post_tool_hooks`] used by `ShellHookRunner`. /// -/// Stdin includes `tool_result` field in addition to tool_name and tool_input. -/// If any hook returns `stop_reason`, returns `StopContinuation`. -pub async fn run_post_tool_hooks( +/// Takes the already-extracted tool_result data (`&Value`) instead of a +/// `&ToolResult`, so `cc-types` does not need to depend on the main crate's +/// tool types. +pub(crate) async fn run_post_tool_hooks_data( tool_name: &str, input: &Value, - result: &ToolResult, + tool_result_data: &Value, hook_configs: &[HookEventConfig], ) -> Result { if hook_configs.is_empty() { @@ -34,7 +29,7 @@ pub async fn run_post_tool_hooks( let stdin_json = serde_json::json!({ "tool_name": tool_name, "tool_input": input, - "tool_result": result.data, + "tool_result": tool_result_data, }); for config in hook_configs { @@ -53,7 +48,6 @@ pub async fn run_post_tool_hooks( match execute_command_hook(command, &stdin_json, *timeout).await { Ok(output) => { - // Check for stop if !output.should_continue { let message = output .stop_reason @@ -83,6 +77,28 @@ pub async fn run_post_tool_hooks( Ok(PostToolHookResult::Continue) } +// --------------------------------------------------------------------------- +// Post-tool hooks +// --------------------------------------------------------------------------- + +/// Run post-tool hooks after successful tool execution. +/// +/// Corresponds to TypeScript: `runPostToolUseHooks()` in toolExecution.ts +/// +/// Stdin includes `tool_result` field in addition to tool_name and tool_input. +/// If any hook returns `stop_reason`, returns `StopContinuation`. +pub async fn run_post_tool_hooks( + tool_name: &str, + input: &Value, + result: &ToolResult, + hook_configs: &[HookEventConfig], +) -> Result { + // Delegate to the Value-only variant — keeps the logic in one place and + // lets the HookRunner trait operate without pulling the `ToolResult` type + // into `cc-types`. + run_post_tool_hooks_data(tool_name, input, &result.data, hook_configs).await +} + /// Run post-tool failure hooks after failed tool execution. /// /// Corresponds to TypeScript: `runPostToolUseFailureHooks()` in toolExecution.ts diff --git a/crates/claude-code-rs/src/tools/mod.rs b/crates/claude-code-rs/src/tools/mod.rs index e246fcf6..3f00a7b7 100644 --- a/crates/claude-code-rs/src/tools/mod.rs +++ b/crates/claude-code-rs/src/tools/mod.rs @@ -32,8 +32,6 @@ pub mod registry; pub mod ask_user; pub mod skill; -// Agent sub-domain (already grouped). -pub mod agent; // Background agent types (used by Agent tool + query loop + event loop). pub mod background_agents; diff --git a/crates/claude-code-rs/src/tools/plan_mode.rs b/crates/claude-code-rs/src/tools/plan_mode.rs index ff9c1245..adac5f05 100644 --- a/crates/claude-code-rs/src/tools/plan_mode.rs +++ b/crates/claude-code-rs/src/tools/plan_mode.rs @@ -278,6 +278,8 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } diff --git a/crates/claude-code-rs/src/tools/registry.rs b/crates/claude-code-rs/src/tools/registry.rs index 0fa713b4..0e66532e 100644 --- a/crates/claude-code-rs/src/tools/registry.rs +++ b/crates/claude-code-rs/src/tools/registry.rs @@ -5,7 +5,6 @@ use tracing::warn; use crate::types::tool::Tools; -use super::agent::AgentTool; use super::ask_user::AskUserQuestionTool; use super::brief::BriefTool; use super::config_tool::ConfigTool; @@ -48,7 +47,7 @@ fn base_tools() -> Tools { // Single-tool / small-cluster modules (not yet a sub-domain). tools.extend([ Arc::new(AskUserQuestionTool) as _, - Arc::new(AgentTool) as _, + Arc::new(crate::engine::agent::AgentTool) as _, Arc::new(SkillTool) as _, Arc::new(ConfigTool) as _, Arc::new(StructuredOutputTool) as _, diff --git a/crates/claude-code-rs/src/tools/send_message.rs b/crates/claude-code-rs/src/tools/send_message.rs index 7891ece9..1724eac6 100644 --- a/crates/claude-code-rs/src/tools/send_message.rs +++ b/crates/claude-code-rs/src/tools/send_message.rs @@ -446,6 +446,8 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } } diff --git a/crates/claude-code-rs/src/tools/skill.rs b/crates/claude-code-rs/src/tools/skill.rs index 3d2355e8..24e7c46b 100644 --- a/crates/claude-code-rs/src/tools/skill.rs +++ b/crates/claude-code-rs/src/tools/skill.rs @@ -386,6 +386,10 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: std::sync::Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: std::sync::Arc::new( + cc_types::commands::NoopCommandDispatcher::new(), + ), }; // Missing skill field entirely @@ -468,6 +472,10 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: std::sync::Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: std::sync::Arc::new( + cc_types::commands::NoopCommandDispatcher::new(), + ), }; let result = tool diff --git a/crates/claude-code-rs/src/tools/web_search/tests.rs b/crates/claude-code-rs/src/tools/web_search/tests.rs index effabd0f..24dbb57f 100644 --- a/crates/claude-code-rs/src/tools/web_search/tests.rs +++ b/crates/claude-code-rs/src/tools/web_search/tests.rs @@ -223,6 +223,8 @@ fn make_test_ctx() -> ToolUseContext { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } diff --git a/crates/claude-code-rs/src/tools/web_search/tool.rs b/crates/claude-code-rs/src/tools/web_search/tool.rs index 8c6075a3..3b125dd4 100644 --- a/crates/claude-code-rs/src/tools/web_search/tool.rs +++ b/crates/claude-code-rs/src/tools/web_search/tool.rs @@ -393,6 +393,8 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } diff --git a/crates/claude-code-rs/src/tools/worktree.rs b/crates/claude-code-rs/src/tools/worktree.rs index 64634bd6..8c61ec3a 100644 --- a/crates/claude-code-rs/src/tools/worktree.rs +++ b/crates/claude-code-rs/src/tools/worktree.rs @@ -595,6 +595,8 @@ mod tests { permission_callback: None, ask_user_callback: None, bg_agent_tx: None, + hook_runner: Arc::new(cc_types::hooks::NoopHookRunner::new()), + command_dispatcher: Arc::new(cc_types::commands::NoopCommandDispatcher::new()), } } diff --git a/crates/claude-code-rs/src/types/tool.rs b/crates/claude-code-rs/src/types/tool.rs index 9dbf1204..733e2c0c 100644 --- a/crates/claude-code-rs/src/types/tool.rs +++ b/crates/claude-code-rs/src/types/tool.rs @@ -137,6 +137,13 @@ pub struct ToolUseContext { /// When `Some`, the Agent tool can spawn background tasks. /// When `None`, `run_in_background` falls back to synchronous execution. pub bg_agent_tx: Option, + /// Hook runner used by tools (e.g. the Agent tool fires SubagentStart / + /// SubagentStop events through this trait rather than importing + /// `crate::tools::hooks` directly). + pub hook_runner: Arc, + /// Command dispatcher — propagated to child engines spawned by the Agent + /// tool so they inherit the same slash-command registry. + pub command_dispatcher: Arc, } /// 工具使用选项 (不可变配置) diff --git a/crates/claude-code-rs/src/web/handlers.rs b/crates/claude-code-rs/src/web/handlers.rs index 4041296d..786df93c 100644 --- a/crates/claude-code-rs/src/web/handlers.rs +++ b/crates/claude-code-rs/src/web/handlers.rs @@ -680,6 +680,8 @@ fn rebuild_engine_with_session_id( cfg.initial_messages = seed; let mut engine = QueryEngine::new(cfg); + engine.set_hook_runner(current.hook_runner()); + engine.set_command_dispatcher(current.command_dispatcher()); if let Some(id) = session_id { engine.session_id = SessionId::from_string(id); }