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    let claude_dir = crate::setup::claude_config_dir(&home);
16    let claude_hooks = claude_dir.join("hooks/lean-ctx-rewrite.sh").exists()
17        || claude_dir.join("settings.json").exists()
18            && std::fs::read_to_string(claude_dir.join("settings.json"))
19                .unwrap_or_default()
20                .contains("lean-ctx");
21
22    if claude_hooks {
23        install_claude_hook_scripts(&home);
24        install_claude_hook_config(&home);
25    }
26
27    let cursor_hooks = home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
28        || home.join(".cursor/hooks.json").exists()
29            && std::fs::read_to_string(home.join(".cursor/hooks.json"))
30                .unwrap_or_default()
31                .contains("lean-ctx");
32
33    if cursor_hooks {
34        install_cursor_hook_scripts(&home);
35        install_cursor_hook_config(&home);
36    }
37
38    let gemini_rewrite = home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh");
39    let gemini_legacy = home.join(".gemini/hooks/lean-ctx-hook-gemini.sh");
40    if gemini_rewrite.exists() || gemini_legacy.exists() {
41        install_gemini_hook_scripts(&home);
42        install_gemini_hook_config(&home);
43    }
44
45    if home.join(".codex/hooks/lean-ctx-rewrite-codex.sh").exists() {
46        install_codex_hook_scripts(&home);
47    }
48}
49
50fn resolve_binary_path() -> String {
51    if is_lean_ctx_in_path() {
52        return "lean-ctx".to_string();
53    }
54    std::env::current_exe()
55        .map(|p| p.to_string_lossy().to_string())
56        .unwrap_or_else(|_| "lean-ctx".to_string())
57}
58
59fn is_lean_ctx_in_path() -> bool {
60    let which_cmd = if cfg!(windows) { "where" } else { "which" };
61    std::process::Command::new(which_cmd)
62        .arg("lean-ctx")
63        .stdout(std::process::Stdio::null())
64        .stderr(std::process::Stdio::null())
65        .status()
66        .map(|s| s.success())
67        .unwrap_or(false)
68}
69
70fn resolve_binary_path_for_bash() -> String {
71    let path = resolve_binary_path();
72    to_bash_compatible_path(&path)
73}
74
75pub fn to_bash_compatible_path(path: &str) -> String {
76    let path = path.replace('\\', "/");
77    if path.len() >= 2 && path.as_bytes()[1] == b':' {
78        let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
79        format!("/{drive}{}", &path[2..])
80    } else {
81        path
82    }
83}
84
85/// Normalize paths from any client format to a consistent OS-native form.
86/// Handles MSYS2/Git Bash (`/c/Users/...` -> `C:/Users/...`), mixed separators,
87/// double slashes, and trailing slashes. Always uses forward slashes for consistency.
88pub fn normalize_tool_path(path: &str) -> String {
89    let mut p = path.to_string();
90
91    // MSYS2/Git Bash: /c/Users/... -> C:/Users/...
92    if p.len() >= 3
93        && p.starts_with('/')
94        && p.as_bytes()[1].is_ascii_alphabetic()
95        && p.as_bytes()[2] == b'/'
96    {
97        let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
98        p = format!("{drive}:{}", &p[2..]);
99    }
100
101    p = p.replace('\\', "/");
102
103    // Collapse double slashes (preserve UNC paths starting with //)
104    while p.contains("//") && !p.starts_with("//") {
105        p = p.replace("//", "/");
106    }
107
108    // Remove trailing slash (unless root like "/" or "C:/")
109    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
110        p.pop();
111    }
112
113    p
114}
115
116pub fn generate_rewrite_script(binary: &str) -> String {
117    format!(
118        r#"#!/usr/bin/env bash
119# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
120set -euo pipefail
121
122LEAN_CTX_BIN="{binary}"
123
124INPUT=$(cat)
125TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
126
127if [ "$TOOL" != "Bash" ] && [ "$TOOL" != "bash" ]; then
128  exit 0
129fi
130
131CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
132
133if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
134  exit 0
135fi
136
137case "$CMD" in
138  git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|yarn\ *|docker\ *|kubectl\ *|pip\ *|pip3\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|cat\ *|head\ *|tail\ *|ls\ *|ls|eslint*|prettier*|tsc*|pytest*|mypy*|aws\ *|helm\ *)
139    # Shell-escape then JSON-escape (two passes)
140    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
141    REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
142    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
143    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
144    ;;
145  *) exit 0 ;;
146esac
147"#
148    )
149}
150
151pub fn generate_compact_rewrite_script(binary: &str) -> String {
152    format!(
153        r#"#!/usr/bin/env bash
154# lean-ctx hook — rewrites shell commands
155set -euo pipefail
156LEAN_CTX_BIN="{binary}"
157INPUT=$(cat)
158CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
159if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
160case "$CMD" in
161  git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|docker\ *|kubectl\ *|pip\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|ls\ *|ls|cat\ *|aws\ *|helm\ *)
162    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
163    REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
164    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
165    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
166  *) exit 0 ;;
167esac
168"#
169    )
170}
171
172const REDIRECT_SCRIPT_CLAUDE: &str = r#"#!/usr/bin/env bash
173# lean-ctx PreToolUse hook — all native tools pass through
174# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
175# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
176exit 0
177"#;
178
179const REDIRECT_SCRIPT_GENERIC: &str = r#"#!/usr/bin/env bash
180# lean-ctx hook — all native tools pass through
181exit 0
182"#;
183
184pub fn install_project_rules() {
185    let cwd = std::env::current_dir().unwrap_or_default();
186
187    ensure_project_agents_integration(&cwd);
188
189    let cursorrules = cwd.join(".cursorrules");
190    if !cursorrules.exists()
191        || !std::fs::read_to_string(&cursorrules)
192            .unwrap_or_default()
193            .contains("lean-ctx")
194    {
195        let content = CURSORRULES_TEMPLATE;
196        if cursorrules.exists() {
197            let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
198            if !existing.ends_with('\n') {
199                existing.push('\n');
200            }
201            existing.push('\n');
202            existing.push_str(content);
203            write_file(&cursorrules, &existing);
204        } else {
205            write_file(&cursorrules, content);
206        }
207        println!("Created/updated .cursorrules in project root.");
208    }
209
210    let kiro_dir = cwd.join(".kiro");
211    if kiro_dir.exists() {
212        let steering_dir = kiro_dir.join("steering");
213        let steering_file = steering_dir.join("lean-ctx.md");
214        if !steering_file.exists()
215            || !std::fs::read_to_string(&steering_file)
216                .unwrap_or_default()
217                .contains("lean-ctx")
218        {
219            let _ = std::fs::create_dir_all(&steering_dir);
220            write_file(&steering_file, KIRO_STEERING_TEMPLATE);
221            println!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
222        }
223    }
224}
225
226const PROJECT_LEAN_CTX_MD_MARKER: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
227const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
228const PROJECT_AGENTS_MD: &str = "AGENTS.md";
229const AGENTS_BLOCK_START: &str = "<!-- lean-ctx -->";
230const AGENTS_BLOCK_END: &str = "<!-- /lean-ctx -->";
231
232fn ensure_project_agents_integration(cwd: &std::path::Path) {
233    let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
234    let desired = format!(
235        "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
236        crate::rules_inject::rules_dedicated_markdown()
237    );
238
239    if !lean_ctx_md.exists() {
240        write_file(&lean_ctx_md, &desired);
241    } else if std::fs::read_to_string(&lean_ctx_md)
242        .unwrap_or_default()
243        .contains(PROJECT_LEAN_CTX_MD_MARKER)
244    {
245        let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
246        if !current.contains(crate::rules_inject::RULES_VERSION_STR) {
247            write_file(&lean_ctx_md, &desired);
248        }
249    }
250
251    let block = format!(
252        "{AGENTS_BLOCK_START}\n\
253## lean-ctx\n\n\
254Prefer lean-ctx MCP tools over native equivalents for token savings.\n\
255Full rules: @{PROJECT_LEAN_CTX_MD}\n\
256{AGENTS_BLOCK_END}\n"
257    );
258
259    let agents_md = cwd.join(PROJECT_AGENTS_MD);
260    if !agents_md.exists() {
261        let content = format!("# Agent Instructions\n\n{block}");
262        write_file(&agents_md, &content);
263        println!("Created AGENTS.md in project root (lean-ctx reference only).");
264        return;
265    }
266
267    let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
268    if existing.contains(AGENTS_BLOCK_START) {
269        let updated = replace_marked_block(&existing, AGENTS_BLOCK_START, AGENTS_BLOCK_END, &block);
270        if updated != existing {
271            write_file(&agents_md, &updated);
272        }
273        return;
274    }
275
276    if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
277        return;
278    }
279
280    let mut out = existing;
281    if !out.ends_with('\n') {
282        out.push('\n');
283    }
284    out.push('\n');
285    out.push_str(&block);
286    write_file(&agents_md, &out);
287    println!("Updated AGENTS.md (added lean-ctx reference block).");
288}
289
290fn replace_marked_block(content: &str, start: &str, end: &str, replacement: &str) -> String {
291    let s = content.find(start);
292    let e = content.find(end);
293    match (s, e) {
294        (Some(si), Some(ei)) if ei >= si => {
295            let after_end = ei + end.len();
296            let before = &content[..si];
297            let after = &content[after_end..];
298            let mut out = String::new();
299            out.push_str(before.trim_end_matches('\n'));
300            out.push('\n');
301            out.push('\n');
302            out.push_str(replacement.trim_end_matches('\n'));
303            out.push('\n');
304            out.push_str(after.trim_start_matches('\n'));
305            out
306        }
307        _ => content.to_string(),
308    }
309}
310
311const CURSORRULES_TEMPLATE: &str = "\
312# lean-ctx — Context Engineering Layer
313
314PREFER lean-ctx MCP tools over native equivalents for token savings:
315
316| PREFER | OVER | Why |
317|--------|------|-----|
318| `ctx_read(path)` | `Read` | Cached, 8 compression modes |
319| `ctx_shell(command)` | `Shell` | Pattern compression |
320| `ctx_search(pattern, path)` | `Grep` | Compact results |
321| `ctx_tree(path, depth)` | `ls` / `find` | Directory maps |
322| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
323
324Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
325Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
326";
327
328pub const KIRO_STEERING_TEMPLATE: &str = "\
329---
330inclusion: always
331---
332
333# lean-ctx — Context Engineering Layer
334
335The workspace has the `lean-ctx` MCP server installed. You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.
336
337## Mandatory Tool Preferences
338
339| Use this | Instead of | Why |
340|----------|-----------|-----|
341| `mcp_lean_ctx_ctx_read` | `readFile`, `readCode` | Cached reads, 8 compression modes, re-reads cost ~13 tokens |
342| `mcp_lean_ctx_ctx_multi_read` | `readMultipleFiles` | Batch cached reads in one call |
343| `mcp_lean_ctx_ctx_shell` | `executeBash` | Pattern compression for git/npm/test output |
344| `mcp_lean_ctx_ctx_search` | `grepSearch` | Compact, .gitignore-aware results |
345| `mcp_lean_ctx_ctx_tree` | `listDirectory` | Compact directory maps with file counts |
346
347## When to use native Kiro tools instead
348
349- `fsWrite` / `fsAppend` — always use native (lean-ctx doesn't write files)
350- `strReplace` — always use native (precise string replacement)
351- `semanticRename` / `smartRelocate` — always use native (IDE integration)
352- `getDiagnostics` — always use native (language server diagnostics)
353- `deleteFile` — always use native
354
355## Session management
356
357- At the start of a long task, call `mcp_lean_ctx_ctx_preload` with a task description to warm the cache
358- Use `mcp_lean_ctx_ctx_compress` periodically in long conversations to checkpoint context
359- Use `mcp_lean_ctx_ctx_knowledge` to persist important discoveries across sessions
360
361## Rules
362
363- NEVER loop on edit failures — switch to `mcp_lean_ctx_ctx_edit` immediately
364- For large files, use `mcp_lean_ctx_ctx_read` with `mode: \"signatures\"` or `mode: \"map\"` first
365- For re-reading a file you already read, just call `mcp_lean_ctx_ctx_read` again (cache hit = ~13 tokens)
366- When running tests or build commands, use `mcp_lean_ctx_ctx_shell` for compressed output
367";
368
369pub fn install_agent_hook(agent: &str, global: bool) {
370    match agent {
371        "claude" | "claude-code" => install_claude_hook(global),
372        "cursor" => install_cursor_hook(global),
373        "gemini" | "antigravity" => install_gemini_hook(),
374        "codex" => install_codex_hook(),
375        "windsurf" => install_windsurf_rules(global),
376        "cline" | "roo" => install_cline_rules(global),
377        "copilot" => install_copilot_hook(global),
378        "pi" => install_pi_hook(global),
379        "qwen" => install_mcp_json_agent(
380            "Qwen Code",
381            "~/.qwen/mcp.json",
382            &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
383        ),
384        "trae" => install_mcp_json_agent(
385            "Trae",
386            "~/.trae/mcp.json",
387            &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
388        ),
389        "amazonq" => install_mcp_json_agent(
390            "Amazon Q Developer",
391            "~/.aws/amazonq/mcp.json",
392            &dirs::home_dir()
393                .unwrap_or_default()
394                .join(".aws/amazonq/mcp.json"),
395        ),
396        "jetbrains" => install_mcp_json_agent(
397            "JetBrains IDEs",
398            "~/.jb-mcp.json",
399            &dirs::home_dir().unwrap_or_default().join(".jb-mcp.json"),
400        ),
401        "kiro" => install_kiro_hook(),
402        "verdent" => install_mcp_json_agent(
403            "Verdent",
404            "~/.verdent/mcp.json",
405            &dirs::home_dir()
406                .unwrap_or_default()
407                .join(".verdent/mcp.json"),
408        ),
409        "opencode" => install_mcp_json_agent(
410            "OpenCode",
411            "~/.opencode/mcp.json",
412            &dirs::home_dir()
413                .unwrap_or_default()
414                .join(".opencode/mcp.json"),
415        ),
416        "aider" => install_mcp_json_agent(
417            "Aider",
418            "~/.aider/mcp.json",
419            &dirs::home_dir().unwrap_or_default().join(".aider/mcp.json"),
420        ),
421        "amp" => install_mcp_json_agent(
422            "Amp",
423            "~/.amp/mcp.json",
424            &dirs::home_dir().unwrap_or_default().join(".amp/mcp.json"),
425        ),
426        "crush" => install_crush_hook(),
427        _ => {
428            eprintln!("Unknown agent: {agent}");
429            eprintln!("  Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains, kiro, verdent, opencode, aider, amp, crush, antigravity");
430            std::process::exit(1);
431        }
432    }
433}
434
435fn install_claude_hook(global: bool) {
436    let home = match dirs::home_dir() {
437        Some(h) => h,
438        None => {
439            eprintln!("Cannot resolve home directory");
440            return;
441        }
442    };
443
444    install_claude_hook_scripts(&home);
445    install_claude_hook_config(&home);
446    install_claude_rules_file(&home);
447    install_claude_skill(&home);
448
449    let _ = global;
450}
451
452fn install_claude_skill(home: &std::path::Path) {
453    let skill_dir = home.join(".claude/skills/lean-ctx");
454    let _ = std::fs::create_dir_all(skill_dir.join("scripts"));
455
456    let skill_md = include_str!("../skills/lean-ctx/SKILL.md");
457    let install_sh = include_str!("../skills/lean-ctx/scripts/install.sh");
458
459    let skill_path = skill_dir.join("SKILL.md");
460    let script_path = skill_dir.join("scripts/install.sh");
461
462    write_file(&skill_path, skill_md);
463    write_file(&script_path, install_sh);
464
465    #[cfg(unix)]
466    {
467        use std::os::unix::fs::PermissionsExt;
468        if let Ok(mut perms) = std::fs::metadata(&script_path).map(|m| m.permissions()) {
469            perms.set_mode(0o755);
470            let _ = std::fs::set_permissions(&script_path, perms);
471        }
472    }
473}
474
475fn install_claude_rules_file(home: &std::path::Path) {
476    let rules_dir = crate::core::editor_registry::claude_rules_dir(home);
477    let _ = std::fs::create_dir_all(&rules_dir);
478    let rules_path = rules_dir.join("lean-ctx.md");
479
480    let desired = crate::rules_inject::rules_dedicated_markdown();
481    let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
482
483    if existing.is_empty() {
484        write_file(&rules_path, desired);
485        return;
486    }
487    if existing.contains(crate::rules_inject::RULES_VERSION_STR) {
488        return;
489    }
490    if existing.contains("<!-- lean-ctx-rules-") {
491        write_file(&rules_path, desired);
492    }
493}
494
495fn install_claude_hook_scripts(home: &std::path::Path) {
496    let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
497    let _ = std::fs::create_dir_all(&hooks_dir);
498
499    let binary = resolve_binary_path();
500
501    let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
502    let rewrite_script = generate_rewrite_script(&resolve_binary_path_for_bash());
503    write_file(&rewrite_path, &rewrite_script);
504    make_executable(&rewrite_path);
505
506    let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
507    write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
508    make_executable(&redirect_path);
509
510    let wrapper = |subcommand: &str| -> String {
511        if cfg!(windows) {
512            format!("{binary} hook {subcommand}")
513        } else {
514            format!("{} hook {subcommand}", resolve_binary_path_for_bash())
515        }
516    };
517
518    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
519    write_file(
520        &rewrite_native,
521        &format!(
522            "#!/bin/sh\nexec {} hook rewrite\n",
523            resolve_binary_path_for_bash()
524        ),
525    );
526    make_executable(&rewrite_native);
527
528    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
529    write_file(
530        &redirect_native,
531        &format!(
532            "#!/bin/sh\nexec {} hook redirect\n",
533            resolve_binary_path_for_bash()
534        ),
535    );
536    make_executable(&redirect_native);
537
538    let _ = wrapper; // suppress unused warning on unix
539}
540
541fn install_claude_hook_config(home: &std::path::Path) {
542    let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
543    let binary = resolve_binary_path();
544
545    let rewrite_cmd = format!("{binary} hook rewrite");
546    let redirect_cmd = format!("{binary} hook redirect");
547
548    let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
549    let settings_content = if settings_path.exists() {
550        std::fs::read_to_string(&settings_path).unwrap_or_default()
551    } else {
552        String::new()
553    };
554
555    let needs_update =
556        !settings_content.contains("hook rewrite") || !settings_content.contains("hook redirect");
557    let has_old_hooks = settings_content.contains("lean-ctx-rewrite.sh")
558        || settings_content.contains("lean-ctx-redirect.sh");
559
560    if !needs_update && !has_old_hooks {
561        return;
562    }
563
564    let hook_entry = serde_json::json!({
565        "hooks": {
566            "PreToolUse": [
567                {
568                    "matcher": "Bash|bash",
569                    "hooks": [{
570                        "type": "command",
571                        "command": rewrite_cmd
572                    }]
573                },
574                {
575                    "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
576                    "hooks": [{
577                        "type": "command",
578                        "command": redirect_cmd
579                    }]
580                }
581            ]
582        }
583    });
584
585    if settings_content.is_empty() {
586        write_file(
587            &settings_path,
588            &serde_json::to_string_pretty(&hook_entry).unwrap(),
589        );
590    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
591        if let Some(obj) = existing.as_object_mut() {
592            obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
593            write_file(
594                &settings_path,
595                &serde_json::to_string_pretty(&existing).unwrap(),
596            );
597        }
598    }
599    if !mcp_server_quiet_mode() {
600        println!("Installed Claude Code hooks at {}", hooks_dir.display());
601    }
602}
603
604fn install_cursor_hook(global: bool) {
605    let home = match dirs::home_dir() {
606        Some(h) => h,
607        None => {
608            eprintln!("Cannot resolve home directory");
609            return;
610        }
611    };
612
613    install_cursor_hook_scripts(&home);
614    install_cursor_hook_config(&home);
615
616    if !global {
617        let rules_dir = PathBuf::from(".cursor").join("rules");
618        let _ = std::fs::create_dir_all(&rules_dir);
619        let rule_path = rules_dir.join("lean-ctx.mdc");
620        if !rule_path.exists() {
621            let rule_content = include_str!("templates/lean-ctx.mdc");
622            write_file(&rule_path, rule_content);
623            println!("Created .cursor/rules/lean-ctx.mdc in current project.");
624        } else {
625            println!("Cursor rule already exists.");
626        }
627    } else {
628        println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
629    }
630
631    println!("Restart Cursor to activate.");
632}
633
634fn install_cursor_hook_scripts(home: &std::path::Path) {
635    let hooks_dir = home.join(".cursor").join("hooks");
636    let _ = std::fs::create_dir_all(&hooks_dir);
637
638    let binary = resolve_binary_path_for_bash();
639
640    let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
641    let rewrite_script = generate_compact_rewrite_script(&binary);
642    write_file(&rewrite_path, &rewrite_script);
643    make_executable(&rewrite_path);
644
645    let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
646    write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
647    make_executable(&redirect_path);
648
649    let native_binary = resolve_binary_path();
650    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
651    write_file(
652        &rewrite_native,
653        &format!("#!/bin/sh\nexec {} hook rewrite\n", native_binary),
654    );
655    make_executable(&rewrite_native);
656
657    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
658    write_file(
659        &redirect_native,
660        &format!("#!/bin/sh\nexec {} hook redirect\n", native_binary),
661    );
662    make_executable(&redirect_native);
663}
664
665fn install_cursor_hook_config(home: &std::path::Path) {
666    let binary = resolve_binary_path();
667    let rewrite_cmd = format!("{binary} hook rewrite");
668    let redirect_cmd = format!("{binary} hook redirect");
669
670    let hooks_json = home.join(".cursor").join("hooks.json");
671
672    let hook_config = serde_json::json!({
673        "version": 1,
674        "hooks": {
675            "preToolUse": [
676                {
677                    "matcher": "terminal_command",
678                    "command": rewrite_cmd
679                },
680                {
681                    "matcher": "read_file|grep|search|list_files|list_directory",
682                    "command": redirect_cmd
683                }
684            ]
685        }
686    });
687
688    let content = if hooks_json.exists() {
689        std::fs::read_to_string(&hooks_json).unwrap_or_default()
690    } else {
691        String::new()
692    };
693
694    let has_correct_format = content.contains("\"version\"") && content.contains("\"preToolUse\"");
695    if has_correct_format && content.contains("hook rewrite") && content.contains("hook redirect") {
696        return;
697    }
698
699    if content.is_empty() || !content.contains("\"version\"") {
700        write_file(
701            &hooks_json,
702            &serde_json::to_string_pretty(&hook_config).unwrap(),
703        );
704    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&content) {
705        if let Some(obj) = existing.as_object_mut() {
706            obj.insert("version".to_string(), serde_json::json!(1));
707            obj.insert("hooks".to_string(), hook_config["hooks"].clone());
708            write_file(
709                &hooks_json,
710                &serde_json::to_string_pretty(&existing).unwrap(),
711            );
712        }
713    } else {
714        write_file(
715            &hooks_json,
716            &serde_json::to_string_pretty(&hook_config).unwrap(),
717        );
718    }
719
720    if !mcp_server_quiet_mode() {
721        println!("Installed Cursor hooks at {}", hooks_json.display());
722    }
723}
724
725fn install_gemini_hook() {
726    let home = match dirs::home_dir() {
727        Some(h) => h,
728        None => {
729            eprintln!("Cannot resolve home directory");
730            return;
731        }
732    };
733
734    install_gemini_hook_scripts(&home);
735    install_gemini_hook_config(&home);
736}
737
738fn install_gemini_hook_scripts(home: &std::path::Path) {
739    let hooks_dir = home.join(".gemini").join("hooks");
740    let _ = std::fs::create_dir_all(&hooks_dir);
741
742    let binary = resolve_binary_path_for_bash();
743
744    let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
745    let rewrite_script = generate_compact_rewrite_script(&binary);
746    write_file(&rewrite_path, &rewrite_script);
747    make_executable(&rewrite_path);
748
749    let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
750    write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
751    make_executable(&redirect_path);
752}
753
754fn install_gemini_hook_config(home: &std::path::Path) {
755    let binary = resolve_binary_path();
756    let rewrite_cmd = format!("{binary} hook rewrite");
757    let redirect_cmd = format!("{binary} hook redirect");
758
759    let settings_path = home.join(".gemini").join("settings.json");
760    let settings_content = if settings_path.exists() {
761        std::fs::read_to_string(&settings_path).unwrap_or_default()
762    } else {
763        String::new()
764    };
765
766    let has_new_format = settings_content.contains("hook rewrite")
767        && settings_content.contains("hook redirect")
768        && settings_content.contains("\"type\"");
769    let has_old_hooks = settings_content.contains("lean-ctx-rewrite")
770        || settings_content.contains("lean-ctx-redirect")
771        || (settings_content.contains("hook rewrite") && !settings_content.contains("\"type\""));
772
773    if has_new_format && !has_old_hooks {
774        return;
775    }
776
777    let hook_config = serde_json::json!({
778        "hooks": {
779            "BeforeTool": [
780                {
781                    "hooks": [{
782                        "type": "command",
783                        "command": rewrite_cmd
784                    }]
785                },
786                {
787                    "hooks": [{
788                        "type": "command",
789                        "command": redirect_cmd
790                    }]
791                }
792            ]
793        }
794    });
795
796    if settings_content.is_empty() {
797        write_file(
798            &settings_path,
799            &serde_json::to_string_pretty(&hook_config).unwrap(),
800        );
801    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
802        if let Some(obj) = existing.as_object_mut() {
803            obj.insert("hooks".to_string(), hook_config["hooks"].clone());
804            write_file(
805                &settings_path,
806                &serde_json::to_string_pretty(&existing).unwrap(),
807            );
808        }
809    }
810    if !mcp_server_quiet_mode() {
811        println!(
812            "Installed Gemini CLI hooks at {}",
813            settings_path.parent().unwrap_or(&settings_path).display()
814        );
815    }
816}
817
818fn install_codex_hook() {
819    let home = match dirs::home_dir() {
820        Some(h) => h,
821        None => {
822            eprintln!("Cannot resolve home directory");
823            return;
824        }
825    };
826
827    let codex_dir = home.join(".codex");
828    let _ = std::fs::create_dir_all(&codex_dir);
829
830    install_codex_hook_scripts(&home);
831
832    let agents_path = codex_dir.join("AGENTS.md");
833    let agents_content = "# Global Agent Instructions\n\n@LEAN-CTX.md\n";
834
835    let lean_ctx_md = codex_dir.join("LEAN-CTX.md");
836    let binary = resolve_binary_path();
837    let lean_ctx_content = format!(
838        r#"# lean-ctx — Token Optimization
839
840Prefix all shell commands with `{binary} -c` for compressed output:
841
842```bash
843{binary} -c git status    # instead of: git status
844{binary} -c cargo test    # instead of: cargo test
845{binary} -c ls src/       # instead of: ls src/
846```
847
848This 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.
849Use `{binary} -c --raw <cmd>` to skip compression and get full output.
850"#
851    );
852
853    if agents_path.exists() {
854        let content = std::fs::read_to_string(&agents_path).unwrap_or_default();
855        if content.contains("lean-ctx") || content.contains("LEAN-CTX") {
856            println!("Codex AGENTS.md already configured.");
857            return;
858        }
859    }
860
861    write_file(&agents_path, agents_content);
862    write_file(&lean_ctx_md, &lean_ctx_content);
863    println!("Installed Codex instructions at {}", codex_dir.display());
864}
865
866fn install_codex_hook_scripts(home: &std::path::Path) {
867    let hooks_dir = home.join(".codex").join("hooks");
868    let _ = std::fs::create_dir_all(&hooks_dir);
869
870    let binary = resolve_binary_path_for_bash();
871    let rewrite_path = hooks_dir.join("lean-ctx-rewrite-codex.sh");
872    let rewrite_script = generate_compact_rewrite_script(&binary);
873    write_file(&rewrite_path, &rewrite_script);
874    make_executable(&rewrite_path);
875    if !mcp_server_quiet_mode() {
876        println!(
877            "  \x1b[32m✓\x1b[0m Installed Codex hook scripts at {}",
878            hooks_dir.display()
879        );
880    }
881}
882
883fn install_windsurf_rules(global: bool) {
884    if global {
885        println!("Global mode: skipping project-local .windsurfrules (use without --global in a project).");
886        return;
887    }
888
889    let rules_path = PathBuf::from(".windsurfrules");
890    if rules_path.exists() {
891        let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
892        if content.contains("lean-ctx") {
893            println!(".windsurfrules already configured.");
894            return;
895        }
896    }
897
898    let rules = include_str!("templates/windsurfrules.txt");
899    write_file(&rules_path, rules);
900    println!("Installed .windsurfrules in current project.");
901}
902
903fn install_cline_rules(global: bool) {
904    if global {
905        println!(
906            "Global mode: skipping project-local .clinerules (use without --global in a project)."
907        );
908        return;
909    }
910
911    let rules_path = PathBuf::from(".clinerules");
912    if rules_path.exists() {
913        let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
914        if content.contains("lean-ctx") {
915            println!(".clinerules already configured.");
916            return;
917        }
918    }
919
920    let binary = resolve_binary_path();
921    let rules = format!(
922        r#"# lean-ctx Shell Optimization
923# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
924
925When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
926- `{binary} -c git status` instead of `git status`
927- `{binary} -c cargo test` instead of `cargo test`
928- `{binary} -c ls src/` instead of `ls src/`
929
930Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
931"#
932    );
933
934    write_file(&rules_path, &rules);
935    println!("Installed .clinerules in current project.");
936}
937
938fn install_pi_hook(global: bool) {
939    let has_pi = std::process::Command::new("pi")
940        .arg("--version")
941        .output()
942        .is_ok();
943
944    if !has_pi {
945        println!("Pi Coding Agent not found in PATH.");
946        println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
947        println!();
948    }
949
950    println!("Installing pi-lean-ctx Pi Package...");
951    println!();
952
953    let install_result = std::process::Command::new("pi")
954        .args(["install", "npm:pi-lean-ctx"])
955        .status();
956
957    match install_result {
958        Ok(status) if status.success() => {
959            println!("Installed pi-lean-ctx Pi Package.");
960        }
961        _ => {
962            println!("Could not auto-install pi-lean-ctx. Install manually:");
963            println!("  pi install npm:pi-lean-ctx");
964            println!();
965        }
966    }
967
968    write_pi_mcp_config();
969
970    if !global {
971        let agents_md = PathBuf::from("AGENTS.md");
972        if !agents_md.exists()
973            || !std::fs::read_to_string(&agents_md)
974                .unwrap_or_default()
975                .contains("lean-ctx")
976        {
977            let content = include_str!("templates/PI_AGENTS.md");
978            write_file(&agents_md, content);
979            println!("Created AGENTS.md in current project directory.");
980        } else {
981            println!("AGENTS.md already contains lean-ctx configuration.");
982        }
983    } else {
984        println!(
985            "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
986        );
987    }
988
989    println!();
990    println!("Setup complete. All Pi tools (bash, read, grep, find, ls) route through lean-ctx.");
991    println!("MCP tools (ctx_session, ctx_knowledge, ctx_semantic_search, ...) also available.");
992    println!("Use /lean-ctx in Pi to verify the binary path and MCP status.");
993}
994
995fn write_pi_mcp_config() {
996    let home = match dirs::home_dir() {
997        Some(h) => h,
998        None => return,
999    };
1000
1001    let mcp_config_path = home.join(".pi/agent/mcp.json");
1002
1003    if !home.join(".pi/agent").exists() {
1004        println!("  \x1b[2m○ ~/.pi/agent/ not found — skipping MCP config\x1b[0m");
1005        return;
1006    }
1007
1008    if mcp_config_path.exists() {
1009        let content = match std::fs::read_to_string(&mcp_config_path) {
1010            Ok(c) => c,
1011            Err(_) => return,
1012        };
1013        if content.contains("lean-ctx") {
1014            println!("  \x1b[32m✓\x1b[0m Pi MCP config already contains lean-ctx");
1015            return;
1016        }
1017
1018        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1019            if let Some(obj) = json.as_object_mut() {
1020                let servers = obj
1021                    .entry("mcpServers")
1022                    .or_insert_with(|| serde_json::json!({}));
1023                if let Some(servers_obj) = servers.as_object_mut() {
1024                    servers_obj.insert("lean-ctx".to_string(), pi_mcp_server_entry());
1025                }
1026                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1027                    let _ = std::fs::write(&mcp_config_path, formatted);
1028                    println!(
1029                        "  \x1b[32m✓\x1b[0m Added lean-ctx to Pi MCP config (~/.pi/agent/mcp.json)"
1030                    );
1031                }
1032            }
1033        }
1034        return;
1035    }
1036
1037    let content = serde_json::json!({
1038        "mcpServers": {
1039            "lean-ctx": pi_mcp_server_entry()
1040        }
1041    });
1042    if let Ok(formatted) = serde_json::to_string_pretty(&content) {
1043        let _ = std::fs::write(&mcp_config_path, formatted);
1044        println!("  \x1b[32m✓\x1b[0m Created Pi MCP config (~/.pi/agent/mcp.json)");
1045    }
1046}
1047
1048fn pi_mcp_server_entry() -> serde_json::Value {
1049    let binary = resolve_binary_path();
1050    serde_json::json!({
1051        "command": binary,
1052        "lifecycle": "lazy",
1053        "directTools": true
1054    })
1055}
1056
1057fn install_copilot_hook(global: bool) {
1058    let binary = resolve_binary_path();
1059
1060    if global {
1061        let mcp_path = copilot_global_mcp_path();
1062        if mcp_path.as_os_str() == "/nonexistent" {
1063            println!("  \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
1064            return;
1065        }
1066        write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
1067    } else {
1068        let vscode_dir = PathBuf::from(".vscode");
1069        let _ = std::fs::create_dir_all(&vscode_dir);
1070        let mcp_path = vscode_dir.join("mcp.json");
1071        write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
1072    }
1073}
1074
1075fn copilot_global_mcp_path() -> PathBuf {
1076    if let Some(home) = dirs::home_dir() {
1077        #[cfg(target_os = "macos")]
1078        {
1079            return home.join("Library/Application Support/Code/User/mcp.json");
1080        }
1081        #[cfg(target_os = "linux")]
1082        {
1083            return home.join(".config/Code/User/mcp.json");
1084        }
1085        #[cfg(target_os = "windows")]
1086        {
1087            if let Ok(appdata) = std::env::var("APPDATA") {
1088                return PathBuf::from(appdata).join("Code/User/mcp.json");
1089            }
1090        }
1091        #[allow(unreachable_code)]
1092        home.join(".config/Code/User/mcp.json")
1093    } else {
1094        PathBuf::from("/nonexistent")
1095    }
1096}
1097
1098fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
1099    let desired = serde_json::json!({ "command": binary, "args": [] });
1100    if mcp_path.exists() {
1101        let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
1102        match serde_json::from_str::<serde_json::Value>(&content) {
1103            Ok(mut json) => {
1104                if let Some(obj) = json.as_object_mut() {
1105                    let servers = obj
1106                        .entry("servers")
1107                        .or_insert_with(|| serde_json::json!({}));
1108                    if let Some(servers_obj) = servers.as_object_mut() {
1109                        if servers_obj.get("lean-ctx") == Some(&desired) {
1110                            println!("  \x1b[32m✓\x1b[0m Copilot already configured in {label}");
1111                            return;
1112                        }
1113                        servers_obj.insert("lean-ctx".to_string(), desired);
1114                    }
1115                    write_file(
1116                        mcp_path,
1117                        &serde_json::to_string_pretty(&json).unwrap_or_default(),
1118                    );
1119                    println!("  \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
1120                    return;
1121                }
1122            }
1123            Err(e) => {
1124                eprintln!(
1125                    "Could not parse VS Code MCP config at {}: {e}\nAdd to \"servers\": \"lean-ctx\": {{ \"command\": \"{}\", \"args\": [] }}",
1126                    mcp_path.display(),
1127                    binary
1128                );
1129                return;
1130            }
1131        };
1132    }
1133
1134    if let Some(parent) = mcp_path.parent() {
1135        let _ = std::fs::create_dir_all(parent);
1136    }
1137
1138    let config = serde_json::json!({
1139        "servers": {
1140            "lean-ctx": {
1141                "command": binary,
1142                "args": []
1143            }
1144        }
1145    });
1146
1147    write_file(
1148        mcp_path,
1149        &serde_json::to_string_pretty(&config).unwrap_or_default(),
1150    );
1151    println!("  \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
1152}
1153
1154fn write_file(path: &std::path::Path, content: &str) {
1155    if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
1156        eprintln!("Error writing {}: {e}", path.display());
1157    }
1158}
1159
1160#[cfg(unix)]
1161fn make_executable(path: &PathBuf) {
1162    use std::os::unix::fs::PermissionsExt;
1163    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1164}
1165
1166#[cfg(not(unix))]
1167fn make_executable(_path: &PathBuf) {}
1168
1169fn install_crush_hook() {
1170    let binary = resolve_binary_path();
1171    let home = dirs::home_dir().unwrap_or_default();
1172    let config_path = home.join(".config/crush/crush.json");
1173    let display_path = "~/.config/crush/crush.json";
1174
1175    if let Some(parent) = config_path.parent() {
1176        let _ = std::fs::create_dir_all(parent);
1177    }
1178
1179    if config_path.exists() {
1180        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1181        if content.contains("lean-ctx") {
1182            println!("Crush MCP already configured at {display_path}");
1183            return;
1184        }
1185
1186        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1187            if let Some(obj) = json.as_object_mut() {
1188                let servers = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1189                if let Some(servers_obj) = servers.as_object_mut() {
1190                    servers_obj.insert(
1191                        "lean-ctx".to_string(),
1192                        serde_json::json!({ "type": "stdio", "command": binary }),
1193                    );
1194                }
1195                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1196                    let _ = std::fs::write(&config_path, formatted);
1197                    println!("  \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1198                    return;
1199                }
1200            }
1201        }
1202    }
1203
1204    let content = serde_json::to_string_pretty(&serde_json::json!({
1205        "mcp": {
1206            "lean-ctx": {
1207                "type": "stdio",
1208                "command": binary
1209            }
1210        }
1211    }));
1212
1213    if let Ok(json_str) = content {
1214        let _ = std::fs::write(&config_path, json_str);
1215        println!("  \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1216    } else {
1217        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure Crush");
1218    }
1219}
1220
1221fn install_kiro_hook() {
1222    let home = dirs::home_dir().unwrap_or_default();
1223
1224    install_mcp_json_agent(
1225        "AWS Kiro",
1226        "~/.kiro/settings/mcp.json",
1227        &home.join(".kiro/settings/mcp.json"),
1228    );
1229
1230    let cwd = std::env::current_dir().unwrap_or_default();
1231    let steering_dir = cwd.join(".kiro").join("steering");
1232    let steering_file = steering_dir.join("lean-ctx.md");
1233
1234    if steering_file.exists()
1235        && std::fs::read_to_string(&steering_file)
1236            .unwrap_or_default()
1237            .contains("lean-ctx")
1238    {
1239        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1240    } else {
1241        let _ = std::fs::create_dir_all(&steering_dir);
1242        write_file(&steering_file, KIRO_STEERING_TEMPLATE);
1243        println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1244    }
1245}
1246
1247fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
1248    let binary = resolve_binary_path();
1249
1250    if let Some(parent) = config_path.parent() {
1251        let _ = std::fs::create_dir_all(parent);
1252    }
1253
1254    if config_path.exists() {
1255        let content = std::fs::read_to_string(config_path).unwrap_or_default();
1256        if content.contains("lean-ctx") {
1257            println!("{name} MCP already configured at {display_path}");
1258            return;
1259        }
1260
1261        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1262            if let Some(obj) = json.as_object_mut() {
1263                let servers = obj
1264                    .entry("mcpServers")
1265                    .or_insert_with(|| serde_json::json!({}));
1266                if let Some(servers_obj) = servers.as_object_mut() {
1267                    servers_obj.insert(
1268                        "lean-ctx".to_string(),
1269                        serde_json::json!({ "command": binary }),
1270                    );
1271                }
1272                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1273                    let _ = std::fs::write(config_path, formatted);
1274                    println!("  \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1275                    return;
1276                }
1277            }
1278        }
1279    }
1280
1281    let content = serde_json::to_string_pretty(&serde_json::json!({
1282        "mcpServers": {
1283            "lean-ctx": {
1284                "command": binary
1285            }
1286        }
1287    }));
1288
1289    if let Ok(json_str) = content {
1290        let _ = std::fs::write(config_path, json_str);
1291        println!("  \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1292    } else {
1293        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure {name}");
1294    }
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300
1301    #[test]
1302    fn bash_path_unix_unchanged() {
1303        assert_eq!(
1304            to_bash_compatible_path("/usr/local/bin/lean-ctx"),
1305            "/usr/local/bin/lean-ctx"
1306        );
1307    }
1308
1309    #[test]
1310    fn bash_path_home_unchanged() {
1311        assert_eq!(
1312            to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
1313            "/home/user/.cargo/bin/lean-ctx"
1314        );
1315    }
1316
1317    #[test]
1318    fn bash_path_windows_drive_converted() {
1319        assert_eq!(
1320            to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
1321            "/c/Users/Fraser/bin/lean-ctx.exe"
1322        );
1323    }
1324
1325    #[test]
1326    fn bash_path_windows_lowercase_drive() {
1327        assert_eq!(
1328            to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1329            "/d/tools/lean-ctx.exe"
1330        );
1331    }
1332
1333    #[test]
1334    fn bash_path_windows_forward_slashes() {
1335        assert_eq!(
1336            to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1337            "/c/Users/Fraser/bin/lean-ctx.exe"
1338        );
1339    }
1340
1341    #[test]
1342    fn bash_path_bare_name_unchanged() {
1343        assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1344    }
1345
1346    #[test]
1347    fn normalize_msys2_path() {
1348        assert_eq!(
1349            normalize_tool_path("/c/Users/game/Downloads/project"),
1350            "C:/Users/game/Downloads/project"
1351        );
1352    }
1353
1354    #[test]
1355    fn normalize_msys2_drive_d() {
1356        assert_eq!(
1357            normalize_tool_path("/d/Projects/app/src"),
1358            "D:/Projects/app/src"
1359        );
1360    }
1361
1362    #[test]
1363    fn normalize_backslashes() {
1364        assert_eq!(
1365            normalize_tool_path("C:\\Users\\game\\project\\src"),
1366            "C:/Users/game/project/src"
1367        );
1368    }
1369
1370    #[test]
1371    fn normalize_mixed_separators() {
1372        assert_eq!(
1373            normalize_tool_path("C:\\Users/game\\project/src"),
1374            "C:/Users/game/project/src"
1375        );
1376    }
1377
1378    #[test]
1379    fn normalize_double_slashes() {
1380        assert_eq!(
1381            normalize_tool_path("/home/user//project///src"),
1382            "/home/user/project/src"
1383        );
1384    }
1385
1386    #[test]
1387    fn normalize_trailing_slash() {
1388        assert_eq!(
1389            normalize_tool_path("/home/user/project/"),
1390            "/home/user/project"
1391        );
1392    }
1393
1394    #[test]
1395    fn normalize_root_preserved() {
1396        assert_eq!(normalize_tool_path("/"), "/");
1397    }
1398
1399    #[test]
1400    fn normalize_windows_root_preserved() {
1401        assert_eq!(normalize_tool_path("C:/"), "C:/");
1402    }
1403
1404    #[test]
1405    fn normalize_unix_path_unchanged() {
1406        assert_eq!(
1407            normalize_tool_path("/home/user/project/src/main.rs"),
1408            "/home/user/project/src/main.rs"
1409        );
1410    }
1411
1412    #[test]
1413    fn normalize_relative_path_unchanged() {
1414        assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1415    }
1416
1417    #[test]
1418    fn normalize_dot_unchanged() {
1419        assert_eq!(normalize_tool_path("."), ".");
1420    }
1421
1422    #[test]
1423    fn normalize_unc_path_preserved() {
1424        assert_eq!(
1425            normalize_tool_path("//server/share/file"),
1426            "//server/share/file"
1427        );
1428    }
1429
1430    #[test]
1431    fn cursor_hook_config_has_version_and_object_hooks() {
1432        let config = serde_json::json!({
1433            "version": 1,
1434            "hooks": {
1435                "preToolUse": [
1436                    {
1437                        "matcher": "terminal_command",
1438                        "command": "lean-ctx hook rewrite"
1439                    },
1440                    {
1441                        "matcher": "read_file|grep|search|list_files|list_directory",
1442                        "command": "lean-ctx hook redirect"
1443                    }
1444                ]
1445            }
1446        });
1447
1448        let json_str = serde_json::to_string_pretty(&config).unwrap();
1449        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1450
1451        assert_eq!(parsed["version"], 1);
1452        assert!(parsed["hooks"].is_object());
1453        assert!(parsed["hooks"]["preToolUse"].is_array());
1454        assert_eq!(parsed["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
1455        assert_eq!(
1456            parsed["hooks"]["preToolUse"][0]["matcher"],
1457            "terminal_command"
1458        );
1459    }
1460
1461    #[test]
1462    fn cursor_hook_detects_old_format_needs_migration() {
1463        let old_format = r#"{"hooks":[{"event":"preToolUse","command":"lean-ctx hook rewrite"}]}"#;
1464        let has_correct =
1465            old_format.contains("\"version\"") && old_format.contains("\"preToolUse\"");
1466        assert!(
1467            !has_correct,
1468            "Old format should be detected as needing migration"
1469        );
1470    }
1471
1472    #[test]
1473    fn gemini_hook_config_has_type_command() {
1474        let binary = "lean-ctx";
1475        let rewrite_cmd = format!("{binary} hook rewrite");
1476        let redirect_cmd = format!("{binary} hook redirect");
1477
1478        let hook_config = serde_json::json!({
1479            "hooks": {
1480                "BeforeTool": [
1481                    {
1482                        "hooks": [{
1483                            "type": "command",
1484                            "command": rewrite_cmd
1485                        }]
1486                    },
1487                    {
1488                        "hooks": [{
1489                            "type": "command",
1490                            "command": redirect_cmd
1491                        }]
1492                    }
1493                ]
1494            }
1495        });
1496
1497        let parsed = hook_config;
1498        let before_tool = parsed["hooks"]["BeforeTool"].as_array().unwrap();
1499        assert_eq!(before_tool.len(), 2);
1500
1501        let first_hook = &before_tool[0]["hooks"][0];
1502        assert_eq!(first_hook["type"], "command");
1503        assert_eq!(first_hook["command"], "lean-ctx hook rewrite");
1504
1505        let second_hook = &before_tool[1]["hooks"][0];
1506        assert_eq!(second_hook["type"], "command");
1507        assert_eq!(second_hook["command"], "lean-ctx hook redirect");
1508    }
1509
1510    #[test]
1511    fn gemini_hook_old_format_detected() {
1512        let old_format = r#"{"hooks":{"BeforeTool":[{"command":"lean-ctx hook rewrite"}]}}"#;
1513        let has_new = old_format.contains("hook rewrite")
1514            && old_format.contains("hook redirect")
1515            && old_format.contains("\"type\"");
1516        assert!(!has_new, "Missing 'type' field should trigger migration");
1517    }
1518}