Skip to main content

lean_ctx/hooks/agents/
cursor.rs

1use std::path::PathBuf;
2
3use super::super::{
4    make_executable, mcp_server_quiet_mode, resolve_binary_path, write_file, HookMode,
5};
6use super::shared::install_standard_hook_scripts;
7
8fn ensure_pretooluse_hook(
9    pre: &mut Vec<serde_json::Value>,
10    matcher_variants: &[&str],
11    desired_matcher: &str,
12    desired_command: &str,
13) {
14    if let Some(existing) = pre.iter_mut().find(|v| {
15        v.get("matcher")
16            .and_then(|m| m.as_str())
17            .is_some_and(|m| matcher_variants.contains(&m))
18    }) {
19        if let Some(obj) = existing.as_object_mut() {
20            obj.insert(
21                "matcher".to_string(),
22                serde_json::Value::String(desired_matcher.to_string()),
23            );
24            obj.insert(
25                "command".to_string(),
26                serde_json::Value::String(desired_command.to_string()),
27            );
28        }
29        return;
30    }
31    pre.push(serde_json::json!({
32        "matcher": desired_matcher,
33        "command": desired_command
34    }));
35}
36
37fn merge_cursor_hooks(existing: &mut serde_json::Value, rewrite_cmd: &str, redirect_cmd: &str) {
38    let root = existing.as_object_mut().unwrap();
39    root.insert("version".to_string(), serde_json::json!(1));
40
41    let hooks = root
42        .entry("hooks".to_string())
43        .or_insert_with(|| serde_json::json!({}));
44    if !hooks.is_object() {
45        *hooks = serde_json::json!({});
46    }
47    let hooks_obj = hooks.as_object_mut().unwrap();
48
49    let pre = hooks_obj
50        .entry("preToolUse".to_string())
51        .or_insert_with(|| serde_json::json!([]));
52    if !pre.is_array() {
53        *pre = serde_json::json!([]);
54    }
55    let pre_arr = pre.as_array_mut().unwrap();
56
57    ensure_pretooluse_hook(pre_arr, &["Shell"], "Shell", rewrite_cmd);
58    ensure_pretooluse_hook(
59        pre_arr,
60        &["Read|Grep", "Read", "Grep"],
61        "Read|Grep",
62        redirect_cmd,
63    );
64}
65
66pub fn install_cursor_hook(global: bool) {
67    let Some(home) = crate::core::home::resolve_home_dir() else {
68        tracing::error!("Cannot resolve home directory");
69        return;
70    };
71
72    install_cursor_hook_scripts(&home);
73    install_cursor_hook_config(&home);
74
75    let scope = crate::core::config::Config::load().rules_scope_effective();
76    let skip_project = global || scope == crate::core::config::RulesScope::Global;
77
78    if skip_project {
79        if !mcp_server_quiet_mode() {
80            eprintln!(
81                "Global mode: skipping project-local .cursor/rules/ (use without --global in a project)."
82            );
83        }
84    } else {
85        let rules_dir = PathBuf::from(".cursor").join("rules");
86        let _ = std::fs::create_dir_all(&rules_dir);
87        let rule_path = rules_dir.join("lean-ctx.mdc");
88        if rule_path.exists() {
89            if !mcp_server_quiet_mode() {
90                eprintln!("Cursor rule already exists.");
91            }
92        } else {
93            let rule_content = include_str!("../../templates/lean-ctx.mdc");
94            write_file(&rule_path, rule_content);
95            if !mcp_server_quiet_mode() {
96                eprintln!("Created .cursor/rules/lean-ctx.mdc in current project.");
97            }
98        }
99    }
100
101    if !mcp_server_quiet_mode() {
102        eprintln!("Restart Cursor to activate.");
103    }
104}
105
106pub(crate) fn install_cursor_hook_with_mode(global: bool, mode: HookMode) {
107    match mode {
108        HookMode::Mcp => install_cursor_hook(global),
109        HookMode::CliRedirect | HookMode::Hybrid => {
110            install_cursor_hook(global);
111            install_cursor_rules_for_mode(global, mode);
112        }
113    }
114}
115
116fn install_cursor_rules_for_mode(global: bool, mode: HookMode) {
117    let content = cursor_mdc_for_mode(mode);
118    let mode_name = match mode {
119        HookMode::CliRedirect => "cli-redirect",
120        HookMode::Hybrid => "hybrid",
121        HookMode::Mcp => "mcp",
122    };
123
124    if global {
125        if let Some(home) = crate::core::home::resolve_home_dir() {
126            let global_rules_dir = home.join(".cursor").join("rules");
127            let _ = std::fs::create_dir_all(&global_rules_dir);
128            let global_path = global_rules_dir.join("lean-ctx.mdc");
129            write_file(&global_path, &content);
130            if !mcp_server_quiet_mode() {
131                eprintln!(
132                    "Installed Cursor rules in {mode_name} mode at {}",
133                    global_path.display()
134                );
135            }
136        }
137    } else {
138        let rules_dir = PathBuf::from(".cursor").join("rules");
139        let _ = std::fs::create_dir_all(&rules_dir);
140        let rule_path = rules_dir.join("lean-ctx.mdc");
141        write_file(&rule_path, &content);
142        if !mcp_server_quiet_mode() {
143            eprintln!("Installed Cursor rules in {mode_name} mode at .cursor/rules/lean-ctx.mdc");
144        }
145    }
146}
147
148fn cursor_mdc_for_mode(mode: HookMode) -> String {
149    match mode {
150        HookMode::CliRedirect => {
151            include_str!("../../templates/lean-ctx-cli-redirect.mdc").to_string()
152        }
153        HookMode::Hybrid => include_str!("../../templates/lean-ctx-hybrid.mdc").to_string(),
154        HookMode::Mcp => include_str!("../../templates/lean-ctx.mdc").to_string(),
155    }
156}
157
158pub(crate) fn install_cursor_hook_scripts(home: &std::path::Path) {
159    let hooks_dir = home.join(".cursor").join("hooks");
160    install_standard_hook_scripts(&hooks_dir, "lean-ctx-rewrite.sh", "lean-ctx-redirect.sh");
161
162    let native_binary = resolve_binary_path();
163    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
164    write_file(
165        &rewrite_native,
166        &format!("#!/bin/sh\nexec {native_binary} hook rewrite\n"),
167    );
168    make_executable(&rewrite_native);
169
170    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
171    write_file(
172        &redirect_native,
173        &format!("#!/bin/sh\nexec {native_binary} hook redirect\n"),
174    );
175    make_executable(&redirect_native);
176}
177
178pub(crate) fn install_cursor_hook_config(home: &std::path::Path) {
179    let binary = resolve_binary_path();
180    let rewrite_cmd = format!("{binary} hook rewrite");
181    let redirect_cmd = format!("{binary} hook redirect");
182
183    let hooks_json = home.join(".cursor").join("hooks.json");
184
185    let content = if hooks_json.exists() {
186        std::fs::read_to_string(&hooks_json).unwrap_or_default()
187    } else {
188        String::new()
189    };
190
191    let mut existing = if content.trim().is_empty() {
192        serde_json::json!({})
193    } else {
194        crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
195    };
196
197    if !existing.is_object() {
198        existing = serde_json::json!({});
199    }
200
201    // Merge-based: preserve other hooks/plugins. Only upsert lean-ctx entries.
202    merge_cursor_hooks(&mut existing, &rewrite_cmd, &redirect_cmd);
203
204    let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
205    write_file(&hooks_json, &formatted);
206
207    if !mcp_server_quiet_mode() {
208        eprintln!("Installed Cursor hooks at {}", hooks_json.display());
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn cursor_hooks_merge_preserves_other_entries() {
218        let mut v = serde_json::json!({
219            "version": 1,
220            "hooks": {
221                "preToolUse": [
222                    { "matcher": "Shell", "command": "/old/bin hook rewrite" },
223                    { "matcher": "Other", "command": "do-something" }
224                ],
225                "postToolUse": [
226                    { "matcher": "Shell", "command": "post" }
227                ]
228            },
229            "otherKey": { "x": 1 }
230        });
231
232        merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
233
234        assert!(v.get("otherKey").is_some());
235        assert!(v.pointer("/hooks/postToolUse").is_some());
236
237        let pre = v
238            .pointer("/hooks/preToolUse")
239            .and_then(|x| x.as_array())
240            .unwrap();
241        assert!(pre
242            .iter()
243            .any(|e| e.get("matcher").and_then(|m| m.as_str()) == Some("Other")));
244        assert!(pre.iter().any(|e| {
245            e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
246                && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook rewrite")
247        }));
248        assert!(pre.iter().any(|e| {
249            e.get("matcher").and_then(|m| m.as_str()) == Some("Read|Grep")
250                && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect")
251        }));
252    }
253}