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