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