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