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