Skip to main content

lean_ctx/hooks/
mod.rs

1mod agents;
2
3use std::path::PathBuf;
4
5pub(crate) fn mcp_server_quiet_mode() -> bool {
6    std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
7}
8
9/// Silently refresh all hook scripts for agents that are already configured.
10/// Called after updates and on MCP server start to ensure hooks match the current binary version.
11pub fn refresh_installed_hooks() {
12    let home = match dirs::home_dir() {
13        Some(h) => h,
14        None => return,
15    };
16
17    let claude_dir = crate::setup::claude_config_dir(&home);
18    let claude_hooks = claude_dir.join("hooks/lean-ctx-rewrite.sh").exists()
19        || claude_dir.join("settings.json").exists()
20            && std::fs::read_to_string(claude_dir.join("settings.json"))
21                .unwrap_or_default()
22                .contains("lean-ctx");
23
24    if claude_hooks {
25        agents::install_claude_hook_scripts(&home);
26        agents::install_claude_hook_config(&home);
27    }
28
29    let cursor_hooks = home.join(".cursor/hooks/lean-ctx-rewrite.sh").exists()
30        || home.join(".cursor/hooks.json").exists()
31            && std::fs::read_to_string(home.join(".cursor/hooks.json"))
32                .unwrap_or_default()
33                .contains("lean-ctx");
34
35    if cursor_hooks {
36        agents::install_cursor_hook_scripts(&home);
37        agents::install_cursor_hook_config(&home);
38    }
39
40    let gemini_rewrite = home.join(".gemini/hooks/lean-ctx-rewrite-gemini.sh");
41    let gemini_legacy = home.join(".gemini/hooks/lean-ctx-hook-gemini.sh");
42    if gemini_rewrite.exists() || gemini_legacy.exists() {
43        agents::install_gemini_hook_scripts(&home);
44        agents::install_gemini_hook_config(&home);
45    }
46
47    if home.join(".codex/hooks/lean-ctx-rewrite-codex.sh").exists() {
48        agents::install_codex_hook_scripts(&home);
49    }
50}
51
52fn resolve_binary_path() -> String {
53    if is_lean_ctx_in_path() {
54        return "lean-ctx".to_string();
55    }
56    std::env::current_exe()
57        .map(|p| p.to_string_lossy().to_string())
58        .unwrap_or_else(|_| "lean-ctx".to_string())
59}
60
61fn is_lean_ctx_in_path() -> bool {
62    let which_cmd = if cfg!(windows) { "where" } else { "which" };
63    std::process::Command::new(which_cmd)
64        .arg("lean-ctx")
65        .stdout(std::process::Stdio::null())
66        .stderr(std::process::Stdio::null())
67        .status()
68        .map(|s| s.success())
69        .unwrap_or(false)
70}
71
72fn resolve_binary_path_for_bash() -> String {
73    let path = resolve_binary_path();
74    to_bash_compatible_path(&path)
75}
76
77pub fn to_bash_compatible_path(path: &str) -> String {
78    let path = path.replace('\\', "/");
79    if path.len() >= 2 && path.as_bytes()[1] == b':' {
80        let drive = (path.as_bytes()[0] as char).to_ascii_lowercase();
81        format!("/{drive}{}", &path[2..])
82    } else {
83        path
84    }
85}
86
87/// Normalize paths from any client format to a consistent OS-native form.
88/// Handles MSYS2/Git Bash (`/c/Users/...` -> `C:/Users/...`), mixed separators,
89/// double slashes, and trailing slashes. Always uses forward slashes for consistency.
90pub fn normalize_tool_path(path: &str) -> String {
91    let mut p = path.to_string();
92
93    // MSYS2/Git Bash: /c/Users/... -> C:/Users/...
94    if p.len() >= 3
95        && p.starts_with('/')
96        && p.as_bytes()[1].is_ascii_alphabetic()
97        && p.as_bytes()[2] == b'/'
98    {
99        let drive = p.as_bytes()[1].to_ascii_uppercase() as char;
100        p = format!("{drive}:{}", &p[2..]);
101    }
102
103    p = p.replace('\\', "/");
104
105    // Collapse double slashes (preserve UNC paths starting with //)
106    while p.contains("//") && !p.starts_with("//") {
107        p = p.replace("//", "/");
108    }
109
110    // Remove trailing slash (unless root like "/" or "C:/")
111    if p.len() > 1 && p.ends_with('/') && !p.ends_with(":/") {
112        p.pop();
113    }
114
115    p
116}
117
118pub fn generate_rewrite_script(binary: &str) -> String {
119    format!(
120        r#"#!/usr/bin/env bash
121# lean-ctx PreToolUse hook — rewrites bash commands to lean-ctx equivalents
122set -euo pipefail
123
124LEAN_CTX_BIN="{binary}"
125
126INPUT=$(cat)
127TOOL=$(echo "$INPUT" | grep -oE '"tool_name":"([^"\\]|\\.)*"' | head -1 | sed 's/^"tool_name":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
128
129if [ "$TOOL" != "Bash" ] && [ "$TOOL" != "bash" ]; then
130  exit 0
131fi
132
133CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g')
134
135if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then
136  exit 0
137fi
138
139case "$CMD" in
140  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\ *)
141    # Shell-escape then JSON-escape (two passes)
142    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
143    REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
144    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
145    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD"
146    ;;
147  *) exit 0 ;;
148esac
149"#
150    )
151}
152
153pub fn generate_compact_rewrite_script(binary: &str) -> String {
154    format!(
155        r#"#!/usr/bin/env bash
156# lean-ctx hook — rewrites shell commands
157set -euo pipefail
158LEAN_CTX_BIN="{binary}"
159INPUT=$(cat)
160CMD=$(echo "$INPUT" | grep -oE '"command":"([^"\\]|\\.)*"' | head -1 | sed 's/^"command":"//;s/"$//' | sed 's/\\"/"/g;s/\\\\/\\/g' 2>/dev/null || echo "")
161if [ -z "$CMD" ] || echo "$CMD" | grep -qE "^(lean-ctx |$LEAN_CTX_BIN )"; then exit 0; fi
162case "$CMD" in
163  git\ *|gh\ *|cargo\ *|npm\ *|pnpm\ *|docker\ *|kubectl\ *|pip\ *|ruff\ *|go\ *|curl\ *|grep\ *|rg\ *|find\ *|ls\ *|ls|cat\ *|aws\ *|helm\ *)
164    SHELL_ESC=$(printf '%s' "$CMD" | sed 's/\\/\\\\/g;s/"/\\"/g')
165    REWRITE="$LEAN_CTX_BIN -c \"$SHELL_ESC\""
166    JSON_CMD=$(printf '%s' "$REWRITE" | sed 's/\\/\\\\/g;s/"/\\"/g')
167    printf '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{{"command":"%s"}}}}}}' "$JSON_CMD" ;;
168  *) exit 0 ;;
169esac
170"#
171    )
172}
173
174pub(crate) const REDIRECT_SCRIPT_CLAUDE: &str = r#"#!/usr/bin/env bash
175# lean-ctx PreToolUse hook — all native tools pass through
176# Read/Grep/ListFiles are allowed so Edit (which requires native Read) works.
177# The MCP instructions guide the AI to prefer ctx_read/ctx_search/ctx_tree.
178exit 0
179"#;
180
181pub(crate) const REDIRECT_SCRIPT_GENERIC: &str = r#"#!/usr/bin/env bash
182# lean-ctx hook — all native tools pass through
183exit 0
184"#;
185
186pub fn install_project_rules() {
187    let cwd = std::env::current_dir().unwrap_or_default();
188
189    ensure_project_agents_integration(&cwd);
190
191    let cursorrules = cwd.join(".cursorrules");
192    if !cursorrules.exists()
193        || !std::fs::read_to_string(&cursorrules)
194            .unwrap_or_default()
195            .contains("lean-ctx")
196    {
197        let content = CURSORRULES_TEMPLATE;
198        if cursorrules.exists() {
199            let mut existing = std::fs::read_to_string(&cursorrules).unwrap_or_default();
200            if !existing.ends_with('\n') {
201                existing.push('\n');
202            }
203            existing.push('\n');
204            existing.push_str(content);
205            write_file(&cursorrules, &existing);
206        } else {
207            write_file(&cursorrules, content);
208        }
209        println!("Created/updated .cursorrules in project root.");
210    }
211
212    let kiro_dir = cwd.join(".kiro");
213    if kiro_dir.exists() {
214        let steering_dir = kiro_dir.join("steering");
215        let steering_file = steering_dir.join("lean-ctx.md");
216        if !steering_file.exists()
217            || !std::fs::read_to_string(&steering_file)
218                .unwrap_or_default()
219                .contains("lean-ctx")
220        {
221            let _ = std::fs::create_dir_all(&steering_dir);
222            write_file(&steering_file, KIRO_STEERING_TEMPLATE);
223            println!("Created .kiro/steering/lean-ctx.md (Kiro steering).");
224        }
225    }
226}
227
228const PROJECT_LEAN_CTX_MD_MARKER: &str = "<!-- lean-ctx-owned: PROJECT-LEAN-CTX.md v1 -->";
229const PROJECT_LEAN_CTX_MD: &str = "LEAN-CTX.md";
230const PROJECT_AGENTS_MD: &str = "AGENTS.md";
231const AGENTS_BLOCK_START: &str = "<!-- lean-ctx -->";
232const AGENTS_BLOCK_END: &str = "<!-- /lean-ctx -->";
233
234fn ensure_project_agents_integration(cwd: &std::path::Path) {
235    let lean_ctx_md = cwd.join(PROJECT_LEAN_CTX_MD);
236    let desired = format!(
237        "{PROJECT_LEAN_CTX_MD_MARKER}\n{}\n",
238        crate::rules_inject::rules_dedicated_markdown()
239    );
240
241    if !lean_ctx_md.exists() {
242        write_file(&lean_ctx_md, &desired);
243    } else if std::fs::read_to_string(&lean_ctx_md)
244        .unwrap_or_default()
245        .contains(PROJECT_LEAN_CTX_MD_MARKER)
246    {
247        let current = std::fs::read_to_string(&lean_ctx_md).unwrap_or_default();
248        if !current.contains(crate::rules_inject::RULES_VERSION_STR) {
249            write_file(&lean_ctx_md, &desired);
250        }
251    }
252
253    let block = format!(
254        "{AGENTS_BLOCK_START}\n\
255## lean-ctx\n\n\
256Prefer lean-ctx MCP tools over native equivalents for token savings.\n\
257Full rules: @{PROJECT_LEAN_CTX_MD}\n\
258{AGENTS_BLOCK_END}\n"
259    );
260
261    let agents_md = cwd.join(PROJECT_AGENTS_MD);
262    if !agents_md.exists() {
263        let content = format!("# Agent Instructions\n\n{block}");
264        write_file(&agents_md, &content);
265        println!("Created AGENTS.md in project root (lean-ctx reference only).");
266        return;
267    }
268
269    let existing = std::fs::read_to_string(&agents_md).unwrap_or_default();
270    if existing.contains(AGENTS_BLOCK_START) {
271        let updated = replace_marked_block(&existing, AGENTS_BLOCK_START, AGENTS_BLOCK_END, &block);
272        if updated != existing {
273            write_file(&agents_md, &updated);
274        }
275        return;
276    }
277
278    if existing.contains("lean-ctx") && existing.contains(PROJECT_LEAN_CTX_MD) {
279        return;
280    }
281
282    let mut out = existing;
283    if !out.ends_with('\n') {
284        out.push('\n');
285    }
286    out.push('\n');
287    out.push_str(&block);
288    write_file(&agents_md, &out);
289    println!("Updated AGENTS.md (added lean-ctx reference block).");
290}
291
292fn replace_marked_block(content: &str, start: &str, end: &str, replacement: &str) -> String {
293    let s = content.find(start);
294    let e = content.find(end);
295    match (s, e) {
296        (Some(si), Some(ei)) if ei >= si => {
297            let after_end = ei + end.len();
298            let before = &content[..si];
299            let after = &content[after_end..];
300            let mut out = String::new();
301            out.push_str(before.trim_end_matches('\n'));
302            out.push('\n');
303            out.push('\n');
304            out.push_str(replacement.trim_end_matches('\n'));
305            out.push('\n');
306            out.push_str(after.trim_start_matches('\n'));
307            out
308        }
309        _ => content.to_string(),
310    }
311}
312
313const CURSORRULES_TEMPLATE: &str = "\
314# lean-ctx — Context Engineering Layer
315
316PREFER lean-ctx MCP tools over native equivalents for token savings:
317
318| PREFER | OVER | Why |
319|--------|------|-----|
320| `ctx_read(path)` | `Read` | Cached, 8 compression modes |
321| `ctx_shell(command)` | `Shell` | Pattern compression |
322| `ctx_search(pattern, path)` | `Grep` | Compact results |
323| `ctx_tree(path, depth)` | `ls` / `find` | Directory maps |
324| `ctx_edit(path, old_string, new_string)` | `Edit` (when Read unavailable) | Search-and-replace without native Read |
325
326Edit files: use native Edit/StrReplace if available. If Edit requires Read and Read is unavailable, use ctx_edit.
327Write, Delete, Glob — use normally. NEVER loop on Edit failures — switch to ctx_edit immediately.
328";
329
330pub const KIRO_STEERING_TEMPLATE: &str = "\
331---
332inclusion: always
333---
334
335# lean-ctx — Context Engineering Layer
336
337The workspace has the `lean-ctx` MCP server installed. You MUST prefer lean-ctx tools over native equivalents for token efficiency and caching.
338
339## Mandatory Tool Preferences
340
341| Use this | Instead of | Why |
342|----------|-----------|-----|
343| `mcp_lean_ctx_ctx_read` | `readFile`, `readCode` | Cached reads, 8 compression modes, re-reads cost ~13 tokens |
344| `mcp_lean_ctx_ctx_multi_read` | `readMultipleFiles` | Batch cached reads in one call |
345| `mcp_lean_ctx_ctx_shell` | `executeBash` | Pattern compression for git/npm/test output |
346| `mcp_lean_ctx_ctx_search` | `grepSearch` | Compact, .gitignore-aware results |
347| `mcp_lean_ctx_ctx_tree` | `listDirectory` | Compact directory maps with file counts |
348
349## When to use native Kiro tools instead
350
351- `fsWrite` / `fsAppend` — always use native (lean-ctx doesn't write files)
352- `strReplace` — always use native (precise string replacement)
353- `semanticRename` / `smartRelocate` — always use native (IDE integration)
354- `getDiagnostics` — always use native (language server diagnostics)
355- `deleteFile` — always use native
356
357## Session management
358
359- At the start of a long task, call `mcp_lean_ctx_ctx_preload` with a task description to warm the cache
360- Use `mcp_lean_ctx_ctx_compress` periodically in long conversations to checkpoint context
361- Use `mcp_lean_ctx_ctx_knowledge` to persist important discoveries across sessions
362
363## Rules
364
365- NEVER loop on edit failures — switch to `mcp_lean_ctx_ctx_edit` immediately
366- For large files, use `mcp_lean_ctx_ctx_read` with `mode: \"signatures\"` or `mode: \"map\"` first
367- For re-reading a file you already read, just call `mcp_lean_ctx_ctx_read` again (cache hit = ~13 tokens)
368- When running tests or build commands, use `mcp_lean_ctx_ctx_shell` for compressed output
369";
370
371pub fn install_agent_hook(agent: &str, global: bool) {
372    match agent {
373        "claude" | "claude-code" => agents::install_claude_hook(global),
374        "cursor" => agents::install_cursor_hook(global),
375        "gemini" | "antigravity" => agents::install_gemini_hook(),
376        "codex" => agents::install_codex_hook(),
377        "windsurf" => agents::install_windsurf_rules(global),
378        "cline" | "roo" => agents::install_cline_rules(global),
379        "copilot" => agents::install_copilot_hook(global),
380        "pi" => agents::install_pi_hook(global),
381        "qwen" => agents::install_mcp_json_agent(
382            "Qwen Code",
383            "~/.qwen/mcp.json",
384            &dirs::home_dir().unwrap_or_default().join(".qwen/mcp.json"),
385        ),
386        "trae" => agents::install_mcp_json_agent(
387            "Trae",
388            "~/.trae/mcp.json",
389            &dirs::home_dir().unwrap_or_default().join(".trae/mcp.json"),
390        ),
391        "amazonq" => agents::install_mcp_json_agent(
392            "Amazon Q Developer",
393            "~/.aws/amazonq/mcp.json",
394            &dirs::home_dir()
395                .unwrap_or_default()
396                .join(".aws/amazonq/mcp.json"),
397        ),
398        "jetbrains" => agents::install_mcp_json_agent(
399            "JetBrains IDEs",
400            "~/.jb-mcp.json",
401            &dirs::home_dir().unwrap_or_default().join(".jb-mcp.json"),
402        ),
403        "kiro" => agents::install_kiro_hook(),
404        "verdent" => agents::install_mcp_json_agent(
405            "Verdent",
406            "~/.verdent/mcp.json",
407            &dirs::home_dir()
408                .unwrap_or_default()
409                .join(".verdent/mcp.json"),
410        ),
411        "opencode" => agents::install_mcp_json_agent(
412            "OpenCode",
413            "~/.opencode/mcp.json",
414            &dirs::home_dir()
415                .unwrap_or_default()
416                .join(".opencode/mcp.json"),
417        ),
418        "aider" => agents::install_mcp_json_agent(
419            "Aider",
420            "~/.aider/mcp.json",
421            &dirs::home_dir().unwrap_or_default().join(".aider/mcp.json"),
422        ),
423        "amp" => agents::install_mcp_json_agent(
424            "Amp",
425            "~/.amp/mcp.json",
426            &dirs::home_dir().unwrap_or_default().join(".amp/mcp.json"),
427        ),
428        "crush" => agents::install_crush_hook(),
429        _ => {
430            eprintln!("Unknown agent: {agent}");
431            eprintln!("  Supported: claude, cursor, gemini, codex, windsurf, cline, roo, copilot, pi, qwen, trae, amazonq, jetbrains, kiro, verdent, opencode, aider, amp, crush, antigravity");
432            std::process::exit(1);
433        }
434    }
435}
436
437fn write_file(path: &std::path::Path, content: &str) {
438    if let Err(e) = crate::config_io::write_atomic_with_backup(path, content) {
439        eprintln!("Error writing {}: {e}", path.display());
440    }
441}
442
443#[cfg(unix)]
444fn make_executable(path: &PathBuf) {
445    use std::os::unix::fs::PermissionsExt;
446    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
447}
448
449#[cfg(not(unix))]
450fn make_executable(_path: &PathBuf) {}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn bash_path_unix_unchanged() {
458        assert_eq!(
459            to_bash_compatible_path("/usr/local/bin/lean-ctx"),
460            "/usr/local/bin/lean-ctx"
461        );
462    }
463
464    #[test]
465    fn bash_path_home_unchanged() {
466        assert_eq!(
467            to_bash_compatible_path("/home/user/.cargo/bin/lean-ctx"),
468            "/home/user/.cargo/bin/lean-ctx"
469        );
470    }
471
472    #[test]
473    fn bash_path_windows_drive_converted() {
474        assert_eq!(
475            to_bash_compatible_path("C:\\Users\\Fraser\\bin\\lean-ctx.exe"),
476            "/c/Users/Fraser/bin/lean-ctx.exe"
477        );
478    }
479
480    #[test]
481    fn bash_path_windows_lowercase_drive() {
482        assert_eq!(
483            to_bash_compatible_path("D:\\tools\\lean-ctx.exe"),
484            "/d/tools/lean-ctx.exe"
485        );
486    }
487
488    #[test]
489    fn bash_path_windows_forward_slashes() {
490        assert_eq!(
491            to_bash_compatible_path("C:/Users/Fraser/bin/lean-ctx.exe"),
492            "/c/Users/Fraser/bin/lean-ctx.exe"
493        );
494    }
495
496    #[test]
497    fn bash_path_bare_name_unchanged() {
498        assert_eq!(to_bash_compatible_path("lean-ctx"), "lean-ctx");
499    }
500
501    #[test]
502    fn normalize_msys2_path() {
503        assert_eq!(
504            normalize_tool_path("/c/Users/game/Downloads/project"),
505            "C:/Users/game/Downloads/project"
506        );
507    }
508
509    #[test]
510    fn normalize_msys2_drive_d() {
511        assert_eq!(
512            normalize_tool_path("/d/Projects/app/src"),
513            "D:/Projects/app/src"
514        );
515    }
516
517    #[test]
518    fn normalize_backslashes() {
519        assert_eq!(
520            normalize_tool_path("C:\\Users\\game\\project\\src"),
521            "C:/Users/game/project/src"
522        );
523    }
524
525    #[test]
526    fn normalize_mixed_separators() {
527        assert_eq!(
528            normalize_tool_path("C:\\Users/game\\project/src"),
529            "C:/Users/game/project/src"
530        );
531    }
532
533    #[test]
534    fn normalize_double_slashes() {
535        assert_eq!(
536            normalize_tool_path("/home/user//project///src"),
537            "/home/user/project/src"
538        );
539    }
540
541    #[test]
542    fn normalize_trailing_slash() {
543        assert_eq!(
544            normalize_tool_path("/home/user/project/"),
545            "/home/user/project"
546        );
547    }
548
549    #[test]
550    fn normalize_root_preserved() {
551        assert_eq!(normalize_tool_path("/"), "/");
552    }
553
554    #[test]
555    fn normalize_windows_root_preserved() {
556        assert_eq!(normalize_tool_path("C:/"), "C:/");
557    }
558
559    #[test]
560    fn normalize_unix_path_unchanged() {
561        assert_eq!(
562            normalize_tool_path("/home/user/project/src/main.rs"),
563            "/home/user/project/src/main.rs"
564        );
565    }
566
567    #[test]
568    fn normalize_relative_path_unchanged() {
569        assert_eq!(normalize_tool_path("src/main.rs"), "src/main.rs");
570    }
571
572    #[test]
573    fn normalize_dot_unchanged() {
574        assert_eq!(normalize_tool_path("."), ".");
575    }
576
577    #[test]
578    fn normalize_unc_path_preserved() {
579        assert_eq!(
580            normalize_tool_path("//server/share/file"),
581            "//server/share/file"
582        );
583    }
584
585    #[test]
586    fn cursor_hook_config_has_version_and_object_hooks() {
587        let config = serde_json::json!({
588            "version": 1,
589            "hooks": {
590                "preToolUse": [
591                    {
592                        "matcher": "terminal_command",
593                        "command": "lean-ctx hook rewrite"
594                    },
595                    {
596                        "matcher": "read_file|grep|search|list_files|list_directory",
597                        "command": "lean-ctx hook redirect"
598                    }
599                ]
600            }
601        });
602
603        let json_str = serde_json::to_string_pretty(&config).unwrap();
604        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
605
606        assert_eq!(parsed["version"], 1);
607        assert!(parsed["hooks"].is_object());
608        assert!(parsed["hooks"]["preToolUse"].is_array());
609        assert_eq!(parsed["hooks"]["preToolUse"].as_array().unwrap().len(), 2);
610        assert_eq!(
611            parsed["hooks"]["preToolUse"][0]["matcher"],
612            "terminal_command"
613        );
614    }
615
616    #[test]
617    fn cursor_hook_detects_old_format_needs_migration() {
618        let old_format = r#"{"hooks":[{"event":"preToolUse","command":"lean-ctx hook rewrite"}]}"#;
619        let has_correct =
620            old_format.contains("\"version\"") && old_format.contains("\"preToolUse\"");
621        assert!(
622            !has_correct,
623            "Old format should be detected as needing migration"
624        );
625    }
626
627    #[test]
628    fn gemini_hook_config_has_type_command() {
629        let binary = "lean-ctx";
630        let rewrite_cmd = format!("{binary} hook rewrite");
631        let redirect_cmd = format!("{binary} hook redirect");
632
633        let hook_config = serde_json::json!({
634            "hooks": {
635                "BeforeTool": [
636                    {
637                        "hooks": [{
638                            "type": "command",
639                            "command": rewrite_cmd
640                        }]
641                    },
642                    {
643                        "hooks": [{
644                            "type": "command",
645                            "command": redirect_cmd
646                        }]
647                    }
648                ]
649            }
650        });
651
652        let parsed = hook_config;
653        let before_tool = parsed["hooks"]["BeforeTool"].as_array().unwrap();
654        assert_eq!(before_tool.len(), 2);
655
656        let first_hook = &before_tool[0]["hooks"][0];
657        assert_eq!(first_hook["type"], "command");
658        assert_eq!(first_hook["command"], "lean-ctx hook rewrite");
659
660        let second_hook = &before_tool[1]["hooks"][0];
661        assert_eq!(second_hook["type"], "command");
662        assert_eq!(second_hook["command"], "lean-ctx hook redirect");
663    }
664
665    #[test]
666    fn gemini_hook_old_format_detected() {
667        let old_format = r#"{"hooks":{"BeforeTool":[{"command":"lean-ctx hook rewrite"}]}}"#;
668        let has_new = old_format.contains("hook rewrite")
669            && old_format.contains("hook redirect")
670            && old_format.contains("\"type\"");
671        assert!(!has_new, "Missing 'type' field should trigger migration");
672    }
673}