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