1use std::path::PathBuf;
2
3fn mcp_server_quiet_mode() -> bool {
4 std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
5}
6
7pub fn refresh_installed_hooks() {
10 let home = match dirs::home_dir() {
11 Some(h) => h,
12 None => return,
13 };
14
15 let claude_hooks = home.join(".claude/hooks/lean-ctx-rewrite.sh").exists()
16 || home.join(".claude/settings.json").exists()
17 && std::fs::read_to_string(home.join(".claude/settings.json"))
18 .unwrap_or_default()
19 .contains("lean-ctx");
20
21 if claude_hooks {
22 install_claude_hook_scripts(&home);
23 install_claude_hook_config(&home);
24 }
25
26 let cursor_hooks = home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
27 || home.join(".cursor/hooks.json").exists()
28 && std::fs::read_to_string(home.join(".cursor/hooks.json"))
29 .unwrap_or_default()
30 .contains("lean-ctx");
31
32 if cursor_hooks {
33 install_cursor_hook_scripts(&home);
34 install_cursor_hook_config(&home);
35 }
36
37 let gemini_rewrite = home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh");
38 let gemini_legacy = home.join(".gemini/hooks/lean-ctx-hook-gemini.sh");
39 if gemini_rewrite.exists() || gemini_legacy.exists() {
40 install_gemini_hook_scripts(&home);
41 install_gemini_hook_config(&home);
42 }
43
44 if home.join(".codex/hooks/lean-ctx-rewrite-codex.sh").exists() {
45 install_codex_hook_scripts(&home);
46 }
47}
48
49fn resolve_binary_path() -> String {
50 if is_lean_ctx_in_path() {
51 return "lean-ctx".to_string();
52 }
53 std::env::current_exe()
54 .map(|p| p.to_string_lossy().to_string())
55 .unwrap_or_else(|_| "lean-ctx".to_string())
56}
57
58fn is_lean_ctx_in_path() -> bool {
59 let which_cmd = if cfg!(windows) { "where" } else { "which" };
60 std::process::Command::new(which_cmd)
61 .arg("lean-ctx")
62 .stdout(std::process::Stdio::null())
63 .stderr(std::process::Stdio::null())
64 .status()
65 .map(|s| s.success())
66 .unwrap_or(false)
67}
68
69fn resolve_binary_path_for_bash() -> String {
70 let path = resolve_binary_path();
71 to_bash_compatible_path(&path)
72}
73
74pub fn to_bash_compatible_path(path: &str) -> String {
75 let path = path.replace('\\', "/");
76 if path.len() >= 2 && path.as_bytes()[1] == b':' {
77 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
78 format!("/{drive}{}", &path[2..])
79 } else {
80 path
81 }
82}
83
84pub fn normalize_tool_path(path: &str) -> String {
88 let mut p = path.to_string();
89
90 if p.len() >= 3
92 && p.starts_with('/')
93 && p.as_bytes()[1].is_ascii_alphabetic()
94 && p.as_bytes()[2] == b'/'
95 {
96 let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
97 p = format!("{drive}:{}", &p[2..]);
98 }
99
100 p = p.replace('\\', "/");
101
102 while p.contains("//") && !p.starts_with("//") {
104 p = p.replace("//", "/");
105 }
106
107 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
109 p.pop();
110 }
111
112 p
113}
114
115fn generate_rewrite_script(binary: &str) -> String {
116 format!(
117 r#"#!/usr/bin/env bash
118# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
119set -euo pipefail
120
121LEAN_CTX_BIN="{binary}"
122
123INPUT=$(cat)
124TOOL=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
125
126if [ "$TOOL" != "Bash" ] && [ "$TOOL" != "bash" ]; then
127 exit 0
128fi
129
130CMD=$(echo "$INPUT" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4)
131
132if echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
133 exit 0
134fi
135
136REWRITE=""
137case "$CMD" in
138 git\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
139 gh\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
140 cargo\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
141 npm\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
142 pnpm\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
143 yarn\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
144 docker\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
145 kubectl\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
146 pip\ *|pip3\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
147 ruff\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
148 go\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
149 curl\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
150 grep\ *|rg\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
151 find\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
152 cat\ *|head\ *|tail\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
153 ls\ *|ls) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
154 eslint*|prettier*|tsc*) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
155 pytest*|ruff\ *|mypy*) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
156 aws\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
157 helm\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
158 *) exit 0 ;;
159esac
160
161if [ -n "$REWRITE" ]; then
162 echo "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"$REWRITE\"}}}}}}"
163fi
164"#
165 )
166}
167
168fn generate_compact_rewrite_script(binary: &str) -> String {
169 format!(
170 r#"#!/usr/bin/env bash
171# lean-ctx hook — rewrites shell commands
172set -euo pipefail
173LEAN_CTX_BIN="{binary}"
174INPUT=$(cat)
175CMD=$(echo "$INPUT" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
176if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
177case "$CMD" in
178 git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|docker\ *|kubectl\ *|pip\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|ls\ *|ls|cat\ *|aws\ *|helm\ *)
179 echo "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"$LEAN_CTX_BIN -c $CMD\"}}}}}}" ;;
180 *) exit 0 ;;
181esac
182"#
183 )
184}
185
186const REDIRECT_SCRIPT_CLAUDE: &str = r#"#!/usr/bin/env bash
187# lean-ctx PreToolUse hook — redirects Read/Grep/List to MCP equivalents
188set -euo pipefail
189
190INPUT=$(cat)
191TOOL=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
192
193is_binary() {
194 local f="$1"
195 case "${f##*.}" in
196 png|jpg|jpeg|gif|webp|svg|ico|bmp|tiff|tif|avif|heic|heif|pdf|zip|gz|tar|bz2|xz|7z|rar|wasm|exe|dll|so|dylib|bin|dat|db|sqlite|sqlite3|mp3|mp4|wav|ogg|flac|avi|mov|mkv|webm|ttf|otf|woff|woff2|eot|psd|class|jar|pyc|o|a|lib|obj) return 0 ;;
197 *) return 1 ;;
198 esac
199}
200
201FILE_PATH=$(echo "$INPUT" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
202[ -z "$FILE_PATH" ] && FILE_PATH=$(echo "$INPUT" | grep -o '"path":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
203
204case "$TOOL" in
205 Read|read|ReadFile|read_file|View|view)
206 if [ -n "$FILE_PATH" ] && is_binary "$FILE_PATH"; then
207 exit 0
208 fi
209 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
210 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_read(path) from the lean-ctx MCP server instead. It saves 60-80% input tokens via caching and compression. Available modes: full, map, signatures, diff, lines:N-M. Never use native Read — always use ctx_read."}}'
211 fi
212 ;;
213 Grep|grep|Search|search|RipGrep|ripgrep)
214 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
215 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_search(pattern, path) from the lean-ctx MCP server instead. It provides compact, token-efficient results with .gitignore awareness. Never use native Grep — always use ctx_search."}}'
216 fi
217 ;;
218 ListFiles|list_files|ListDirectory|list_directory)
219 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
220 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_tree(path, depth) from the lean-ctx MCP server instead. It provides compact directory maps with file counts. Never use native ListFiles — always use ctx_tree."}}'
221 fi
222 ;;
223esac
224"#;
225
226const REDIRECT_SCRIPT_GENERIC: &str = r#"#!/usr/bin/env bash
227# lean-ctx hook — redirects Read/Grep to MCP equivalents
228set -euo pipefail
229
230INPUT=$(cat)
231TOOL=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
232
233is_binary() {
234 local f="$1"
235 case "${f##*.}" in
236 png|jpg|jpeg|gif|webp|svg|ico|bmp|tiff|tif|avif|heic|heif|pdf|zip|gz|tar|bz2|xz|7z|rar|wasm|exe|dll|so|dylib|bin|dat|db|sqlite|sqlite3|mp3|mp4|wav|ogg|flac|avi|mov|mkv|webm|ttf|otf|woff|woff2|eot|psd|class|jar|pyc|o|a|lib|obj) return 0 ;;
237 *) return 1 ;;
238 esac
239}
240
241FILE_PATH=$(echo "$INPUT" | grep -o '"file_path":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
242[ -z "$FILE_PATH" ] && FILE_PATH=$(echo "$INPUT" | grep -o '"path":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
243
244case "$TOOL" in
245 Read|read|ReadFile|read_file)
246 if [ -n "$FILE_PATH" ] && is_binary "$FILE_PATH"; then
247 exit 0
248 fi
249 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
250 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_read(path) from lean-ctx MCP instead. Saves 60-80% tokens."}}'
251 fi
252 ;;
253 Grep|grep|Search|search)
254 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
255 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_search(pattern, path) from lean-ctx MCP instead."}}'
256 fi
257 ;;
258 ListFiles|list_files|ListDirectory|list_directory)
259 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
260 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_tree(path, depth) from lean-ctx MCP instead."}}'
261 fi
262 ;;
263esac
264"#;
265
266pub fn install_project_rules() {
267 let cwd = std::env::current_dir().unwrap_or_default();
268
269 let agents_md = cwd.join("AGENTS.md");
270 if !agents_md.exists()
271 || !std::fs::read_to_string(&agents_md)
272 .unwrap_or_default()
273 .contains("lean-ctx")
274 {
275 let content = AGENTS_MD_TEMPLATE;
276 write_file(&agents_md, content);
277 println!("Created AGENTS.md in project root.");
278 }
279
280 let cursorrules = cwd.join(".cursorrules");
281 if !cursorrules.exists()
282 || !std::fs::read_to_string(&cursorrules)
283 .unwrap_or_default()
284 .contains("lean-ctx")
285 {
286 let content = CURSORRULES_TEMPLATE;
287 if cursorrules.exists() {
288 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
289 if !existing.ends_with('\n') {
290 existing.push('\n');
291 }
292 existing.push('\n');
293 existing.push_str(content);
294 write_file(&cursorrules, &existing);
295 } else {
296 write_file(&cursorrules, content);
297 }
298 println!("Created/updated .cursorrules in project root.");
299 }
300}
301
302const AGENTS_MD_TEMPLATE: &str = "\
303# lean-ctx — Context Engineering Layer
304
305PREFER lean-ctx MCP tools over native equivalents for token savings:
306
307| PREFER | OVER | Why |
308|--------|------|-----|
309| `ctx_read(path)` | Read / cat / head / tail | Cached, 8 compression modes, re-reads ~13 tokens |
310| `ctx_shell(command)` | Shell / bash / terminal | Pattern compression for git/npm/cargo output |
311| `ctx_search(pattern, path)` | Grep / rg / search | Compact, token-efficient results |
312| `ctx_tree(path, depth)` | ls / find / tree | Compact directory maps |
313| `ctx_edit(path, old_string, new_string)` | Edit (when Read unavailable) | Search-and-replace without native Read |
314
315Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
316Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
317";
318
319const CURSORRULES_TEMPLATE: &str = "\
320# lean-ctx — Context Engineering Layer
321
322PREFER lean-ctx MCP tools over native equivalents for token savings:
323
324| PREFER | OVER | Why |
325|--------|------|-----|
326| `ctx_read(path)` | `Read` | Cached, 8 compression modes |
327| `ctx_shell(command)` | `Shell` | Pattern compression |
328| `ctx_search(pattern, path)` | `Grep` | Compact results |
329| `ctx_tree(path, depth)` | `ls` / `find` | Directory maps |
330| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
331
332Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
333Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
334";
335
336pub fn install_agent_hook(agent: &str, global: bool) {
337 match agent {
338 "claude" | "claude-code" => install_claude_hook(global),
339 "cursor" => install_cursor_hook(global),
340 "gemini" => install_gemini_hook(),
341 "codex" => install_codex_hook(),
342 "windsurf" => install_windsurf_rules(global),
343 "cline" | "roo" => install_cline_rules(global),
344 "copilot" => install_copilot_hook(global),
345 "pi" => install_pi_hook(global),
346 "qwen" => install_mcp_json_agent(
347 "Qwen Code",
348 "~/.qwen/mcp.json",
349 &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
350 ),
351 "trae" => install_mcp_json_agent(
352 "Trae",
353 "~/.trae/mcp.json",
354 &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
355 ),
356 "amazonq" => install_mcp_json_agent(
357 "Amazon Q Developer",
358 "~/.aws/amazonq/mcp.json",
359 &dirs::home_dir()
360 .unwrap_or_default()
361 .join(".aws/amazonq/mcp.json"),
362 ),
363 "jetbrains" => install_mcp_json_agent(
364 "JetBrains IDEs",
365 "~/.jb-mcp.json",
366 &dirs::home_dir().unwrap_or_default().join(".jb-mcp.json"),
367 ),
368 "kiro" => install_mcp_json_agent(
369 "AWS Kiro",
370 "~/.kiro/settings/mcp.json",
371 &dirs::home_dir()
372 .unwrap_or_default()
373 .join(".kiro/settings/mcp.json"),
374 ),
375 "verdent" => install_mcp_json_agent(
376 "Verdent",
377 "~/.verdent/mcp.json",
378 &dirs::home_dir()
379 .unwrap_or_default()
380 .join(".verdent/mcp.json"),
381 ),
382 "opencode" => install_mcp_json_agent(
383 "OpenCode",
384 "~/.opencode/mcp.json",
385 &dirs::home_dir()
386 .unwrap_or_default()
387 .join(".opencode/mcp.json"),
388 ),
389 "aider" => install_mcp_json_agent(
390 "Aider",
391 "~/.aider/mcp.json",
392 &dirs::home_dir().unwrap_or_default().join(".aider/mcp.json"),
393 ),
394 "amp" => install_mcp_json_agent(
395 "Amp",
396 "~/.amp/mcp.json",
397 &dirs::home_dir().unwrap_or_default().join(".amp/mcp.json"),
398 ),
399 "crush" => install_crush_hook(),
400 _ => {
401 eprintln!("Unknown agent: {agent}");
402 eprintln!(" Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains, kiro, verdent, opencode, aider, amp, crush");
403 std::process::exit(1);
404 }
405 }
406}
407
408fn install_claude_hook(global: bool) {
409 let home = match dirs::home_dir() {
410 Some(h) => h,
411 None => {
412 eprintln!("Cannot resolve home directory");
413 return;
414 }
415 };
416
417 install_claude_hook_scripts(&home);
418 install_claude_hook_config(&home);
419
420 install_claude_global_md(&home);
421
422 if !global {
423 let claude_md = PathBuf::from("CLAUDE.md");
424 if !claude_md.exists()
425 || !std::fs::read_to_string(&claude_md)
426 .unwrap_or_default()
427 .contains("lean-ctx")
428 {
429 let content = include_str!("templates/CLAUDE.md");
430 write_file(&claude_md, content);
431 println!("Created CLAUDE.md in current project directory.");
432 } else {
433 println!("CLAUDE.md already configured.");
434 }
435 }
436}
437
438fn install_claude_global_md(home: &std::path::Path) {
439 let claude_dir = home.join(".claude");
440 let _ = std::fs::create_dir_all(&claude_dir);
441 let global_md = claude_dir.join("CLAUDE.md");
442
443 let existing = std::fs::read_to_string(&global_md).unwrap_or_default();
444 if existing.contains("lean-ctx") {
445 println!(" \x1b[32m✓\x1b[0m ~/.claude/CLAUDE.md already configured");
446 return;
447 }
448
449 let content = include_str!("templates/CLAUDE_GLOBAL.md");
450
451 if existing.is_empty() {
452 write_file(&global_md, content);
453 } else {
454 let mut merged = existing;
455 if !merged.ends_with('\n') {
456 merged.push('\n');
457 }
458 merged.push('\n');
459 merged.push_str(content);
460 write_file(&global_md, &merged);
461 }
462 println!(" \x1b[32m✓\x1b[0m Installed global ~/.claude/CLAUDE.md");
463}
464
465fn install_claude_hook_scripts(home: &std::path::Path) {
466 let hooks_dir = home.join(".claude").join("hooks");
467 let _ = std::fs::create_dir_all(&hooks_dir);
468
469 let binary = resolve_binary_path();
470
471 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
472 let rewrite_script = generate_rewrite_script(&resolve_binary_path_for_bash());
473 write_file(&rewrite_path, &rewrite_script);
474 make_executable(&rewrite_path);
475
476 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
477 write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
478 make_executable(&redirect_path);
479
480 let wrapper = |subcommand: &str| -> String {
481 if cfg!(windows) {
482 format!("{binary} hook {subcommand}")
483 } else {
484 format!("{} hook {subcommand}", resolve_binary_path_for_bash())
485 }
486 };
487
488 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
489 write_file(
490 &rewrite_native,
491 &format!(
492 "#!/bin/sh\nexec {} hook rewrite\n",
493 resolve_binary_path_for_bash()
494 ),
495 );
496 make_executable(&rewrite_native);
497
498 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
499 write_file(
500 &redirect_native,
501 &format!(
502 "#!/bin/sh\nexec {} hook redirect\n",
503 resolve_binary_path_for_bash()
504 ),
505 );
506 make_executable(&redirect_native);
507
508 let _ = wrapper; }
510
511fn install_claude_hook_config(home: &std::path::Path) {
512 let hooks_dir = home.join(".claude").join("hooks");
513 let binary = resolve_binary_path();
514
515 let rewrite_cmd = format!("{binary} hook rewrite");
516 let redirect_cmd = format!("{binary} hook redirect");
517
518 let settings_path = home.join(".claude").join("settings.json");
519 let settings_content = if settings_path.exists() {
520 std::fs::read_to_string(&settings_path).unwrap_or_default()
521 } else {
522 String::new()
523 };
524
525 let needs_update =
526 !settings_content.contains("hook rewrite") || !settings_content.contains("hook redirect");
527 let has_old_hooks = settings_content.contains("lean-ctx-rewrite.sh")
528 || settings_content.contains("lean-ctx-redirect.sh");
529
530 if !needs_update && !has_old_hooks {
531 return;
532 }
533
534 let hook_entry = serde_json::json!({
535 "hooks": {
536 "PreToolUse": [
537 {
538 "matcher": "Bash|bash",
539 "hooks": [{
540 "type": "command",
541 "command": rewrite_cmd
542 }]
543 },
544 {
545 "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
546 "hooks": [{
547 "type": "command",
548 "command": redirect_cmd
549 }]
550 }
551 ]
552 }
553 });
554
555 if settings_content.is_empty() {
556 write_file(
557 &settings_path,
558 &serde_json::to_string_pretty(&hook_entry).unwrap(),
559 );
560 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
561 if let Some(obj) = existing.as_object_mut() {
562 obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
563 write_file(
564 &settings_path,
565 &serde_json::to_string_pretty(&existing).unwrap(),
566 );
567 }
568 }
569 if !mcp_server_quiet_mode() {
570 println!("Installed Claude Code hooks at {}", hooks_dir.display());
571 }
572}
573
574fn install_cursor_hook(global: bool) {
575 let home = match dirs::home_dir() {
576 Some(h) => h,
577 None => {
578 eprintln!("Cannot resolve home directory");
579 return;
580 }
581 };
582
583 install_cursor_hook_scripts(&home);
584 install_cursor_hook_config(&home);
585
586 if !global {
587 let rules_dir = PathBuf::from(".cursor").join("rules");
588 let _ = std::fs::create_dir_all(&rules_dir);
589 let rule_path = rules_dir.join("lean-ctx.mdc");
590 if !rule_path.exists() {
591 let rule_content = include_str!("templates/lean-ctx.mdc");
592 write_file(&rule_path, rule_content);
593 println!("Created .cursor/rules/lean-ctx.mdc in current project.");
594 } else {
595 println!("Cursor rule already exists.");
596 }
597 } else {
598 println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
599 }
600
601 println!("Restart Cursor to activate.");
602}
603
604fn install_cursor_hook_scripts(home: &std::path::Path) {
605 let hooks_dir = home.join(".cursor").join("hooks");
606 let _ = std::fs::create_dir_all(&hooks_dir);
607
608 let binary = resolve_binary_path_for_bash();
609
610 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
611 let rewrite_script = generate_compact_rewrite_script(&binary);
612 write_file(&rewrite_path, &rewrite_script);
613 make_executable(&rewrite_path);
614
615 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
616 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
617 make_executable(&redirect_path);
618
619 let native_binary = resolve_binary_path();
620 let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
621 write_file(
622 &rewrite_native,
623 &format!("#!/bin/sh\nexec {} hook rewrite\n", native_binary),
624 );
625 make_executable(&rewrite_native);
626
627 let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
628 write_file(
629 &redirect_native,
630 &format!("#!/bin/sh\nexec {} hook redirect\n", native_binary),
631 );
632 make_executable(&redirect_native);
633}
634
635fn install_cursor_hook_config(home: &std::path::Path) {
636 let binary = resolve_binary_path();
637 let rewrite_cmd = format!("{binary} hook rewrite");
638 let redirect_cmd = format!("{binary} hook redirect");
639
640 let hooks_json = home.join(".cursor").join("hooks.json");
641
642 let hook_config = serde_json::json!({
643 "version": 1,
644 "hooks": {
645 "preToolUse": [
646 {
647 "matcher": "terminal_command",
648 "command": rewrite_cmd
649 },
650 {
651 "matcher": "read_file|grep|search|list_files|list_directory",
652 "command": redirect_cmd
653 }
654 ]
655 }
656 });
657
658 let content = if hooks_json.exists() {
659 std::fs::read_to_string(&hooks_json).unwrap_or_default()
660 } else {
661 String::new()
662 };
663
664 let has_correct_format = content.contains("\"version\"") && content.contains("\"preToolUse\"");
665 if has_correct_format && content.contains("hook rewrite") && content.contains("hook redirect") {
666 return;
667 }
668
669 if content.is_empty() || !content.contains("\"version\"") {
670 write_file(
671 &hooks_json,
672 &serde_json::to_string_pretty(&hook_config).unwrap(),
673 );
674 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&content) {
675 if let Some(obj) = existing.as_object_mut() {
676 obj.insert("version".to_string(), serde_json::json!(1));
677 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
678 write_file(
679 &hooks_json,
680 &serde_json::to_string_pretty(&existing).unwrap(),
681 );
682 }
683 } else {
684 write_file(
685 &hooks_json,
686 &serde_json::to_string_pretty(&hook_config).unwrap(),
687 );
688 }
689
690 if !mcp_server_quiet_mode() {
691 println!("Installed Cursor hooks at {}", hooks_json.display());
692 }
693}
694
695fn install_gemini_hook() {
696 let home = match dirs::home_dir() {
697 Some(h) => h,
698 None => {
699 eprintln!("Cannot resolve home directory");
700 return;
701 }
702 };
703
704 install_gemini_hook_scripts(&home);
705 install_gemini_hook_config(&home);
706}
707
708fn install_gemini_hook_scripts(home: &std::path::Path) {
709 let hooks_dir = home.join(".gemini").join("hooks");
710 let _ = std::fs::create_dir_all(&hooks_dir);
711
712 let binary = resolve_binary_path_for_bash();
713
714 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
715 let rewrite_script = generate_compact_rewrite_script(&binary);
716 write_file(&rewrite_path, &rewrite_script);
717 make_executable(&rewrite_path);
718
719 let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
720 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
721 make_executable(&redirect_path);
722}
723
724fn install_gemini_hook_config(home: &std::path::Path) {
725 let binary = resolve_binary_path();
726 let rewrite_cmd = format!("{binary} hook rewrite");
727 let redirect_cmd = format!("{binary} hook redirect");
728
729 let settings_path = home.join(".gemini").join("settings.json");
730 let settings_content = if settings_path.exists() {
731 std::fs::read_to_string(&settings_path).unwrap_or_default()
732 } else {
733 String::new()
734 };
735
736 let has_new_format = settings_content.contains("hook rewrite")
737 && settings_content.contains("hook redirect")
738 && settings_content.contains("\"type\"");
739 let has_old_hooks = settings_content.contains("lean-ctx-rewrite")
740 || settings_content.contains("lean-ctx-redirect")
741 || (settings_content.contains("hook rewrite") && !settings_content.contains("\"type\""));
742
743 if has_new_format && !has_old_hooks {
744 return;
745 }
746
747 let hook_config = serde_json::json!({
748 "hooks": {
749 "BeforeTool": [
750 {
751 "hooks": [{
752 "type": "command",
753 "command": rewrite_cmd
754 }]
755 },
756 {
757 "hooks": [{
758 "type": "command",
759 "command": redirect_cmd
760 }]
761 }
762 ]
763 }
764 });
765
766 if settings_content.is_empty() {
767 write_file(
768 &settings_path,
769 &serde_json::to_string_pretty(&hook_config).unwrap(),
770 );
771 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
772 if let Some(obj) = existing.as_object_mut() {
773 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
774 write_file(
775 &settings_path,
776 &serde_json::to_string_pretty(&existing).unwrap(),
777 );
778 }
779 }
780 if !mcp_server_quiet_mode() {
781 println!(
782 "Installed Gemini CLI hooks at {}",
783 settings_path.parent().unwrap_or(&settings_path).display()
784 );
785 }
786}
787
788fn install_codex_hook() {
789 let home = match dirs::home_dir() {
790 Some(h) => h,
791 None => {
792 eprintln!("Cannot resolve home directory");
793 return;
794 }
795 };
796
797 let codex_dir = home.join(".codex");
798 let _ = std::fs::create_dir_all(&codex_dir);
799
800 install_codex_hook_scripts(&home);
801
802 let agents_path = codex_dir.join("AGENTS.md");
803 let agents_content = "# Global Agent Instructions\n\n@LEAN-CTX.md\n";
804
805 let lean_ctx_md = codex_dir.join("LEAN-CTX.md");
806 let binary = resolve_binary_path();
807 let lean_ctx_content = format!(
808 r#"# lean-ctx — Token Optimization
809
810Prefix all shell commands with `{binary} -c` for compressed output:
811
812```bash
813{binary} -c git status # instead of: git status
814{binary} -c cargo test # instead of: cargo test
815{binary} -c ls src/ # instead of: ls src/
816```
817
818This saves 60-90% tokens per command. Works with: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more commands.
819Use `{binary} -c --raw <cmd>` to skip compression and get full output.
820"#
821 );
822
823 if agents_path.exists() {
824 let content = std::fs::read_to_string(&agents_path).unwrap_or_default();
825 if content.contains("lean-ctx") || content.contains("LEAN-CTX") {
826 println!("Codex AGENTS.md already configured.");
827 return;
828 }
829 }
830
831 write_file(&agents_path, agents_content);
832 write_file(&lean_ctx_md, &lean_ctx_content);
833 println!("Installed Codex instructions at {}", codex_dir.display());
834}
835
836fn install_codex_hook_scripts(home: &std::path::Path) {
837 let hooks_dir = home.join(".codex").join("hooks");
838 let _ = std::fs::create_dir_all(&hooks_dir);
839
840 let binary = resolve_binary_path_for_bash();
841 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-codex.sh");
842 let rewrite_script = generate_compact_rewrite_script(&binary);
843 write_file(&rewrite_path, &rewrite_script);
844 make_executable(&rewrite_path);
845 if !mcp_server_quiet_mode() {
846 println!(
847 " \x1b[32m✓\x1b[0m Installed Codex hook scripts at {}",
848 hooks_dir.display()
849 );
850 }
851}
852
853fn install_windsurf_rules(global: bool) {
854 if global {
855 println!("Global mode: skipping project-local .windsurfrules (use without --global in a project).");
856 return;
857 }
858
859 let rules_path = PathBuf::from(".windsurfrules");
860 if rules_path.exists() {
861 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
862 if content.contains("lean-ctx") {
863 println!(".windsurfrules already configured.");
864 return;
865 }
866 }
867
868 let rules = include_str!("templates/windsurfrules.txt");
869 write_file(&rules_path, rules);
870 println!("Installed .windsurfrules in current project.");
871}
872
873fn install_cline_rules(global: bool) {
874 if global {
875 println!(
876 "Global mode: skipping project-local .clinerules (use without --global in a project)."
877 );
878 return;
879 }
880
881 let rules_path = PathBuf::from(".clinerules");
882 if rules_path.exists() {
883 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
884 if content.contains("lean-ctx") {
885 println!(".clinerules already configured.");
886 return;
887 }
888 }
889
890 let binary = resolve_binary_path();
891 let rules = format!(
892 r#"# lean-ctx Shell Optimization
893# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
894
895When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
896- `{binary} -c git status` instead of `git status`
897- `{binary} -c cargo test` instead of `cargo test`
898- `{binary} -c ls src/` instead of `ls src/`
899
900Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
901"#
902 );
903
904 write_file(&rules_path, &rules);
905 println!("Installed .clinerules in current project.");
906}
907
908fn install_pi_hook(global: bool) {
909 let has_pi = std::process::Command::new("pi")
910 .arg("--version")
911 .output()
912 .is_ok();
913
914 if !has_pi {
915 println!("Pi Coding Agent not found in PATH.");
916 println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
917 println!();
918 }
919
920 println!("Installing pi-lean-ctx Pi Package...");
921 println!();
922
923 let install_result = std::process::Command::new("pi")
924 .args(["install", "npm:pi-lean-ctx"])
925 .status();
926
927 match install_result {
928 Ok(status) if status.success() => {
929 println!("Installed pi-lean-ctx Pi Package.");
930 }
931 _ => {
932 println!("Could not auto-install pi-lean-ctx. Install manually:");
933 println!(" pi install npm:pi-lean-ctx");
934 println!();
935 }
936 }
937
938 if !global {
939 let agents_md = PathBuf::from("AGENTS.md");
940 if !agents_md.exists()
941 || !std::fs::read_to_string(&agents_md)
942 .unwrap_or_default()
943 .contains("lean-ctx")
944 {
945 let content = include_str!("templates/PI_AGENTS.md");
946 write_file(&agents_md, content);
947 println!("Created AGENTS.md in current project directory.");
948 } else {
949 println!("AGENTS.md already contains lean-ctx configuration.");
950 }
951 } else {
952 println!(
953 "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
954 );
955 }
956
957 println!();
958 println!(
959 "Setup complete. All Pi tools (bash, read, grep, find, ls) now route through lean-ctx."
960 );
961 println!("Use /lean-ctx in Pi to verify the binary path.");
962}
963
964fn install_copilot_hook(global: bool) {
965 let binary = resolve_binary_path();
966
967 if global {
968 let mcp_path = copilot_global_mcp_path();
969 if mcp_path.as_os_str() == "/nonexistent" {
970 println!(" \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
971 return;
972 }
973 write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
974 } else {
975 let vscode_dir = PathBuf::from(".vscode");
976 let _ = std::fs::create_dir_all(&vscode_dir);
977 let mcp_path = vscode_dir.join("mcp.json");
978 write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
979 }
980}
981
982fn copilot_global_mcp_path() -> PathBuf {
983 if let Some(home) = dirs::home_dir() {
984 #[cfg(target_os = "macos")]
985 {
986 return home.join("Library/Application Support/Code/User/mcp.json");
987 }
988 #[cfg(target_os = "linux")]
989 {
990 return home.join(".config/Code/User/mcp.json");
991 }
992 #[cfg(target_os = "windows")]
993 {
994 if let Ok(appdata) = std::env::var("APPDATA") {
995 return PathBuf::from(appdata).join("Code/User/mcp.json");
996 }
997 }
998 #[allow(unreachable_code)]
999 home.join(".config/Code/User/mcp.json")
1000 } else {
1001 PathBuf::from("/nonexistent")
1002 }
1003}
1004
1005fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
1006 if mcp_path.exists() {
1007 let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
1008 if content.contains("lean-ctx") {
1009 println!(" \x1b[32m✓\x1b[0m Copilot already configured in {label}");
1010 return;
1011 }
1012
1013 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1014 if let Some(obj) = json.as_object_mut() {
1015 let servers = obj
1016 .entry("servers")
1017 .or_insert_with(|| serde_json::json!({}));
1018 if let Some(servers_obj) = servers.as_object_mut() {
1019 servers_obj.insert(
1020 "lean-ctx".to_string(),
1021 serde_json::json!({ "command": binary, "args": [] }),
1022 );
1023 }
1024 write_file(
1025 mcp_path,
1026 &serde_json::to_string_pretty(&json).unwrap_or_default(),
1027 );
1028 println!(" \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
1029 return;
1030 }
1031 }
1032 }
1033
1034 if let Some(parent) = mcp_path.parent() {
1035 let _ = std::fs::create_dir_all(parent);
1036 }
1037
1038 let config = serde_json::json!({
1039 "servers": {
1040 "lean-ctx": {
1041 "command": binary,
1042 "args": []
1043 }
1044 }
1045 });
1046
1047 write_file(
1048 mcp_path,
1049 &serde_json::to_string_pretty(&config).unwrap_or_default(),
1050 );
1051 println!(" \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
1052}
1053
1054fn write_file(path: &PathBuf, content: &str) {
1055 if let Err(e) = std::fs::write(path, content) {
1056 eprintln!("Error writing {}: {e}", path.display());
1057 }
1058}
1059
1060#[cfg(unix)]
1061fn make_executable(path: &PathBuf) {
1062 use std::os::unix::fs::PermissionsExt;
1063 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1064}
1065
1066#[cfg(not(unix))]
1067fn make_executable(_path: &PathBuf) {}
1068
1069fn install_crush_hook() {
1070 let binary = resolve_binary_path();
1071 let home = dirs::home_dir().unwrap_or_default();
1072 let config_path = home.join(".config/crush/crush.json");
1073 let display_path = "~/.config/crush/crush.json";
1074
1075 if let Some(parent) = config_path.parent() {
1076 let _ = std::fs::create_dir_all(parent);
1077 }
1078
1079 if config_path.exists() {
1080 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1081 if content.contains("lean-ctx") {
1082 println!("Crush MCP already configured at {display_path}");
1083 return;
1084 }
1085
1086 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1087 if let Some(obj) = json.as_object_mut() {
1088 let servers = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1089 if let Some(servers_obj) = servers.as_object_mut() {
1090 servers_obj.insert(
1091 "lean-ctx".to_string(),
1092 serde_json::json!({ "type": "stdio", "command": binary }),
1093 );
1094 }
1095 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1096 let _ = std::fs::write(&config_path, formatted);
1097 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1098 return;
1099 }
1100 }
1101 }
1102 }
1103
1104 let content = serde_json::to_string_pretty(&serde_json::json!({
1105 "mcp": {
1106 "lean-ctx": {
1107 "type": "stdio",
1108 "command": binary
1109 }
1110 }
1111 }));
1112
1113 if let Ok(json_str) = content {
1114 let _ = std::fs::write(&config_path, json_str);
1115 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1116 } else {
1117 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure Crush");
1118 }
1119}
1120
1121fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
1122 let binary = resolve_binary_path();
1123
1124 if let Some(parent) = config_path.parent() {
1125 let _ = std::fs::create_dir_all(parent);
1126 }
1127
1128 if config_path.exists() {
1129 let content = std::fs::read_to_string(config_path).unwrap_or_default();
1130 if content.contains("lean-ctx") {
1131 println!("{name} MCP already configured at {display_path}");
1132 return;
1133 }
1134
1135 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1136 if let Some(obj) = json.as_object_mut() {
1137 let servers = obj
1138 .entry("mcpServers")
1139 .or_insert_with(|| serde_json::json!({}));
1140 if let Some(servers_obj) = servers.as_object_mut() {
1141 servers_obj.insert(
1142 "lean-ctx".to_string(),
1143 serde_json::json!({ "command": binary }),
1144 );
1145 }
1146 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1147 let _ = std::fs::write(config_path, formatted);
1148 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1149 return;
1150 }
1151 }
1152 }
1153 }
1154
1155 let content = serde_json::to_string_pretty(&serde_json::json!({
1156 "mcpServers": {
1157 "lean-ctx": {
1158 "command": binary
1159 }
1160 }
1161 }));
1162
1163 if let Ok(json_str) = content {
1164 let _ = std::fs::write(config_path, json_str);
1165 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1166 } else {
1167 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure {name}");
1168 }
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173 use super::*;
1174
1175 #[test]
1176 fn bash_path_unix_unchanged() {
1177 assert_eq!(
1178 to_bash_compatible_path("/usr/local/bin/lean-ctx"),
1179 "/usr/local/bin/lean-ctx"
1180 );
1181 }
1182
1183 #[test]
1184 fn bash_path_home_unchanged() {
1185 assert_eq!(
1186 to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
1187 "/home/user/.cargo/bin/lean-ctx"
1188 );
1189 }
1190
1191 #[test]
1192 fn bash_path_windows_drive_converted() {
1193 assert_eq!(
1194 to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
1195 "/c/Users/Fraser/bin/lean-ctx.exe"
1196 );
1197 }
1198
1199 #[test]
1200 fn bash_path_windows_lowercase_drive() {
1201 assert_eq!(
1202 to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1203 "/d/tools/lean-ctx.exe"
1204 );
1205 }
1206
1207 #[test]
1208 fn bash_path_windows_forward_slashes() {
1209 assert_eq!(
1210 to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1211 "/c/Users/Fraser/bin/lean-ctx.exe"
1212 );
1213 }
1214
1215 #[test]
1216 fn bash_path_bare_name_unchanged() {
1217 assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1218 }
1219
1220 #[test]
1221 fn normalize_msys2_path() {
1222 assert_eq!(
1223 normalize_tool_path("/c/Users/game/Downloads/project"),
1224 "C:/Users/game/Downloads/project"
1225 );
1226 }
1227
1228 #[test]
1229 fn normalize_msys2_drive_d() {
1230 assert_eq!(
1231 normalize_tool_path("/d/Projects/app/src"),
1232 "D:/Projects/app/src"
1233 );
1234 }
1235
1236 #[test]
1237 fn normalize_backslashes() {
1238 assert_eq!(
1239 normalize_tool_path("C:\\Users\\game\\project\\src"),
1240 "C:/Users/game/project/src"
1241 );
1242 }
1243
1244 #[test]
1245 fn normalize_mixed_separators() {
1246 assert_eq!(
1247 normalize_tool_path("C:\\Users/game\\project/src"),
1248 "C:/Users/game/project/src"
1249 );
1250 }
1251
1252 #[test]
1253 fn normalize_double_slashes() {
1254 assert_eq!(
1255 normalize_tool_path("/home/user//project///src"),
1256 "/home/user/project/src"
1257 );
1258 }
1259
1260 #[test]
1261 fn normalize_trailing_slash() {
1262 assert_eq!(
1263 normalize_tool_path("/home/user/project/"),
1264 "/home/user/project"
1265 );
1266 }
1267
1268 #[test]
1269 fn normalize_root_preserved() {
1270 assert_eq!(normalize_tool_path("/"), "/");
1271 }
1272
1273 #[test]
1274 fn normalize_windows_root_preserved() {
1275 assert_eq!(normalize_tool_path("C:/"), "C:/");
1276 }
1277
1278 #[test]
1279 fn normalize_unix_path_unchanged() {
1280 assert_eq!(
1281 normalize_tool_path("/home/user/project/src/main.rs"),
1282 "/home/user/project/src/main.rs"
1283 );
1284 }
1285
1286 #[test]
1287 fn normalize_relative_path_unchanged() {
1288 assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1289 }
1290
1291 #[test]
1292 fn normalize_dot_unchanged() {
1293 assert_eq!(normalize_tool_path("."), ".");
1294 }
1295
1296 #[test]
1297 fn normalize_unc_path_preserved() {
1298 assert_eq!(
1299 normalize_tool_path("//server/share/file"),
1300 "//server/share/file"
1301 );
1302 }
1303
1304 #[test]
1305 fn cursor_hook_config_has_version_and_object_hooks() {
1306 let config = serde_json::json!({
1307 "version": 1,
1308 "hooks": {
1309 "preToolUse": [
1310 {
1311 "matcher": "terminal_command",
1312 "command": "lean-ctx hook rewrite"
1313 },
1314 {
1315 "matcher": "read_file|grep|search|list_files|list_directory",
1316 "command": "lean-ctx hook redirect"
1317 }
1318 ]
1319 }
1320 });
1321
1322 let json_str = serde_json::to_string_pretty(&config).unwrap();
1323 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1324
1325 assert_eq!(parsed["version"], 1);
1326 assert!(parsed["hooks"].is_object());
1327 assert!(parsed["hooks"]["preToolUse"].is_array());
1328 assert_eq!(parsed["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
1329 assert_eq!(
1330 parsed["hooks"]["preToolUse"][0]["matcher"],
1331 "terminal_command"
1332 );
1333 }
1334
1335 #[test]
1336 fn cursor_hook_detects_old_format_needs_migration() {
1337 let old_format = r#"{"hooks":[{"event":"preToolUse","command":"lean-ctx hook rewrite"}]}"#;
1338 let has_correct =
1339 old_format.contains("\"version\"") && old_format.contains("\"preToolUse\"");
1340 assert!(
1341 !has_correct,
1342 "Old format should be detected as needing migration"
1343 );
1344 }
1345
1346 #[test]
1347 fn gemini_hook_config_has_type_command() {
1348 let binary = "lean-ctx";
1349 let rewrite_cmd = format!("{binary} hook rewrite");
1350 let redirect_cmd = format!("{binary} hook redirect");
1351
1352 let hook_config = serde_json::json!({
1353 "hooks": {
1354 "BeforeTool": [
1355 {
1356 "hooks": [{
1357 "type": "command",
1358 "command": rewrite_cmd
1359 }]
1360 },
1361 {
1362 "hooks": [{
1363 "type": "command",
1364 "command": redirect_cmd
1365 }]
1366 }
1367 ]
1368 }
1369 });
1370
1371 let parsed = hook_config;
1372 let before_tool = parsed["hooks"]["BeforeTool"].as_array().unwrap();
1373 assert_eq!(before_tool.len(), 2);
1374
1375 let first_hook = &before_tool[0]["hooks"][0];
1376 assert_eq!(first_hook["type"], "command");
1377 assert_eq!(first_hook["command"], "lean-ctx hook rewrite");
1378
1379 let second_hook = &before_tool[1]["hooks"][0];
1380 assert_eq!(second_hook["type"], "command");
1381 assert_eq!(second_hook["command"], "lean-ctx hook redirect");
1382 }
1383
1384 #[test]
1385 fn gemini_hook_old_format_detected() {
1386 let old_format = r#"{"hooks":{"BeforeTool":[{"command":"lean-ctx hook rewrite"}]}}"#;
1387 let has_new = old_format.contains("hook rewrite")
1388 && old_format.contains("hook redirect")
1389 && old_format.contains("\"type\"");
1390 assert!(!has_new, "Missing 'type' field should trigger migration");
1391 }
1392}