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