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| `ctx_edit(path, old_string, new_string)` | Edit (when Read unavailable) | Search-and-replace without native Read |
271
272Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
273Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
274";
275
276const CURSORRULES_TEMPLATE: &str = "\
277# lean-ctx — Context Engineering Layer
278
279PREFER lean-ctx MCP tools over native equivalents for token savings:
280
281| PREFER | OVER | Why |
282|--------|------|-----|
283| `ctx_read(path)` | `Read` | Cached, 8 compression modes |
284| `ctx_shell(command)` | `Shell` | Pattern compression |
285| `ctx_search(pattern, path)` | `Grep` | Compact results |
286| `ctx_tree(path, depth)` | `ls` / `find` | Directory maps |
287| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
288
289Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
290Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
291";
292
293pub fn install_agent_hook(agent: &str, global: bool) {
294 match agent {
295 "claude" | "claude-code" => install_claude_hook(global),
296 "cursor" => install_cursor_hook(global),
297 "gemini" => install_gemini_hook(),
298 "codex" => install_codex_hook(),
299 "windsurf" => install_windsurf_rules(global),
300 "cline" | "roo" => install_cline_rules(global),
301 "copilot" => install_copilot_hook(global),
302 "pi" => install_pi_hook(global),
303 "qwen" => install_mcp_json_agent(
304 "Qwen Code",
305 "~/.qwen/mcp.json",
306 &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
307 ),
308 "trae" => install_mcp_json_agent(
309 "Trae",
310 "~/.trae/mcp.json",
311 &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
312 ),
313 "amazonq" => install_mcp_json_agent(
314 "Amazon Q Developer",
315 "~/.aws/amazonq/mcp.json",
316 &dirs::home_dir()
317 .unwrap_or_default()
318 .join(".aws/amazonq/mcp.json"),
319 ),
320 "jetbrains" => install_mcp_json_agent(
321 "JetBrains IDEs",
322 "~/.jb-mcp.json",
323 &dirs::home_dir().unwrap_or_default().join(".jb-mcp.json"),
324 ),
325 "kiro" => install_mcp_json_agent(
326 "AWS Kiro",
327 "~/.kiro/settings/mcp.json",
328 &dirs::home_dir()
329 .unwrap_or_default()
330 .join(".kiro/settings/mcp.json"),
331 ),
332 "verdent" => install_mcp_json_agent(
333 "Verdent",
334 "~/.verdent/mcp.json",
335 &dirs::home_dir()
336 .unwrap_or_default()
337 .join(".verdent/mcp.json"),
338 ),
339 "opencode" => install_mcp_json_agent(
340 "OpenCode",
341 "~/.opencode/mcp.json",
342 &dirs::home_dir()
343 .unwrap_or_default()
344 .join(".opencode/mcp.json"),
345 ),
346 "aider" => install_mcp_json_agent(
347 "Aider",
348 "~/.aider/mcp.json",
349 &dirs::home_dir().unwrap_or_default().join(".aider/mcp.json"),
350 ),
351 "amp" => install_mcp_json_agent(
352 "Amp",
353 "~/.amp/mcp.json",
354 &dirs::home_dir().unwrap_or_default().join(".amp/mcp.json"),
355 ),
356 _ => {
357 eprintln!("Unknown agent: {agent}");
358 eprintln!(" Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains, kiro, verdent, opencode, aider, amp");
359 std::process::exit(1);
360 }
361 }
362}
363
364fn install_claude_hook(global: bool) {
365 let home = match dirs::home_dir() {
366 Some(h) => h,
367 None => {
368 eprintln!("Cannot resolve home directory");
369 return;
370 }
371 };
372
373 install_claude_hook_scripts(&home);
374 install_claude_hook_config(&home);
375
376 install_claude_global_md(&home);
377
378 if !global {
379 let claude_md = PathBuf::from("CLAUDE.md");
380 if !claude_md.exists()
381 || !std::fs::read_to_string(&claude_md)
382 .unwrap_or_default()
383 .contains("lean-ctx")
384 {
385 let content = include_str!("templates/CLAUDE.md");
386 write_file(&claude_md, content);
387 println!("Created CLAUDE.md in current project directory.");
388 } else {
389 println!("CLAUDE.md already configured.");
390 }
391 }
392}
393
394fn install_claude_global_md(home: &std::path::Path) {
395 let claude_dir = home.join(".claude");
396 let _ = std::fs::create_dir_all(&claude_dir);
397 let global_md = claude_dir.join("CLAUDE.md");
398
399 let existing = std::fs::read_to_string(&global_md).unwrap_or_default();
400 if existing.contains("lean-ctx") {
401 println!(" \x1b[32m✓\x1b[0m ~/.claude/CLAUDE.md already configured");
402 return;
403 }
404
405 let content = include_str!("templates/CLAUDE_GLOBAL.md");
406
407 if existing.is_empty() {
408 write_file(&global_md, content);
409 } else {
410 let mut merged = existing;
411 if !merged.ends_with('\n') {
412 merged.push('\n');
413 }
414 merged.push('\n');
415 merged.push_str(content);
416 write_file(&global_md, &merged);
417 }
418 println!(" \x1b[32m✓\x1b[0m Installed global ~/.claude/CLAUDE.md");
419}
420
421fn install_claude_hook_scripts(home: &std::path::Path) {
422 let hooks_dir = home.join(".claude").join("hooks");
423 let _ = std::fs::create_dir_all(&hooks_dir);
424
425 let binary = resolve_binary_path_for_bash();
426
427 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
428 let rewrite_script = generate_rewrite_script(&binary);
429 write_file(&rewrite_path, &rewrite_script);
430 make_executable(&rewrite_path);
431
432 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
433 write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
434 make_executable(&redirect_path);
435}
436
437fn install_claude_hook_config(home: &std::path::Path) {
438 let hooks_dir = home.join(".claude").join("hooks");
439 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
440 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
441
442 let settings_path = home.join(".claude").join("settings.json");
443 let settings_content = if settings_path.exists() {
444 std::fs::read_to_string(&settings_path).unwrap_or_default()
445 } else {
446 String::new()
447 };
448
449 if settings_content.contains("lean-ctx-rewrite")
450 && settings_content.contains("lean-ctx-redirect")
451 {
452 return;
453 }
454
455 let hook_entry = serde_json::json!({
456 "hooks": {
457 "PreToolUse": [
458 {
459 "matcher": "Bash|bash",
460 "hooks": [{
461 "type": "command",
462 "command": rewrite_path.to_string_lossy()
463 }]
464 },
465 {
466 "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
467 "hooks": [{
468 "type": "command",
469 "command": redirect_path.to_string_lossy()
470 }]
471 }
472 ]
473 }
474 });
475
476 if settings_content.is_empty() {
477 write_file(
478 &settings_path,
479 &serde_json::to_string_pretty(&hook_entry).unwrap(),
480 );
481 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
482 if let Some(obj) = existing.as_object_mut() {
483 obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
484 write_file(
485 &settings_path,
486 &serde_json::to_string_pretty(&existing).unwrap(),
487 );
488 }
489 }
490 println!("Installed Claude Code hooks at {}", hooks_dir.display());
491}
492
493fn install_cursor_hook(global: bool) {
494 let home = match dirs::home_dir() {
495 Some(h) => h,
496 None => {
497 eprintln!("Cannot resolve home directory");
498 return;
499 }
500 };
501
502 install_cursor_hook_scripts(&home);
503 install_cursor_hook_config(&home);
504
505 if !global {
506 let rules_dir = PathBuf::from(".cursor").join("rules");
507 let _ = std::fs::create_dir_all(&rules_dir);
508 let rule_path = rules_dir.join("lean-ctx.mdc");
509 if !rule_path.exists() {
510 let rule_content = include_str!("templates/lean-ctx.mdc");
511 write_file(&rule_path, rule_content);
512 println!("Created .cursor/rules/lean-ctx.mdc in current project.");
513 } else {
514 println!("Cursor rule already exists.");
515 }
516 } else {
517 println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
518 }
519
520 println!("Restart Cursor to activate.");
521}
522
523fn install_cursor_hook_scripts(home: &std::path::Path) {
524 let hooks_dir = home.join(".cursor").join("hooks");
525 let _ = std::fs::create_dir_all(&hooks_dir);
526
527 let binary = resolve_binary_path_for_bash();
528
529 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
530 let rewrite_script = generate_compact_rewrite_script(&binary);
531 write_file(&rewrite_path, &rewrite_script);
532 make_executable(&rewrite_path);
533
534 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
535 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
536 make_executable(&redirect_path);
537}
538
539fn install_cursor_hook_config(home: &std::path::Path) {
540 let hooks_dir = home.join(".cursor").join("hooks");
541 let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
542 let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
543
544 let hooks_json = home.join(".cursor").join("hooks.json");
545 let hook_config = serde_json::json!({
546 "hooks": [
547 {
548 "event": "preToolUse",
549 "matcher": {
550 "tool": "terminal_command"
551 },
552 "command": rewrite_path.to_string_lossy()
553 },
554 {
555 "event": "preToolUse",
556 "matcher": {
557 "tool": "read_file|grep|search|list_files|list_directory"
558 },
559 "command": redirect_path.to_string_lossy()
560 }
561 ]
562 });
563
564 let content = if hooks_json.exists() {
565 std::fs::read_to_string(&hooks_json).unwrap_or_default()
566 } else {
567 String::new()
568 };
569
570 if content.contains("lean-ctx-rewrite") && content.contains("lean-ctx-redirect") {
571 return;
572 }
573
574 write_file(
575 &hooks_json,
576 &serde_json::to_string_pretty(&hook_config).unwrap(),
577 );
578 println!("Installed Cursor hooks at {}", hooks_json.display());
579}
580
581fn install_gemini_hook() {
582 let home = match dirs::home_dir() {
583 Some(h) => h,
584 None => {
585 eprintln!("Cannot resolve home directory");
586 return;
587 }
588 };
589
590 install_gemini_hook_scripts(&home);
591 install_gemini_hook_config(&home);
592}
593
594fn install_gemini_hook_scripts(home: &std::path::Path) {
595 let hooks_dir = home.join(".gemini").join("hooks");
596 let _ = std::fs::create_dir_all(&hooks_dir);
597
598 let binary = resolve_binary_path_for_bash();
599
600 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
601 let rewrite_script = generate_compact_rewrite_script(&binary);
602 write_file(&rewrite_path, &rewrite_script);
603 make_executable(&rewrite_path);
604
605 let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
606 write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
607 make_executable(&redirect_path);
608}
609
610fn install_gemini_hook_config(home: &std::path::Path) {
611 let hooks_dir = home.join(".gemini").join("hooks");
612 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
613 let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
614
615 let settings_path = home.join(".gemini").join("settings.json");
616 let settings_content = if settings_path.exists() {
617 std::fs::read_to_string(&settings_path).unwrap_or_default()
618 } else {
619 String::new()
620 };
621
622 if settings_content.contains("lean-ctx-rewrite")
623 && settings_content.contains("lean-ctx-redirect")
624 {
625 return;
626 }
627
628 let hook_config = serde_json::json!({
629 "hooks": {
630 "BeforeTool": [
631 {
632 "command": rewrite_path.to_string_lossy()
633 },
634 {
635 "command": redirect_path.to_string_lossy()
636 }
637 ]
638 }
639 });
640
641 if settings_content.is_empty() {
642 write_file(
643 &settings_path,
644 &serde_json::to_string_pretty(&hook_config).unwrap(),
645 );
646 } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
647 if let Some(obj) = existing.as_object_mut() {
648 obj.insert("hooks".to_string(), hook_config["hooks"].clone());
649 write_file(
650 &settings_path,
651 &serde_json::to_string_pretty(&existing).unwrap(),
652 );
653 }
654 }
655 println!("Installed Gemini CLI hooks at {}", hooks_dir.display());
656}
657
658fn install_codex_hook() {
659 let home = match dirs::home_dir() {
660 Some(h) => h,
661 None => {
662 eprintln!("Cannot resolve home directory");
663 return;
664 }
665 };
666
667 let codex_dir = home.join(".codex");
668 let _ = std::fs::create_dir_all(&codex_dir);
669
670 install_codex_hook_scripts(&home);
671
672 let agents_path = codex_dir.join("AGENTS.md");
673 let agents_content = "# Global Agent Instructions\n\n@LEAN-CTX.md\n";
674
675 let lean_ctx_md = codex_dir.join("LEAN-CTX.md");
676 let binary = resolve_binary_path();
677 let lean_ctx_content = format!(
678 r#"# lean-ctx — Token Optimization
679
680Prefix all shell commands with `{binary} -c` for compressed output:
681
682```bash
683{binary} -c git status # instead of: git status
684{binary} -c cargo test # instead of: cargo test
685{binary} -c ls src/ # instead of: ls src/
686```
687
688This 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.
689Use `{binary} -c --raw <cmd>` to skip compression and get full output.
690"#
691 );
692
693 if agents_path.exists() {
694 let content = std::fs::read_to_string(&agents_path).unwrap_or_default();
695 if content.contains("lean-ctx") || content.contains("LEAN-CTX") {
696 println!("Codex AGENTS.md already configured.");
697 return;
698 }
699 }
700
701 write_file(&agents_path, agents_content);
702 write_file(&lean_ctx_md, &lean_ctx_content);
703 println!("Installed Codex instructions at {}", codex_dir.display());
704}
705
706fn install_codex_hook_scripts(home: &std::path::Path) {
707 let hooks_dir = home.join(".codex").join("hooks");
708 let _ = std::fs::create_dir_all(&hooks_dir);
709
710 let binary = resolve_binary_path_for_bash();
711 let rewrite_path = hooks_dir.join("lean-ctx-rewrite-codex.sh");
712 let rewrite_script = generate_compact_rewrite_script(&binary);
713 write_file(&rewrite_path, &rewrite_script);
714 make_executable(&rewrite_path);
715 if !mcp_server_quiet_mode() {
716 println!(
717 " \x1b[32m✓\x1b[0m Installed Codex hook scripts at {}",
718 hooks_dir.display()
719 );
720 }
721}
722
723fn install_windsurf_rules(global: bool) {
724 if global {
725 println!("Global mode: skipping project-local .windsurfrules (use without --global in a project).");
726 return;
727 }
728
729 let rules_path = PathBuf::from(".windsurfrules");
730 if rules_path.exists() {
731 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
732 if content.contains("lean-ctx") {
733 println!(".windsurfrules already configured.");
734 return;
735 }
736 }
737
738 let rules = include_str!("templates/windsurfrules.txt");
739 write_file(&rules_path, rules);
740 println!("Installed .windsurfrules in current project.");
741}
742
743fn install_cline_rules(global: bool) {
744 if global {
745 println!(
746 "Global mode: skipping project-local .clinerules (use without --global in a project)."
747 );
748 return;
749 }
750
751 let rules_path = PathBuf::from(".clinerules");
752 if rules_path.exists() {
753 let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
754 if content.contains("lean-ctx") {
755 println!(".clinerules already configured.");
756 return;
757 }
758 }
759
760 let binary = resolve_binary_path();
761 let rules = format!(
762 r#"# lean-ctx Shell Optimization
763# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
764
765When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
766- `{binary} -c git status` instead of `git status`
767- `{binary} -c cargo test` instead of `cargo test`
768- `{binary} -c ls src/` instead of `ls src/`
769
770Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
771"#
772 );
773
774 write_file(&rules_path, &rules);
775 println!("Installed .clinerules in current project.");
776}
777
778fn install_pi_hook(global: bool) {
779 let has_pi = std::process::Command::new("pi")
780 .arg("--version")
781 .output()
782 .is_ok();
783
784 if !has_pi {
785 println!("Pi Coding Agent not found in PATH.");
786 println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
787 println!();
788 }
789
790 println!("Installing pi-lean-ctx Pi Package...");
791 println!();
792
793 let install_result = std::process::Command::new("pi")
794 .args(["install", "npm:pi-lean-ctx"])
795 .status();
796
797 match install_result {
798 Ok(status) if status.success() => {
799 println!("Installed pi-lean-ctx Pi Package.");
800 }
801 _ => {
802 println!("Could not auto-install pi-lean-ctx. Install manually:");
803 println!(" pi install npm:pi-lean-ctx");
804 println!();
805 }
806 }
807
808 if !global {
809 let agents_md = PathBuf::from("AGENTS.md");
810 if !agents_md.exists()
811 || !std::fs::read_to_string(&agents_md)
812 .unwrap_or_default()
813 .contains("lean-ctx")
814 {
815 let content = include_str!("templates/PI_AGENTS.md");
816 write_file(&agents_md, content);
817 println!("Created AGENTS.md in current project directory.");
818 } else {
819 println!("AGENTS.md already contains lean-ctx configuration.");
820 }
821 } else {
822 println!(
823 "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
824 );
825 }
826
827 println!();
828 println!(
829 "Setup complete. All Pi tools (bash, read, grep, find, ls) now route through lean-ctx."
830 );
831 println!("Use /lean-ctx in Pi to verify the binary path.");
832}
833
834fn install_copilot_hook(global: bool) {
835 let binary = resolve_binary_path();
836
837 if global {
838 let mcp_path = copilot_global_mcp_path();
839 if mcp_path.as_os_str() == "/nonexistent" {
840 println!(" \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
841 return;
842 }
843 write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
844 } else {
845 let vscode_dir = PathBuf::from(".vscode");
846 let _ = std::fs::create_dir_all(&vscode_dir);
847 let mcp_path = vscode_dir.join("mcp.json");
848 write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
849 }
850}
851
852fn copilot_global_mcp_path() -> PathBuf {
853 if let Some(home) = dirs::home_dir() {
854 #[cfg(target_os = "macos")]
855 {
856 return home.join("Library/Application Support/Code/User/mcp.json");
857 }
858 #[cfg(target_os = "linux")]
859 {
860 return home.join(".config/Code/User/mcp.json");
861 }
862 #[cfg(target_os = "windows")]
863 {
864 if let Ok(appdata) = std::env::var("APPDATA") {
865 return PathBuf::from(appdata).join("Code/User/mcp.json");
866 }
867 }
868 #[allow(unreachable_code)]
869 home.join(".config/Code/User/mcp.json")
870 } else {
871 PathBuf::from("/nonexistent")
872 }
873}
874
875fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
876 if mcp_path.exists() {
877 let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
878 if content.contains("lean-ctx") {
879 println!(" \x1b[32m✓\x1b[0m Copilot already configured in {label}");
880 return;
881 }
882
883 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
884 if let Some(obj) = json.as_object_mut() {
885 let servers = obj
886 .entry("servers")
887 .or_insert_with(|| serde_json::json!({}));
888 if let Some(servers_obj) = servers.as_object_mut() {
889 servers_obj.insert(
890 "lean-ctx".to_string(),
891 serde_json::json!({ "command": binary, "args": [] }),
892 );
893 }
894 write_file(
895 mcp_path,
896 &serde_json::to_string_pretty(&json).unwrap_or_default(),
897 );
898 println!(" \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
899 return;
900 }
901 }
902 }
903
904 if let Some(parent) = mcp_path.parent() {
905 let _ = std::fs::create_dir_all(parent);
906 }
907
908 let config = serde_json::json!({
909 "servers": {
910 "lean-ctx": {
911 "command": binary,
912 "args": []
913 }
914 }
915 });
916
917 write_file(
918 mcp_path,
919 &serde_json::to_string_pretty(&config).unwrap_or_default(),
920 );
921 println!(" \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
922}
923
924fn write_file(path: &PathBuf, content: &str) {
925 if let Err(e) = std::fs::write(path, content) {
926 eprintln!("Error writing {}: {e}", path.display());
927 }
928}
929
930#[cfg(unix)]
931fn make_executable(path: &PathBuf) {
932 use std::os::unix::fs::PermissionsExt;
933 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
934}
935
936#[cfg(not(unix))]
937fn make_executable(_path: &PathBuf) {}
938
939fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
940 let binary = resolve_binary_path();
941
942 if let Some(parent) = config_path.parent() {
943 let _ = std::fs::create_dir_all(parent);
944 }
945
946 if config_path.exists() {
947 let content = std::fs::read_to_string(config_path).unwrap_or_default();
948 if content.contains("lean-ctx") {
949 println!("{name} MCP already configured at {display_path}");
950 return;
951 }
952
953 if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
954 if let Some(obj) = json.as_object_mut() {
955 let servers = obj
956 .entry("mcpServers")
957 .or_insert_with(|| serde_json::json!({}));
958 if let Some(servers_obj) = servers.as_object_mut() {
959 servers_obj.insert(
960 "lean-ctx".to_string(),
961 serde_json::json!({ "command": binary }),
962 );
963 }
964 if let Ok(formatted) = serde_json::to_string_pretty(&json) {
965 let _ = std::fs::write(config_path, formatted);
966 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
967 return;
968 }
969 }
970 }
971 }
972
973 let content = serde_json::to_string_pretty(&serde_json::json!({
974 "mcpServers": {
975 "lean-ctx": {
976 "command": binary
977 }
978 }
979 }));
980
981 if let Ok(json_str) = content {
982 let _ = std::fs::write(config_path, json_str);
983 println!(" \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
984 } else {
985 eprintln!(" \x1b[31m✗\x1b[0m Failed to configure {name}");
986 }
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 #[test]
994 fn bash_path_unix_unchanged() {
995 assert_eq!(
996 to_bash_compatible_path("/usr/local/bin/lean-ctx"),
997 "/usr/local/bin/lean-ctx"
998 );
999 }
1000
1001 #[test]
1002 fn bash_path_home_unchanged() {
1003 assert_eq!(
1004 to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
1005 "/home/user/.cargo/bin/lean-ctx"
1006 );
1007 }
1008
1009 #[test]
1010 fn bash_path_windows_drive_converted() {
1011 assert_eq!(
1012 to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
1013 "/c/Users/Fraser/bin/lean-ctx.exe"
1014 );
1015 }
1016
1017 #[test]
1018 fn bash_path_windows_lowercase_drive() {
1019 assert_eq!(
1020 to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1021 "/d/tools/lean-ctx.exe"
1022 );
1023 }
1024
1025 #[test]
1026 fn bash_path_windows_forward_slashes() {
1027 assert_eq!(
1028 to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1029 "/c/Users/Fraser/bin/lean-ctx.exe"
1030 );
1031 }
1032
1033 #[test]
1034 fn bash_path_bare_name_unchanged() {
1035 assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1036 }
1037
1038 #[test]
1039 fn normalize_msys2_path() {
1040 assert_eq!(
1041 normalize_tool_path("/c/Users/game/Downloads/project"),
1042 "C:/Users/game/Downloads/project"
1043 );
1044 }
1045
1046 #[test]
1047 fn normalize_msys2_drive_d() {
1048 assert_eq!(
1049 normalize_tool_path("/d/Projects/app/src"),
1050 "D:/Projects/app/src"
1051 );
1052 }
1053
1054 #[test]
1055 fn normalize_backslashes() {
1056 assert_eq!(
1057 normalize_tool_path("C:\\Users\\game\\project\\src"),
1058 "C:/Users/game/project/src"
1059 );
1060 }
1061
1062 #[test]
1063 fn normalize_mixed_separators() {
1064 assert_eq!(
1065 normalize_tool_path("C:\\Users/game\\project/src"),
1066 "C:/Users/game/project/src"
1067 );
1068 }
1069
1070 #[test]
1071 fn normalize_double_slashes() {
1072 assert_eq!(
1073 normalize_tool_path("/home/user//project///src"),
1074 "/home/user/project/src"
1075 );
1076 }
1077
1078 #[test]
1079 fn normalize_trailing_slash() {
1080 assert_eq!(
1081 normalize_tool_path("/home/user/project/"),
1082 "/home/user/project"
1083 );
1084 }
1085
1086 #[test]
1087 fn normalize_root_preserved() {
1088 assert_eq!(normalize_tool_path("/"), "/");
1089 }
1090
1091 #[test]
1092 fn normalize_windows_root_preserved() {
1093 assert_eq!(normalize_tool_path("C:/"), "C:/");
1094 }
1095
1096 #[test]
1097 fn normalize_unix_path_unchanged() {
1098 assert_eq!(
1099 normalize_tool_path("/home/user/project/src/main.rs"),
1100 "/home/user/project/src/main.rs"
1101 );
1102 }
1103
1104 #[test]
1105 fn normalize_relative_path_unchanged() {
1106 assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1107 }
1108
1109 #[test]
1110 fn normalize_dot_unchanged() {
1111 assert_eq!(normalize_tool_path("."), ".");
1112 }
1113
1114 #[test]
1115 fn normalize_unc_path_preserved() {
1116 assert_eq!(
1117 normalize_tool_path("//server/share/file"),
1118 "//server/share/file"
1119 );
1120 }
1121}