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(
99 pre_arr,
100 &["Read|Grep|Glob", "Read|Grep", "Grep|Glob", "Grep", "Read"],
101 "Read|Grep|Glob",
102 redirect_cmd,
103 );
104
105 let observe_cmd = rewrite_cmd.replace("hook rewrite", "hook observe");
108 ensure_observe_hook(hooks_obj, "sessionStart", &observe_cmd);
109 ensure_observe_hook(hooks_obj, "preCompact", &observe_cmd);
110
111 for stale in &[
113 "postToolUse",
114 "afterShellExecution",
115 "afterMCPExecution",
116 "beforeReadFile",
117 "afterAgentResponse",
118 "afterAgentThought",
119 "beforeSubmitPrompt",
120 "sessionEnd",
121 ] {
122 remove_observe_hook(hooks_obj, stale, &observe_cmd);
123 }
124}
125
126fn remove_observe_hook(
127 hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
128 event: &str,
129 _observe_cmd: &str,
130) {
131 let Some(arr) = hooks_obj.get_mut(event).and_then(|v| v.as_array_mut()) else {
132 return;
133 };
134 arr.retain(|e| {
135 !e.get("command")
136 .and_then(|c| c.as_str())
137 .is_some_and(|c| c.contains("hook observe"))
138 });
139 if arr.is_empty() {
140 hooks_obj.remove(event);
141 }
142}
143
144pub fn install_cursor_hook(global: bool) {
145 let Some(home) = crate::core::home::resolve_home_dir() else {
146 tracing::error!("Cannot resolve home directory");
147 return;
148 };
149
150 install_cursor_hook_scripts(&home);
151 install_cursor_hook_config(&home);
152
153 let scope = crate::core::config::Config::load().rules_scope_effective();
154 let skip_project = global || scope == crate::core::config::RulesScope::Global;
155
156 if skip_project {
157 if !mcp_server_quiet_mode() {
158 eprintln!(
159 "Global mode: skipping project-local .cursor/rules/ (use without --global in a project)."
160 );
161 }
162 } else {
163 let rules_dir = PathBuf::from(".cursor").join("rules");
164 let _ = std::fs::create_dir_all(&rules_dir);
165 let rule_path = rules_dir.join("lean-ctx.mdc");
166 if rule_path.exists() {
167 if !mcp_server_quiet_mode() {
168 eprintln!("Cursor rule already exists.");
169 }
170 } else {
171 write_file(&rule_path, &cursor_mdc_content(&home));
172 if !mcp_server_quiet_mode() {
173 eprintln!("Created .cursor/rules/lean-ctx.mdc in current project.");
174 }
175 }
176 }
177
178 if !mcp_server_quiet_mode() {
179 eprintln!("Restart Cursor to activate.");
180 }
181}
182
183pub(crate) fn install_cursor_hook_with_mode(global: bool, mode: HookMode) {
184 match mode {
185 HookMode::Mcp => install_cursor_hook(global),
186 HookMode::Hybrid => {
187 install_cursor_hook(global);
188 install_cursor_rules_for_mode(global, mode);
189 }
190 HookMode::Replace => {
191 install_cursor_hook(global);
192 install_cursor_deny_hook(global);
193 install_cursor_rules_for_mode(global, mode);
194 }
195 }
196}
197
198fn install_cursor_rules_for_mode(global: bool, mode: HookMode) {
199 let Some(home) = crate::core::home::resolve_home_dir() else {
200 return;
201 };
202 let content = cursor_mdc_content(&home);
203 let mode_name = match mode {
204 HookMode::Hybrid => "hybrid",
205 HookMode::Mcp => "mcp",
206 HookMode::Replace => "replace",
207 };
208
209 if global {
210 let global_rules_dir = home.join(".cursor").join("rules");
211 let _ = std::fs::create_dir_all(&global_rules_dir);
212 let global_path = global_rules_dir.join("lean-ctx.mdc");
213 write_file(&global_path, &content);
214 if !mcp_server_quiet_mode() {
215 eprintln!(
216 "Installed Cursor rules in {mode_name} mode at {}",
217 global_path.display()
218 );
219 }
220 } else {
221 let rules_dir = PathBuf::from(".cursor").join("rules");
222 let _ = std::fs::create_dir_all(&rules_dir);
223 let rule_path = rules_dir.join("lean-ctx.mdc");
224 write_file(&rule_path, &content);
225 if !mcp_server_quiet_mode() {
226 eprintln!("Installed Cursor rules in {mode_name} mode at .cursor/rules/lean-ctx.mdc");
227 }
228 }
229}
230
231fn cursor_mdc_content(home: &std::path::Path) -> String {
237 let cfg = crate::core::config::Config::load();
238 let wrapper = if crate::core::rules_channel::cursor_hooks_cover_native_tools(home) {
239 crate::core::rules_canonical::Wrapper::HookCovered
240 } else {
241 crate::core::rules_canonical::Wrapper::Dedicated
242 };
243 let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
244 let body = crate::core::rules_canonical::render(
245 cfg.shadow_mode,
246 wrapper,
247 crate::core::config::CompressionLevel::effective(&cfg),
248 &profile,
249 );
250 crate::rules_inject::cursor_mdc_document(&body)
251}
252
253pub(crate) fn install_cursor_hook_scripts(home: &std::path::Path) {
254 let hooks_dir = home.join(".cursor").join("hooks");
255 install_standard_hook_scripts(
256 &hooks_dir,
257 home,
258 "lean-ctx-rewrite.sh",
259 "lean-ctx-redirect.sh",
260 );
261
262 let native_binary = shell_quoted_binary(&resolve_binary_path_for_bash());
265 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
266 write_wrapper_file(
267 &rewrite_native,
268 &format!("#!/bin/sh\nexec {native_binary} hook rewrite\n"),
269 home,
270 );
271 make_executable(&rewrite_native);
272
273 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
274 write_wrapper_file(
275 &redirect_native,
276 &format!("#!/bin/sh\nexec {native_binary} hook redirect\n"),
277 home,
278 );
279 make_executable(&redirect_native);
280}
281
282pub(crate) fn install_cursor_deny_hook(_global: bool) {
285 let Some(home) = crate::core::home::resolve_home_dir() else {
286 return;
287 };
288 let binary = resolve_hook_command_binary();
289 let deny_cmd = format!("{binary} hook deny");
290
291 let hooks_json = home.join(".cursor").join("hooks.json");
292 let content = if hooks_json.exists() {
293 std::fs::read_to_string(&hooks_json).unwrap_or_default()
294 } else {
295 String::new()
296 };
297
298 let mut existing = if content.trim().is_empty() {
299 serde_json::json!({})
300 } else {
301 crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
302 };
303
304 if !existing.is_object() {
305 existing = serde_json::json!({});
306 }
307
308 let root = existing.as_object_mut().unwrap();
309 root.insert("version".to_string(), serde_json::json!(1));
310
311 let hooks = root
312 .entry("hooks".to_string())
313 .or_insert_with(|| serde_json::json!({}));
314 if !hooks.is_object() {
315 *hooks = serde_json::json!({});
316 }
317 let hooks_obj = hooks.as_object_mut().unwrap();
318
319 let pre = hooks_obj
320 .entry("preToolUse".to_string())
321 .or_insert_with(|| serde_json::json!([]));
322 if !pre.is_array() {
323 *pre = serde_json::json!([]);
324 }
325 let pre_arr = pre.as_array_mut().unwrap();
326
327 let redirect_cmd = deny_cmd.replace("hook deny", "hook redirect");
330 ensure_pretooluse_hook(pre_arr, &["Read"], "Read", &redirect_cmd);
331
332 ensure_pretooluse_hook(
334 pre_arr,
335 &["Read|Grep|Glob", "Read|Grep", "Grep", "Grep|Glob"],
336 "Grep|Glob",
337 &deny_cmd,
338 );
339
340 ensure_pretooluse_hook(
344 pre_arr,
345 &["StrReplace|Write|Edit|EditNotebook|MultiEdit"],
346 "StrReplace|Write|Edit|EditNotebook|MultiEdit",
347 &deny_cmd,
348 );
349
350 let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
351 write_file(&hooks_json, &formatted);
352
353 if !mcp_server_quiet_mode() {
354 eprintln!(" \x1b[32m✓\x1b[0m Cursor deny hook installed (Replace mode)");
355 }
356}
357
358pub(crate) fn install_cursor_hook_config(home: &std::path::Path) {
359 let binary = resolve_hook_command_binary();
360 let rewrite_cmd = format!("{binary} hook rewrite");
361 let redirect_cmd = format!("{binary} hook redirect");
362
363 let hooks_json = home.join(".cursor").join("hooks.json");
364
365 let content = if hooks_json.exists() {
366 std::fs::read_to_string(&hooks_json).unwrap_or_default()
367 } else {
368 String::new()
369 };
370
371 let mut existing = if content.trim().is_empty() {
372 serde_json::json!({})
373 } else {
374 crate::core::jsonc::parse_jsonc(&content).unwrap_or_else(|_| serde_json::json!({}))
375 };
376
377 if !existing.is_object() {
378 existing = serde_json::json!({});
379 }
380
381 merge_cursor_hooks(&mut existing, &rewrite_cmd, &redirect_cmd);
383
384 let formatted = serde_json::to_string_pretty(&existing).unwrap_or_default();
385 write_file(&hooks_json, &formatted);
386
387 if !mcp_server_quiet_mode() {
388 eprintln!("Installed Cursor hooks at {}", hooks_json.display());
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn cursor_hooks_merge_preserves_other_entries() {
398 let mut v = serde_json::json!({
399 "version": 1,
400 "hooks": {
401 "preToolUse": [
402 { "matcher": "Shell", "command": "/old/bin hook rewrite" },
403 { "matcher": "Other", "command": "do-something" }
404 ],
405 "postToolUse": [
406 { "matcher": "Shell", "command": "post" }
407 ]
408 },
409 "otherKey": { "x": 1 }
410 });
411
412 merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
413
414 assert!(v.get("otherKey").is_some());
415 assert!(v.pointer("/hooks/postToolUse").is_some());
416
417 let pre = v
418 .pointer("/hooks/preToolUse")
419 .and_then(|x| x.as_array())
420 .unwrap();
421 assert!(
422 pre.iter()
423 .any(|e| e.get("matcher").and_then(|m| m.as_str()) == Some("Other"))
424 );
425 assert!(pre.iter().any(|e| {
426 e.get("matcher").and_then(|m| m.as_str()) == Some("Shell")
427 && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook rewrite")
428 }));
429 assert!(pre.iter().any(|e| {
430 e.get("matcher").and_then(|m| m.as_str()) == Some("Read|Grep|Glob")
431 && e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect")
432 }));
433 }
434
435 #[test]
436 fn cursor_redirect_matcher_migrates_legacy_to_read_grep_glob() {
437 let mut v = serde_json::json!({
440 "version": 1,
441 "hooks": {
442 "preToolUse": [
443 { "matcher": "Grep", "command": "/old/bin hook redirect" }
444 ]
445 }
446 });
447
448 merge_cursor_hooks(&mut v, "/new/bin hook rewrite", "/new/bin hook redirect");
449
450 let pre = v
451 .pointer("/hooks/preToolUse")
452 .and_then(|x| x.as_array())
453 .unwrap();
454 let redirects: Vec<_> = pre
455 .iter()
456 .filter(|e| e.get("command").and_then(|c| c.as_str()) == Some("/new/bin hook redirect"))
457 .collect();
458 assert_eq!(redirects.len(), 1, "must migrate in place, not duplicate");
459 assert_eq!(
460 redirects[0].get("matcher").and_then(|m| m.as_str()),
461 Some("Read|Grep|Glob"),
462 "matcher must be Read|Grep|Glob (smart redirect for all)"
463 );
464 }
465
466 #[test]
467 fn replace_mode_redirects_read_and_denies_grep_glob() {
468 let mut v = serde_json::json!({
469 "version": 1,
470 "hooks": {
471 "preToolUse": [
472 { "matcher": "Shell", "command": "/bin/lean-ctx hook rewrite" },
473 { "matcher": "Read|Grep", "command": "/bin/lean-ctx hook redirect" }
474 ]
475 }
476 });
477
478 let deny_cmd = "/bin/lean-ctx hook deny";
479 let redirect_cmd = "/bin/lean-ctx hook redirect";
480 let pre = v
481 .pointer_mut("/hooks/preToolUse")
482 .and_then(|x| x.as_array_mut())
483 .unwrap();
484
485 ensure_pretooluse_hook(pre, &["Read"], "Read", redirect_cmd);
487 ensure_pretooluse_hook(
489 pre,
490 &["Read|Grep|Glob", "Read|Grep", "Grep", "Grep|Glob"],
491 "Grep|Glob",
492 deny_cmd,
493 );
494
495 let pre = v
496 .pointer("/hooks/preToolUse")
497 .and_then(|x| x.as_array())
498 .unwrap();
499
500 assert!(pre.iter().any(|e| {
502 e.get("matcher").and_then(|m| m.as_str()) == Some("Read")
503 && e.get("command")
504 .and_then(|c| c.as_str())
505 .is_some_and(|c| c.contains("redirect"))
506 }));
507 assert!(pre.iter().any(|e| {
509 e.get("matcher").and_then(|m| m.as_str()) == Some("Grep|Glob")
510 && e.get("command").and_then(|c| c.as_str()) == Some("/bin/lean-ctx hook deny")
511 }));
512 assert!(!pre.iter().any(|e| {
514 e.get("matcher")
515 .and_then(|m| m.as_str())
516 .is_some_and(|m| m == "Read|Grep")
517 }));
518 }
519
520 #[test]
521 fn replace_mode_registers_write_tool_deny_guard() {
522 let mut v = serde_json::json!({
524 "version": 1,
525 "hooks": { "preToolUse": [] }
526 });
527
528 let deny_cmd = "/bin/lean-ctx hook deny";
529 let redirect_cmd = "/bin/lean-ctx hook redirect";
530 let pre = v
531 .pointer_mut("/hooks/preToolUse")
532 .and_then(|x| x.as_array_mut())
533 .unwrap();
534
535 ensure_pretooluse_hook(pre, &["Read"], "Read", redirect_cmd);
536 ensure_pretooluse_hook(
537 pre,
538 &["Read|Grep|Glob", "Read|Grep", "Grep", "Grep|Glob"],
539 "Grep|Glob",
540 deny_cmd,
541 );
542 ensure_pretooluse_hook(
543 pre,
544 &["StrReplace|Write|Edit|EditNotebook|MultiEdit"],
545 "StrReplace|Write|Edit|EditNotebook|MultiEdit",
546 deny_cmd,
547 );
548
549 let pre = v
550 .pointer("/hooks/preToolUse")
551 .and_then(|x| x.as_array())
552 .unwrap();
553
554 assert!(
555 pre.iter().any(|e| {
556 e.get("matcher")
557 .and_then(|m| m.as_str())
558 .is_some_and(|m| m.contains("StrReplace"))
559 && e.get("command").and_then(|c| c.as_str()) == Some(deny_cmd)
560 }),
561 "write tools must be registered on hook deny"
562 );
563 }
564}