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