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