Skip to main content

oxicode_sdk/ports/
hooks.rs

1//! Port 16 — HookRunner: user-configurable event→shell-command hooks.
2//!
3//! See spec at `docs/superpowers/specs/2026-08-04-hooks-system-design.md`.
4
5use std::future::Future;
6use std::path::PathBuf;
7use std::pin::Pin;
8
9use serde::{Deserialize, Serialize};
10
11/// Event kinds a hook can subscribe to. Serialised PascalCase to match
12/// Claude Code's `settings.json` schema (and our own `[[hooks]]` config).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "PascalCase")]
15pub enum HookEvent {
16    /// Fires before a tool is executed. Exit 2 (block) prevents the call.
17    #[default]
18    PreToolUse,
19    /// Fires after a tool executes. Can override the result.
20    PostToolUse,
21    /// Fires when the agent is about to stop after a turn. Exit 2 keeps it going.
22    Stop,
23    /// Fires when a subagent (the `subagent` tool) completes.
24    SubagentStop,
25    /// Fires when a session starts.
26    SessionStart,
27    /// Fires when a session ends.
28    SessionEnd,
29    /// Fires on notifications (e.g. permission requests).
30    Notification,
31}
32
33/// Payload passed to a hook. Serialised to JSON on the script's stdin.
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct HookContext {
36    /// Event being fired.
37    pub event: HookEvent,
38    /// Tool name (PreToolUse/PostToolUse/SubagentStop).
39    pub tool_name: Option<String>,
40    /// Tool arguments (PreToolUse). For PostToolUse the input is omitted to
41    /// keep the payload small; consumers that need it can match by `tool_name`.
42    pub tool_args: Option<serde_json::Value>,
43    /// Tool result content (PostToolUse).
44    pub tool_result: Option<String>,
45    /// Whether the result was an error (PostToolUse).
46    pub is_error: Option<bool>,
47    /// Identifier of the owning session.
48    pub session_id: Option<String>,
49    /// CWD of the owning session.
50    pub session_cwd: Option<PathBuf>,
51    /// Escape hatch for future fields without breaking the contract.
52    #[serde(skip_serializing_if = "Option::is_none", default)]
53    pub extra: Option<serde_json::Value>,
54}
55
56/// Outcome of a hook invocation.
57///
58/// `block` corresponds to exit code 2. The semantic of "block" depends on
59/// the event:
60/// - PreToolUse → block the tool call (`BeforeToolCallResult { block: true }`)
61/// - Stop → block the stop (agent continues running)
62/// - Other events → block has no effect (notification only)
63#[derive(Debug, Clone, Default)]
64pub struct HookOutcome {
65    /// Exit code 2 from a script → `true`. See struct doc for semantics.
66    pub block: bool,
67    /// Human-readable reason (maps to `reason` in `BeforeToolCallResult`).
68    pub reason: Option<String>,
69    /// PostToolUse only: override the tool's result content.
70    pub override_content: Option<String>,
71}
72
73/// A user-configured hook spec. Mirrors the `[[hooks]]` config schema.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct HookSpec {
76    /// Event the hook subscribes to.
77    pub event: HookEvent,
78    /// Tool-name glob matcher (e.g. `"bash|write"`). `None` matches all.
79    #[serde(default)]
80    pub matcher: Option<String>,
81    /// Shell command to execute. The runner uses `sh -c "<command>"`.
82    pub command: String,
83    /// Per-invocation timeout in seconds. `None` → runner default (60s).
84    #[serde(default)]
85    pub timeout_secs: Option<u64>,
86}
87
88/// The hook runner contract. SDK defines the trait + a noop fallback;
89/// products (cli, oxios) register concrete implementations.
90pub trait HookRunner: Send + Sync + 'static {
91    /// Run every spec that matches `(event, tool_name)` and merge results.
92    ///
93    /// Implementations are expected to be fail-open: a script that errors,
94    /// times out, or returns a non-zero exit code other than 2 must NOT
95    /// propagate the error as `SdkError` — log and return the merged
96    /// outcome with `block = false` for that script's contribution.
97    fn run<'a>(
98        &'a self,
99        event: HookEvent,
100        ctx: &'a HookContext,
101    ) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>>;
102}
103
104/// Noop runner: never blocks, never overrides. The default for products
105/// that don't opt into hooks.
106#[derive(Debug, Default, Clone, Copy)]
107pub struct NoopHookRunner;
108
109impl HookRunner for NoopHookRunner {
110    fn run<'a>(
111        &'a self,
112        _event: HookEvent,
113        _ctx: &'a HookContext,
114    ) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>> {
115        Box::pin(async { HookOutcome::default() })
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[tokio::test]
124    async fn noop_runner_returns_default_outcome() {
125        let runner = NoopHookRunner;
126        let ctx = HookContext {
127            event: HookEvent::PreToolUse,
128            tool_name: Some("bash".into()),
129            ..Default::default()
130        };
131        let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
132        assert!(!outcome.block);
133        assert!(outcome.reason.is_none());
134        assert!(outcome.override_content.is_none());
135    }
136
137    #[test]
138    fn hook_event_serialises_pascalcase() {
139        let json = serde_json::to_string(&HookEvent::PreToolUse).unwrap();
140        assert_eq!(json, "\"PreToolUse\"");
141        let json = serde_json::to_string(&HookEvent::SessionStart).unwrap();
142        assert_eq!(json, "\"SessionStart\"");
143        // Round-trip
144        let parsed: HookEvent = serde_json::from_str("\"SubagentStop\"").unwrap();
145        assert_eq!(parsed, HookEvent::SubagentStop);
146    }
147
148    #[test]
149    fn hook_context_serialises_with_extras() {
150        let ctx = HookContext {
151            event: HookEvent::PreToolUse,
152            tool_name: Some("bash".into()),
153            tool_args: Some(serde_json::json!({"command": "ls"})),
154            ..Default::default()
155        };
156        let json = serde_json::to_value(&ctx).unwrap();
157        assert_eq!(json["event"], "PreToolUse");
158        assert_eq!(json["tool_name"], "bash");
159        assert_eq!(json["tool_args"]["command"], "ls");
160        // `extra` is None so should be absent
161        assert!(json.get("extra").is_none());
162    }
163
164    #[test]
165    fn hook_spec_minimal_parses() {
166        let toml = r#"
167            event = "PreToolUse"
168            command = "echo hi"
169        "#;
170        let spec: HookSpec = toml::from_str(toml).unwrap();
171        assert_eq!(spec.event, HookEvent::PreToolUse);
172        assert_eq!(spec.command, "echo hi");
173        assert!(spec.matcher.is_none());
174        assert!(spec.timeout_secs.is_none());
175    }
176}