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