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
115pub fn 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
150pub fn 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" | "antigravity" => 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, antigravity, 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 write_pi_mcp_config();
856
857 if !global {
858 let agents_md = PathBuf::from("AGENTS.md");
859 if !agents_md.exists()
860 || !std::fs::read_to_string(&agents_md)
861 .unwrap_or_default()
862 .contains("lean-ctx")
863 {
864 let content = include_str!("templates/PI_AGENTS.md");
865 write_file(&agents_md, content);
866 println!("Created AGENTS.md in current project directory.");
867 } else {
868 println!("AGENTS.md already contains lean-ctx configuration.");
869 }
870 } else {
871 println!(
872 "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
873 );
874 }
875
876 println!();
877 println!("Setup complete. All Pi tools (bash, read, grep, find, ls) route through lean-ctx.");
878 println!("MCP tools (ctx_session, ctx_knowledge, ctx_semantic_search, ...) also available.");
879 println!("Use /lean-ctx in Pi to verify the binary path and MCP status.");
880}
881
882fn write_pi_mcp_config() {
883 let home = match dirs::home_dir() {
884 Some(h) => h,
885 None => return,
886 };
887
888 let mcp_config_path = home.join(".pi/agent/mcp.json");
889
890 if !home.join(".pi/agent").exists() {
891 println!(" \x1b[2m○ ~/.pi/agent/ not found — skipping MCP config\x1b[0m");
892 return;
893 }
894
895 if mcp_config_path.exists() {
896 let content = match std::fs::read_to_string(&mcp_config_path) {
897 Ok(c) => c,
898 Err(_) => return,
899 };
900 if content.contains("lean-ctx") {
901 println!(" \x1b[32m✓\x1b[0m Pi MCP config already contains lean-ctx");
902 return;
903 }
904
905 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
906 if let Some(obj) = json.as_object_mut() {
907 let servers = obj
908 .entry("mcpServers")
909 .or_insert_with(|| serde_json::json!({}));
910 if let Some(servers_obj) = servers.as_object_mut() {
911 servers_obj.insert("lean-ctx".to_string(), pi_mcp_server_entry());
912 }
913 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
914 let _ = std::fs::write(&mcp_config_path, formatted);
915 println!(
916 " \x1b[32m✓\x1b[0m Added lean-ctx to Pi MCP config (~/.pi/agent/mcp.json)"
917 );
918 }
919 }
920 }
921 return;
922 }
923
924 let content = serde_json::json!({
925 "mcpServers": {
926 "lean-ctx": pi_mcp_server_entry()
927 }
928 });
929 if let Ok(formatted) = serde_json::to_string_pretty(&content) {
930 let _ = std::fs::write(&mcp_config_path, formatted);
931 println!(" \x1b[32m✓\x1b[0m Created Pi MCP config (~/.pi/agent/mcp.json)");
932 }
933}
934
935fn pi_mcp_server_entry() -> serde_json::Value {
936 let binary = resolve_binary_path();
937 serde_json::json!({
938 "command": binary,
939 "lifecycle": "lazy",
940 "directTools": true
941 })
942}
943
944fn install_copilot_hook(global: bool) {
945 let binary = resolve_binary_path();
946
947 if global {
948 let mcp_path = copilot_global_mcp_path();
949 if mcp_path.as_os_str() == "/nonexistent" {
950 println!(" \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
951 return;
952 }
953 write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
954 } else {
955 let vscode_dir = PathBuf::from(".vscode");
956 let _ = std::fs::create_dir_all(&vscode_dir);
957 let mcp_path = vscode_dir.join("mcp.json");
958 write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
959 }
960}
961
962fn copilot_global_mcp_path() -> PathBuf {
963 if let Some(home) = dirs::home_dir() {
964 #[cfg(target_os = "macos")]
965 {
966 return home.join("Library/Application Support/Code/User/mcp.json");
967 }
968 #[cfg(target_os = "linux")]
969 {
970 return home.join(".config/Code/User/mcp.json");
971 }
972 #[cfg(target_os = "windows")]
973 {
974 if let Ok(appdata) = std::env::var("APPDATA") {
975 return PathBuf::from(appdata).join("Code/User/mcp.json");
976 }
977 }
978 #[allow(unreachable_code)]
979 home.join(".config/Code/User/mcp.json")
980 } else {
981 PathBuf::from("/nonexistent")
982 }
983}
984
985fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
986 if mcp_path.exists() {
987 let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
988 if content.contains("lean-ctx") {
989 println!(" \x1b[32m✓\x1b[0m Copilot already configured in {label}");
990 return;
991 }
992
993 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
994 if let Some(obj) = json.as_object_mut() {
995 let servers = obj
996 .entry("servers")
997 .or_insert_with(|| serde_json::json!({}));
998 if let Some(servers_obj) = servers.as_object_mut() {
999 servers_obj.insert(
1000 "lean-ctx".to_string(),
1001 serde_json::json!({ "command": binary, "args": [] }),
1002 );
1003 }
1004 write_file(
1005 mcp_path,
1006 &serde_json::to_string_pretty(&json).unwrap_or_default(),
1007 );
1008 println!(" \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
1009 return;
1010 }
1011 }
1012 }
1013
1014 if let Some(parent) = mcp_path.parent() {
1015 let _ = std::fs::create_dir_all(parent);
1016 }
1017
1018 let config = serde_json::json!({
1019 "servers": {
1020 "lean-ctx": {
1021 "command": binary,
1022 "args": []
1023 }
1024 }
1025 });
1026
1027 write_file(
1028 mcp_path,
1029 &serde_json::to_string_pretty(&config).unwrap_or_default(),
1030 );
1031 println!(" \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
1032}
1033
1034fn write_file(path: &PathBuf, content: &str) {
1035 if let Err(e) = std::fs::write(path, content) {
1036 eprintln!("Error writing {}: {e}", path.display());
1037 }
1038}
1039
1040#[cfg(unix)]
1041fn make_executable(path: &PathBuf) {
1042 use std::os::unix::fs::PermissionsExt;
1043 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1044}
1045
1046#[cfg(not(unix))]
1047fn make_executable(_path: &PathBuf) {}
1048
1049fn install_crush_hook() {
1050 let binary = resolve_binary_path();
1051 let home = dirs::home_dir().unwrap_or_default();
1052 let config_path = home.join(".config/crush/crush.json");
1053 let display_path = "~/.config/crush/crush.json";
1054
1055 if let Some(parent) = config_path.parent() {
1056 let _ = std::fs::create_dir_all(parent);
1057 }
1058
1059 if config_path.exists() {
1060 let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1061 if content.contains("lean-ctx") {
1062 println!("Crush MCP already configured at {display_path}");
1063 return;
1064 }
1065
1066 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1067 if let Some(obj) = json.as_object_mut() {
1068 let servers = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1069 if let Some(servers_obj) = servers.as_object_mut() {
1070 servers_obj.insert(
1071 "lean-ctx".to_string(),
1072 serde_json::json!({ "type": "stdio", "command": binary }),
1073 );
1074 }
1075 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1076 let _ = std::fs::write(&config_path, formatted);
1077 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1078 return;
1079 }
1080 }
1081 }
1082 }
1083
1084 let content = serde_json::to_string_pretty(&serde_json::json!({
1085 "mcp": {
1086 "lean-ctx": {
1087 "type": "stdio",
1088 "command": binary
1089 }
1090 }
1091 }));
1092
1093 if let Ok(json_str) = content {
1094 let _ = std::fs::write(&config_path, json_str);
1095 println!(" \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1096 } else {
1097 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure Crush");
1098 }
1099}
1100
1101fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
1102 let binary = resolve_binary_path();
1103
1104 if let Some(parent) = config_path.parent() {
1105 let _ = std::fs::create_dir_all(parent);
1106 }
1107
1108 if config_path.exists() {
1109 let content = std::fs::read_to_string(config_path).unwrap_or_default();
1110 if content.contains("lean-ctx") {
1111 println!("{name} MCP already configured at {display_path}");
1112 return;
1113 }
1114
1115 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1116 if let Some(obj) = json.as_object_mut() {
1117 let servers = obj
1118 .entry("mcpServers")
1119 .or_insert_with(|| serde_json::json!({}));
1120 if let Some(servers_obj) = servers.as_object_mut() {
1121 servers_obj.insert(
1122 "lean-ctx".to_string(),
1123 serde_json::json!({ "command": binary }),
1124 );
1125 }
1126 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1127 let _ = std::fs::write(config_path, formatted);
1128 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1129 return;
1130 }
1131 }
1132 }
1133 }
1134
1135 let content = serde_json::to_string_pretty(&serde_json::json!({
1136 "mcpServers": {
1137 "lean-ctx": {
1138 "command": binary
1139 }
1140 }
1141 }));
1142
1143 if let Ok(json_str) = content {
1144 let _ = std::fs::write(config_path, json_str);
1145 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1146 } else {
1147 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure {name}");
1148 }
1149}
1150
1151#[cfg(test)]
1152mod tests {
1153 use super::*;
1154
1155 #[test]
1156 fn bash_path_unix_unchanged() {
1157 assert_eq!(
1158 to_bash_compatible_path("/usr/local/bin/lean-ctx"),
1159 "/usr/local/bin/lean-ctx"
1160 );
1161 }
1162
1163 #[test]
1164 fn bash_path_home_unchanged() {
1165 assert_eq!(
1166 to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
1167 "/home/user/.cargo/bin/lean-ctx"
1168 );
1169 }
1170
1171 #[test]
1172 fn bash_path_windows_drive_converted() {
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_windows_lowercase_drive() {
1181 assert_eq!(
1182 to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1183 "/d/tools/lean-ctx.exe"
1184 );
1185 }
1186
1187 #[test]
1188 fn bash_path_windows_forward_slashes() {
1189 assert_eq!(
1190 to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1191 "/c/Users/Fraser/bin/lean-ctx.exe"
1192 );
1193 }
1194
1195 #[test]
1196 fn bash_path_bare_name_unchanged() {
1197 assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1198 }
1199
1200 #[test]
1201 fn normalize_msys2_path() {
1202 assert_eq!(
1203 normalize_tool_path("/c/Users/game/Downloads/project"),
1204 "C:/Users/game/Downloads/project"
1205 );
1206 }
1207
1208 #[test]
1209 fn normalize_msys2_drive_d() {
1210 assert_eq!(
1211 normalize_tool_path("/d/Projects/app/src"),
1212 "D:/Projects/app/src"
1213 );
1214 }
1215
1216 #[test]
1217 fn normalize_backslashes() {
1218 assert_eq!(
1219 normalize_tool_path("C:\\Users\\game\\project\\src"),
1220 "C:/Users/game/project/src"
1221 );
1222 }
1223
1224 #[test]
1225 fn normalize_mixed_separators() {
1226 assert_eq!(
1227 normalize_tool_path("C:\\Users/game\\project/src"),
1228 "C:/Users/game/project/src"
1229 );
1230 }
1231
1232 #[test]
1233 fn normalize_double_slashes() {
1234 assert_eq!(
1235 normalize_tool_path("/home/user//project///src"),
1236 "/home/user/project/src"
1237 );
1238 }
1239
1240 #[test]
1241 fn normalize_trailing_slash() {
1242 assert_eq!(
1243 normalize_tool_path("/home/user/project/"),
1244 "/home/user/project"
1245 );
1246 }
1247
1248 #[test]
1249 fn normalize_root_preserved() {
1250 assert_eq!(normalize_tool_path("/"), "/");
1251 }
1252
1253 #[test]
1254 fn normalize_windows_root_preserved() {
1255 assert_eq!(normalize_tool_path("C:/"), "C:/");
1256 }
1257
1258 #[test]
1259 fn normalize_unix_path_unchanged() {
1260 assert_eq!(
1261 normalize_tool_path("/home/user/project/src/main.rs"),
1262 "/home/user/project/src/main.rs"
1263 );
1264 }
1265
1266 #[test]
1267 fn normalize_relative_path_unchanged() {
1268 assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1269 }
1270
1271 #[test]
1272 fn normalize_dot_unchanged() {
1273 assert_eq!(normalize_tool_path("."), ".");
1274 }
1275
1276 #[test]
1277 fn normalize_unc_path_preserved() {
1278 assert_eq!(
1279 normalize_tool_path("//server/share/file"),
1280 "//server/share/file"
1281 );
1282 }
1283
1284 #[test]
1285 fn cursor_hook_config_has_version_and_object_hooks() {
1286 let config = serde_json::json!({
1287 "version": 1,
1288 "hooks": {
1289 "preToolUse": [
1290 {
1291 "matcher": "terminal_command",
1292 "command": "lean-ctx hook rewrite"
1293 },
1294 {
1295 "matcher": "read_file|grep|search|list_files|list_directory",
1296 "command": "lean-ctx hook redirect"
1297 }
1298 ]
1299 }
1300 });
1301
1302 let json_str = serde_json::to_string_pretty(&config).unwrap();
1303 let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1304
1305 assert_eq!(parsed["version"], 1);
1306 assert!(parsed["hooks"].is_object());
1307 assert!(parsed["hooks"]["preToolUse"].is_array());
1308 assert_eq!(parsed["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
1309 assert_eq!(
1310 parsed["hooks"]["preToolUse"][0]["matcher"],
1311 "terminal_command"
1312 );
1313 }
1314
1315 #[test]
1316 fn cursor_hook_detects_old_format_needs_migration() {
1317 let old_format = r#"{"hooks":[{"event":"preToolUse","command":"lean-ctx hook rewrite"}]}"#;
1318 let has_correct =
1319 old_format.contains("\"version\"") && old_format.contains("\"preToolUse\"");
1320 assert!(
1321 !has_correct,
1322 "Old format should be detected as needing migration"
1323 );
1324 }
1325
1326 #[test]
1327 fn gemini_hook_config_has_type_command() {
1328 let binary = "lean-ctx";
1329 let rewrite_cmd = format!("{binary} hook rewrite");
1330 let redirect_cmd = format!("{binary} hook redirect");
1331
1332 let hook_config = serde_json::json!({
1333 "hooks": {
1334 "BeforeTool": [
1335 {
1336 "hooks": [{
1337 "type": "command",
1338 "command": rewrite_cmd
1339 }]
1340 },
1341 {
1342 "hooks": [{
1343 "type": "command",
1344 "command": redirect_cmd
1345 }]
1346 }
1347 ]
1348 }
1349 });
1350
1351 let parsed = hook_config;
1352 let before_tool = parsed["hooks"]["BeforeTool"].as_array().unwrap();
1353 assert_eq!(before_tool.len(), 2);
1354
1355 let first_hook = &before_tool[0]["hooks"][0];
1356 assert_eq!(first_hook["type"], "command");
1357 assert_eq!(first_hook["command"], "lean-ctx hook rewrite");
1358
1359 let second_hook = &before_tool[1]["hooks"][0];
1360 assert_eq!(second_hook["type"], "command");
1361 assert_eq!(second_hook["command"], "lean-ctx hook redirect");
1362 }
1363
1364 #[test]
1365 fn gemini_hook_old_format_detected() {
1366 let old_format = r#"{"hooks":{"BeforeTool":[{"command":"lean-ctx hook rewrite"}]}}"#;
1367 let has_new = old_format.contains("hook rewrite")
1368 && old_format.contains("hook redirect")
1369 && old_format.contains("\"type\"");
1370 assert!(!has_new, "Missing 'type' field should trigger migration");
1371 }
1372}