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    let case_pattern = crate::rewrite_registry::bash_case_pattern();
118    format!(
119        r#"#!/usr/bin/env bash
120# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
121set -euo pipefail
122
123LEAN_CTX_BIN="{binary}"
124
125INPUT=$(cat)
126TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
127
128if [ "$TOOL" != "Bash" ] && [ "$TOOL" != "bash" ]; then
129  exit 0
130fi
131
132CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
133
134if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
135  exit 0
136fi
137
138case "$CMD" in
139  {case_pattern})
140    # Shell-escape then JSON-escape (two passes)
141    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
142    REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
143    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
144    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
145    ;;
146  *) exit 0 ;;
147esac
148"#
149    )
150}
151
152pub fn generate_compact_rewrite_script(binary: &str) -> String {
153    let case_pattern = crate::rewrite_registry::bash_case_pattern();
154    format!(
155        r#"#!/usr/bin/env bash
156# lean-ctx hook — rewrites shell commands
157set -euo pipefail
158LEAN_CTX_BIN="{binary}"
159INPUT=$(cat)
160CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
161if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
162case "$CMD" in
163  {case_pattern})
164    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
165    REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
166    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
167    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
168  *) exit 0 ;;
169esac
170"#
171    )
172}
173
174const REDIRECT_SCRIPT_CLAUDE: &str = r#"#!/usr/bin/env bash
175# lean-ctx PreToolUse hook — all native tools pass through
176# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
177# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
178exit 0
179"#;
180
181const REDIRECT_SCRIPT_GENERIC: &str = r#"#!/usr/bin/env bash
182# lean-ctx hook — all native tools pass through
183exit 0
184"#;
185
186pub fn install_project_rules() {
187    let cwd = std::env::current_dir().unwrap_or_default();
188
189    if !is_inside_git_repo(&cwd) {
190        eprintln!(
191            "  Skipping project files: not inside a git repository.\n  \
192             Run this command from your project root to create CLAUDE.md / AGENTS.md."
193        );
194        return;
195    }
196
197    let home = dirs::home_dir().unwrap_or_default();
198    if cwd == home {
199        eprintln!(
200            "  Skipping project files: current directory is your home folder.\n  \
201             Run this command from a project directory instead."
202        );
203        return;
204    }
205
206    ensure_project_agents_integration(&cwd);
207
208    let cursorrules = cwd.join(".cursorrules");
209    if !cursorrules.exists()
210        || !std::fs::read_to_string(&cursorrules)
211            .unwrap_or_default()
212            .contains("lean-ctx")
213    {
214        let content = CURSORRULES_TEMPLATE;
215        if cursorrules.exists() {
216            let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
217            if !existing.ends_with('\n') {
218                existing.push('\n');
219            }
220            existing.push('\n');
221            existing.push_str(content);
222            write_file(&cursorrules, &existing);
223        } else {
224            write_file(&cursorrules, content);
225        }
226        println!("Created/updated .cursorrules in project root.");
227    }
228
229    let kiro_dir = cwd.join(".kiro");
230    if kiro_dir.exists() {
231        let steering_dir = kiro_dir.join("steering");
232        let steering_file = steering_dir.join("lean-ctx.md");
233        if !steering_file.exists()
234            || !std::fs::read_to_string(&steering_file)
235                .unwrap_or_default()
236                .contains("lean-ctx")
237        {
238            let _ = std::fs::create_dir_all(&steering_dir);
239            write_file(&steering_file, KIRO_STEERING_TEMPLATE);
240            println!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
241        }
242    }
243}
244
245const PROJECT_LEAN_CTX_MD_MARKER: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
246const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
247const PROJECT_AGENTS_MD: &str = "AGENTS.md";
248const AGENTS_BLOCK_START: &str = "<!-- lean-ctx -->";
249const AGENTS_BLOCK_END: &str = "<!-- /lean-ctx -->";
250
251fn ensure_project_agents_integration(cwd: &std::path::Path) {
252    let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
253    let desired = format!(
254        "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
255        crate::rules_inject::rules_dedicated_markdown()
256    );
257
258    if !lean_ctx_md.exists() {
259        write_file(&lean_ctx_md, &desired);
260    } else if std::fs::read_to_string(&lean_ctx_md)
261        .unwrap_or_default()
262        .contains(PROJECT_LEAN_CTX_MD_MARKER)
263    {
264        let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
265        if !current.contains(crate::rules_inject::RULES_VERSION_STR) {
266            write_file(&lean_ctx_md, &desired);
267        }
268    }
269
270    let block = format!(
271        "{AGENTS_BLOCK_START}\n\
272## lean-ctx\n\n\
273Prefer lean-ctx MCP tools over native equivalents for token savings.\n\
274Full rules: @{PROJECT_LEAN_CTX_MD}\n\
275{AGENTS_BLOCK_END}\n"
276    );
277
278    let agents_md = cwd.join(PROJECT_AGENTS_MD);
279    if !agents_md.exists() {
280        let content = format!("# Agent Instructions\n\n{block}");
281        write_file(&agents_md, &content);
282        println!("Created AGENTS.md in project root (lean-ctx reference only).");
283        return;
284    }
285
286    let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
287    if existing.contains(AGENTS_BLOCK_START) {
288        let updated = replace_marked_block(&existing, AGENTS_BLOCK_START, AGENTS_BLOCK_END, &block);
289        if updated != existing {
290            write_file(&agents_md, &updated);
291        }
292        return;
293    }
294
295    if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
296        return;
297    }
298
299    let mut out = existing;
300    if !out.ends_with('\n') {
301        out.push('\n');
302    }
303    out.push('\n');
304    out.push_str(&block);
305    write_file(&agents_md, &out);
306    println!("Updated AGENTS.md (added lean-ctx reference block).");
307}
308
309fn replace_marked_block(content: &str, start: &str, end: &str, replacement: &str) -> String {
310    let s = content.find(start);
311    let e = content.find(end);
312    match (s, e) {
313        (Some(si), Some(ei)) if ei >= si => {
314            let after_end = ei + end.len();
315            let before = &content[..si];
316            let after = &content[after_end..];
317            let mut out = String::new();
318            out.push_str(before.trim_end_matches('\n'));
319            out.push('\n');
320            out.push('\n');
321            out.push_str(replacement.trim_end_matches('\n'));
322            out.push('\n');
323            out.push_str(after.trim_start_matches('\n'));
324            out
325        }
326        _ => content.to_string(),
327    }
328}
329
330const CURSORRULES_TEMPLATE: &str = "\
331# lean-ctx — Context Engineering Layer
332
333PREFER lean-ctx MCP tools over native equivalents for token savings:
334
335| PREFER | OVER | Why |
336|--------|------|-----|
337| `ctx_read(path)` | `Read` | Cached, 8 compression modes |
338| `ctx_shell(command)` | `Shell` | Pattern compression |
339| `ctx_search(pattern, path)` | `Grep` | Compact results |
340| `ctx_tree(path, depth)` | `ls` / `find` | Directory maps |
341| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
342
343Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
344Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
345";
346
347pub const KIRO_STEERING_TEMPLATE: &str = "\
348---
349inclusion: always
350---
351
352# lean-ctx — Context Engineering Layer
353
354The workspace has the `lean-ctx` MCP server installed. You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.
355
356## Mandatory Tool Preferences
357
358| Use this | Instead of | Why |
359|----------|-----------|-----|
360| `mcp_lean_ctx_ctx_read` | `readFile`, `readCode` | Cached reads, 8 compression modes, re-reads cost ~13 tokens |
361| `mcp_lean_ctx_ctx_multi_read` | `readMultipleFiles` | Batch cached reads in one call |
362| `mcp_lean_ctx_ctx_shell` | `executeBash` | Pattern compression for git/npm/test output |
363| `mcp_lean_ctx_ctx_search` | `grepSearch` | Compact, .gitignore-aware results |
364| `mcp_lean_ctx_ctx_tree` | `listDirectory` | Compact directory maps with file counts |
365
366## When to use native Kiro tools instead
367
368- `fsWrite` / `fsAppend` — always use native (lean-ctx doesn't write files)
369- `strReplace` — always use native (precise string replacement)
370- `semanticRename` / `smartRelocate` — always use native (IDE integration)
371- `getDiagnostics` — always use native (language server diagnostics)
372- `deleteFile` — always use native
373
374## Session management
375
376- At the start of a long task, call `mcp_lean_ctx_ctx_preload` with a task description to warm the cache
377- Use `mcp_lean_ctx_ctx_compress` periodically in long conversations to checkpoint context
378- Use `mcp_lean_ctx_ctx_knowledge` to persist important discoveries across sessions
379
380## Rules
381
382- NEVER loop on edit failures — switch to `mcp_lean_ctx_ctx_edit` immediately
383- For large files, use `mcp_lean_ctx_ctx_read` with `mode: \"signatures\"` or `mode: \"map\"` first
384- For re-reading a file you already read, just call `mcp_lean_ctx_ctx_read` again (cache hit = ~13 tokens)
385- When running tests or build commands, use `mcp_lean_ctx_ctx_shell` for compressed output
386";
387
388pub fn install_agent_hook(agent: &str, global: bool) {
389    match agent {
390        "claude" | "claude-code" => install_claude_hook(global),
391        "cursor" => install_cursor_hook(global),
392        "gemini" | "antigravity" => install_gemini_hook(),
393        "codex" => install_codex_hook(),
394        "windsurf" => install_windsurf_rules(global),
395        "cline" | "roo" => install_cline_rules(global),
396        "copilot" => install_copilot_hook(global),
397        "pi" => install_pi_hook(global),
398        "qwen" => install_mcp_json_agent(
399            "Qwen Code",
400            "~/.qwen/mcp.json",
401            &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
402        ),
403        "trae" => install_mcp_json_agent(
404            "Trae",
405            "~/.trae/mcp.json",
406            &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
407        ),
408        "amazonq" => install_mcp_json_agent(
409            "Amazon Q Developer",
410            "~/.aws/amazonq/mcp.json",
411            &dirs::home_dir()
412                .unwrap_or_default()
413                .join(".aws/amazonq/mcp.json"),
414        ),
415        "jetbrains" => install_jetbrains_hook(),
416        "kiro" => install_kiro_hook(),
417        "verdent" => install_mcp_json_agent(
418            "Verdent",
419            "~/.verdent/mcp.json",
420            &dirs::home_dir()
421                .unwrap_or_default()
422                .join(".verdent/mcp.json"),
423        ),
424        "opencode" => install_opencode_hook(),
425        "aider" => install_mcp_json_agent(
426            "Aider",
427            "~/.aider/mcp.json",
428            &dirs::home_dir().unwrap_or_default().join(".aider/mcp.json"),
429        ),
430        "amp" => install_amp_hook(),
431        "crush" => install_crush_hook(),
432        _ => {
433            eprintln!("Unknown agent: {agent}");
434            eprintln!("  Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains, kiro, verdent, opencode, aider, amp, crush, antigravity");
435            std::process::exit(1);
436        }
437    }
438}
439
440fn install_claude_hook(global: bool) {
441    let home = match dirs::home_dir() {
442        Some(h) => h,
443        None => {
444            eprintln!("Cannot resolve home directory");
445            return;
446        }
447    };
448
449    install_claude_hook_scripts(&home);
450    install_claude_hook_config(&home);
451    install_claude_rules_file(&home);
452    install_claude_global_claude_md(&home);
453    install_claude_skill(&home);
454
455    let _ = global;
456}
457
458const CLAUDE_MD_BLOCK_START: &str = "<!-- lean-ctx -->";
459const CLAUDE_MD_BLOCK_END: &str = "<!-- /lean-ctx -->";
460const CLAUDE_MD_BLOCK_CONTENT: &str = "\
461<!-- lean-ctx -->
462## lean-ctx — Context Runtime
463
464Always prefer lean-ctx MCP tools over native equivalents:
465- `ctx_read` instead of `Read` / `cat` (cached, 10 modes, re-reads ~13 tokens)
466- `ctx_shell` instead of `bash` / `Shell` (90+ compression patterns)
467- `ctx_search` instead of `Grep` / `rg` (compact results)
468- `ctx_tree` instead of `ls` / `find` (compact directory maps)
469- Native Edit/Write/StrReplace stay unchanged.
470
471Full rules: @rules/lean-ctx.md
472
473Verify setup: run `/mcp` to check lean-ctx is connected, `/memory` to confirm this file loaded.
474<!-- /lean-ctx -->";
475
476fn install_claude_global_claude_md(home: &std::path::Path) {
477    let claude_dir = crate::core::editor_registry::claude_state_dir(home);
478    let _ = std::fs::create_dir_all(&claude_dir);
479    let claude_md_path = claude_dir.join("CLAUDE.md");
480
481    let existing = std::fs::read_to_string(&claude_md_path).unwrap_or_default();
482
483    if existing.contains(CLAUDE_MD_BLOCK_START) {
484        if existing.contains(crate::rules_inject::RULES_VERSION_STR)
485            || existing.contains("@rules/lean-ctx.md")
486        {
487            return;
488        }
489        let cleaned = remove_block(&existing, CLAUDE_MD_BLOCK_START, CLAUDE_MD_BLOCK_END);
490        let updated = format!("{}\n\n{}\n", cleaned.trim(), CLAUDE_MD_BLOCK_CONTENT);
491        write_file(&claude_md_path, &updated);
492        return;
493    }
494
495    if existing.trim().is_empty() {
496        write_file(&claude_md_path, CLAUDE_MD_BLOCK_CONTENT);
497    } else {
498        let updated = format!("{}\n\n{}\n", existing.trim(), CLAUDE_MD_BLOCK_CONTENT);
499        write_file(&claude_md_path, &updated);
500    }
501}
502
503fn remove_block(content: &str, start: &str, end: &str) -> String {
504    let s = content.find(start);
505    let e = content.find(end);
506    match (s, e) {
507        (Some(si), Some(ei)) if ei >= si => {
508            let after_end = ei + end.len();
509            let before = content[..si].trim_end_matches('\n');
510            let after = &content[after_end..];
511            let mut out = before.to_string();
512            out.push('\n');
513            if !after.trim().is_empty() {
514                out.push('\n');
515                out.push_str(after.trim_start_matches('\n'));
516            }
517            out
518        }
519        _ => content.to_string(),
520    }
521}
522
523fn install_claude_skill(home: &std::path::Path) {
524    let skill_dir = home.join(".claude/skills/lean-ctx");
525    let _ = std::fs::create_dir_all(skill_dir.join("scripts"));
526
527    let skill_md = include_str!("../skills/lean-ctx/SKILL.md");
528    let install_sh = include_str!("../skills/lean-ctx/scripts/install.sh");
529
530    let skill_path = skill_dir.join("SKILL.md");
531    let script_path = skill_dir.join("scripts/install.sh");
532
533    write_file(&skill_path, skill_md);
534    write_file(&script_path, install_sh);
535
536    #[cfg(unix)]
537    {
538        use std::os::unix::fs::PermissionsExt;
539        if let Ok(mut perms) = std::fs::metadata(&script_path).map(|m| m.permissions()) {
540            perms.set_mode(0o755);
541            let _ = std::fs::set_permissions(&script_path, perms);
542        }
543    }
544}
545
546fn install_claude_rules_file(home: &std::path::Path) {
547    let rules_dir = crate::core::editor_registry::claude_rules_dir(home);
548    let _ = std::fs::create_dir_all(&rules_dir);
549    let rules_path = rules_dir.join("lean-ctx.md");
550
551    let desired = crate::rules_inject::rules_dedicated_markdown();
552    let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
553
554    if existing.is_empty() {
555        write_file(&rules_path, desired);
556        return;
557    }
558    if existing.contains(crate::rules_inject::RULES_VERSION_STR) {
559        return;
560    }
561    if existing.contains("<!-- lean-ctx-rules-") {
562        write_file(&rules_path, desired);
563    }
564}
565
566fn install_claude_hook_scripts(home: &std::path::Path) {
567    let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
568    let _ = std::fs::create_dir_all(&hooks_dir);
569
570    let binary = resolve_binary_path();
571
572    let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
573    let rewrite_script = generate_rewrite_script(&resolve_binary_path_for_bash());
574    write_file(&rewrite_path, &rewrite_script);
575    make_executable(&rewrite_path);
576
577    let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
578    write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
579    make_executable(&redirect_path);
580
581    let wrapper = |subcommand: &str| -> String {
582        if cfg!(windows) {
583            format!("{binary} hook {subcommand}")
584        } else {
585            format!("{} hook {subcommand}", resolve_binary_path_for_bash())
586        }
587    };
588
589    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
590    write_file(
591        &rewrite_native,
592        &format!(
593            "#!/bin/sh\nexec {} hook rewrite\n",
594            resolve_binary_path_for_bash()
595        ),
596    );
597    make_executable(&rewrite_native);
598
599    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
600    write_file(
601        &redirect_native,
602        &format!(
603            "#!/bin/sh\nexec {} hook redirect\n",
604            resolve_binary_path_for_bash()
605        ),
606    );
607    make_executable(&redirect_native);
608
609    let _ = wrapper; // suppress unused warning on unix
610}
611
612fn install_claude_hook_config(home: &std::path::Path) {
613    let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
614    let binary = resolve_binary_path();
615
616    let rewrite_cmd = format!("{binary} hook rewrite");
617    let redirect_cmd = format!("{binary} hook redirect");
618
619    let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
620    let settings_content = if settings_path.exists() {
621        std::fs::read_to_string(&settings_path).unwrap_or_default()
622    } else {
623        String::new()
624    };
625
626    let needs_update =
627        !settings_content.contains("hook rewrite") || !settings_content.contains("hook redirect");
628    let has_old_hooks = settings_content.contains("lean-ctx-rewrite.sh")
629        || settings_content.contains("lean-ctx-redirect.sh");
630
631    if !needs_update && !has_old_hooks {
632        return;
633    }
634
635    let hook_entry = serde_json::json!({
636        "hooks": {
637            "PreToolUse": [
638                {
639                    "matcher": "Bash|bash",
640                    "hooks": [{
641                        "type": "command",
642                        "command": rewrite_cmd
643                    }]
644                },
645                {
646                    "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
647                    "hooks": [{
648                        "type": "command",
649                        "command": redirect_cmd
650                    }]
651                }
652            ]
653        }
654    });
655
656    if settings_content.is_empty() {
657        write_file(
658            &settings_path,
659            &serde_json::to_string_pretty(&hook_entry).unwrap(),
660        );
661    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
662        if let Some(obj) = existing.as_object_mut() {
663            obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
664            write_file(
665                &settings_path,
666                &serde_json::to_string_pretty(&existing).unwrap(),
667            );
668        }
669    }
670    if !mcp_server_quiet_mode() {
671        println!("Installed Claude Code hooks at {}", hooks_dir.display());
672    }
673}
674
675fn install_cursor_hook(global: bool) {
676    let home = match dirs::home_dir() {
677        Some(h) => h,
678        None => {
679            eprintln!("Cannot resolve home directory");
680            return;
681        }
682    };
683
684    install_cursor_hook_scripts(&home);
685    install_cursor_hook_config(&home);
686
687    if !global {
688        let rules_dir = PathBuf::from(".cursor").join("rules");
689        let _ = std::fs::create_dir_all(&rules_dir);
690        let rule_path = rules_dir.join("lean-ctx.mdc");
691        if !rule_path.exists() {
692            let rule_content = include_str!("templates/lean-ctx.mdc");
693            write_file(&rule_path, rule_content);
694            println!("Created .cursor/rules/lean-ctx.mdc in current project.");
695        } else {
696            println!("Cursor rule already exists.");
697        }
698    } else {
699        println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
700    }
701
702    println!("Restart Cursor to activate.");
703}
704
705fn install_cursor_hook_scripts(home: &std::path::Path) {
706    let hooks_dir = home.join(".cursor").join("hooks");
707    let _ = std::fs::create_dir_all(&hooks_dir);
708
709    let binary = resolve_binary_path_for_bash();
710
711    let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
712    let rewrite_script = generate_compact_rewrite_script(&binary);
713    write_file(&rewrite_path, &rewrite_script);
714    make_executable(&rewrite_path);
715
716    let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
717    write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
718    make_executable(&redirect_path);
719
720    let native_binary = resolve_binary_path();
721    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
722    write_file(
723        &rewrite_native,
724        &format!("#!/bin/sh\nexec {} hook rewrite\n", native_binary),
725    );
726    make_executable(&rewrite_native);
727
728    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
729    write_file(
730        &redirect_native,
731        &format!("#!/bin/sh\nexec {} hook redirect\n", native_binary),
732    );
733    make_executable(&redirect_native);
734}
735
736fn install_cursor_hook_config(home: &std::path::Path) {
737    let binary = resolve_binary_path();
738    let rewrite_cmd = format!("{binary} hook rewrite");
739    let redirect_cmd = format!("{binary} hook redirect");
740
741    let hooks_json = home.join(".cursor").join("hooks.json");
742
743    let hook_config = serde_json::json!({
744        "version": 1,
745        "hooks": {
746            "preToolUse": [
747                {
748                    "matcher": "terminal_command",
749                    "command": rewrite_cmd
750                },
751                {
752                    "matcher": "read_file|grep|search|list_files|list_directory",
753                    "command": redirect_cmd
754                }
755            ]
756        }
757    });
758
759    let content = if hooks_json.exists() {
760        std::fs::read_to_string(&hooks_json).unwrap_or_default()
761    } else {
762        String::new()
763    };
764
765    let has_correct_format = content.contains("\"version\"") && content.contains("\"preToolUse\"");
766    if has_correct_format && content.contains("hook rewrite") && content.contains("hook redirect") {
767        return;
768    }
769
770    if content.is_empty() || !content.contains("\"version\"") {
771        write_file(
772            &hooks_json,
773            &serde_json::to_string_pretty(&hook_config).unwrap(),
774        );
775    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&content) {
776        if let Some(obj) = existing.as_object_mut() {
777            obj.insert("version".to_string(), serde_json::json!(1));
778            obj.insert("hooks".to_string(), hook_config["hooks"].clone());
779            write_file(
780                &hooks_json,
781                &serde_json::to_string_pretty(&existing).unwrap(),
782            );
783        }
784    } else {
785        write_file(
786            &hooks_json,
787            &serde_json::to_string_pretty(&hook_config).unwrap(),
788        );
789    }
790
791    if !mcp_server_quiet_mode() {
792        println!("Installed Cursor hooks at {}", hooks_json.display());
793    }
794}
795
796fn install_gemini_hook() {
797    let home = match dirs::home_dir() {
798        Some(h) => h,
799        None => {
800            eprintln!("Cannot resolve home directory");
801            return;
802        }
803    };
804
805    install_gemini_hook_scripts(&home);
806    install_gemini_hook_config(&home);
807}
808
809fn install_gemini_hook_scripts(home: &std::path::Path) {
810    let hooks_dir = home.join(".gemini").join("hooks");
811    let _ = std::fs::create_dir_all(&hooks_dir);
812
813    let binary = resolve_binary_path_for_bash();
814
815    let rewrite_path = hooks_dir.join("lean-ctx-rewrite-gemini.sh");
816    let rewrite_script = generate_compact_rewrite_script(&binary);
817    write_file(&rewrite_path, &rewrite_script);
818    make_executable(&rewrite_path);
819
820    let redirect_path = hooks_dir.join("lean-ctx-redirect-gemini.sh");
821    write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
822    make_executable(&redirect_path);
823}
824
825fn install_gemini_hook_config(home: &std::path::Path) {
826    let binary = resolve_binary_path();
827    let rewrite_cmd = format!("{binary} hook rewrite");
828    let redirect_cmd = format!("{binary} hook redirect");
829
830    let settings_path = home.join(".gemini").join("settings.json");
831    let settings_content = if settings_path.exists() {
832        std::fs::read_to_string(&settings_path).unwrap_or_default()
833    } else {
834        String::new()
835    };
836
837    let has_new_format = settings_content.contains("hook rewrite")
838        && settings_content.contains("hook redirect")
839        && settings_content.contains("\"type\"");
840    let has_old_hooks = settings_content.contains("lean-ctx-rewrite")
841        || settings_content.contains("lean-ctx-redirect")
842        || (settings_content.contains("hook rewrite") && !settings_content.contains("\"type\""));
843
844    if has_new_format && !has_old_hooks {
845        return;
846    }
847
848    let hook_config = serde_json::json!({
849        "hooks": {
850            "BeforeTool": [
851                {
852                    "hooks": [{
853                        "type": "command",
854                        "command": rewrite_cmd
855                    }]
856                },
857                {
858                    "hooks": [{
859                        "type": "command",
860                        "command": redirect_cmd
861                    }]
862                }
863            ]
864        }
865    });
866
867    if settings_content.is_empty() {
868        write_file(
869            &settings_path,
870            &serde_json::to_string_pretty(&hook_config).unwrap(),
871        );
872    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
873        if let Some(obj) = existing.as_object_mut() {
874            obj.insert("hooks".to_string(), hook_config["hooks"].clone());
875            write_file(
876                &settings_path,
877                &serde_json::to_string_pretty(&existing).unwrap(),
878            );
879        }
880    }
881    if !mcp_server_quiet_mode() {
882        println!(
883            "Installed Gemini CLI hooks at {}",
884            settings_path.parent().unwrap_or(&settings_path).display()
885        );
886    }
887}
888
889fn install_codex_hook() {
890    let home = match dirs::home_dir() {
891        Some(h) => h,
892        None => {
893            eprintln!("Cannot resolve home directory");
894            return;
895        }
896    };
897
898    let codex_dir = home.join(".codex");
899    let _ = std::fs::create_dir_all(&codex_dir);
900
901    install_codex_hook_scripts(&home);
902
903    let agents_path = codex_dir.join("AGENTS.md");
904    let agents_content = "# Global Agent Instructions\n\n@LEAN-CTX.md\n";
905
906    let lean_ctx_md = codex_dir.join("LEAN-CTX.md");
907    let binary = resolve_binary_path();
908    let lean_ctx_content = format!(
909        r#"# lean-ctx — Token Optimization
910
911Prefix all shell commands with `{binary} -c` for compressed output:
912
913```bash
914{binary} -c git status    # instead of: git status
915{binary} -c cargo test    # instead of: cargo test
916{binary} -c ls src/       # instead of: ls src/
917```
918
919This 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.
920Use `{binary} -c --raw <cmd>` to skip compression and get full output.
921"#
922    );
923
924    if agents_path.exists() {
925        let content = std::fs::read_to_string(&agents_path).unwrap_or_default();
926        if content.contains("lean-ctx") || content.contains("LEAN-CTX") {
927            println!("Codex AGENTS.md already configured.");
928            return;
929        }
930    }
931
932    write_file(&agents_path, agents_content);
933    write_file(&lean_ctx_md, &lean_ctx_content);
934    println!("Installed Codex instructions at {}", codex_dir.display());
935}
936
937fn install_codex_hook_scripts(home: &std::path::Path) {
938    let hooks_dir = home.join(".codex").join("hooks");
939    let _ = std::fs::create_dir_all(&hooks_dir);
940
941    let binary = resolve_binary_path_for_bash();
942    let rewrite_path = hooks_dir.join("lean-ctx-rewrite-codex.sh");
943    let rewrite_script = generate_compact_rewrite_script(&binary);
944    write_file(&rewrite_path, &rewrite_script);
945    make_executable(&rewrite_path);
946    if !mcp_server_quiet_mode() {
947        println!(
948            "  \x1b[32m✓\x1b[0m Installed Codex hook scripts at {}",
949            hooks_dir.display()
950        );
951    }
952}
953
954fn install_windsurf_rules(global: bool) {
955    if global {
956        println!("Global mode: skipping project-local .windsurfrules (use without --global in a project).");
957        return;
958    }
959
960    let cwd = std::env::current_dir().unwrap_or_default();
961    if !is_inside_git_repo(&cwd) || cwd == dirs::home_dir().unwrap_or_default() {
962        eprintln!("  Skipping .windsurfrules: not inside a git repository or in home directory.");
963        return;
964    }
965
966    let rules_path = PathBuf::from(".windsurfrules");
967    if rules_path.exists() {
968        let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
969        if content.contains("lean-ctx") {
970            println!(".windsurfrules already configured.");
971            return;
972        }
973    }
974
975    let rules = include_str!("templates/windsurfrules.txt");
976    write_file(&rules_path, rules);
977    println!("Installed .windsurfrules in current project.");
978}
979
980fn install_cline_rules(global: bool) {
981    if global {
982        println!(
983            "Global mode: skipping project-local .clinerules (use without --global in a project)."
984        );
985        return;
986    }
987
988    let cwd = std::env::current_dir().unwrap_or_default();
989    if !is_inside_git_repo(&cwd) || cwd == dirs::home_dir().unwrap_or_default() {
990        eprintln!("  Skipping .clinerules: not inside a git repository or in home directory.");
991        return;
992    }
993
994    let rules_path = PathBuf::from(".clinerules");
995    if rules_path.exists() {
996        let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
997        if content.contains("lean-ctx") {
998            println!(".clinerules already configured.");
999            return;
1000        }
1001    }
1002
1003    let binary = resolve_binary_path();
1004    let rules = format!(
1005        r#"# lean-ctx Shell Optimization
1006# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
1007
1008When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
1009- `{binary} -c git status` instead of `git status`
1010- `{binary} -c cargo test` instead of `cargo test`
1011- `{binary} -c ls src/` instead of `ls src/`
1012
1013Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
1014"#
1015    );
1016
1017    write_file(&rules_path, &rules);
1018    println!("Installed .clinerules in current project.");
1019}
1020
1021fn install_pi_hook(global: bool) {
1022    let has_pi = std::process::Command::new("pi")
1023        .arg("--version")
1024        .output()
1025        .is_ok();
1026
1027    if !has_pi {
1028        println!("Pi Coding Agent not found in PATH.");
1029        println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
1030        println!();
1031    }
1032
1033    println!("Installing pi-lean-ctx Pi Package...");
1034    println!();
1035
1036    let install_result = std::process::Command::new("pi")
1037        .args(["install", "npm:pi-lean-ctx"])
1038        .status();
1039
1040    match install_result {
1041        Ok(status) if status.success() => {
1042            println!("Installed pi-lean-ctx Pi Package.");
1043        }
1044        _ => {
1045            println!("Could not auto-install pi-lean-ctx. Install manually:");
1046            println!("  pi install npm:pi-lean-ctx");
1047            println!();
1048        }
1049    }
1050
1051    write_pi_mcp_config();
1052
1053    if !global {
1054        let agents_md = PathBuf::from("AGENTS.md");
1055        if !agents_md.exists()
1056            || !std::fs::read_to_string(&agents_md)
1057                .unwrap_or_default()
1058                .contains("lean-ctx")
1059        {
1060            let content = include_str!("templates/PI_AGENTS.md");
1061            write_file(&agents_md, content);
1062            println!("Created AGENTS.md in current project directory.");
1063        } else {
1064            println!("AGENTS.md already contains lean-ctx configuration.");
1065        }
1066    } else {
1067        println!(
1068            "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
1069        );
1070    }
1071
1072    println!();
1073    println!("Setup complete. All Pi tools (bash, read, grep, find, ls) route through lean-ctx.");
1074    println!("MCP tools (ctx_session, ctx_knowledge, ctx_semantic_search, ...) also available.");
1075    println!("Use /lean-ctx in Pi to verify the binary path and MCP status.");
1076}
1077
1078fn write_pi_mcp_config() {
1079    let home = match dirs::home_dir() {
1080        Some(h) => h,
1081        None => return,
1082    };
1083
1084    let mcp_config_path = home.join(".pi/agent/mcp.json");
1085
1086    if !home.join(".pi/agent").exists() {
1087        println!("  \x1b[2m○ ~/.pi/agent/ not found — skipping MCP config\x1b[0m");
1088        return;
1089    }
1090
1091    if mcp_config_path.exists() {
1092        let content = match std::fs::read_to_string(&mcp_config_path) {
1093            Ok(c) => c,
1094            Err(_) => return,
1095        };
1096        if content.contains("lean-ctx") {
1097            println!("  \x1b[32m✓\x1b[0m Pi MCP config already contains lean-ctx");
1098            return;
1099        }
1100
1101        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1102            if let Some(obj) = json.as_object_mut() {
1103                let servers = obj
1104                    .entry("mcpServers")
1105                    .or_insert_with(|| serde_json::json!({}));
1106                if let Some(servers_obj) = servers.as_object_mut() {
1107                    servers_obj.insert("lean-ctx".to_string(), pi_mcp_server_entry());
1108                }
1109                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1110                    let _ = std::fs::write(&mcp_config_path, formatted);
1111                    println!(
1112                        "  \x1b[32m✓\x1b[0m Added lean-ctx to Pi MCP config (~/.pi/agent/mcp.json)"
1113                    );
1114                }
1115            }
1116        }
1117        return;
1118    }
1119
1120    let content = serde_json::json!({
1121        "mcpServers": {
1122            "lean-ctx": pi_mcp_server_entry()
1123        }
1124    });
1125    if let Ok(formatted) = serde_json::to_string_pretty(&content) {
1126        let _ = std::fs::write(&mcp_config_path, formatted);
1127        println!("  \x1b[32m✓\x1b[0m Created Pi MCP config (~/.pi/agent/mcp.json)");
1128    }
1129}
1130
1131fn pi_mcp_server_entry() -> serde_json::Value {
1132    let binary = resolve_binary_path();
1133    let mut entry = full_server_entry(&binary);
1134    if let Some(obj) = entry.as_object_mut() {
1135        obj.insert("lifecycle".to_string(), serde_json::json!("lazy"));
1136        obj.insert("directTools".to_string(), serde_json::json!(true));
1137    }
1138    entry
1139}
1140
1141fn install_copilot_hook(global: bool) {
1142    let binary = resolve_binary_path();
1143
1144    if global {
1145        let mcp_path = copilot_global_mcp_path();
1146        if mcp_path.as_os_str() == "/nonexistent" {
1147            println!("  \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
1148            return;
1149        }
1150        write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
1151        install_copilot_pretooluse_hook(true);
1152    } else {
1153        let vscode_dir = PathBuf::from(".vscode");
1154        let _ = std::fs::create_dir_all(&vscode_dir);
1155        let mcp_path = vscode_dir.join("mcp.json");
1156        write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
1157        install_copilot_pretooluse_hook(false);
1158    }
1159}
1160
1161fn install_copilot_pretooluse_hook(global: bool) {
1162    let binary = resolve_binary_path();
1163    let rewrite_cmd = format!("{binary} hook rewrite");
1164    let redirect_cmd = format!("{binary} hook redirect");
1165
1166    let hook_config = serde_json::json!({
1167        "hooks": {
1168            "PreToolUse": [
1169                {
1170                    "type": "command",
1171                    "command": rewrite_cmd,
1172                    "timeout": 15
1173                },
1174                {
1175                    "type": "command",
1176                    "command": redirect_cmd,
1177                    "timeout": 5
1178                }
1179            ]
1180        }
1181    });
1182
1183    if global {
1184        if let Some(home) = dirs::home_dir() {
1185            let copilot_hooks_dir = home.join(".copilot").join("hooks");
1186            let _ = std::fs::create_dir_all(&copilot_hooks_dir);
1187            let hook_path = copilot_hooks_dir.join("lean-ctx.json");
1188            write_file(
1189                &hook_path,
1190                &serde_json::to_string_pretty(&hook_config).unwrap(),
1191            );
1192            if !mcp_server_quiet_mode() {
1193                println!(
1194                    "Installed Copilot PreToolUse hooks at {}",
1195                    hook_path.display()
1196                );
1197            }
1198        }
1199    } else {
1200        let hooks_dir = PathBuf::from(".github").join("hooks");
1201        let _ = std::fs::create_dir_all(&hooks_dir);
1202        let hook_path = hooks_dir.join("lean-ctx.json");
1203        write_file(
1204            &hook_path,
1205            &serde_json::to_string_pretty(&hook_config).unwrap(),
1206        );
1207        if !mcp_server_quiet_mode() {
1208            println!(
1209                "Installed Copilot PreToolUse hooks at {}",
1210                hook_path.display()
1211            );
1212        }
1213    }
1214}
1215
1216fn copilot_global_mcp_path() -> PathBuf {
1217    if let Some(home) = dirs::home_dir() {
1218        #[cfg(target_os = "macos")]
1219        {
1220            return home.join("Library/Application Support/Code/User/mcp.json");
1221        }
1222        #[cfg(target_os = "linux")]
1223        {
1224            return home.join(".config/Code/User/mcp.json");
1225        }
1226        #[cfg(target_os = "windows")]
1227        {
1228            if let Ok(appdata) = std::env::var("APPDATA") {
1229                return PathBuf::from(appdata).join("Code/User/mcp.json");
1230            }
1231        }
1232        #[allow(unreachable_code)]
1233        home.join(".config/Code/User/mcp.json")
1234    } else {
1235        PathBuf::from("/nonexistent")
1236    }
1237}
1238
1239fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
1240    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1241        .map(|d| d.to_string_lossy().to_string())
1242        .unwrap_or_default();
1243    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
1244    if mcp_path.exists() {
1245        let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
1246        match serde_json::from_str::<serde_json::Value>(&content) {
1247            Ok(mut json) => {
1248                if let Some(obj) = json.as_object_mut() {
1249                    let servers = obj
1250                        .entry("servers")
1251                        .or_insert_with(|| serde_json::json!({}));
1252                    if let Some(servers_obj) = servers.as_object_mut() {
1253                        if servers_obj.get("lean-ctx") == Some(&desired) {
1254                            println!("  \x1b[32m✓\x1b[0m Copilot already configured in {label}");
1255                            return;
1256                        }
1257                        servers_obj.insert("lean-ctx".to_string(), desired);
1258                    }
1259                    write_file(
1260                        mcp_path,
1261                        &serde_json::to_string_pretty(&json).unwrap_or_default(),
1262                    );
1263                    println!("  \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
1264                    return;
1265                }
1266            }
1267            Err(e) => {
1268                eprintln!(
1269                    "Could not parse VS Code MCP config at {}: {e}\nAdd to \"servers\": \"lean-ctx\": {{ \"command\": \"{}\", \"args\": [] }}",
1270                    mcp_path.display(),
1271                    binary
1272                );
1273                return;
1274            }
1275        };
1276    }
1277
1278    if let Some(parent) = mcp_path.parent() {
1279        let _ = std::fs::create_dir_all(parent);
1280    }
1281
1282    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1283        .map(|d| d.to_string_lossy().to_string())
1284        .unwrap_or_default();
1285    let config = serde_json::json!({
1286        "servers": {
1287            "lean-ctx": {
1288                "type": "stdio",
1289                "command": binary,
1290                "args": [],
1291                "env": { "LEAN_CTX_DATA_DIR": data_dir }
1292            }
1293        }
1294    });
1295
1296    write_file(
1297        mcp_path,
1298        &serde_json::to_string_pretty(&config).unwrap_or_default(),
1299    );
1300    println!("  \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
1301}
1302
1303fn write_file(path: &std::path::Path, content: &str) {
1304    if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
1305        eprintln!("Error writing {}: {e}", path.display());
1306    }
1307}
1308
1309fn is_inside_git_repo(path: &std::path::Path) -> bool {
1310    let mut p = path;
1311    loop {
1312        if p.join(".git").exists() {
1313            return true;
1314        }
1315        match p.parent() {
1316            Some(parent) => p = parent,
1317            None => return false,
1318        }
1319    }
1320}
1321
1322#[cfg(unix)]
1323fn make_executable(path: &PathBuf) {
1324    use std::os::unix::fs::PermissionsExt;
1325    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
1326}
1327
1328#[cfg(not(unix))]
1329fn make_executable(_path: &PathBuf) {}
1330
1331fn install_amp_hook() {
1332    let binary = resolve_binary_path();
1333    let home = dirs::home_dir().unwrap_or_default();
1334    let config_path = home.join(".config/amp/settings.json");
1335    let display_path = "~/.config/amp/settings.json";
1336
1337    if let Some(parent) = config_path.parent() {
1338        let _ = std::fs::create_dir_all(parent);
1339    }
1340
1341    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1342        .map(|d| d.to_string_lossy().to_string())
1343        .unwrap_or_default();
1344    let entry = serde_json::json!({
1345        "command": binary,
1346        "env": { "LEAN_CTX_DATA_DIR": data_dir }
1347    });
1348
1349    if config_path.exists() {
1350        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1351        if content.contains("lean-ctx") {
1352            println!("Amp MCP already configured at {display_path}");
1353            return;
1354        }
1355
1356        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1357            if let Some(obj) = json.as_object_mut() {
1358                let servers = obj
1359                    .entry("amp.mcpServers")
1360                    .or_insert_with(|| serde_json::json!({}));
1361                if let Some(servers_obj) = servers.as_object_mut() {
1362                    servers_obj.insert("lean-ctx".to_string(), entry.clone());
1363                }
1364                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1365                    let _ = std::fs::write(&config_path, formatted);
1366                    println!("  \x1b[32m✓\x1b[0m Amp MCP configured at {display_path}");
1367                    return;
1368                }
1369            }
1370        }
1371    }
1372
1373    let config = serde_json::json!({ "amp.mcpServers": { "lean-ctx": entry } });
1374    if let Ok(json_str) = serde_json::to_string_pretty(&config) {
1375        let _ = std::fs::write(&config_path, json_str);
1376        println!("  \x1b[32m✓\x1b[0m Amp MCP configured at {display_path}");
1377    } else {
1378        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure Amp");
1379    }
1380}
1381
1382fn install_jetbrains_hook() {
1383    let binary = resolve_binary_path();
1384    let home = dirs::home_dir().unwrap_or_default();
1385    let config_path = home.join(".jb-mcp.json");
1386    let display_path = "~/.jb-mcp.json";
1387
1388    let entry = serde_json::json!({
1389        "name": "lean-ctx",
1390        "command": binary,
1391        "args": [],
1392        "env": {
1393            "LEAN_CTX_DATA_DIR": crate::core::data_dir::lean_ctx_data_dir()
1394                .map(|d| d.to_string_lossy().to_string())
1395                .unwrap_or_default()
1396        }
1397    });
1398
1399    if config_path.exists() {
1400        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1401        if content.contains("lean-ctx") {
1402            println!("JetBrains MCP already configured at {display_path}");
1403            return;
1404        }
1405
1406        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1407            if let Some(obj) = json.as_object_mut() {
1408                let servers = obj
1409                    .entry("servers")
1410                    .or_insert_with(|| serde_json::json!([]));
1411                if let Some(arr) = servers.as_array_mut() {
1412                    arr.push(entry.clone());
1413                }
1414                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1415                    let _ = std::fs::write(&config_path, formatted);
1416                    println!("  \x1b[32m✓\x1b[0m JetBrains MCP configured at {display_path}");
1417                    return;
1418                }
1419            }
1420        }
1421    }
1422
1423    let config = serde_json::json!({ "servers": [entry] });
1424    if let Ok(json_str) = serde_json::to_string_pretty(&config) {
1425        let _ = std::fs::write(&config_path, json_str);
1426        println!("  \x1b[32m✓\x1b[0m JetBrains MCP configured at {display_path}");
1427    } else {
1428        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure JetBrains");
1429    }
1430}
1431
1432fn install_opencode_hook() {
1433    let binary = resolve_binary_path();
1434    let home = dirs::home_dir().unwrap_or_default();
1435    let config_path = home.join(".config/opencode/opencode.json");
1436    let display_path = "~/.config/opencode/opencode.json";
1437
1438    if let Some(parent) = config_path.parent() {
1439        let _ = std::fs::create_dir_all(parent);
1440    }
1441
1442    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1443        .map(|d| d.to_string_lossy().to_string())
1444        .unwrap_or_default();
1445    let desired = serde_json::json!({
1446        "type": "local",
1447        "command": [&binary],
1448        "enabled": true,
1449        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
1450    });
1451
1452    if config_path.exists() {
1453        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1454        if content.contains("lean-ctx") {
1455            println!("OpenCode MCP already configured at {display_path}");
1456        } else if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1457            if let Some(obj) = json.as_object_mut() {
1458                let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1459                if let Some(mcp_obj) = mcp.as_object_mut() {
1460                    mcp_obj.insert("lean-ctx".to_string(), desired.clone());
1461                }
1462                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1463                    let _ = std::fs::write(&config_path, formatted);
1464                    println!("  \x1b[32m✓\x1b[0m OpenCode MCP configured at {display_path}");
1465                }
1466            }
1467        }
1468    } else {
1469        let content = serde_json::to_string_pretty(&serde_json::json!({
1470            "$schema": "https://opencode.ai/config.json",
1471            "mcp": {
1472                "lean-ctx": desired
1473            }
1474        }));
1475
1476        if let Ok(json_str) = content {
1477            let _ = std::fs::write(&config_path, json_str);
1478            println!("  \x1b[32m✓\x1b[0m OpenCode MCP configured at {display_path}");
1479        } else {
1480            eprintln!("  \x1b[31m✗\x1b[0m Failed to configure OpenCode");
1481        }
1482    }
1483
1484    install_opencode_plugin(&home);
1485}
1486
1487fn install_opencode_plugin(home: &std::path::Path) {
1488    let plugin_dir = home.join(".config/opencode/plugins");
1489    let _ = std::fs::create_dir_all(&plugin_dir);
1490    let plugin_path = plugin_dir.join("lean-ctx.ts");
1491
1492    let plugin_content = include_str!("templates/opencode-plugin.ts");
1493    let _ = std::fs::write(&plugin_path, plugin_content);
1494
1495    if !mcp_server_quiet_mode() {
1496        println!(
1497            "  \x1b[32m✓\x1b[0m OpenCode plugin installed at {}",
1498            plugin_path.display()
1499        );
1500    }
1501}
1502
1503fn install_crush_hook() {
1504    let binary = resolve_binary_path();
1505    let home = dirs::home_dir().unwrap_or_default();
1506    let config_path = home.join(".config/crush/crush.json");
1507    let display_path = "~/.config/crush/crush.json";
1508
1509    if let Some(parent) = config_path.parent() {
1510        let _ = std::fs::create_dir_all(parent);
1511    }
1512
1513    if config_path.exists() {
1514        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1515        if content.contains("lean-ctx") {
1516            println!("Crush MCP already configured at {display_path}");
1517            return;
1518        }
1519
1520        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1521            if let Some(obj) = json.as_object_mut() {
1522                let servers = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1523                if let Some(servers_obj) = servers.as_object_mut() {
1524                    servers_obj.insert(
1525                        "lean-ctx".to_string(),
1526                        serde_json::json!({ "type": "stdio", "command": binary }),
1527                    );
1528                }
1529                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1530                    let _ = std::fs::write(&config_path, formatted);
1531                    println!("  \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1532                    return;
1533                }
1534            }
1535        }
1536    }
1537
1538    let content = serde_json::to_string_pretty(&serde_json::json!({
1539        "mcp": {
1540            "lean-ctx": {
1541                "type": "stdio",
1542                "command": binary
1543            }
1544        }
1545    }));
1546
1547    if let Ok(json_str) = content {
1548        let _ = std::fs::write(&config_path, json_str);
1549        println!("  \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1550    } else {
1551        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure Crush");
1552    }
1553}
1554
1555fn install_kiro_hook() {
1556    let home = dirs::home_dir().unwrap_or_default();
1557
1558    install_mcp_json_agent(
1559        "AWS Kiro",
1560        "~/.kiro/settings/mcp.json",
1561        &home.join(".kiro/settings/mcp.json"),
1562    );
1563
1564    let cwd = std::env::current_dir().unwrap_or_default();
1565    let steering_dir = cwd.join(".kiro").join("steering");
1566    let steering_file = steering_dir.join("lean-ctx.md");
1567
1568    if steering_file.exists()
1569        && std::fs::read_to_string(&steering_file)
1570            .unwrap_or_default()
1571            .contains("lean-ctx")
1572    {
1573        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1574    } else {
1575        let _ = std::fs::create_dir_all(&steering_dir);
1576        write_file(&steering_file, KIRO_STEERING_TEMPLATE);
1577        println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1578    }
1579}
1580
1581fn full_server_entry(binary: &str) -> serde_json::Value {
1582    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1583        .map(|d| d.to_string_lossy().to_string())
1584        .unwrap_or_default();
1585    let auto_approve = crate::core::editor_registry::auto_approve_tools();
1586    serde_json::json!({
1587        "command": binary,
1588        "env": { "LEAN_CTX_DATA_DIR": data_dir },
1589        "autoApprove": auto_approve
1590    })
1591}
1592
1593fn install_mcp_json_agent(name: &str, display_path: &str, config_path: &std::path::Path) {
1594    let binary = resolve_binary_path();
1595    let entry = full_server_entry(&binary);
1596
1597    if let Some(parent) = config_path.parent() {
1598        let _ = std::fs::create_dir_all(parent);
1599    }
1600
1601    if config_path.exists() {
1602        let content = std::fs::read_to_string(config_path).unwrap_or_default();
1603        if content.contains("lean-ctx") {
1604            println!("{name} MCP already configured at {display_path}");
1605            return;
1606        }
1607
1608        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1609            if let Some(obj) = json.as_object_mut() {
1610                let servers = obj
1611                    .entry("mcpServers")
1612                    .or_insert_with(|| serde_json::json!({}));
1613                if let Some(servers_obj) = servers.as_object_mut() {
1614                    servers_obj.insert("lean-ctx".to_string(), entry.clone());
1615                }
1616                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1617                    let _ = std::fs::write(config_path, formatted);
1618                    println!("  \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1619                    return;
1620                }
1621            }
1622        }
1623    }
1624
1625    let content = serde_json::to_string_pretty(&serde_json::json!({
1626        "mcpServers": {
1627            "lean-ctx": entry
1628        }
1629    }));
1630
1631    if let Ok(json_str) = content {
1632        let _ = std::fs::write(config_path, json_str);
1633        println!("  \x1b[32m✓\x1b[0m {name} MCP configured at {display_path}");
1634    } else {
1635        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure {name}");
1636    }
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641    use super::*;
1642
1643    #[test]
1644    fn bash_path_unix_unchanged() {
1645        assert_eq!(
1646            to_bash_compatible_path("/usr/local/bin/lean-ctx"),
1647            "/usr/local/bin/lean-ctx"
1648        );
1649    }
1650
1651    #[test]
1652    fn bash_path_home_unchanged() {
1653        assert_eq!(
1654            to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
1655            "/home/user/.cargo/bin/lean-ctx"
1656        );
1657    }
1658
1659    #[test]
1660    fn bash_path_windows_drive_converted() {
1661        assert_eq!(
1662            to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
1663            "/c/Users/Fraser/bin/lean-ctx.exe"
1664        );
1665    }
1666
1667    #[test]
1668    fn bash_path_windows_lowercase_drive() {
1669        assert_eq!(
1670            to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
1671            "/d/tools/lean-ctx.exe"
1672        );
1673    }
1674
1675    #[test]
1676    fn bash_path_windows_forward_slashes() {
1677        assert_eq!(
1678            to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
1679            "/c/Users/Fraser/bin/lean-ctx.exe"
1680        );
1681    }
1682
1683    #[test]
1684    fn bash_path_bare_name_unchanged() {
1685        assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
1686    }
1687
1688    #[test]
1689    fn normalize_msys2_path() {
1690        assert_eq!(
1691            normalize_tool_path("/c/Users/game/Downloads/project"),
1692            "C:/Users/game/Downloads/project"
1693        );
1694    }
1695
1696    #[test]
1697    fn normalize_msys2_drive_d() {
1698        assert_eq!(
1699            normalize_tool_path("/d/Projects/app/src"),
1700            "D:/Projects/app/src"
1701        );
1702    }
1703
1704    #[test]
1705    fn normalize_backslashes() {
1706        assert_eq!(
1707            normalize_tool_path("C:\\Users\\game\\project\\src"),
1708            "C:/Users/game/project/src"
1709        );
1710    }
1711
1712    #[test]
1713    fn normalize_mixed_separators() {
1714        assert_eq!(
1715            normalize_tool_path("C:\\Users/game\\project/src"),
1716            "C:/Users/game/project/src"
1717        );
1718    }
1719
1720    #[test]
1721    fn normalize_double_slashes() {
1722        assert_eq!(
1723            normalize_tool_path("/home/user//project///src"),
1724            "/home/user/project/src"
1725        );
1726    }
1727
1728    #[test]
1729    fn normalize_trailing_slash() {
1730        assert_eq!(
1731            normalize_tool_path("/home/user/project/"),
1732            "/home/user/project"
1733        );
1734    }
1735
1736    #[test]
1737    fn normalize_root_preserved() {
1738        assert_eq!(normalize_tool_path("/"), "/");
1739    }
1740
1741    #[test]
1742    fn normalize_windows_root_preserved() {
1743        assert_eq!(normalize_tool_path("C:/"), "C:/");
1744    }
1745
1746    #[test]
1747    fn normalize_unix_path_unchanged() {
1748        assert_eq!(
1749            normalize_tool_path("/home/user/project/src/main.rs"),
1750            "/home/user/project/src/main.rs"
1751        );
1752    }
1753
1754    #[test]
1755    fn normalize_relative_path_unchanged() {
1756        assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
1757    }
1758
1759    #[test]
1760    fn normalize_dot_unchanged() {
1761        assert_eq!(normalize_tool_path("."), ".");
1762    }
1763
1764    #[test]
1765    fn normalize_unc_path_preserved() {
1766        assert_eq!(
1767            normalize_tool_path("//server/share/file"),
1768            "//server/share/file"
1769        );
1770    }
1771
1772    #[test]
1773    fn cursor_hook_config_has_version_and_object_hooks() {
1774        let config = serde_json::json!({
1775            "version": 1,
1776            "hooks": {
1777                "preToolUse": [
1778                    {
1779                        "matcher": "terminal_command",
1780                        "command": "lean-ctx hook rewrite"
1781                    },
1782                    {
1783                        "matcher": "read_file|grep|search|list_files|list_directory",
1784                        "command": "lean-ctx hook redirect"
1785                    }
1786                ]
1787            }
1788        });
1789
1790        let json_str = serde_json::to_string_pretty(&config).unwrap();
1791        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1792
1793        assert_eq!(parsed["version"], 1);
1794        assert!(parsed["hooks"].is_object());
1795        assert!(parsed["hooks"]["preToolUse"].is_array());
1796        assert_eq!(parsed["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
1797        assert_eq!(
1798            parsed["hooks"]["preToolUse"][0]["matcher"],
1799            "terminal_command"
1800        );
1801    }
1802
1803    #[test]
1804    fn cursor_hook_detects_old_format_needs_migration() {
1805        let old_format = r#"{"hooks":[{"event":"preToolUse","command":"lean-ctx hook rewrite"}]}"#;
1806        let has_correct =
1807            old_format.contains("\"version\"") && old_format.contains("\"preToolUse\"");
1808        assert!(
1809            !has_correct,
1810            "Old format should be detected as needing migration"
1811        );
1812    }
1813
1814    #[test]
1815    fn gemini_hook_config_has_type_command() {
1816        let binary = "lean-ctx";
1817        let rewrite_cmd = format!("{binary} hook rewrite");
1818        let redirect_cmd = format!("{binary} hook redirect");
1819
1820        let hook_config = serde_json::json!({
1821            "hooks": {
1822                "BeforeTool": [
1823                    {
1824                        "hooks": [{
1825                            "type": "command",
1826                            "command": rewrite_cmd
1827                        }]
1828                    },
1829                    {
1830                        "hooks": [{
1831                            "type": "command",
1832                            "command": redirect_cmd
1833                        }]
1834                    }
1835                ]
1836            }
1837        });
1838
1839        let parsed = hook_config;
1840        let before_tool = parsed["hooks"]["BeforeTool"].as_array().unwrap();
1841        assert_eq!(before_tool.len(), 2);
1842
1843        let first_hook = &before_tool[0]["hooks"][0];
1844        assert_eq!(first_hook["type"], "command");
1845        assert_eq!(first_hook["command"], "lean-ctx hook rewrite");
1846
1847        let second_hook = &before_tool[1]["hooks"][0];
1848        assert_eq!(second_hook["type"], "command");
1849        assert_eq!(second_hook["command"], "lean-ctx hook redirect");
1850    }
1851
1852    #[test]
1853    fn gemini_hook_old_format_detected() {
1854        let old_format = r#"{"hooks":{"BeforeTool":[{"command":"lean-ctx hook rewrite"}]}}"#;
1855        let has_new = old_format.contains("hook rewrite")
1856            && old_format.contains("hook redirect")
1857            && old_format.contains("\"type\"");
1858        assert!(!has_new, "Missing 'type' field should trigger migration");
1859    }
1860
1861    #[test]
1862    fn rewrite_script_uses_registry_pattern() {
1863        let script = generate_rewrite_script("/usr/bin/lean-ctx");
1864        assert!(script.contains(r"git\ *"), "script missing git pattern");
1865        assert!(script.contains(r"cargo\ *"), "script missing cargo pattern");
1866        assert!(script.contains(r"npm\ *"), "script missing npm pattern");
1867        assert!(
1868            !script.contains(r"rg\ *"),
1869            "script should not contain rg pattern"
1870        );
1871        assert!(
1872            script.contains("LEAN_CTX_BIN=\"/usr/bin/lean-ctx\""),
1873            "script missing binary path"
1874        );
1875    }
1876
1877    #[test]
1878    fn compact_rewrite_script_uses_registry_pattern() {
1879        let script = generate_compact_rewrite_script("/usr/bin/lean-ctx");
1880        assert!(script.contains(r"git\ *"), "compact script missing git");
1881        assert!(script.contains(r"cargo\ *"), "compact script missing cargo");
1882        assert!(
1883            !script.contains(r"rg\ *"),
1884            "compact script should not contain rg"
1885        );
1886    }
1887
1888    #[test]
1889    fn rewrite_scripts_contain_all_registry_commands() {
1890        let script = generate_rewrite_script("lean-ctx");
1891        let compact = generate_compact_rewrite_script("lean-ctx");
1892        for entry in crate::rewrite_registry::REWRITE_COMMANDS {
1893            if entry.category == crate::rewrite_registry::Category::Search {
1894                continue;
1895            }
1896            let pattern = if entry.command.contains('-') {
1897                format!("{}*", entry.command.replace('-', r"\-"))
1898            } else {
1899                format!(r"{}\ *", entry.command)
1900            };
1901            assert!(
1902                script.contains(&pattern),
1903                "rewrite_script missing '{}' (pattern: {})",
1904                entry.command,
1905                pattern
1906            );
1907            assert!(
1908                compact.contains(&pattern),
1909                "compact_rewrite_script missing '{}' (pattern: {})",
1910                entry.command,
1911                pattern
1912            );
1913        }
1914    }
1915}