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