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 if home.join(".claude/hooks/lean-ctx-rewrite.sh").exists() {
16 install_claude_hook_scripts(&home);
17 }
18
19 if home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists() {
20 install_cursor_hook_scripts(&home);
21 }
22
23 let gemini_rewrite = home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh");
24 let gemini_legacy = home.join(".gemini/hooks/lean-ctx-hook-gemini.sh");
25 if gemini_rewrite.exists() || gemini_legacy.exists() {
26 install_gemini_hook_scripts(&home);
27 }
28
29 if home.join(".codex/hooks/lean-ctx-rewrite-codex.sh").exists() {
30 install_codex_hook_scripts(&home);
31 }
32}
33
34fn resolve_binary_path() -> String {
35 if is_lean_ctx_in_path() {
36 return "lean-ctx".to_string();
37 }
38 std::env::current_exe()
39 .map(|p| p.to_string_lossy().to_string())
40 .unwrap_or_else(|_| "lean-ctx".to_string())
41}
42
43fn is_lean_ctx_in_path() -> bool {
44 let which_cmd = if cfg!(windows) { "where" } else { "which" };
45 std::process::Command::new(which_cmd)
46 .arg("lean-ctx")
47 .stdout(std::process::Stdio::null())
48 .stderr(std::process::Stdio::null())
49 .status()
50 .map(|s| s.success())
51 .unwrap_or(false)
52}
53
54fn resolve_binary_path_for_bash() -> String {
55 let path = resolve_binary_path();
56 to_bash_compatible_path(&path)
57}
58
59pub fn to_bash_compatible_path(path: &str) -> String {
60 let path = path.replace('\\', "/");
61 if path.len() >= 2 && path.as_bytes()[1] == b':' {
62 let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
63 format!("/{drive}{}", &path[2..])
64 } else {
65 path
66 }
67}
68
69pub fn normalize_tool_path(path: &str) -> String {
73 let mut p = path.to_string();
74
75 if p.len() >= 3
77 && p.starts_with('/')
78 && p.as_bytes()[1].is_ascii_alphabetic()
79 && p.as_bytes()[2] == b'/'
80 {
81 let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
82 p = format!("{drive}:{}", &p[2..]);
83 }
84
85 p = p.replace('\\', "/");
86
87 while p.contains("//") && !p.starts_with("//") {
89 p = p.replace("//", "/");
90 }
91
92 if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
94 p.pop();
95 }
96
97 p
98}
99
100fn generate_rewrite_script(binary: &str) -> String {
101 format!(
102 r#"#!/usr/bin/env bash
103# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
104set -euo pipefail
105
106LEAN_CTX_BIN="{binary}"
107
108INPUT=$(cat)
109TOOL=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4)
110
111if [ "$TOOL" != "Bash" ] && [ "$TOOL" != "bash" ]; then
112 exit 0
113fi
114
115CMD=$(echo "$INPUT" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4)
116
117if echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
118 exit 0
119fi
120
121REWRITE=""
122case "$CMD" in
123 git\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
124 gh\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
125 cargo\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
126 npm\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
127 pnpm\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
128 yarn\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
129 docker\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
130 kubectl\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
131 pip\ *|pip3\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
132 ruff\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
133 go\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
134 curl\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
135 grep\ *|rg\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
136 find\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
137 cat\ *|head\ *|tail\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
138 ls\ *|ls) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
139 eslint*|prettier*|tsc*) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
140 pytest*|ruff\ *|mypy*) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
141 aws\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
142 helm\ *) REWRITE="$LEAN_CTX_BIN -c $CMD" ;;
143 *) exit 0 ;;
144esac
145
146if [ -n "$REWRITE" ]; then
147 echo "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"$REWRITE\"}}}}}}"
148fi
149"#
150 )
151}
152
153fn generate_compact_rewrite_script(binary: &str) -> String {
154 format!(
155 r#"#!/usr/bin/env bash
156# lean-ctx hook — rewrites shell commands
157set -euo pipefail
158LEAN_CTX_BIN="{binary}"
159INPUT=$(cat)
160CMD=$(echo "$INPUT" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
161if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
162case "$CMD" in
163 git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|docker\ *|kubectl\ *|pip\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|ls\ *|ls|cat\ *|aws\ *|helm\ *)
164 echo "{{\"hookSpecificOutput\":{{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"updatedInput\":{{\"command\":\"$LEAN_CTX_BIN -c $CMD\"}}}}}}" ;;
165 *) exit 0 ;;
166esac
167"#
168 )
169}
170
171const REDIRECT_SCRIPT_CLAUDE: &str = r#"#!/usr/bin/env bash
172# lean-ctx PreToolUse hook — redirects Read/Grep/List to MCP equivalents
173set -euo pipefail
174
175INPUT=$(cat)
176TOOL=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
177
178case "$TOOL" in
179 Read|read|ReadFile|read_file|View|view)
180 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
181 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_read(path) from the lean-ctx MCP server instead. It saves 60-80% input tokens via caching and compression. Available modes: full, map, signatures, diff, lines:N-M. Never use native Read — always use ctx_read."}}'
182 fi
183 ;;
184 Grep|grep|Search|search|RipGrep|ripgrep)
185 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
186 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_search(pattern, path) from the lean-ctx MCP server instead. It provides compact, token-efficient results with .gitignore awareness. Never use native Grep — always use ctx_search."}}'
187 fi
188 ;;
189 ListFiles|list_files|ListDirectory|list_directory)
190 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
191 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_tree(path, depth) from the lean-ctx MCP server instead. It provides compact directory maps with file counts. Never use native ListFiles — always use ctx_tree."}}'
192 fi
193 ;;
194esac
195"#;
196
197const REDIRECT_SCRIPT_GENERIC: &str = r#"#!/usr/bin/env bash
198# lean-ctx hook — redirects Read/Grep to MCP equivalents
199set -euo pipefail
200
201INPUT=$(cat)
202TOOL=$(echo "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 2>/dev/null || echo "")
203
204case "$TOOL" in
205 Read|read|ReadFile|read_file)
206 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
207 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_read(path) from lean-ctx MCP instead. Saves 60-80% tokens."}}'
208 fi
209 ;;
210 Grep|grep|Search|search)
211 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
212 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_search(pattern, path) from lean-ctx MCP instead."}}'
213 fi
214 ;;
215 ListFiles|list_files|ListDirectory|list_directory)
216 if pgrep -f "lean-ctx" >/dev/null 2>&1; then
217 echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"STOP. Use ctx_tree(path, depth) from lean-ctx MCP instead."}}'
218 fi
219 ;;
220esac
221"#;
222
223pub fn install_project_rules() {
224 let cwd = std::env::current_dir().unwrap_or_default();
225
226 let agents_md = cwd.join("AGENTS.md");
227 if !agents_md.exists()
228 || !std::fs::read_to_string(&agents_md)
229 .unwrap_or_default()
230 .contains("lean-ctx")
231 {
232 let content = AGENTS_MD_TEMPLATE;
233 write_file(&agents_md, content);
234 println!("Created AGENTS.md in project root.");
235 }
236
237 let cursorrules = cwd.join(".cursorrules");
238 if !cursorrules.exists()
239 || !std::fs::read_to_string(&cursorrules)
240 .unwrap_or_default()
241 .contains("lean-ctx")
242 {
243 let content = CURSORRULES_TEMPLATE;
244 if cursorrules.exists() {
245 let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
246 if !existing.ends_with('\n') {
247 existing.push('\n');
248 }
249 existing.push('\n');
250 existing.push_str(content);
251 write_file(&cursorrules, &existing);
252 } else {
253 write_file(&cursorrules, content);
254 }
255 println!("Created/updated .cursorrules in project root.");
256 }
257}
258
259const AGENTS_MD_TEMPLATE: &str = "\
260# lean-ctx — Context Engineering Layer
261
262PREFER lean-ctx MCP tools over native equivalents for token savings:
263
264| PREFER | OVER | Why |
265|--------|------|-----|
266| `ctx_read(path)` | Read / cat / head / tail | Cached, 8 compression modes, re-reads ~13 tokens |
267| `ctx_shell(command)` | Shell / bash / terminal | Pattern compression for git/npm/cargo output |
268| `ctx_search(pattern, path)` | Grep / rg / search | Compact, token-efficient results |
269| `ctx_tree(path, depth)` | ls / find / tree | Compact directory maps |
270
271If your Edit tool requires a prior native Read, use native Read for that file — then edit normally.
272Write, StrReplace, Delete, Glob, Edit — use normally. Native tools allowed as fallback.
273";
274
275const CURSORRULES_TEMPLATE: &str = "\
276# lean-ctx — Context Engineering Layer
277
278PREFER lean-ctx MCP tools over native equivalents for token savings:
279
280| PREFER | OVER | Why |
281|--------|------|-----|
282| `ctx_read(path)` | `Read` | Cached, 8 compression modes |
283| `ctx_shell(command)` | `Shell` | Pattern compression |
284| `ctx_search(pattern, path)` | `Grep` | Compact results |
285| `ctx_tree(path, depth)` | `ls` / `find` | Directory maps |
286
287If your Edit tool requires a prior native Read, use native Read for that file — then edit normally.
288Write, StrReplace, Delete, Glob, Edit — use normally. Native tools allowed as fallback.
289";
290
291pub fn install_agent_hook(agent: &str, global: bool) {
292 match agent {
293 "claude" | "claude-code" => install_claude_hook(global),
294 "cursor" => install_cursor_hook(global),
295 "gemini" => install_gemini_hook(),
296 "codex" => install_codex_hook(),
297 "windsurf" => install_windsurf_rules(global),
298 "cline" | "roo" => install_cline_rules(global),
299 "copilot" => install_copilot_hook(global),
300 "pi" => install_pi_hook(global),
301 "qwen" => install_mcp_json_agent(
302 "Qwen Code",
303 "~/.qwen/mcp.json",
304 &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
305 ),
306 "trae" => install_mcp_json_agent(
307 "Trae",
308 "~/.trae/mcp.json",
309 &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
310 ),
311 "amazonq" => install_mcp_json_agent(
312 "Amazon Q Developer",
313 "~/.aws/amazonq/mcp.json",
314 &dirs::home_dir()
315 .unwrap_or_default()
316 .join(".aws/amazonq/mcp.json"),
317 ),
318 "jetbrains" => install_mcp_json_agent(
319 "JetBrains IDEs",
320 "~/.jb-mcp.json",
321 &dirs::home_dir().unwrap_or_default().join(".jb-mcp.json"),
322 ),
323 "kiro" => install_mcp_json_agent(
324 "AWS Kiro",
325 "~/.kiro/settings/mcp.json",
326 &dirs::home_dir()
327 .unwrap_or_default()
328 .join(".kiro/settings/mcp.json"),
329 ),
330 "verdent" => install_mcp_json_agent(
331 "Verdent",
332 "~/.verdent/mcp.json",
333 &dirs::home_dir()
334 .unwrap_or_default()
335 .join(".verdent/mcp.json"),
336 ),
337 "opencode" => install_mcp_json_agent(
338 "OpenCode",
339 "~/.opencode/mcp.json",
340 &dirs::home_dir()
341 .unwrap_or_default()
342 .join(".opencode/mcp.json"),
343 ),
344 "aider" => install_mcp_json_agent(
345 "Aider",
346 "~/.aider/mcp.json",
347 &dirs::home_dir().unwrap_or_default().join(".aider/mcp.json"),
348 ),
349 "amp" => install_mcp_json_agent(
350 "Amp",
351 "~/.amp/mcp.json",
352 &dirs::home_dir().unwrap_or_default().join(".amp/mcp.json"),
353 ),
354 _ => {
355 eprintln!("Unknown agent: {agent}");
356 eprintln!(" Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains, kiro, verdent, opencode, aider, amp");
357 std::process::exit(1);
358 }
359 }
360}
361
362fn install_claude_hook(global: bool) {
363 let home = match dirs::home_dir() {
364 Some(h) => h,
365 None => {
366 eprintln!("Cannot resolve home directory");
367 return;
368 }
369 };
370
371 install_claude_hook_scripts(&home);
372 install_claude_hook_config(&home);
373
374 install_claude_global_md(&home);
375
376 if !global {
377 let claude_md = PathBuf::from("CLAUDE.md");
378 if !claude_md.exists()
379 || !std::fs::read_to_string(&claude_md)
380 .unwrap_or_default()
381 .contains("lean-ctx")
382 {
383 let content = include_str!("templates/CLAUDE.md");
384 write_file(&claude_md, content);
385 println!("Created CLAUDE.md in current project directory.");
386 } else {
387 println!("CLAUDE.md already configured.");
388 }
389 }
390}
391
392fn install_claude_global_md(home: &std::path::Path) {
393 let claude_dir = home.join(".claude");
394 let _ = std::fs::create_dir_all(&claude_dir);
395 let global_md = claude_dir.join("CLAUDE.md");
396
397 let existing = std::fs::read_to_string(&global_md).unwrap_or_default();
398 if existing.contains("lean-ctx") {
399 println!(" \x1b[32m✓\x1b[0m ~/.claude/CLAUDE.md already configured");
400 return;
401 }
402
403 let content = include_str!("templates/CLAUDE_GLOBAL.md");
404
405 if existing.is_empty() {
406 write_file(&global_md, content);
407 } else {
408 let mut merged = existing;
409 if !merged.ends_with('\n') {
410 merged.push('\n');
411 }
412 merged.push('\n');
413 merged.push_str(content);
414 write_file(&global_md, &merged);
415 }
416 println!(" \x1b[32m✓\x1b[0m Installed global ~/.claude/CLAUDE.md");
417}
418
419fn install_claude_hook_scripts(home: &std::path::Path) {
420 let hooks_dir = home.join(".claude").join("hooks");
421 let _ = std::fs::create_dir_all(&hooks_dir);
422
423 let binary = resolve_binary_path_for_bash();
424
425 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
426 let rewrite_script = generate_rewrite_script(&binary);
427 write_file(&rewrite_path, &rewrite_script);
428 make_executable(&rewrite_path);
429
430 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
431 write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
432 make_executable(&redirect_path);
433}
434
435fn install_claude_hook_config(home: &std::path::Path) {
436 let hooks_dir = home.join(".claude").join("hooks");
437 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
438 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
439
440 let settings_path = home.join(".claude").join("settings.json");
441 let settings_content = if settings_path.exists() {
442 std::fs::read_to_string(&settings_path).unwrap_or_default()
443 } else {
444 String::new()
445 };
446
447 if settings_content.contains("lean-ctx-rewrite")
448 && settings_content.contains("lean-ctx-redirect")
449 {
450 return;
451 }
452
453 let hook_entry = serde_json::json!({
454 "hooks": {
455 "PreToolUse": [
456 {
457 "matcher": "Bash|bash",
458 "hooks": [{
459 "type": "command",
460 "command": rewrite_path.to_string_lossy()
461 }]
462 },
463 {
464 "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
465 "hooks": [{
466 "type": "command",
467 "command": redirect_path.to_string_lossy()
468 }]
469 }
470 ]
471 }
472 });
473
474 if settings_content.is_empty() {
475 write_file(
476 &settings_path,
477 &serde_json::to_string_pretty(&hook_entry).unwrap(),
478 );
479 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
480 if let Some(obj) = existing.as_object_mut() {
481 obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
482 write_file(
483 &settings_path,
484 &serde_json::to_string_pretty(&existing).unwrap(),
485 );
486 }
487 }
488 println!("Installed Claude Code hooks at {}", hooks_dir.display());
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
537fn install_cursor_hook_config(home: &std::path::Path) {
538 let hooks_dir = home.join(".cursor").join("hooks");
539 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
540 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
541
542 let hooks_json = home.join(".cursor").join("hooks.json");
543 let hook_config = serde_json::json!({
544 "hooks": [
545 {
546 "event": "preToolUse",
547 "matcher": {
548 "tool": "terminal_command"
549 },
550 "command": rewrite_path.to_string_lossy()
551 },
552 {
553 "event": "preToolUse",
554 "matcher": {
555 "tool": "read_file|grep|search|list_files|list_directory"
556 },
557 "command": redirect_path.to_string_lossy()
558 }
559 ]
560 });
561
562 let content = if hooks_json.exists() {
563 std::fs::read_to_string(&hooks_json).unwrap_or_default()
564 } else {
565 String::new()
566 };
567
568 if content.contains("lean-ctx-rewrite") && content.contains("lean-ctx-redirect") {
569 return;
570 }
571
572 write_file(
573 &hooks_json,
574 &serde_json::to_string_pretty(&hook_config).unwrap(),
575 );
576 println!("Installed Cursor hooks at {}", hooks_json.display());
577}
578
579fn install_gemini_hook() {
580 let home = match dirs::home_dir() {
581 Some(h) => h,
582 None => {
583 eprintln!("Cannot resolve home directory");
584 return;
585 }
586 };
587
588 install_gemini_hook_scripts(&home);
589 install_gemini_hook_config(&home);
590}
591
592fn install_gemini_hook_scripts(home: &std::path::Path) {
593 let hooks_dir = home.join(".gemini").join("hooks");
594 let _ = std::fs::create_dir_all(&hooks_dir);
595
596 let binary = resolve_binary_path_for_bash();
597
598 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
599 let rewrite_script = generate_compact_rewrite_script(&binary);
600 write_file(&rewrite_path, &rewrite_script);
601 make_executable(&rewrite_path);
602
603 let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
604 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
605 make_executable(&redirect_path);
606}
607
608fn install_gemini_hook_config(home: &std::path::Path) {
609 let hooks_dir = home.join(".gemini").join("hooks");
610 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
611 let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
612
613 let settings_path = home.join(".gemini").join("settings.json");
614 let settings_content = if settings_path.exists() {
615 std::fs::read_to_string(&settings_path).unwrap_or_default()
616 } else {
617 String::new()
618 };
619
620 if settings_content.contains("lean-ctx-rewrite")
621 && settings_content.contains("lean-ctx-redirect")
622 {
623 return;
624 }
625
626 let hook_config = serde_json::json!({
627 "hooks": {
628 "BeforeTool": [
629 {
630 "command": rewrite_path.to_string_lossy()
631 },
632 {
633 "command": redirect_path.to_string_lossy()
634 }
635 ]
636 }
637 });
638
639 if settings_content.is_empty() {
640 write_file(
641 &settings_path,
642 &serde_json::to_string_pretty(&hook_config).unwrap(),
643 );
644 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
645 if let Some(obj) = existing.as_object_mut() {
646 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
647 write_file(
648 &settings_path,
649 &serde_json::to_string_pretty(&existing).unwrap(),
650 );
651 }
652 }
653 println!("Installed Gemini CLI hooks at {}", hooks_dir.display());
654}
655
656fn install_codex_hook() {
657 let home = match dirs::home_dir() {
658 Some(h) => h,
659 None => {
660 eprintln!("Cannot resolve home directory");
661 return;
662 }
663 };
664
665 let codex_dir = home.join(".codex");
666 let _ = std::fs::create_dir_all(&codex_dir);
667
668 install_codex_hook_scripts(&home);
669
670 let agents_path = codex_dir.join("AGENTS.md");
671 let agents_content = "# Global Agent Instructions\n\n@LEAN-CTX.md\n";
672
673 let lean_ctx_md = codex_dir.join("LEAN-CTX.md");
674 let binary = resolve_binary_path();
675 let lean_ctx_content = format!(
676 r#"# lean-ctx — Token Optimization
677
678Prefix all shell commands with `{binary} -c` for compressed output:
679
680```bash
681{binary} -c git status # instead of: git status
682{binary} -c cargo test # instead of: cargo test
683{binary} -c ls src/ # instead of: ls src/
684```
685
686This 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.
687Use `{binary} -c --raw <cmd>` to skip compression and get full output.
688"#
689 );
690
691 if agents_path.exists() {
692 let content = std::fs::read_to_string(&agents_path).unwrap_or_default();
693 if content.contains("lean-ctx") || content.contains("LEAN-CTX") {
694 println!("Codex AGENTS.md already configured.");
695 return;
696 }
697 }
698
699 write_file(&agents_path, agents_content);
700 write_file(&lean_ctx_md, &lean_ctx_content);
701 println!("Installed Codex instructions at {}", codex_dir.display());
702}
703
704fn install_codex_hook_scripts(home: &std::path::Path) {
705 let hooks_dir = home.join(".codex").join("hooks");
706 let _ = std::fs::create_dir_all(&hooks_dir);
707
708 let binary = resolve_binary_path_for_bash();
709 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-codex.sh");
710 let rewrite_script = generate_compact_rewrite_script(&binary);
711 write_file(&rewrite_path, &rewrite_script);
712 make_executable(&rewrite_path);
713 if !mcp_server_quiet_mode() {
714 println!(
715 " \x1b[32m✓\x1b[0m Installed Codex hook scripts at {}",
716 hooks_dir.display()
717 );
718 }
719}
720
721fn install_windsurf_rules(global: bool) {
722 if global {
723 println!("Global mode: skipping project-local .windsurfrules (use without --global in a project).");
724 return;
725 }
726
727 let rules_path = PathBuf::from(".windsurfrules");
728 if rules_path.exists() {
729 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
730 if content.contains("lean-ctx") {
731 println!(".windsurfrules already configured.");
732 return;
733 }
734 }
735
736 let rules = include_str!("templates/windsurfrules.txt");
737 write_file(&rules_path, rules);
738 println!("Installed .windsurfrules in current project.");
739}
740
741fn install_cline_rules(global: bool) {
742 if global {
743 println!(
744 "Global mode: skipping project-local .clinerules (use without --global in a project)."
745 );
746 return;
747 }
748
749 let rules_path = PathBuf::from(".clinerules");
750 if rules_path.exists() {
751 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
752 if content.contains("lean-ctx") {
753 println!(".clinerules already configured.");
754 return;
755 }
756 }
757
758 let binary = resolve_binary_path();
759 let rules = format!(
760 r#"# lean-ctx Shell Optimization
761# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
762
763When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
764- `{binary} -c git status` instead of `git status`
765- `{binary} -c cargo test` instead of `cargo test`
766- `{binary} -c ls src/` instead of `ls src/`
767
768Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
769"#
770 );
771
772 write_file(&rules_path, &rules);
773 println!("Installed .clinerules in current project.");
774}
775
776fn install_pi_hook(global: bool) {
777 let has_pi = std::process::Command::new("pi")
778 .arg("--version")
779 .output()
780 .is_ok();
781
782 if !has_pi {
783 println!("Pi Coding Agent not found in PATH.");
784 println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
785 println!();
786 }
787
788 println!("Installing pi-lean-ctx Pi Package...");
789 println!();
790
791 let install_result = std::process::Command::new("pi")
792 .args(["install", "npm:pi-lean-ctx"])
793 .status();
794
795 match install_result {
796 Ok(status) if status.success() => {
797 println!("Installed pi-lean-ctx Pi Package.");
798 }
799 _ => {
800 println!("Could not auto-install pi-lean-ctx. Install manually:");
801 println!(" pi install npm:pi-lean-ctx");
802 println!();
803 }
804 }
805
806 if !global {
807 let agents_md = PathBuf::from("AGENTS.md");
808 if !agents_md.exists()
809 || !std::fs::read_to_string(&agents_md)
810 .unwrap_or_default()
811 .contains("lean-ctx")
812 {
813 let content = include_str!("templates/PI_AGENTS.md");
814 write_file(&agents_md, content);
815 println!("Created AGENTS.md in current project directory.");
816 } else {
817 println!("AGENTS.md already contains lean-ctx configuration.");
818 }
819 } else {
820 println!(
821 "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
822 );
823 }
824
825 println!();
826 println!(
827 "Setup complete. All Pi tools (bash, read, grep, find, ls) now route through lean-ctx."
828 );
829 println!("Use /lean-ctx in Pi to verify the binary path.");
830}
831
832fn install_copilot_hook(global: bool) {
833 let binary = resolve_binary_path();
834
835 if global {
836 let mcp_path = copilot_global_mcp_path();
837 if mcp_path.as_os_str() == "/nonexistent" {
838 println!(" \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
839 return;
840 }
841 write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
842 } else {
843 let vscode_dir = PathBuf::from(".vscode");
844 let _ = std::fs::create_dir_all(&vscode_dir);
845 let mcp_path = vscode_dir.join("mcp.json");
846 write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
847 }
848}
849
850fn copilot_global_mcp_path() -> PathBuf {
851 if let Some(home) = dirs::home_dir() {
852 #[cfg(target_os = "macos")]
853 {
854 return home.join("Library/Application Support/Code/User/mcp.json");
855 }
856 #[cfg(target_os = "linux")]
857 {
858 return home.join(".config/Code/User/mcp.json");
859 }
860 #[cfg(target_os = "windows")]
861 {
862 if let Ok(appdata) = std::env::var("APPDATA") {
863 return PathBuf::from(appdata).join("Code/User/mcp.json");
864 }
865 }
866 #[allow(unreachable_code)]
867 home.join(".config/Code/User/mcp.json")
868 } else {
869 PathBuf::from("/nonexistent")
870 }
871}
872
873fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
874 if mcp_path.exists() {
875 let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
876 if content.contains("lean-ctx") {
877 println!(" \x1b[32m✓\x1b[0m Copilot already configured in {label}");
878 return;
879 }
880
881 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
882 if let Some(obj) = json.as_object_mut() {
883 let servers = obj
884 .entry("servers")
885 .or_insert_with(|| serde_json::json!({}));
886 if let Some(servers_obj) = servers.as_object_mut() {
887 servers_obj.insert(
888 "lean-ctx".to_string(),
889 serde_json::json!({ "command": binary, "args": [] }),
890 );
891 }
892 write_file(
893 mcp_path,
894 &serde_json::to_string_pretty(&json).unwrap_or_default(),
895 );
896 println!(" \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
897 return;
898 }
899 }
900 }
901
902 if let Some(parent) = mcp_path.parent() {
903 let _ = std::fs::create_dir_all(parent);
904 }
905
906 let config = serde_json::json!({
907 "servers": {
908 "lean-ctx": {
909 "command": binary,
910 "args": []
911 }
912 }
913 });
914
915 write_file(
916 mcp_path,
917 &serde_json::to_string_pretty(&config).unwrap_or_default(),
918 );
919 println!(" \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
920}
921
922fn write_file(path: &PathBuf, content: &str) {
923 if let Err(e) = std::fs::write(path, content) {
924 eprintln!("Error writing {}: {e}", path.display());
925 }
926}
927
928#[cfg(unix)]
929fn make_executable(path: &PathBuf) {
930 use std::os::unix::fs::PermissionsExt;
931 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
932}
933
934#[cfg(not(unix))]
935fn make_executable(_path: &PathBuf) {}
936
937fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
938 let binary = resolve_binary_path();
939
940 if let Some(parent) = config_path.parent() {
941 let _ = std::fs::create_dir_all(parent);
942 }
943
944 if config_path.exists() {
945 let content = std::fs::read_to_string(config_path).unwrap_or_default();
946 if content.contains("lean-ctx") {
947 println!("{name} MCP already configured at {display_path}");
948 return;
949 }
950
951 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
952 if let Some(obj) = json.as_object_mut() {
953 let servers = obj
954 .entry("mcpServers")
955 .or_insert_with(|| serde_json::json!({}));
956 if let Some(servers_obj) = servers.as_object_mut() {
957 servers_obj.insert(
958 "lean-ctx".to_string(),
959 serde_json::json!({ "command": binary }),
960 );
961 }
962 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
963 let _ = std::fs::write(config_path, formatted);
964 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
965 return;
966 }
967 }
968 }
969 }
970
971 let content = serde_json::to_string_pretty(&serde_json::json!({
972 "mcpServers": {
973 "lean-ctx": {
974 "command": binary
975 }
976 }
977 }));
978
979 if let Ok(json_str) = content {
980 let _ = std::fs::write(config_path, json_str);
981 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
982 } else {
983 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure {name}");
984 }
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990
991 #[test]
992 fn bash_path_unix_unchanged() {
993 assert_eq!(
994 to_bash_compatible_path("/usr/local/bin/lean-ctx"),
995 "/usr/local/bin/lean-ctx"
996 );
997 }
998
999 #[test]
1000 fn bash_path_home_unchanged() {
1001 assert_eq!(
1002 to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
1003 "/home/user/.cargo/bin/lean-ctx"
1004 );
1005 }
1006
1007 #[test]
1008 fn bash_path_windows_drive_converted() {
1009 assert_eq!(
1010 to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
1011 "/c/Users/Fraser/bin/lean-ctx.exe"
1012 );
1013 }
1014
1015 #[test]
1016 fn bash_path_windows_lowercase_drive() {
1017 assert_eq!(
1018 to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1019 "/d/tools/lean-ctx.exe"
1020 );
1021 }
1022
1023 #[test]
1024 fn bash_path_windows_forward_slashes() {
1025 assert_eq!(
1026 to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1027 "/c/Users/Fraser/bin/lean-ctx.exe"
1028 );
1029 }
1030
1031 #[test]
1032 fn bash_path_bare_name_unchanged() {
1033 assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1034 }
1035
1036 #[test]
1037 fn normalize_msys2_path() {
1038 assert_eq!(
1039 normalize_tool_path("/c/Users/game/Downloads/project"),
1040 "C:/Users/game/Downloads/project"
1041 );
1042 }
1043
1044 #[test]
1045 fn normalize_msys2_drive_d() {
1046 assert_eq!(
1047 normalize_tool_path("/d/Projects/app/src"),
1048 "D:/Projects/app/src"
1049 );
1050 }
1051
1052 #[test]
1053 fn normalize_backslashes() {
1054 assert_eq!(
1055 normalize_tool_path("C:\\Users\\game\\project\\src"),
1056 "C:/Users/game/project/src"
1057 );
1058 }
1059
1060 #[test]
1061 fn normalize_mixed_separators() {
1062 assert_eq!(
1063 normalize_tool_path("C:\\Users/game\\project/src"),
1064 "C:/Users/game/project/src"
1065 );
1066 }
1067
1068 #[test]
1069 fn normalize_double_slashes() {
1070 assert_eq!(
1071 normalize_tool_path("/home/user//project///src"),
1072 "/home/user/project/src"
1073 );
1074 }
1075
1076 #[test]
1077 fn normalize_trailing_slash() {
1078 assert_eq!(
1079 normalize_tool_path("/home/user/project/"),
1080 "/home/user/project"
1081 );
1082 }
1083
1084 #[test]
1085 fn normalize_root_preserved() {
1086 assert_eq!(normalize_tool_path("/"), "/");
1087 }
1088
1089 #[test]
1090 fn normalize_windows_root_preserved() {
1091 assert_eq!(normalize_tool_path("C:/"), "C:/");
1092 }
1093
1094 #[test]
1095 fn normalize_unix_path_unchanged() {
1096 assert_eq!(
1097 normalize_tool_path("/home/user/project/src/main.rs"),
1098 "/home/user/project/src/main.rs"
1099 );
1100 }
1101
1102 #[test]
1103 fn normalize_relative_path_unchanged() {
1104 assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1105 }
1106
1107 #[test]
1108 fn normalize_dot_unchanged() {
1109 assert_eq!(normalize_tool_path("."), ".");
1110 }
1111
1112 #[test]
1113 fn normalize_unc_path_preserved() {
1114 assert_eq!(
1115 normalize_tool_path("//server/share/file"),
1116 "//server/share/file"
1117 );
1118 }
1119}