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