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, write_file,
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|Glob", "Read|Grep", "Read", "Grep"],
95        "Read|Grep|Glob",
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 body = crate::core::rules_canonical::render(
141                false,
142                crate::core::rules_canonical::Wrapper::Dedicated,
143                crate::core::config::CompressionLevel::Off,
144            );
145            let rule_content = format!(
146                "---\n\
147                 description: \"lean-ctx: context compression layer. \
148                 Tools replace native Read/Grep/Shell — see rule body.\"\n\
149                 globs: **/*\n\
150                 alwaysApply: true\n\
151                 ---\n\n\
152                 {body}"
153            );
154            write_file(&rule_path, &rule_content);
155            if !mcp_server_quiet_mode() {
156                eprintln!("Created .cursor/rules/lean-ctx.mdc in current project.");
157            }
158        }
159    }
160
161    if !mcp_server_quiet_mode() {
162        eprintln!("Restart Cursor to activate.");
163    }
164}
165
166pub(crate) fn install_cursor_hook_with_mode(global: bool, mode: HookMode) {
167    match mode {
168        HookMode::Mcp => install_cursor_hook(global),
169        HookMode::Hybrid => {
170            install_cursor_hook(global);
171            install_cursor_rules_for_mode(global, mode);
172        }
173    }
174}
175
176fn install_cursor_rules_for_mode(global: bool, mode: HookMode) {
177    let content = cursor_mdc_for_mode(mode);
178    let mode_name = match mode {
179        HookMode::Hybrid => "hybrid",
180        HookMode::Mcp => "mcp",
181    };
182
183    if global {
184        if let Some(home) = crate::core::home::resolve_home_dir() {
185            let global_rules_dir = home.join(".cursor").join("rules");
186            let _ = std::fs::create_dir_all(&global_rules_dir);
187            let global_path = global_rules_dir.join("lean-ctx.mdc");
188            write_file(&global_path, &content);
189            if !mcp_server_quiet_mode() {
190                eprintln!(
191                    "Installed Cursor rules in {mode_name} mode at {}",
192                    global_path.display()
193                );
194            }
195        }
196    } else {
197        let rules_dir = PathBuf::from(".cursor").join("rules");
198        let _ = std::fs::create_dir_all(&rules_dir);
199        let rule_path = rules_dir.join("lean-ctx.mdc");
200        write_file(&rule_path, &content);
201        if !mcp_server_quiet_mode() {
202            eprintln!("Installed Cursor rules in {mode_name} mode at .cursor/rules/lean-ctx.mdc");
203        }
204    }
205}
206
207fn cursor_mdc_for_mode(_mode: HookMode) -> String {
208    let body = crate::core::rules_canonical::render(
209        false,
210        crate::core::rules_canonical::Wrapper::Dedicated,
211        crate::core::config::CompressionLevel::Off,
212    );
213    format!(
214        "---\n\
215         description: \"lean-ctx: context compression layer. \
216         Tools replace native Read/Grep/Shell — see rule body.\"\n\
217         globs: **/*\n\
218         alwaysApply: true\n\
219         ---\n\n\
220         {body}"
221    )
222}
223
224pub(crate) fn install_cursor_hook_scripts(home: &std::path::Path) {
225    let hooks_dir = home.join(".cursor").join("hooks");
226    install_standard_hook_scripts(&hooks_dir, "lean-ctx-rewrite.sh", "lean-ctx-redirect.sh");
227
228    let native_binary = resolve_binary_path();
229    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
230    write_file(
231        &rewrite_native,
232        &format!("#!/bin/sh\nexec {native_binary} hook rewrite\n"),
233    );
234    make_executable(&rewrite_native);
235
236    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
237    write_file(
238        &redirect_native,
239        &format!("#!/bin/sh\nexec {native_binary} hook redirect\n"),
240    );
241    make_executable(&redirect_native);
242}
243
244pub(crate) fn install_cursor_hook_config(home: &std::path::Path) {
245    let binary = resolve_binary_path();
246    let rewrite_cmd = format!("{binary} hook rewrite");
247    let redirect_cmd = format!("{binary} hook redirect");
248
249    let hooks_json = home.join(".cursor").join("hooks.json");
250
251    let content = if hooks_json.exists() {
252        std::fs::read_to_string(&hooks_json).unwrap_or_default()
253    } else {
254        String::new()
255    };
256
257    let mut existing = if content.trim().is_empty() {
258        serde_json::json!({})
259    } else {
260        crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
261    };
262
263    if !existing.is_object() {
264        existing = serde_json::json!({});
265    }
266
267    // Merge-based: preserve other hooks/plugins. Only upsert lean-ctx entries.
268    merge_cursor_hooks(&mut existing, &rewrite_cmd, &redirect_cmd);
269
270    let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
271    write_file(&hooks_json, &formatted);
272
273    if !mcp_server_quiet_mode() {
274        eprintln!("Installed Cursor hooks at {}", hooks_json.display());
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn cursor_hooks_merge_preserves_other_entries() {
284        let mut v = serde_json::json!({
285            "version": 1,
286            "hooks": {
287                "preToolUse": [
288                    { "matcher": "Shell", "command": "/old/bin hook rewrite" },
289                    { "matcher": "Other", "command": "do-something" }
290                ],
291                "postToolUse": [
292                    { "matcher": "Shell", "command": "post" }
293                ]
294            },
295            "otherKey": { "x": 1 }
296        });
297
298        merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
299
300        assert!(v.get("otherKey").is_some());
301        assert!(v.pointer("/hooks/postToolUse").is_some());
302
303        let pre = v
304            .pointer("/hooks/preToolUse")
305            .and_then(|x| x.as_array())
306            .unwrap();
307        assert!(
308            pre.iter()
309                .any(|e| e.get("matcher").and_then(|m| m.as_str()) == Some("Other"))
310        );
311        assert!(pre.iter().any(|e| {
312            e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
313                && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook rewrite")
314        }));
315        assert!(pre.iter().any(|e| {
316            e.get("matcher").and_then(|m| m.as_str()) == Some("Read|Grep|Glob")
317                && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect")
318        }));
319    }
320}