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