Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/cc-types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
72 changes: 72 additions & 0 deletions crates/cc-types/src/commands.rs
Original file line number Diff line number Diff line change
@@ -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<dyn CommandDispatcher>`.
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<ParsedCommand>;

/// 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<String>;
}

// ---------------------------------------------------------------------------
// 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<ParsedCommand> {
None
}

fn command_name(&self, _index: usize) -> Option<String> {
None
}
}
263 changes: 263 additions & 0 deletions crates/cc-types/src/hooks.rs
Original file line number Diff line number Diff line change
@@ -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<Value>,
/// Permission override from hook.
permission_override: Option<PermissionOverride>,
},
/// 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<String>,
/// List of hook entries to run when this config matches.
pub hooks: Vec<HookEntry>,
}

/// 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<String>,
/// Decision string (e.g., "allow", "deny", "block").
pub decision: Option<String>,
/// Reason for the decision.
pub reason: Option<String>,
/// Permission decision for pre-tool hooks ("allow" or "deny").
pub permission_decision: Option<String>,
/// Modified tool input (pre-tool hooks).
pub updated_input: Option<Value>,
/// Additional context to include in messages.
pub additional_context: Option<String>,
}

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<String, Value>;

/// 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<dyn HookRunner>`.
#[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<HookEventConfig>;

/// 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<PreToolHookResult>;

/// 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<PostToolHookResult>;

/// 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<HookOutput>;
}

// ---------------------------------------------------------------------------
// 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<HookEventConfig> {
Vec::new()
}

async fn run_pre_tool_hooks(
&self,
_tool_name: &str,
_input: &Value,
_hook_configs: &[HookEventConfig],
) -> anyhow::Result<PreToolHookResult> {
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<PostToolHookResult> {
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<HookOutput> {
Ok(HookOutput::default())
}
}
2 changes: 2 additions & 0 deletions crates/cc-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading