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 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 ensure_pretooluse_hook(
98 pre_arr,
99 &["Read|Grep|Glob", "Read|Grep", "Read", "Grep"],
100 "Read|Grep",
101 redirect_cmd,
102 );
103
104 let observe_cmd = rewrite_cmd.replace("hook rewrite", "hook observe");
106 ensure_observe_hook(hooks_obj, "afterMCPExecution", &observe_cmd);
107 ensure_observe_hook(hooks_obj, "postToolUse", &observe_cmd);
108 ensure_observe_hook(hooks_obj, "afterShellExecution", &observe_cmd);
109 ensure_observe_hook(hooks_obj, "beforeReadFile", &observe_cmd);
110 ensure_observe_hook(hooks_obj, "afterAgentResponse", &observe_cmd);
111 ensure_observe_hook(hooks_obj, "afterAgentThought", &observe_cmd);
112 ensure_observe_hook(hooks_obj, "beforeSubmitPrompt", &observe_cmd);
113 ensure_observe_hook(hooks_obj, "preCompact", &observe_cmd);
114 ensure_observe_hook(hooks_obj, "sessionStart", &observe_cmd);
115 ensure_observe_hook(hooks_obj, "sessionEnd", &observe_cmd);
116}
117
118pub fn install_cursor_hook(global: bool) {
119 let Some(home) = crate::core::home::resolve_home_dir() else {
120 tracing::error!("Cannot resolve home directory");
121 return;
122 };
123
124 install_cursor_hook_scripts(&home);
125 install_cursor_hook_config(&home);
126
127 let scope = crate::core::config::Config::load().rules_scope_effective();
128 let skip_project = global || scope == crate::core::config::RulesScope::Global;
129
130 if skip_project {
131 if !mcp_server_quiet_mode() {
132 eprintln!(
133 "Global mode: skipping project-local .cursor/rules/ (use without --global in a project)."
134 );
135 }
136 } else {
137 let rules_dir = PathBuf::from(".cursor").join("rules");
138 let _ = std::fs::create_dir_all(&rules_dir);
139 let rule_path = rules_dir.join("lean-ctx.mdc");
140 if rule_path.exists() {
141 if !mcp_server_quiet_mode() {
142 eprintln!("Cursor rule already exists.");
143 }
144 } else {
145 write_file(&rule_path, &cursor_mdc_content(&home));
146 if !mcp_server_quiet_mode() {
147 eprintln!("Created .cursor/rules/lean-ctx.mdc in current project.");
148 }
149 }
150 }
151
152 if !mcp_server_quiet_mode() {
153 eprintln!("Restart Cursor to activate.");
154 }
155}
156
157pub(crate) fn install_cursor_hook_with_mode(global: bool, mode: HookMode) {
158 match mode {
159 HookMode::Mcp => install_cursor_hook(global),
160 HookMode::Hybrid => {
161 install_cursor_hook(global);
162 install_cursor_rules_for_mode(global, mode);
163 }
164 }
165}
166
167fn install_cursor_rules_for_mode(global: bool, mode: HookMode) {
168 let Some(home) = crate::core::home::resolve_home_dir() else {
169 return;
170 };
171 let content = cursor_mdc_content(&home);
172 let mode_name = match mode {
173 HookMode::Hybrid => "hybrid",
174 HookMode::Mcp => "mcp",
175 };
176
177 if global {
178 let global_rules_dir = home.join(".cursor").join("rules");
179 let _ = std::fs::create_dir_all(&global_rules_dir);
180 let global_path = global_rules_dir.join("lean-ctx.mdc");
181 write_file(&global_path, &content);
182 if !mcp_server_quiet_mode() {
183 eprintln!(
184 "Installed Cursor rules in {mode_name} mode at {}",
185 global_path.display()
186 );
187 }
188 } else {
189 let rules_dir = PathBuf::from(".cursor").join("rules");
190 let _ = std::fs::create_dir_all(&rules_dir);
191 let rule_path = rules_dir.join("lean-ctx.mdc");
192 write_file(&rule_path, &content);
193 if !mcp_server_quiet_mode() {
194 eprintln!("Installed Cursor rules in {mode_name} mode at .cursor/rules/lean-ctx.mdc");
195 }
196 }
197}
198
199fn cursor_mdc_content(home: &std::path::Path) -> String {
205 let cfg = crate::core::config::Config::load();
206 let wrapper = if crate::core::rules_channel::cursor_hooks_cover_native_tools(home) {
207 crate::core::rules_canonical::Wrapper::HookCovered
208 } else {
209 crate::core::rules_canonical::Wrapper::Dedicated
210 };
211 let body = crate::core::rules_canonical::render(
212 cfg.shadow_mode,
213 wrapper,
214 crate::core::config::CompressionLevel::effective(&cfg),
215 );
216 crate::rules_inject::cursor_mdc_document(&body)
217}
218
219pub(crate) fn install_cursor_hook_scripts(home: &std::path::Path) {
220 let hooks_dir = home.join(".cursor").join("hooks");
221 install_standard_hook_scripts(
222 &hooks_dir,
223 home,
224 "lean-ctx-rewrite.sh",
225 "lean-ctx-redirect.sh",
226 );
227
228 let native_binary = shell_quoted_binary(&resolve_binary_path_for_bash());
231 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
232 write_wrapper_file(
233 &rewrite_native,
234 &format!("#!/bin/sh\nexec {native_binary} hook rewrite\n"),
235 home,
236 );
237 make_executable(&rewrite_native);
238
239 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
240 write_wrapper_file(
241 &redirect_native,
242 &format!("#!/bin/sh\nexec {native_binary} hook redirect\n"),
243 home,
244 );
245 make_executable(&redirect_native);
246}
247
248pub(crate) fn install_cursor_hook_config(home: &std::path::Path) {
249 let binary = resolve_hook_command_binary();
250 let rewrite_cmd = format!("{binary} hook rewrite");
251 let redirect_cmd = format!("{binary} hook redirect");
252
253 let hooks_json = home.join(".cursor").join("hooks.json");
254
255 let content = if hooks_json.exists() {
256 std::fs::read_to_string(&hooks_json).unwrap_or_default()
257 } else {
258 String::new()
259 };
260
261 let mut existing = if content.trim().is_empty() {
262 serde_json::json!({})
263 } else {
264 crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
265 };
266
267 if !existing.is_object() {
268 existing = serde_json::json!({});
269 }
270
271 merge_cursor_hooks(&mut existing, &rewrite_cmd, &redirect_cmd);
273
274 let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
275 write_file(&hooks_json, &formatted);
276
277 if !mcp_server_quiet_mode() {
278 eprintln!("Installed Cursor hooks at {}", hooks_json.display());
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn cursor_hooks_merge_preserves_other_entries() {
288 let mut v = serde_json::json!({
289 "version": 1,
290 "hooks": {
291 "preToolUse": [
292 { "matcher": "Shell", "command": "/old/bin hook rewrite" },
293 { "matcher": "Other", "command": "do-something" }
294 ],
295 "postToolUse": [
296 { "matcher": "Shell", "command": "post" }
297 ]
298 },
299 "otherKey": { "x": 1 }
300 });
301
302 merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
303
304 assert!(v.get("otherKey").is_some());
305 assert!(v.pointer("/hooks/postToolUse").is_some());
306
307 let pre = v
308 .pointer("/hooks/preToolUse")
309 .and_then(|x| x.as_array())
310 .unwrap();
311 assert!(
312 pre.iter()
313 .any(|e| e.get("matcher").and_then(|m| m.as_str()) == Some("Other"))
314 );
315 assert!(pre.iter().any(|e| {
316 e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
317 && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook rewrite")
318 }));
319 assert!(pre.iter().any(|e| {
320 e.get("matcher").and_then(|m| m.as_str()) == Some("Read|Grep")
321 && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect")
322 }));
323 }
324
325 #[test]
326 fn cursor_redirect_matcher_migrates_legacy_glob_arm() {
327 let mut v = serde_json::json!({
331 "version": 1,
332 "hooks": {
333 "preToolUse": [
334 { "matcher": "Read|Grep|Glob", "command": "/old/bin hook redirect" }
335 ]
336 }
337 });
338
339 merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
340
341 let pre = v
342 .pointer("/hooks/preToolUse")
343 .and_then(|x| x.as_array())
344 .unwrap();
345 let redirects: Vec<_> = pre
346 .iter()
347 .filter(|e| e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect"))
348 .collect();
349 assert_eq!(redirects.len(), 1, "must migrate in place, not duplicate");
350 assert_eq!(
351 redirects[0].get("matcher").and_then(|m| m.as_str()),
352 Some("Read|Grep")
353 );
354 assert!(
355 !pre.iter().any(|e| e
356 .get("matcher")
357 .and_then(|m| m.as_str())
358 .is_some_and(|m| m.contains("Glob"))),
359 "no Glob arm should remain for Cursor"
360 );
361 }
362}