Skip to main content

better_ctx/
hooks.rs

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