Skip to main content

lean_ctx/hooks/agents/
cursor.rs

1use std::path::PathBuf;
2
3use super::super::{
4    HookMode, make_executable, mcp_server_quiet_mode, resolve_binary_path_for_bash,
5    resolve_hook_command_binary, shell_quoted_binary, write_file, write_wrapper_file,
6};
7use super::shared::install_standard_hook_scripts;
8
9fn ensure_pretooluse_hook(
10    pre: &mut Vec<serde_json::Value>,
11    matcher_variants: &[&str],
12    desired_matcher: &str,
13    desired_command: &str,
14) {
15    if let Some(existing) = pre.iter_mut().find(|v| {
16        v.get("matcher")
17            .and_then(|m| m.as_str())
18            .is_some_and(|m| matcher_variants.contains(&m))
19    }) {
20        if let Some(obj) = existing.as_object_mut() {
21            obj.insert(
22                "matcher".to_string(),
23                serde_json::Value::String(desired_matcher.to_string()),
24            );
25            obj.insert(
26                "command".to_string(),
27                serde_json::Value::String(desired_command.to_string()),
28            );
29        }
30        return;
31    }
32    pre.push(serde_json::json!({
33        "matcher": desired_matcher,
34        "command": desired_command
35    }));
36}
37
38fn ensure_observe_hook(
39    hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
40    event: &str,
41    observe_cmd: &str,
42) {
43    let arr = hooks_obj
44        .entry(event.to_string())
45        .or_insert_with(|| serde_json::json!([]));
46    if !arr.is_array() {
47        *arr = serde_json::json!([]);
48    }
49    let Some(entries) = arr.as_array_mut() else {
50        return;
51    };
52    let already = entries.iter().any(|e| {
53        e.get("command")
54            .and_then(|c| c.as_str())
55            .is_some_and(|c| c.contains("hook observe"))
56    });
57    if !already {
58        entries.push(serde_json::json!({ "command": observe_cmd }));
59    }
60}
61
62fn merge_cursor_hooks(existing: &mut serde_json::Value, rewrite_cmd: &str, redirect_cmd: &str) {
63    if !existing.is_object() {
64        *existing = serde_json::json!({});
65    }
66    let Some(root) = existing.as_object_mut() else {
67        return;
68    };
69    root.insert("version".to_string(), serde_json::json!(1));
70
71    let hooks = root
72        .entry("hooks".to_string())
73        .or_insert_with(|| serde_json::json!({}));
74    if !hooks.is_object() {
75        *hooks = serde_json::json!({});
76    }
77    let Some(hooks_obj) = hooks.as_object_mut() else {
78        return;
79    };
80
81    // PreToolUse hooks (rewrite + redirect)
82    let pre = hooks_obj
83        .entry("preToolUse".to_string())
84        .or_insert_with(|| serde_json::json!([]));
85    if !pre.is_array() {
86        *pre = serde_json::json!([]);
87    }
88    let Some(pre_arr) = pre.as_array_mut() else {
89        return;
90    };
91
92    ensure_pretooluse_hook(pre_arr, &["Shell"], "Shell", rewrite_cmd);
93    // Cursor has no `Glob` tool — valid preToolUse matchers are
94    // Shell|Read|Write|Grep|Delete|Task|MCP:* (Cursor Docs). The legacy
95    // "…|Glob" arm (GL #890) never fired here, so install "Read|Grep" and keep
96    // the old strings in `matcher_variants` to migrate existing hooks in place.
97    ensure_pretooluse_hook(
98        pre_arr,
99        &["Read|Grep|Glob", "Read|Grep", "Read", "Grep"],
100        "Read|Grep",
101        redirect_cmd,
102    );
103
104    // Observe hooks for full context awareness
105    let observe_cmd = rewrite_cmd.replace("hook rewrite", "hook observe");
106    ensure_observe_hook(hooks_obj, "afterMCPExecution", &observe_cmd);
107    ensure_observe_hook(hooks_obj, "postToolUse", &observe_cmd);
108    ensure_observe_hook(hooks_obj, "afterShellExecution", &observe_cmd);
109    ensure_observe_hook(hooks_obj, "beforeReadFile", &observe_cmd);
110    ensure_observe_hook(hooks_obj, "afterAgentResponse", &observe_cmd);
111    ensure_observe_hook(hooks_obj, "afterAgentThought", &observe_cmd);
112    ensure_observe_hook(hooks_obj, "beforeSubmitPrompt", &observe_cmd);
113    ensure_observe_hook(hooks_obj, "preCompact", &observe_cmd);
114    ensure_observe_hook(hooks_obj, "sessionStart", &observe_cmd);
115    ensure_observe_hook(hooks_obj, "sessionEnd", &observe_cmd);
116}
117
118pub fn install_cursor_hook(global: bool) {
119    let Some(home) = crate::core::home::resolve_home_dir() else {
120        tracing::error!("Cannot resolve home directory");
121        return;
122    };
123
124    install_cursor_hook_scripts(&home);
125    install_cursor_hook_config(&home);
126
127    let scope = crate::core::config::Config::load().rules_scope_effective();
128    let skip_project = global || scope == crate::core::config::RulesScope::Global;
129
130    if skip_project {
131        if !mcp_server_quiet_mode() {
132            eprintln!(
133                "Global mode: skipping project-local .cursor/rules/ (use without --global in a project)."
134            );
135        }
136    } else {
137        let rules_dir = PathBuf::from(".cursor").join("rules");
138        let _ = std::fs::create_dir_all(&rules_dir);
139        let rule_path = rules_dir.join("lean-ctx.mdc");
140        if rule_path.exists() {
141            if !mcp_server_quiet_mode() {
142                eprintln!("Cursor rule already exists.");
143            }
144        } else {
145            write_file(&rule_path, &cursor_mdc_content(&home));
146            if !mcp_server_quiet_mode() {
147                eprintln!("Created .cursor/rules/lean-ctx.mdc in current project.");
148            }
149        }
150    }
151
152    if !mcp_server_quiet_mode() {
153        eprintln!("Restart Cursor to activate.");
154    }
155}
156
157pub(crate) fn install_cursor_hook_with_mode(global: bool, mode: HookMode) {
158    match mode {
159        HookMode::Mcp => install_cursor_hook(global),
160        HookMode::Hybrid => {
161            install_cursor_hook(global);
162            install_cursor_rules_for_mode(global, mode);
163        }
164    }
165}
166
167fn install_cursor_rules_for_mode(global: bool, mode: HookMode) {
168    let Some(home) = crate::core::home::resolve_home_dir() else {
169        return;
170    };
171    let content = cursor_mdc_content(&home);
172    let mode_name = match mode {
173        HookMode::Hybrid => "hybrid",
174        HookMode::Mcp => "mcp",
175    };
176
177    if global {
178        let global_rules_dir = home.join(".cursor").join("rules");
179        let _ = std::fs::create_dir_all(&global_rules_dir);
180        let global_path = global_rules_dir.join("lean-ctx.mdc");
181        write_file(&global_path, &content);
182        if !mcp_server_quiet_mode() {
183            eprintln!(
184                "Installed Cursor rules in {mode_name} mode at {}",
185                global_path.display()
186            );
187        }
188    } else {
189        let rules_dir = PathBuf::from(".cursor").join("rules");
190        let _ = std::fs::create_dir_all(&rules_dir);
191        let rule_path = rules_dir.join("lean-ctx.mdc");
192        write_file(&rule_path, &content);
193        if !mcp_server_quiet_mode() {
194            eprintln!("Installed Cursor rules in {mode_name} mode at .cursor/rules/lean-ctx.mdc");
195        }
196    }
197}
198
199/// The Cursor mdc document this installer writes. Config-driven (GL #1156 —
200/// previously hardcoded `shadow=false`, `CompressionLevel::Off`) and
201/// hook-aware (GL #1153): right after `install_cursor_hook_config` wrote the
202/// rewrite+redirect hooks, the coverage check selects the honest HookCovered
203/// profile; without hooks it falls back to the full Dedicated mapping.
204fn cursor_mdc_content(home: &std::path::Path) -> String {
205    let cfg = crate::core::config::Config::load();
206    let wrapper = if crate::core::rules_channel::cursor_hooks_cover_native_tools(home) {
207        crate::core::rules_canonical::Wrapper::HookCovered
208    } else {
209        crate::core::rules_canonical::Wrapper::Dedicated
210    };
211    let body = crate::core::rules_canonical::render(
212        cfg.shadow_mode,
213        wrapper,
214        crate::core::config::CompressionLevel::effective(&cfg),
215    );
216    crate::rules_inject::cursor_mdc_document(&body)
217}
218
219pub(crate) fn install_cursor_hook_scripts(home: &std::path::Path) {
220    let hooks_dir = home.join(".cursor").join("hooks");
221    install_standard_hook_scripts(
222        &hooks_dir,
223        home,
224        "lean-ctx-rewrite.sh",
225        "lean-ctx-redirect.sh",
226    );
227
228    // #719: quoted + heal-safe like the Claude wrappers; bash-compatible path
229    // because these are `#!/bin/sh` scripts.
230    let native_binary = shell_quoted_binary(&resolve_binary_path_for_bash());
231    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
232    write_wrapper_file(
233        &rewrite_native,
234        &format!("#!/bin/sh\nexec {native_binary} hook rewrite\n"),
235        home,
236    );
237    make_executable(&rewrite_native);
238
239    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
240    write_wrapper_file(
241        &redirect_native,
242        &format!("#!/bin/sh\nexec {native_binary} hook redirect\n"),
243        home,
244    );
245    make_executable(&redirect_native);
246}
247
248pub(crate) fn install_cursor_hook_config(home: &std::path::Path) {
249    let binary = resolve_hook_command_binary();
250    let rewrite_cmd = format!("{binary} hook rewrite");
251    let redirect_cmd = format!("{binary} hook redirect");
252
253    let hooks_json = home.join(".cursor").join("hooks.json");
254
255    let content = if hooks_json.exists() {
256        std::fs::read_to_string(&hooks_json).unwrap_or_default()
257    } else {
258        String::new()
259    };
260
261    let mut existing = if content.trim().is_empty() {
262        serde_json::json!({})
263    } else {
264        crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
265    };
266
267    if !existing.is_object() {
268        existing = serde_json::json!({});
269    }
270
271    // Merge-based: preserve other hooks/plugins. Only upsert lean-ctx entries.
272    merge_cursor_hooks(&mut existing, &rewrite_cmd, &redirect_cmd);
273
274    let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
275    write_file(&hooks_json, &formatted);
276
277    if !mcp_server_quiet_mode() {
278        eprintln!("Installed Cursor hooks at {}", hooks_json.display());
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn cursor_hooks_merge_preserves_other_entries() {
288        let mut v = serde_json::json!({
289            "version": 1,
290            "hooks": {
291                "preToolUse": [
292                    { "matcher": "Shell", "command": "/old/bin hook rewrite" },
293                    { "matcher": "Other", "command": "do-something" }
294                ],
295                "postToolUse": [
296                    { "matcher": "Shell", "command": "post" }
297                ]
298            },
299            "otherKey": { "x": 1 }
300        });
301
302        merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
303
304        assert!(v.get("otherKey").is_some());
305        assert!(v.pointer("/hooks/postToolUse").is_some());
306
307        let pre = v
308            .pointer("/hooks/preToolUse")
309            .and_then(|x| x.as_array())
310            .unwrap();
311        assert!(
312            pre.iter()
313                .any(|e| e.get("matcher").and_then(|m| m.as_str()) == Some("Other"))
314        );
315        assert!(pre.iter().any(|e| {
316            e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
317                && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook rewrite")
318        }));
319        assert!(pre.iter().any(|e| {
320            e.get("matcher").and_then(|m| m.as_str()) == Some("Read|Grep")
321                && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect")
322        }));
323    }
324
325    #[test]
326    fn cursor_redirect_matcher_migrates_legacy_glob_arm() {
327        // GL #1018: `Glob` is not a Cursor tool, so the legacy "Read|Grep|Glob"
328        // matcher (GL #890) must be rewritten in place to "Read|Grep" — never
329        // duplicated — when re-installing over an older hooks.json.
330        let mut v = serde_json::json!({
331            "version": 1,
332            "hooks": {
333                "preToolUse": [
334                    { "matcher": "Read|Grep|Glob", "command": "/old/bin hook redirect" }
335                ]
336            }
337        });
338
339        merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
340
341        let pre = v
342            .pointer("/hooks/preToolUse")
343            .and_then(|x| x.as_array())
344            .unwrap();
345        let redirects: Vec<_> = pre
346            .iter()
347            .filter(|e| e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect"))
348            .collect();
349        assert_eq!(redirects.len(), 1, "must migrate in place, not duplicate");
350        assert_eq!(
351            redirects[0].get("matcher").and_then(|m| m.as_str()),
352            Some("Read|Grep")
353        );
354        assert!(
355            !pre.iter().any(|e| e
356                .get("matcher")
357                .and_then(|m| m.as_str())
358                .is_some_and(|m| m.contains("Glob"))),
359            "no Glob arm should remain for Cursor"
360        );
361    }
362}