Skip to main content

coding_agent_hooks/agents/
claude.rs

1//! Claude Code hook protocol implementation.
2//!
3//! Claude Code has a unique output format using `HookOutput` structs with
4//! `permissionDecision`, `hookSpecificOutput`, etc. This requires custom
5//! format methods — the defaults don't apply here.
6
7use anyhow::Result;
8use serde_json::Value;
9
10use super::{AgentKind, resolve_permission_mode, resolve_tool_name};
11use crate::input::{SessionStartHookInput, StopHookInput, ToolUseHookInput};
12use crate::output::HookOutput;
13use crate::protocol::HookProtocol;
14
15pub struct ClaudeProtocol;
16
17impl HookProtocol for ClaudeProtocol {
18    fn agent(&self) -> AgentKind {
19        AgentKind::Claude
20    }
21
22    fn parse_tool_use(&self, raw: &Value) -> Result<ToolUseHookInput> {
23        let mut input: ToolUseHookInput = serde_json::from_value(raw.clone())?;
24        let original = input.tool_name.clone();
25        input.tool_name = resolve_tool_name(AgentKind::Claude, &original).to_string();
26        input.original_tool_name = Some(original);
27        input.agent = Some(AgentKind::Claude);
28        input.permission_mode =
29            resolve_permission_mode(AgentKind::Claude, &input.permission_mode).to_string();
30        Ok(input)
31    }
32
33    fn parse_session_start(&self, raw: &Value) -> Result<SessionStartHookInput> {
34        let mut input: SessionStartHookInput = serde_json::from_value(raw.clone())?;
35        if let Some(mode) = &input.permission_mode {
36            input.permission_mode =
37                Some(resolve_permission_mode(AgentKind::Claude, mode).to_string());
38        }
39        Ok(input)
40    }
41
42    fn parse_stop(&self, raw: &Value) -> Result<StopHookInput> {
43        Ok(serde_json::from_value(raw.clone())?)
44    }
45
46    // Claude has a unique output format — must override all format methods.
47
48    fn format_allow(
49        &self,
50        reason: Option<&str>,
51        context: Option<&str>,
52        updated_input: Option<Value>,
53    ) -> Value {
54        let mut output = HookOutput::allow(reason.map(String::from), context.map(String::from));
55        if let Some(ui) = updated_input {
56            output.set_updated_input(ui);
57        }
58        serde_json::to_value(output).expect("HookOutput serialization cannot fail")
59    }
60
61    fn format_deny(&self, reason: &str, context: Option<&str>) -> Value {
62        let output = HookOutput::deny(reason.to_string(), context.map(String::from));
63        serde_json::to_value(output).expect("HookOutput serialization cannot fail")
64    }
65
66    fn format_ask(&self, reason: Option<&str>, context: Option<&str>) -> Value {
67        let output = HookOutput::ask(reason.map(String::from), context.map(String::from));
68        serde_json::to_value(output).expect("HookOutput serialization cannot fail")
69    }
70
71    fn format_session_start(&self, context: Option<&str>) -> Value {
72        let output = HookOutput::session_start(context.map(String::from));
73        serde_json::to_value(output).expect("HookOutput serialization cannot fail")
74    }
75
76    // parse_post_tool_use — uses default (delegates to parse_tool_use)
77    // rewrite_for_sandbox — uses default (rewrites "Bash" command field)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn parse_tool_use_normalizes_name() {
86        let raw = serde_json::json!({
87            "session_id": "test",
88            "transcript_path": "/tmp/t.jsonl",
89            "cwd": "/tmp",
90            "permission_mode": "default",
91            "hook_event_name": "PreToolUse",
92            "tool_name": "Bash",
93            "tool_input": {"command": "ls"},
94            "tool_use_id": "toolu_01"
95        });
96        let input = ClaudeProtocol.parse_tool_use(&raw).unwrap();
97        assert_eq!(input.tool_name, "Bash");
98        assert_eq!(input.original_tool_name.as_deref(), Some("Bash"));
99        assert_eq!(input.agent, Some(AgentKind::Claude));
100    }
101
102    #[test]
103    fn format_allow_matches_existing_format() {
104        let output = ClaudeProtocol.format_allow(Some("safe"), None, None);
105        assert_eq!(output["continue"], true);
106        assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "allow");
107    }
108
109    #[test]
110    fn format_deny_matches_existing_format() {
111        let output = ClaudeProtocol.format_deny("blocked", Some("context"));
112        assert_eq!(output["continue"], true);
113        assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "deny");
114    }
115
116    #[test]
117    fn format_ask_matches_existing_format() {
118        let output = ClaudeProtocol.format_ask(None, None);
119        assert_eq!(output["continue"], true);
120        assert_eq!(output["hookSpecificOutput"]["permissionDecision"], "ask");
121    }
122
123    #[test]
124    fn rewrite_for_sandbox_uses_default() {
125        let input = ToolUseHookInput {
126            tool_name: "Bash".into(),
127            tool_input: serde_json::json!({"command": "ls -la"}),
128            cwd: "/home/user".into(),
129            ..Default::default()
130        };
131        let result = ClaudeProtocol
132            .rewrite_for_sandbox(&input, "/usr/bin/clash")
133            .unwrap();
134        assert!(result["command"].as_str().unwrap().contains("clash"));
135    }
136}