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