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