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