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