Skip to main content

coding_agent_hooks/
input.rs

1//! Hook input types — normalized representations of what agents send via stdin.
2//!
3//! These types are deserialized from JSON and represent the agent's hook event.
4//! Agent-specific protocol adapters normalize native JSON into these types via
5//! the [`HookProtocol`](crate::protocol::HookProtocol) trait.
6
7use std::io::Read;
8
9use serde::Deserialize;
10use tracing::{Level, instrument};
11
12use crate::AgentKind;
13
14/// The complete hook input received from an agent via stdin.
15#[derive(Debug, Clone, Deserialize)]
16#[serde(untagged)]
17pub enum HookInput {
18    /// PreToolUse, PostToolUse, PermissionRequest events
19    ToolUse(ToolUseHookInput),
20    /// SessionStart events
21    SessionStart(SessionStartHookInput),
22}
23
24/// Hook input for tool-related events (PreToolUse, PostToolUse, PermissionRequest).
25///
26/// The `tool_name` field carries the internal (Claude-style) name after protocol
27/// normalization. The original agent-native name is preserved in `original_tool_name`.
28#[derive(Debug, Clone, Deserialize, Default)]
29pub struct ToolUseHookInput {
30    pub session_id: String,
31    pub transcript_path: String,
32    pub cwd: String,
33    pub permission_mode: String,
34    pub hook_event_name: String,
35    pub tool_name: String,
36    pub tool_input: serde_json::Value,
37    pub tool_use_id: Option<String>,
38    /// Present in PostToolUse events
39    #[serde(default)]
40    pub tool_response: Option<serde_json::Value>,
41
42    // -- Multi-agent fields (not deserialized from JSON, set by protocol layer) --
43    /// Which agent sent this hook input.
44    #[serde(skip)]
45    pub agent: Option<AgentKind>,
46    /// The agent's original tool name before normalization (e.g. "run_shell_command").
47    /// For Claude, this is the same as `tool_name`.
48    #[serde(skip)]
49    pub original_tool_name: Option<String>,
50}
51
52/// Hook input for SessionStart events.
53#[derive(Debug, Clone, Deserialize, Default)]
54pub struct SessionStartHookInput {
55    #[serde(default)]
56    pub session_id: String,
57    #[serde(default)]
58    pub transcript_path: String,
59    #[serde(default)]
60    pub cwd: String,
61    #[serde(default)]
62    pub permission_mode: Option<String>,
63    #[serde(default)]
64    pub hook_event_name: String,
65    #[serde(default)]
66    pub source: Option<String>,
67    #[serde(default)]
68    pub model: Option<String>,
69}
70
71impl SessionStartHookInput {
72    /// Parse from any reader (for testability).
73    #[instrument(level = Level::TRACE, skip(reader))]
74    pub fn from_reader(reader: impl Read) -> anyhow::Result<Self> {
75        Ok(serde_json::from_reader(reader)?)
76    }
77}
78
79/// Hook input for Stop events (conversation turn ended without a tool call).
80#[derive(Debug, Clone, Deserialize, Default)]
81pub struct StopHookInput {
82    #[serde(default)]
83    pub session_id: String,
84    #[serde(default)]
85    pub transcript_path: String,
86    #[serde(default)]
87    pub cwd: String,
88    #[serde(default)]
89    pub hook_event_name: String,
90}
91
92impl StopHookInput {
93    /// Parse from any reader (for testability).
94    #[instrument(level = Level::TRACE, skip(reader))]
95    pub fn from_reader(reader: impl Read) -> anyhow::Result<Self> {
96        Ok(serde_json::from_reader(reader)?)
97    }
98}
99
100impl HookInput {
101    /// Parse from any reader (for testability).
102    #[instrument(level = Level::TRACE, skip(reader))]
103    pub fn from_reader(reader: impl Read) -> anyhow::Result<Self> {
104        Ok(serde_json::from_reader(reader)?)
105    }
106
107    /// Parse from stdin (convenience wrapper for production).
108    #[instrument(level = Level::TRACE)]
109    pub fn from_stdin() -> anyhow::Result<Self> {
110        Self::from_reader(std::io::stdin().lock())
111    }
112
113    /// Get the hook event name.
114    pub fn hook_event_name(&self) -> &str {
115        match self {
116            HookInput::ToolUse(input) => &input.hook_event_name,
117            HookInput::SessionStart(input) => &input.hook_event_name,
118        }
119    }
120
121    /// Get the session ID.
122    pub fn session_id(&self) -> &str {
123        match self {
124            HookInput::ToolUse(input) => &input.session_id,
125            HookInput::SessionStart(input) => &input.session_id,
126        }
127    }
128
129    /// Check if this is a tool use event.
130    pub fn as_tool_use(&self) -> Option<&ToolUseHookInput> {
131        match self {
132            HookInput::ToolUse(input) => Some(input),
133            _ => None,
134        }
135    }
136
137    /// Check if this is a session start event.
138    pub fn as_session_start(&self) -> Option<&SessionStartHookInput> {
139        match self {
140            HookInput::SessionStart(input) => Some(input),
141            _ => None,
142        }
143    }
144}
145
146impl ToolUseHookInput {
147    /// Parse from any reader (for testability).
148    #[instrument(level = Level::TRACE, skip(reader))]
149    pub fn from_reader(reader: impl Read) -> anyhow::Result<Self> {
150        Ok(serde_json::from_reader(reader)?)
151    }
152}
153
154/// Exit codes for hook responses.
155pub mod exit_code {
156    /// Success - response written to stdout.
157    pub const SUCCESS: i32 = 0;
158    /// Blocking error - stderr message fed to agent.
159    pub const BLOCKING_ERROR: i32 = 2;
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn sample_tool_use_json() -> &'static str {
167        r#"{
168            "session_id": "test-session",
169            "transcript_path": "/tmp/transcript.jsonl",
170            "cwd": "/home/user/project",
171            "permission_mode": "default",
172            "hook_event_name": "PreToolUse",
173            "tool_name": "Bash",
174            "tool_input": {"command": "git status", "timeout": 120000},
175            "tool_use_id": "toolu_01ABC"
176        }"#
177    }
178
179    #[test]
180    fn test_parse_tool_use_input() {
181        let input = HookInput::from_reader(sample_tool_use_json().as_bytes()).unwrap();
182        assert_eq!(input.session_id(), "test-session");
183        assert_eq!(input.hook_event_name(), "PreToolUse");
184
185        let tool_use = input.as_tool_use().expect("Should be ToolUse variant");
186        assert_eq!(tool_use.tool_name, "Bash");
187    }
188}