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