Skip to main content

lean_ctx/
hooks.rs

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