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