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