Skip to main content

lean_ctx/
hooks.rs

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