Skip to main content

difflore_cli/hooks/
claude_code.rs

1//! Claude Code hook adapter.
2//!
3//! Claude Code (the official Anthropic CLI) invokes hooks with a JSON
4//! object on stdin that looks like:
5//!
6//! ```json
7//! {
8//!   "session_id": "...",
9//!   "cwd": "/abs/path/to/repo",
10//!   "hook_event_name": "PostToolUse",
11//!   "tool_name": "Edit",
12//!   "tool_input": { "file_path": "src/foo.rs", "old_string": "...", "new_string": "..." },
13//!   "tool_response": { ... },
14//!   "transcript_path": "/abs/path/to/session.jsonl"
15//! }
16//! ```
17//!
18//! Not every field is present on every event — `SessionStart` for
19//! instance only carries `session_id` + `cwd`. The adapter is
20//! permissive on absent fields (returns `None` / falls through) and
21//! strict only on the ones it needs for a given event kind.
22//!
23//! The output shape Claude Code expects is:
24//!
25//! ```json
26//! {
27//!   "continue": true,
28//!   "systemMessage": "optional short string",
29//!   "hookSpecificOutput": { "additionalContext": "optional long string" }
30//! }
31//! ```
32//!
33//! We camelCase the JSON keys on output (matching Claude Code's
34//! convention) while keeping our internal `HookResult` `snake_case`.
35
36use serde::{Deserialize, Serialize};
37use serde_json::{Value, json};
38
39use super::synth;
40use super::types::{HookEvent, HookResult};
41use super::{PayloadAdapter, PlatformAdapter};
42
43/// Zero-sized marker type. The adapter holds no state — every
44/// invocation is a pure stdin-in, stdout-out transformation.
45pub struct ClaudeCodeAdapter;
46
47/// Typed view of Claude Code's hook stdin payload. Everything except
48/// `hook_event_name` is optional because Claude Code sends different
49/// subsets of fields per event. We keep the parse permissive so a new
50/// hook event in a future Claude Code release doesn't break `DiffLore` —
51/// it just lands in `Err(...)` from `to_canonical` and the CLI no-ops.
52#[derive(Debug, Clone, Deserialize, Serialize)]
53#[serde(rename_all = "snake_case")]
54pub(crate) struct ClaudeHookPayload {
55    #[serde(default)]
56    hook_event_name: Option<String>,
57    #[serde(default)]
58    session_id: Option<String>,
59    #[serde(default)]
60    cwd: Option<String>,
61    #[serde(default)]
62    tool_name: Option<String>,
63    #[serde(default)]
64    tool_input: Option<Value>,
65    #[serde(default)]
66    tool_response: Option<Value>,
67    #[serde(default)]
68    transcript_path: Option<String>,
69    /// `UserPromptSubmit` carries the prompt under this key.
70    #[serde(default)]
71    prompt: Option<String>,
72}
73
74impl ClaudeHookPayload {
75    /// Map the parsed payload into our canonical `HookEvent`. Unknown
76    /// event names return `Err` so the CLI can log them — missing
77    /// event names also error (they indicate a malformed stdin, not
78    /// a new-event-we-don't-know-about).
79    fn into_canonical(self) -> Result<HookEvent, String> {
80        let event_name = self
81            .hook_event_name
82            .as_deref()
83            .ok_or_else(|| "missing hook_event_name".to_owned())?;
84        match event_name {
85            "PreToolUse" => {
86                // Only Read is interesting for rule pre-injection. Other
87                // PreToolUse matches (Bash, Write, …) aren't wired — we
88                // fall through to an Err so the CLI logs + no-ops, keeping
89                // the hook advisory rather than a blocking enforcement path.
90                let tool_name = self.tool_name.clone().unwrap_or_default();
91                if tool_name != "Read" {
92                    return Err(format!(
93                        "PreToolUse for `{tool_name}` not wired — Read only",
94                    ));
95                }
96                let file_path = self
97                    .tool_input
98                    .as_ref()
99                    .and_then(|v| v.get("file_path"))
100                    .and_then(|v| v.as_str())
101                    .map(String::from)
102                    .ok_or_else(|| "PreToolUse:Read missing tool_input.file_path".to_owned())?;
103                Ok(HookEvent::PreToolUseRead {
104                    file_path,
105                    session_id: self.session_id.clone(),
106                })
107            }
108            "PostToolUse" => {
109                let tool_name = self.tool_name.clone().unwrap_or_default();
110                // Claude Code nests the edited path under
111                // `tool_input.file_path` for Edit/Write, which is the
112                // only shape we act on today. Everything else (Bash,
113                // Read, …) flows through with `file_path = None` so
114                // upstream logic can cheaply decide to ignore it.
115                let file_path = self
116                    .tool_input
117                    .as_ref()
118                    .and_then(|v| v.get("file_path"))
119                    .and_then(|v| v.as_str())
120                    .map(String::from);
121                let diff = synthesise_diff(self.tool_input.as_ref(), self.tool_response.as_ref());
122                let (old_text, new_text) = synth::extract_edit_strings(self.tool_input.as_ref());
123                Ok(HookEvent::PostToolUse {
124                    tool_name,
125                    file_path,
126                    diff,
127                    session_id: self.session_id.clone(),
128                    new_text,
129                    old_text,
130                })
131            }
132            "SessionStart" => Ok(HookEvent::SessionStart {
133                cwd: self.cwd.unwrap_or_default(),
134                session_id: self.session_id.clone(),
135            }),
136            "UserPromptSubmit" => Ok(HookEvent::UserPromptSubmit {
137                prompt: self.prompt.unwrap_or_default(),
138                session_id: self.session_id.clone(),
139            }),
140            "Stop" => Ok(HookEvent::Stop {
141                session_id: self.session_id.clone(),
142                transcript_path: self.transcript_path.clone(),
143                cwd: self.cwd.clone(),
144            }),
145            "SessionEnd" => Ok(HookEvent::SessionEnd {
146                session_id: self.session_id.clone(),
147                transcript_path: self.transcript_path.clone(),
148                cwd: self.cwd.clone(),
149            }),
150            other => Err(format!("unsupported Claude Code hook event: {other}")),
151        }
152    }
153}
154
155/// Best-effort diff synthesis from Claude Code's tool payloads.
156///
157/// Claude Code does NOT hand us a unified diff — it gives us the raw
158/// input/output of the tool call. For `Edit` events `tool_input` carries
159/// `old_string` / `new_string`; for `Write`, just `content`. Line-prefix
160/// mechanics live in `synth::diff_old_new` / `synth::diff_content`.
161fn synthesise_diff(tool_input: Option<&Value>, _tool_response: Option<&Value>) -> Option<String> {
162    let input = tool_input?;
163    if let (Some(old), Some(new)) = (
164        input.get("old_string").and_then(|v| v.as_str()),
165        input.get("new_string").and_then(|v| v.as_str()),
166    ) {
167        return Some(synth::diff_old_new(old, new));
168    }
169    if let Some(content) = input.get("content").and_then(|v| v.as_str()) {
170        return Some(synth::diff_content(content));
171    }
172    None
173}
174
175impl PayloadAdapter for ClaudeCodeAdapter {
176    type Raw = ClaudeHookPayload;
177    const PARSE_LABEL: &'static str = "Claude Code";
178
179    fn into_canonical(raw: Self::Raw) -> Result<HookEvent, String> {
180        raw.into_canonical()
181    }
182}
183
184impl PlatformAdapter for ClaudeCodeAdapter {
185    fn name(&self) -> &'static str {
186        "claude-code"
187    }
188
189    fn parse_stdin(&self, raw: &str) -> Result<HookEvent, String> {
190        Self::parse_stdin_default(raw)
191    }
192
193    fn format_output(&self, result: HookResult) -> String {
194        // Claude Code uses camelCase keys at the top level. The
195        // `hookSpecificOutput` object is where "advisory context for
196        // the next turn" lives. Claude Code validates that
197        // `hookEventName` matches the event that fired the hook — a
198        // mismatch causes it to drop the entire injection with
199        // "Hook returned incorrect event name". Echo the dispatcher's
200        // event name when the caller threaded one through; fall back to
201        // `PostToolUse` for legacy callers that didn't.
202        let mut obj = json!({
203            "continue": result.continue_,
204        });
205        if let Some(msg) = result.system_message {
206            obj["systemMessage"] = Value::String(msg);
207        }
208        if let Some(ctx) = result.additional_context {
209            let event_name = result.event_name.as_deref().unwrap_or("PostToolUse");
210            obj["hookSpecificOutput"] = json!({
211                "hookEventName": event_name,
212                "additionalContext": ctx,
213            });
214        }
215        crate::commands::util::json_compact_or(&obj, "{\"continue\":true}")
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn parse_post_tool_use_edit_extracts_file_path_and_diff() {
225        // The happy path: Claude Code fires PostToolUse after an Edit
226        // that mutated `src/foo.rs`. We MUST pull the file path out of
227        // `tool_input.file_path` and synthesise a diff — this is the
228        // signal the rule retriever uses to scope its cascade.
229        let adapter = ClaudeCodeAdapter;
230        let raw = r#"{
231            "hook_event_name": "PostToolUse",
232            "session_id": "abc",
233            "cwd": "/home/user/proj",
234            "tool_name": "Edit",
235            "tool_input": {
236                "file_path": "src/foo.rs",
237                "old_string": "let x = 1;",
238                "new_string": "let x = 2;"
239            },
240            "tool_response": {}
241        }"#;
242        let event = adapter.parse_stdin(raw).expect("parse ok");
243        match event {
244            HookEvent::PostToolUse {
245                tool_name,
246                file_path,
247                diff,
248                ..
249            } => {
250                assert_eq!(tool_name, "Edit");
251                assert_eq!(file_path.as_deref(), Some("src/foo.rs"));
252                let diff = diff.expect("Edit events always carry a synthesised diff");
253                assert!(
254                    diff.contains("-let x = 1;"),
255                    "diff missing old line: {diff}"
256                );
257                assert!(
258                    diff.contains("+let x = 2;"),
259                    "diff missing new line: {diff}"
260                );
261            }
262            other => panic!("expected PostToolUse, got {other:?}"),
263        }
264    }
265
266    #[test]
267    fn parse_write_event_synthesises_diff_from_content() {
268        // Write events carry `content` instead of old/new — the
269        // synthesiser should emit a `+`-prefixed block so the
270        // retriever has something to match against.
271        let adapter = ClaudeCodeAdapter;
272        let raw = r#"{
273            "hook_event_name": "PostToolUse",
274            "tool_name": "Write",
275            "tool_input": {
276                "file_path": "new.rs",
277                "content": "fn main() {}\n"
278            }
279        }"#;
280        let event = adapter.parse_stdin(raw).expect("parse ok");
281        if let HookEvent::PostToolUse { diff, .. } = event {
282            let diff = diff.expect("Write must synthesise a diff");
283            assert!(diff.contains("+fn main() {}"), "got: {diff}");
284        } else {
285            panic!("expected PostToolUse");
286        }
287    }
288
289    #[test]
290    fn parse_unsupported_event_errors_without_panicking() {
291        // Future-proofing: if Claude Code adds a hook event we don't
292        // yet model, the adapter must return `Err` (so the CLI logs
293        // + no-ops) rather than panic and take the assistant down.
294        let adapter = ClaudeCodeAdapter;
295        let raw = r#"{"hook_event_name":"SomeFutureEventWeHaventHeardOf"}"#;
296        let err = adapter.parse_stdin(raw).unwrap_err();
297        assert!(err.contains("unsupported"), "got: {err}");
298    }
299
300    #[test]
301    fn parse_missing_event_name_errors() {
302        // A stdin payload without `hook_event_name` is structurally
303        // invalid — we must reject, not assume a default.
304        let adapter = ClaudeCodeAdapter;
305        let raw = r#"{"session_id":"abc"}"#;
306        let err = adapter.parse_stdin(raw).unwrap_err();
307        assert!(err.contains("missing"), "got: {err}");
308    }
309
310    #[test]
311    fn format_output_noop_emits_continue_true_only() {
312        // The empty-result case — no context, no message. The stdout
313        // JSON must be minimal but structurally valid so Claude Code
314        // doesn't render a spurious empty system message.
315        let adapter = ClaudeCodeAdapter;
316        let out = adapter.format_output(HookResult::noop());
317        let v: Value = serde_json::from_str(&out).unwrap();
318        assert_eq!(v["continue"], true);
319        assert!(v.get("systemMessage").is_none());
320        assert!(v.get("hookSpecificOutput").is_none());
321    }
322
323    #[test]
324    fn format_output_with_context_nests_additional_context() {
325        // The context-injection case. Claude Code expects the extra
326        // context inside `hookSpecificOutput.additionalContext` — not
327        // at the top level.
328        let adapter = ClaudeCodeAdapter;
329        let out = adapter.format_output(HookResult::with_context("Rule 1: X"));
330        let v: Value = serde_json::from_str(&out).unwrap();
331        assert_eq!(v["continue"], true);
332        assert_eq!(v["hookSpecificOutput"]["additionalContext"], "Rule 1: X");
333    }
334
335    #[test]
336    fn format_output_echoes_event_name_so_pretooluse_injection_lands() {
337        // Regression for "Hook returned incorrect event name" — Claude
338        // Code drops the entire injection if `hookEventName` doesn't
339        // match the event that fired the hook. PreToolUse injections
340        // were being labelled `PostToolUse`, so the rule context never
341        // reached the agent before its first edit.
342        let adapter = ClaudeCodeAdapter;
343        let mut r = HookResult::with_context("Rule 1: cap log volume");
344        r.event_name = Some("PreToolUse".into());
345        let out = adapter.format_output(r);
346        let v: Value = serde_json::from_str(&out).unwrap();
347        assert_eq!(
348            v["hookSpecificOutput"]["hookEventName"], "PreToolUse",
349            "PreToolUse responses must echo the firing event name, not the legacy PostToolUse default; got: {out}"
350        );
351
352        // Backwards-compat: when the dispatcher didn't thread an event
353        // name through (older callers, tests, etc.), keep the
354        // PostToolUse default so existing flows keep working.
355        let r2 = HookResult::with_context("legacy");
356        let v2: Value = serde_json::from_str(&adapter.format_output(r2)).unwrap();
357        assert_eq!(v2["hookSpecificOutput"]["hookEventName"], "PostToolUse");
358    }
359}