Skip to main content

lean_ctx/
hooks.rs

1use std::path::PathBuf;
2
3/// Silently refresh all hook scripts for agents that are already configured.
4/// Called after updates and on MCP server start to ensure hooks match the current binary version.
5pub 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}