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    // Smart Read redirect re-enabled: edit-probe PoC (2026-07) proved that
94    // StrReplace does NOT fire a Read PreToolUse — only "Write" — so Read
95    // redirect is safe. Uses `auto` mode for full reads (87-97% compression)
96    // and `full-compact` for windowed reads (offset/limit).
97    // GH #1250 was caused by an older Cursor version or misattribution.
98    ensure_pretooluse_hook(
99        pre_arr,
100        &["Read|Grep|Glob", "Read|Grep", "Grep|Glob", "Grep", "Read"],
101        "Read|Grep|Glob",
102        redirect_cmd,
103    );
104
105    // Observe hooks — only essential ones (#1200). postToolUse caused
106    // Cursor to append hook stdout to edited files, corrupting source code.
107    let observe_cmd = rewrite_cmd.replace("hook rewrite", "hook observe");
108    ensure_observe_hook(hooks_obj, "sessionStart", &observe_cmd);
109    ensure_observe_hook(hooks_obj, "preCompact", &observe_cmd);
110
111    // Clean up previously installed problematic hooks
112    for stale in &[
113        "postToolUse",
114        "afterShellExecution",
115        "afterMCPExecution",
116        "beforeReadFile",
117        "afterAgentResponse",
118        "afterAgentThought",
119        "beforeSubmitPrompt",
120        "sessionEnd",
121    ] {
122        remove_observe_hook(hooks_obj, stale, &observe_cmd);
123    }
124}
125
126fn remove_observe_hook(
127    hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
128    event: &str,
129    _observe_cmd: &str,
130) {
131    let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
132        return;
133    };
134    arr.retain(|e| {
135        !e.get("command")
136            .and_then(|c| c.as_str())
137            .is_some_and(|c| c.contains("hook observe"))
138    });
139    if arr.is_empty() {
140        hooks_obj.remove(event);
141    }
142}
143
144pub fn install_cursor_hook(global: bool) {
145    let Some(home) = crate::core::home::resolve_home_dir() else {
146        tracing::error!("Cannot resolve home directory");
147        return;
148    };
149
150    install_cursor_hook_scripts(&home);
151    install_cursor_hook_config(&home);
152
153    let scope = crate::core::config::Config::load().rules_scope_effective();
154    let skip_project = global || scope == crate::core::config::RulesScope::Global;
155
156    if skip_project {
157        if !mcp_server_quiet_mode() {
158            eprintln!(
159                "Global mode: skipping project-local .cursor/rules/ (use without --global in a project)."
160            );
161        }
162    } else {
163        let rules_dir = PathBuf::from(".cursor").join("rules");
164        let _ = std::fs::create_dir_all(&rules_dir);
165        let rule_path = rules_dir.join("lean-ctx.mdc");
166        if rule_path.exists() {
167            if !mcp_server_quiet_mode() {
168                eprintln!("Cursor rule already exists.");
169            }
170        } else {
171            write_file(&rule_path, &cursor_mdc_content(&home));
172            if !mcp_server_quiet_mode() {
173                eprintln!("Created .cursor/rules/lean-ctx.mdc in current project.");
174            }
175        }
176    }
177
178    if !mcp_server_quiet_mode() {
179        eprintln!("Restart Cursor to activate.");
180    }
181}
182
183pub(crate) fn install_cursor_hook_with_mode(global: bool, mode: HookMode) {
184    match mode {
185        HookMode::Mcp => install_cursor_hook(global),
186        HookMode::Hybrid => {
187            install_cursor_hook(global);
188            install_cursor_rules_for_mode(global, mode);
189        }
190        HookMode::Replace => {
191            install_cursor_hook(global);
192            install_cursor_deny_hook(global);
193            install_cursor_rules_for_mode(global, mode);
194        }
195    }
196}
197
198fn install_cursor_rules_for_mode(global: bool, mode: HookMode) {
199    let Some(home) = crate::core::home::resolve_home_dir() else {
200        return;
201    };
202    let content = cursor_mdc_content(&home);
203    let mode_name = match mode {
204        HookMode::Hybrid => "hybrid",
205        HookMode::Mcp => "mcp",
206        HookMode::Replace => "replace",
207    };
208
209    if global {
210        let global_rules_dir = home.join(".cursor").join("rules");
211        let _ = std::fs::create_dir_all(&global_rules_dir);
212        let global_path = global_rules_dir.join("lean-ctx.mdc");
213        write_file(&global_path, &content);
214        if !mcp_server_quiet_mode() {
215            eprintln!(
216                "Installed Cursor rules in {mode_name} mode at {}",
217                global_path.display()
218            );
219        }
220    } else {
221        let rules_dir = PathBuf::from(".cursor").join("rules");
222        let _ = std::fs::create_dir_all(&rules_dir);
223        let rule_path = rules_dir.join("lean-ctx.mdc");
224        write_file(&rule_path, &content);
225        if !mcp_server_quiet_mode() {
226            eprintln!("Installed Cursor rules in {mode_name} mode at .cursor/rules/lean-ctx.mdc");
227        }
228    }
229}
230
231/// The Cursor mdc document this installer writes. Config-driven (GL #1156 —
232/// previously hardcoded `shadow=false`, `CompressionLevel::Off`) and
233/// hook-aware (GL #1153): right after `install_cursor_hook_config` wrote the
234/// rewrite+redirect hooks, the coverage check selects the honest HookCovered
235/// profile; without hooks it falls back to the full Dedicated mapping.
236fn cursor_mdc_content(home: &std::path::Path) -> String {
237    let cfg = crate::core::config::Config::load();
238    let wrapper = if crate::core::rules_channel::cursor_hooks_cover_native_tools(home) {
239        crate::core::rules_canonical::Wrapper::HookCovered
240    } else {
241        crate::core::rules_canonical::Wrapper::Dedicated
242    };
243    let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
244    let body = crate::core::rules_canonical::render(
245        cfg.shadow_mode,
246        wrapper,
247        crate::core::config::CompressionLevel::effective(&cfg),
248        &profile,
249    );
250    crate::rules_inject::cursor_mdc_document(&body)
251}
252
253pub(crate) fn install_cursor_hook_scripts(home: &std::path::Path) {
254    let hooks_dir = home.join(".cursor").join("hooks");
255    install_standard_hook_scripts(
256        &hooks_dir,
257        home,
258        "lean-ctx-rewrite.sh",
259        "lean-ctx-redirect.sh",
260    );
261
262    // #719: quoted + heal-safe like the Claude wrappers; bash-compatible path
263    // because these are `#!/bin/sh` scripts.
264    let native_binary = shell_quoted_binary(&resolve_binary_path_for_bash());
265    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
266    write_wrapper_file(
267        &rewrite_native,
268        &format!("#!/bin/sh\nexec {native_binary} hook rewrite\n"),
269        home,
270    );
271    make_executable(&rewrite_native);
272
273    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
274    write_wrapper_file(
275        &redirect_native,
276        &format!("#!/bin/sh\nexec {native_binary} hook redirect\n"),
277        home,
278    );
279    make_executable(&redirect_native);
280}
281
282/// In Replace mode, swap the redirect hook for a deny hook that blocks
283/// native Read/Grep and instructs the agent to use ctx_read/ctx_search.
284pub(crate) fn install_cursor_deny_hook(_global: bool) {
285    let Some(home) = crate::core::home::resolve_home_dir() else {
286        return;
287    };
288    let binary = resolve_hook_command_binary();
289    let deny_cmd = format!("{binary} hook deny");
290
291    let hooks_json = home.join(".cursor").join("hooks.json");
292    let content = if hooks_json.exists() {
293        std::fs::read_to_string(&hooks_json).unwrap_or_default()
294    } else {
295        String::new()
296    };
297
298    let mut existing = if content.trim().is_empty() {
299        serde_json::json!({})
300    } else {
301        crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
302    };
303
304    if !existing.is_object() {
305        existing = serde_json::json!({});
306    }
307
308    let root = existing.as_object_mut().unwrap();
309    root.insert("version".to_string(), serde_json::json!(1));
310
311    let hooks = root
312        .entry("hooks".to_string())
313        .or_insert_with(|| serde_json::json!({}));
314    if !hooks.is_object() {
315        *hooks = serde_json::json!({});
316    }
317    let hooks_obj = hooks.as_object_mut().unwrap();
318
319    let pre = hooks_obj
320        .entry("preToolUse".to_string())
321        .or_insert_with(|| serde_json::json!([]));
322    if !pre.is_array() {
323        *pre = serde_json::json!([]);
324    }
325    let pre_arr = pre.as_array_mut().unwrap();
326
327    // Read: redirect with smart compression (auto mode). Safe because
328    // StrReplace does NOT fire Read PreToolUse (edit-probe PoC 2026-07).
329    let redirect_cmd = deny_cmd.replace("hook deny", "hook redirect");
330    ensure_pretooluse_hook(pre_arr, &["Read"], "Read", &redirect_cmd);
331
332    // Grep + Glob: deny (must use ctx_search / ctx_glob instead).
333    ensure_pretooluse_hook(
334        pre_arr,
335        &["Read|Grep|Glob", "Read|Grep", "Grep", "Grep|Glob"],
336        "Grep|Glob",
337        &deny_cmd,
338    );
339
340    // Write tools: deny guard blocks payloads containing compression markers.
341    // Without this, an agent that reads compressed ctx_read output and pastes
342    // it into StrReplace silently corrupts the file (#1302, #1323).
343    ensure_pretooluse_hook(
344        pre_arr,
345        &["StrReplace|Write|Edit|EditNotebook|MultiEdit"],
346        "StrReplace|Write|Edit|EditNotebook|MultiEdit",
347        &deny_cmd,
348    );
349
350    let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
351    write_file(&hooks_json, &formatted);
352
353    if !mcp_server_quiet_mode() {
354        eprintln!("  \x1b[32m✓\x1b[0m Cursor deny hook installed (Replace mode)");
355    }
356}
357
358pub(crate) fn install_cursor_hook_config(home: &std::path::Path) {
359    let binary = resolve_hook_command_binary();
360    let rewrite_cmd = format!("{binary} hook rewrite");
361    let redirect_cmd = format!("{binary} hook redirect");
362
363    let hooks_json = home.join(".cursor").join("hooks.json");
364
365    let content = if hooks_json.exists() {
366        std::fs::read_to_string(&hooks_json).unwrap_or_default()
367    } else {
368        String::new()
369    };
370
371    let mut existing = if content.trim().is_empty() {
372        serde_json::json!({})
373    } else {
374        crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
375    };
376
377    if !existing.is_object() {
378        existing = serde_json::json!({});
379    }
380
381    // Merge-based: preserve other hooks/plugins. Only upsert lean-ctx entries.
382    merge_cursor_hooks(&mut existing, &rewrite_cmd, &redirect_cmd);
383
384    let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
385    write_file(&hooks_json, &formatted);
386
387    if !mcp_server_quiet_mode() {
388        eprintln!("Installed Cursor hooks at {}", hooks_json.display());
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn cursor_hooks_merge_preserves_other_entries() {
398        let mut v = serde_json::json!({
399            "version": 1,
400            "hooks": {
401                "preToolUse": [
402                    { "matcher": "Shell", "command": "/old/bin hook rewrite" },
403                    { "matcher": "Other", "command": "do-something" }
404                ],
405                "postToolUse": [
406                    { "matcher": "Shell", "command": "post" }
407                ]
408            },
409            "otherKey": { "x": 1 }
410        });
411
412        merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
413
414        assert!(v.get("otherKey").is_some());
415        assert!(v.pointer("/hooks/postToolUse").is_some());
416
417        let pre = v
418            .pointer("/hooks/preToolUse")
419            .and_then(|x| x.as_array())
420            .unwrap();
421        assert!(
422            pre.iter()
423                .any(|e| e.get("matcher").and_then(|m| m.as_str()) == Some("Other"))
424        );
425        assert!(pre.iter().any(|e| {
426            e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
427                && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook rewrite")
428        }));
429        assert!(pre.iter().any(|e| {
430            e.get("matcher").and_then(|m| m.as_str()) == Some("Read|Grep|Glob")
431                && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect")
432        }));
433    }
434
435    #[test]
436    fn cursor_redirect_matcher_migrates_legacy_to_read_grep_glob() {
437        // Smart Read redirect re-enabled: legacy "Read|Grep" or "Grep" matcher
438        // is upgraded to "Read|Grep|Glob" with smart auto-mode compression.
439        let mut v = serde_json::json!({
440            "version": 1,
441            "hooks": {
442                "preToolUse": [
443                    { "matcher": "Grep", "command": "/old/bin hook redirect" }
444                ]
445            }
446        });
447
448        merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
449
450        let pre = v
451            .pointer("/hooks/preToolUse")
452            .and_then(|x| x.as_array())
453            .unwrap();
454        let redirects: Vec<_> = pre
455            .iter()
456            .filter(|e| e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect"))
457            .collect();
458        assert_eq!(redirects.len(), 1, "must migrate in place, not duplicate");
459        assert_eq!(
460            redirects[0].get("matcher").and_then(|m| m.as_str()),
461            Some("Read|Grep|Glob"),
462            "matcher must be Read|Grep|Glob (smart redirect for all)"
463        );
464    }
465
466    #[test]
467    fn replace_mode_redirects_read_and_denies_grep_glob() {
468        let mut v = serde_json::json!({
469            "version": 1,
470            "hooks": {
471                "preToolUse": [
472                    { "matcher": "Shell", "command": "/bin/lean-ctx hook rewrite" },
473                    { "matcher": "Read|Grep", "command": "/bin/lean-ctx hook redirect" }
474                ]
475            }
476        });
477
478        let deny_cmd = "/bin/lean-ctx hook deny";
479        let redirect_cmd = "/bin/lean-ctx hook redirect";
480        let pre = v
481            .pointer_mut("/hooks/preToolUse")
482            .and_then(|x| x.as_array_mut())
483            .unwrap();
484
485        // Read: redirect with smart compression (StrReplace doesn't fire Read PreToolUse)
486        ensure_pretooluse_hook(pre, &["Read"], "Read", redirect_cmd);
487        // Grep|Glob: deny
488        ensure_pretooluse_hook(
489            pre,
490            &["Read|Grep|Glob", "Read|Grep", "Grep", "Grep|Glob"],
491            "Grep|Glob",
492            deny_cmd,
493        );
494
495        let pre = v
496            .pointer("/hooks/preToolUse")
497            .and_then(|x| x.as_array())
498            .unwrap();
499
500        // Read should be redirected (smart compression via auto mode)
501        assert!(pre.iter().any(|e| {
502            e.get("matcher").and_then(|m| m.as_str()) == Some("Read")
503                && e.get("command")
504                    .and_then(|c| c.as_str())
505                    .is_some_and(|c| c.contains("redirect"))
506        }));
507        // Grep|Glob should use deny
508        assert!(pre.iter().any(|e| {
509            e.get("matcher").and_then(|m| m.as_str()) == Some("Grep|Glob")
510                && e.get("command").and_then(|c| c.as_str()) == Some("/bin/lean-ctx hook deny")
511        }));
512        // Old combined Read|Grep should be gone
513        assert!(!pre.iter().any(|e| {
514            e.get("matcher")
515                .and_then(|m| m.as_str())
516                .is_some_and(|m| m == "Read|Grep")
517        }));
518    }
519
520    #[test]
521    fn replace_mode_registers_write_tool_deny_guard() {
522        // #1302/#1323: StrReplace/Write/Edit must be hooked for compression guard
523        let mut v = serde_json::json!({
524            "version": 1,
525            "hooks": { "preToolUse": [] }
526        });
527
528        let deny_cmd = "/bin/lean-ctx hook deny";
529        let redirect_cmd = "/bin/lean-ctx hook redirect";
530        let pre = v
531            .pointer_mut("/hooks/preToolUse")
532            .and_then(|x| x.as_array_mut())
533            .unwrap();
534
535        ensure_pretooluse_hook(pre, &["Read"], "Read", redirect_cmd);
536        ensure_pretooluse_hook(
537            pre,
538            &["Read|Grep|Glob", "Read|Grep", "Grep", "Grep|Glob"],
539            "Grep|Glob",
540            deny_cmd,
541        );
542        ensure_pretooluse_hook(
543            pre,
544            &["StrReplace|Write|Edit|EditNotebook|MultiEdit"],
545            "StrReplace|Write|Edit|EditNotebook|MultiEdit",
546            deny_cmd,
547        );
548
549        let pre = v
550            .pointer("/hooks/preToolUse")
551            .and_then(|x| x.as_array())
552            .unwrap();
553
554        assert!(
555            pre.iter().any(|e| {
556                e.get("matcher")
557                    .and_then(|m| m.as_str())
558                    .is_some_and(|m| m.contains("StrReplace"))
559                    && e.get("command").and_then(|c| c.as_str()) == Some(deny_cmd)
560            }),
561            "write tools must be registered on hook deny"
562        );
563    }
564}