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