Skip to main content

difflore_cli/hooks/
cursor.rs

1//! Cursor hook adapter.
2//!
3//! Cursor IDE invokes hooks with a JSON object on stdin whose shape
4//! differs from Claude Code's in three important ways:
5//!
6//!   1. The event discriminator is `hook_event_name` with *camelCase*
7//!      values (`afterFileEdit`, `afterShellExecution`, …) rather than
8//!      Claude's `PostToolUse` naming.
9//!   2. Cursor exposes a `workspace_roots` array instead of a single
10//!      `cwd` string. We use `workspace_roots[0]` as the effective CWD.
11//!   3. Shell-command events come as `command`/`output` pairs rather
12//!      than `tool_input`/`tool_response`. We synthesise a `Bash`-shaped
13//!      tool call from those so the downstream rule retriever sees a
14//!      uniform `PostToolUse` shape regardless of origin.
15//!
16//! Example stdin (from claude-mem's cursor adapter reference):
17//!
18//! ```json
19//! {
20//!   "conversation_id": "...",
21//!   "workspace_roots": ["/path/to/repo"],
22//!   "hook_event_name": "afterFileEdit",
23//!   "tool_name": "Edit",
24//!   "tool_input": { "file_path": "src/foo.rs", "edits": [...] },
25//!   "result_json": { ... }
26//! }
27//! ```
28//!
29//! Cursor's output expectation is minimal — it honours a single
30//! `{ "continue": true|false }` flag. For advisory context we include
31//! a `context` string alongside so Cursor's newer builds can surface
32//! it; older Cursor builds just ignore the extra field.
33
34use serde::{Deserialize, Serialize};
35use serde_json::{Value, json};
36
37use super::synth;
38use super::types::{HookEvent, HookResult};
39use super::{PayloadAdapter, PlatformAdapter};
40
41/// Zero-sized marker — no adapter-local state.
42pub struct CursorAdapter;
43
44/// Typed view of Cursor's hook stdin payload. Everything is optional:
45/// Cursor ships different subsets of fields per event, and we'd rather
46/// silently no-op on a missing field than reject a structurally-valid
47/// payload that just doesn't carry what we need.
48#[derive(Debug, Clone, Deserialize, Serialize, Default)]
49#[serde(rename_all = "snake_case")]
50pub(crate) struct CursorHookPayload {
51    #[serde(default)]
52    hook_event_name: Option<String>,
53    #[serde(default)]
54    conversation_id: Option<String>,
55    #[serde(default)]
56    workspace_roots: Option<Vec<String>>,
57    #[serde(default)]
58    cwd: Option<String>,
59    #[serde(default)]
60    tool_name: Option<String>,
61    #[serde(default)]
62    tool_input: Option<Value>,
63    /// Cursor's analogue of `tool_response`. Named differently from
64    /// Claude Code's schema — we keep the Cursor spelling here.
65    #[serde(default)]
66    result_json: Option<Value>,
67    /// Shell-command events ship these two in place of `tool_input` /
68    /// `tool_response`. We fold them into a synthetic Bash tool call.
69    #[serde(default)]
70    command: Option<String>,
71    #[serde(default)]
72    output: Option<String>,
73    /// `beforeSubmitPrompt` payloads may carry the prompt under any of
74    /// several keys depending on Cursor version. We probe all of them.
75    #[serde(default)]
76    prompt: Option<String>,
77    #[serde(default)]
78    query: Option<String>,
79    #[serde(default)]
80    input: Option<String>,
81    #[serde(default)]
82    message: Option<String>,
83    /// Some Cursor events ship the edited path at the top level rather
84    /// than nested under `tool_input`.
85    #[serde(default)]
86    file_path: Option<String>,
87}
88
89impl CursorHookPayload {
90    /// Map the parsed Cursor payload into our canonical `HookEvent`.
91    /// See module docs for the event-name mapping.
92    fn into_canonical(self) -> Result<HookEvent, String> {
93        let event_name = self
94            .hook_event_name
95            .as_deref()
96            .ok_or_else(|| "missing hook_event_name".to_owned())?;
97        match event_name {
98            "afterFileEdit" => Ok(post_tool_use_for_file_edit(self)),
99            "afterMCPExecution" => Ok(HookEvent::PostToolUse {
100                tool_name: "afterMCPExecution".to_owned(),
101                file_path: None,
102                diff: None,
103                session_id: None,
104                new_text: None,
105                old_text: None,
106            }),
107            "afterShellExecution" => Ok(HookEvent::PostToolUse {
108                // Synthesise a Bash-shaped entry so downstream logic can
109                // uniformly recognise shell activity across clients.
110                tool_name: "Bash".to_owned(),
111                file_path: None,
112                diff: synth::diff_shell(self.command.as_deref(), self.output.as_deref()),
113                session_id: None,
114                new_text: None,
115                old_text: None,
116            }),
117            "beforeSubmitPrompt" => {
118                let prompt = self
119                    .prompt
120                    .or(self.query)
121                    .or(self.input)
122                    .or(self.message)
123                    .unwrap_or_default();
124                Ok(HookEvent::UserPromptSubmit {
125                    prompt,
126                    session_id: None,
127                })
128            }
129            "stop" => Ok(HookEvent::Stop {
130                session_id: None,
131                transcript_path: None,
132                cwd: None,
133            }),
134            other => Err(format!("unsupported Cursor hook event: {other}")),
135        }
136    }
137}
138
139/// Extract a `PostToolUse` for Cursor's `afterFileEdit`.
140///
141/// Cursor's payload shapes vary across releases — the file path may
142/// live at the top level, under `tool_input.file_path`, or under
143/// `tool_input.path`. We probe all three so hooks keep working across
144/// Cursor updates without waiting for a `DiffLore` release.
145fn post_tool_use_for_file_edit(p: CursorHookPayload) -> HookEvent {
146    let file_path = p.file_path.clone().or_else(|| {
147        p.tool_input
148            .as_ref()
149            .and_then(|v| v.get("file_path").or_else(|| v.get("path")))
150            .and_then(|v| v.as_str())
151            .map(String::from)
152    });
153    let diff = synthesise_edit_diff(p.tool_input.as_ref());
154    let (old_text, new_text) = synth::extract_edit_strings(p.tool_input.as_ref());
155    HookEvent::PostToolUse {
156        tool_name: p.tool_name.unwrap_or_else(|| "Edit".to_owned()),
157        file_path,
158        diff,
159        session_id: None,
160        new_text,
161        old_text,
162    }
163}
164
165/// Synthesise a text diff from Cursor's edit payload.
166///
167/// Cursor ships edits in a few shapes across versions:
168///   - `{ "edits": [{ "old_string", "new_string" }, ...] }` (array)
169///   - `{ "old_string": "...", "new_string": "..." }` (flat)
170///   - `{ "content": "..." }` (whole-file write)
171///
172/// Line-prefix mechanics live in `synth`.
173fn synthesise_edit_diff(tool_input: Option<&Value>) -> Option<String> {
174    let input = tool_input?;
175    if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
176        let mut out = String::new();
177        for edit in edits {
178            if let (Some(old), Some(new)) = (
179                edit.get("old_string").and_then(|v| v.as_str()),
180                edit.get("new_string").and_then(|v| v.as_str()),
181            ) {
182                synth::append_old_new(&mut out, old, new);
183            }
184        }
185        if !out.is_empty() {
186            return Some(out);
187        }
188    }
189    if let (Some(old), Some(new)) = (
190        input.get("old_string").and_then(|v| v.as_str()),
191        input.get("new_string").and_then(|v| v.as_str()),
192    ) {
193        return Some(synth::diff_old_new(old, new));
194    }
195    if let Some(content) = input.get("content").and_then(|v| v.as_str()) {
196        return Some(synth::diff_content(content));
197    }
198    None
199}
200
201impl PayloadAdapter for CursorAdapter {
202    type Raw = CursorHookPayload;
203    const PARSE_LABEL: &'static str = "Cursor";
204
205    fn into_canonical(raw: Self::Raw) -> Result<HookEvent, String> {
206        raw.into_canonical()
207    }
208}
209
210impl PlatformAdapter for CursorAdapter {
211    fn name(&self) -> &'static str {
212        "cursor"
213    }
214
215    fn parse_stdin(&self, raw: &str) -> Result<HookEvent, String> {
216        Self::parse_stdin_default(raw)
217    }
218
219    fn format_output(&self, result: HookResult) -> String {
220        // Cursor's minimum contract is `{ "continue": bool }`. Newer
221        // builds additionally pick up a `context` string for advisory
222        // injection; older builds ignore it, so we include it whenever
223        // we have one without version-sniffing Cursor.
224        let mut obj = json!({
225            "continue": result.continue_,
226        });
227        if let Some(ctx) = result.additional_context {
228            obj["context"] = Value::String(ctx);
229        }
230        if let Some(msg) = result.system_message {
231            obj["systemMessage"] = Value::String(msg);
232        }
233        crate::commands::util::json_compact_or(&obj, "{\"continue\":true}")
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn parse_after_file_edit_flat_form() {
243        // Old Cursor shape: old_string/new_string at top of tool_input.
244        let adapter = CursorAdapter;
245        let raw = r#"{
246            "hook_event_name": "afterFileEdit",
247            "workspace_roots": ["/tmp/proj"],
248            "tool_name": "Edit",
249            "tool_input": {
250                "file_path": "src/foo.rs",
251                "old_string": "let x = 1;",
252                "new_string": "let x = 2;"
253            }
254        }"#;
255        let event = adapter.parse_stdin(raw).expect("parse ok");
256        match event {
257            HookEvent::PostToolUse {
258                tool_name,
259                file_path,
260                diff,
261                ..
262            } => {
263                assert_eq!(tool_name, "Edit");
264                assert_eq!(file_path.as_deref(), Some("src/foo.rs"));
265                let d = diff.expect("diff synthesised");
266                assert!(d.contains("-let x = 1;"));
267                assert!(d.contains("+let x = 2;"));
268            }
269            other => panic!("expected PostToolUse, got {other:?}"),
270        }
271    }
272
273    #[test]
274    fn parse_after_file_edit_array_form_with_edits() {
275        // Newer Cursor packs edits as an array; each entry has its own
276        // old/new pair. We must collect all of them into one synthesised
277        // diff so a single Edit call with N hunks isn't silently dropped.
278        let adapter = CursorAdapter;
279        let raw = r#"{
280            "hook_event_name": "afterFileEdit",
281            "tool_input": {
282                "path": "src/bar.rs",
283                "edits": [
284                    { "old_string": "A", "new_string": "B" },
285                    { "old_string": "C", "new_string": "D" }
286                ]
287            }
288        }"#;
289        let event = adapter.parse_stdin(raw).expect("parse ok");
290        if let HookEvent::PostToolUse {
291            file_path, diff, ..
292        } = event
293        {
294            // Must find path under `tool_input.path` (not `file_path`).
295            assert_eq!(file_path.as_deref(), Some("src/bar.rs"));
296            let d = diff.expect("array form diff synthesised");
297            assert!(d.contains("-A") && d.contains("+B"));
298            assert!(d.contains("-C") && d.contains("+D"));
299        } else {
300            panic!("expected PostToolUse");
301        }
302    }
303
304    #[test]
305    fn parse_after_shell_execution_synthesises_bash_diff() {
306        let adapter = CursorAdapter;
307        let raw = r#"{
308            "hook_event_name": "afterShellExecution",
309            "command": "echo hi",
310            "output": "hi\n"
311        }"#;
312        let event = adapter.parse_stdin(raw).expect("parse ok");
313        if let HookEvent::PostToolUse {
314            tool_name,
315            file_path,
316            diff,
317            ..
318        } = event
319        {
320            assert_eq!(tool_name, "Bash");
321            assert!(file_path.is_none());
322            let d = diff.expect("shell diff");
323            assert!(d.contains("$ echo hi"));
324            assert!(d.contains("+hi"));
325        } else {
326            panic!("expected PostToolUse");
327        }
328    }
329
330    #[test]
331    fn parse_before_submit_prompt_probes_alt_keys() {
332        // Regression guard for Cursor's multi-named prompt field. A
333        // version that ships `query` instead of `prompt` must not drop
334        // the payload.
335        let adapter = CursorAdapter;
336        let raw = r#"{"hook_event_name":"beforeSubmitPrompt","query":"hello"}"#;
337        let event = adapter.parse_stdin(raw).expect("parse ok");
338        assert_eq!(
339            event,
340            HookEvent::UserPromptSubmit {
341                prompt: "hello".into(),
342                session_id: None,
343            }
344        );
345    }
346
347    #[test]
348    fn parse_unsupported_event_errors() {
349        let adapter = CursorAdapter;
350        let err = adapter
351            .parse_stdin(r#"{"hook_event_name":"someNewCursorEvent"}"#)
352            .unwrap_err();
353        assert!(err.contains("unsupported"), "got: {err}");
354    }
355
356    #[test]
357    fn format_output_noop_emits_continue_only() {
358        let adapter = CursorAdapter;
359        let out = adapter.format_output(HookResult::noop());
360        let v: Value = serde_json::from_str(&out).unwrap();
361        assert_eq!(v["continue"], true);
362        assert!(v.get("context").is_none());
363    }
364
365    #[test]
366    fn format_output_with_context_includes_context_field() {
367        // Cursor's newer builds honour `context` at the top level — we
368        // always include it when we have advisory context to surface.
369        let adapter = CursorAdapter;
370        let out = adapter.format_output(HookResult::with_context("Rule 1: X"));
371        let v: Value = serde_json::from_str(&out).unwrap();
372        assert_eq!(v["continue"], true);
373        assert_eq!(v["context"], "Rule 1: X");
374    }
375}