Skip to main content

lean_ctx/hooks/
agents.rs

1use std::path::PathBuf;
2
3use super::{
4    ensure_codex_hooks_enabled as shared_ensure_codex_hooks_enabled, full_server_entry,
5    generate_compact_rewrite_script, generate_rewrite_script, install_codex_instruction_docs,
6    install_mcp_json_agent, install_named_json_server, install_project_rules, is_inside_git_repo,
7    make_executable, mcp_server_quiet_mode, resolve_binary_path, resolve_binary_path_for_bash,
8    upsert_lean_ctx_codex_hook_entries, write_file, KIRO_STEERING_TEMPLATE, REDIRECT_SCRIPT_CLAUDE,
9    REDIRECT_SCRIPT_GENERIC,
10};
11
12pub(super) fn install_claude_hook(global: bool) {
13    let home = match dirs::home_dir() {
14        Some(h) => h,
15        None => {
16            eprintln!("Cannot resolve home directory");
17            return;
18        }
19    };
20
21    install_claude_hook_scripts(&home);
22    install_claude_hook_config(&home);
23
24    let scope = crate::core::config::Config::load().rules_scope_effective();
25    if scope != crate::core::config::RulesScope::Project {
26        install_claude_rules_file(&home);
27        install_claude_global_claude_md(&home);
28        install_claude_skill(&home);
29    }
30
31    let _ = global;
32}
33
34const CLAUDE_MD_BLOCK_START: &str = "<!-- lean-ctx -->";
35const CLAUDE_MD_BLOCK_END: &str = "<!-- /lean-ctx -->";
36const CLAUDE_MD_BLOCK_VERSION: &str = "lean-ctx-claude-v2";
37
38const CLAUDE_MD_BLOCK_CONTENT: &str = "\
39<!-- lean-ctx -->
40<!-- lean-ctx-claude-v2 -->
41## lean-ctx — Context Runtime
42
43Always prefer lean-ctx MCP tools over native equivalents:
44- `ctx_read` instead of `Read` / `cat` (cached, 10 modes, re-reads ~13 tokens)
45- `ctx_shell` instead of `bash` / `Shell` (90+ compression patterns)
46- `ctx_search` instead of `Grep` / `rg` (compact results)
47- `ctx_tree` instead of `ls` / `find` (compact directory maps)
48- Native Edit/StrReplace stay unchanged. If Edit requires Read and Read is unavailable, use `ctx_edit(path, old_string, new_string)` instead.
49- Write, Delete, Glob — use normally.
50
51Full rules: @rules/lean-ctx.md
52
53Verify setup: run `/mcp` to check lean-ctx is connected, `/memory` to confirm this file loaded.
54<!-- /lean-ctx -->";
55
56fn install_claude_global_claude_md(home: &std::path::Path) {
57    let claude_dir = crate::core::editor_registry::claude_state_dir(home);
58    let _ = std::fs::create_dir_all(&claude_dir);
59    let claude_md_path = claude_dir.join("CLAUDE.md");
60
61    let existing = std::fs::read_to_string(&claude_md_path).unwrap_or_default();
62
63    if existing.contains(CLAUDE_MD_BLOCK_START) {
64        if existing.contains(CLAUDE_MD_BLOCK_VERSION) {
65            return;
66        }
67        let cleaned = remove_block(&existing, CLAUDE_MD_BLOCK_START, CLAUDE_MD_BLOCK_END);
68        let updated = format!("{}\n\n{}\n", cleaned.trim(), CLAUDE_MD_BLOCK_CONTENT);
69        write_file(&claude_md_path, &updated);
70        return;
71    }
72
73    if existing.trim().is_empty() {
74        write_file(&claude_md_path, CLAUDE_MD_BLOCK_CONTENT);
75    } else {
76        let updated = format!("{}\n\n{}\n", existing.trim(), CLAUDE_MD_BLOCK_CONTENT);
77        write_file(&claude_md_path, &updated);
78    }
79}
80
81fn remove_block(content: &str, start: &str, end: &str) -> String {
82    let s = content.find(start);
83    let e = content.find(end);
84    match (s, e) {
85        (Some(si), Some(ei)) if ei >= si => {
86            let after_end = ei + end.len();
87            let before = content[..si].trim_end_matches('\n');
88            let after = &content[after_end..];
89            let mut out = before.to_string();
90            out.push('\n');
91            if !after.trim().is_empty() {
92                out.push('\n');
93                out.push_str(after.trim_start_matches('\n'));
94            }
95            out
96        }
97        _ => content.to_string(),
98    }
99}
100
101fn install_claude_skill(home: &std::path::Path) {
102    let skill_dir = home.join(".claude/skills/lean-ctx");
103    let _ = std::fs::create_dir_all(skill_dir.join("scripts"));
104
105    let skill_md = include_str!("../../skills/lean-ctx/SKILL.md");
106    let install_sh = include_str!("../../skills/lean-ctx/scripts/install.sh");
107
108    let skill_path = skill_dir.join("SKILL.md");
109    let script_path = skill_dir.join("scripts/install.sh");
110
111    write_file(&skill_path, skill_md);
112    write_file(&script_path, install_sh);
113
114    #[cfg(unix)]
115    {
116        use std::os::unix::fs::PermissionsExt;
117        if let Ok(mut perms) = std::fs::metadata(&script_path).map(|m| m.permissions()) {
118            perms.set_mode(0o755);
119            let _ = std::fs::set_permissions(&script_path, perms);
120        }
121    }
122}
123
124fn install_claude_rules_file(home: &std::path::Path) {
125    let rules_dir = crate::core::editor_registry::claude_rules_dir(home);
126    let _ = std::fs::create_dir_all(&rules_dir);
127    let rules_path = rules_dir.join("lean-ctx.md");
128
129    let desired = crate::rules_inject::rules_dedicated_markdown();
130    let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
131
132    if existing.is_empty() {
133        write_file(&rules_path, desired);
134        return;
135    }
136    if existing.contains(crate::rules_inject::RULES_VERSION_STR) {
137        return;
138    }
139    if existing.contains("<!-- lean-ctx-rules-") {
140        write_file(&rules_path, desired);
141    }
142}
143
144pub(super) fn install_claude_hook_scripts(home: &std::path::Path) {
145    let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
146    let _ = std::fs::create_dir_all(&hooks_dir);
147
148    let binary = resolve_binary_path();
149
150    let rewrite_path = hooks_dir.join("lean-ctx-rewrite.sh");
151    let rewrite_script = generate_rewrite_script(&resolve_binary_path_for_bash());
152    write_file(&rewrite_path, &rewrite_script);
153    make_executable(&rewrite_path);
154
155    let redirect_path = hooks_dir.join("lean-ctx-redirect.sh");
156    write_file(&redirect_path, REDIRECT_SCRIPT_CLAUDE);
157    make_executable(&redirect_path);
158
159    let wrapper = |subcommand: &str| -> String {
160        if cfg!(windows) {
161            format!("{binary} hook {subcommand}")
162        } else {
163            format!("{} hook {subcommand}", resolve_binary_path_for_bash())
164        }
165    };
166
167    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
168    write_file(
169        &rewrite_native,
170        &format!(
171            "#!/bin/sh\nexec {} hook rewrite\n",
172            resolve_binary_path_for_bash()
173        ),
174    );
175    make_executable(&rewrite_native);
176
177    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
178    write_file(
179        &redirect_native,
180        &format!(
181            "#!/bin/sh\nexec {} hook redirect\n",
182            resolve_binary_path_for_bash()
183        ),
184    );
185    make_executable(&redirect_native);
186
187    let _ = wrapper; // suppress unused warning on unix
188}
189
190pub(super) fn install_claude_hook_config(home: &std::path::Path) {
191    let hooks_dir = crate::core::editor_registry::claude_state_dir(home).join("hooks");
192    let binary = resolve_binary_path();
193
194    let rewrite_cmd = format!("{binary} hook rewrite");
195    let redirect_cmd = format!("{binary} hook redirect");
196
197    let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
198    let settings_content = if settings_path.exists() {
199        std::fs::read_to_string(&settings_path).unwrap_or_default()
200    } else {
201        String::new()
202    };
203
204    let needs_update =
205        !settings_content.contains("hook rewrite") || !settings_content.contains("hook redirect");
206    let has_old_hooks = settings_content.contains("lean-ctx-rewrite.sh")
207        || settings_content.contains("lean-ctx-redirect.sh");
208
209    if !needs_update && !has_old_hooks {
210        return;
211    }
212
213    let hook_entry = serde_json::json!({
214        "hooks": {
215            "PreToolUse": [
216                {
217                    "matcher": "Bash|bash",
218                    "hooks": [{
219                        "type": "command",
220                        "command": rewrite_cmd
221                    }]
222                },
223                {
224                    "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
225                    "hooks": [{
226                        "type": "command",
227                        "command": redirect_cmd
228                    }]
229                }
230            ]
231        }
232    });
233
234    if settings_content.is_empty() {
235        write_file(
236            &settings_path,
237            &serde_json::to_string_pretty(&hook_entry).unwrap(),
238        );
239    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
240        if let Some(obj) = existing.as_object_mut() {
241            obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
242            write_file(
243                &settings_path,
244                &serde_json::to_string_pretty(&existing).unwrap(),
245            );
246        }
247    }
248    if !mcp_server_quiet_mode() {
249        println!("Installed Claude Code hooks at {}", hooks_dir.display());
250    }
251}
252
253pub(super) fn install_claude_project_hooks(cwd: &std::path::Path) {
254    let binary = resolve_binary_path();
255    let rewrite_cmd = format!("{binary} hook rewrite");
256    let redirect_cmd = format!("{binary} hook redirect");
257
258    let settings_path = cwd.join(".claude").join("settings.local.json");
259    let _ = std::fs::create_dir_all(cwd.join(".claude"));
260
261    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
262    if existing.contains("hook rewrite") && existing.contains("hook redirect") {
263        return;
264    }
265
266    let hook_entry = serde_json::json!({
267        "hooks": {
268            "PreToolUse": [
269                {
270                    "matcher": "Bash|bash",
271                    "hooks": [{
272                        "type": "command",
273                        "command": rewrite_cmd
274                    }]
275                },
276                {
277                    "matcher": "Read|read|ReadFile|read_file|View|view|Grep|grep|Search|search|ListFiles|list_files|ListDirectory|list_directory",
278                    "hooks": [{
279                        "type": "command",
280                        "command": redirect_cmd
281                    }]
282                }
283            ]
284        }
285    });
286
287    if existing.is_empty() {
288        write_file(
289            &settings_path,
290            &serde_json::to_string_pretty(&hook_entry).unwrap(),
291        );
292    } else if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&existing) {
293        if let Some(obj) = json.as_object_mut() {
294            obj.insert("hooks".to_string(), hook_entry["hooks"].clone());
295            write_file(
296                &settings_path,
297                &serde_json::to_string_pretty(&json).unwrap(),
298            );
299        }
300    }
301    println!("Created .claude/settings.local.json (project-local PreToolUse hooks).");
302}
303
304pub fn install_cursor_hook(global: bool) {
305    let home = match dirs::home_dir() {
306        Some(h) => h,
307        None => {
308            eprintln!("Cannot resolve home directory");
309            return;
310        }
311    };
312
313    install_cursor_hook_scripts(&home);
314    install_cursor_hook_config(&home);
315
316    let scope = crate::core::config::Config::load().rules_scope_effective();
317    let skip_project = global || scope == crate::core::config::RulesScope::Global;
318
319    if !skip_project {
320        let rules_dir = PathBuf::from(".cursor").join("rules");
321        let _ = std::fs::create_dir_all(&rules_dir);
322        let rule_path = rules_dir.join("lean-ctx.mdc");
323        if !rule_path.exists() {
324            let rule_content = include_str!("../templates/lean-ctx.mdc");
325            write_file(&rule_path, rule_content);
326            println!("Created .cursor/rules/lean-ctx.mdc in current project.");
327        } else {
328            println!("Cursor rule already exists.");
329        }
330    } else {
331        println!("Global mode: skipping project-local .cursor/rules/ (use without --global in a project).");
332    }
333
334    println!("Restart Cursor to activate.");
335}
336
337pub(super) fn install_cursor_hook_scripts(home: &std::path::Path) {
338    let hooks_dir = home.join(".cursor").join("hooks");
339    install_standard_hook_scripts(&hooks_dir, "lean-ctx-rewrite.sh", "lean-ctx-redirect.sh");
340
341    let native_binary = resolve_binary_path();
342    let rewrite_native = hooks_dir.join("lean-ctx-rewrite-native");
343    write_file(
344        &rewrite_native,
345        &format!("#!/bin/sh\nexec {} hook rewrite\n", native_binary),
346    );
347    make_executable(&rewrite_native);
348
349    let redirect_native = hooks_dir.join("lean-ctx-redirect-native");
350    write_file(
351        &redirect_native,
352        &format!("#!/bin/sh\nexec {} hook redirect\n", native_binary),
353    );
354    make_executable(&redirect_native);
355}
356
357pub(super) fn install_cursor_hook_config(home: &std::path::Path) {
358    let binary = resolve_binary_path();
359    let rewrite_cmd = format!("{binary} hook rewrite");
360    let redirect_cmd = format!("{binary} hook redirect");
361
362    let hooks_json = home.join(".cursor").join("hooks.json");
363
364    let hook_config = serde_json::json!({
365        "version": 1,
366        "hooks": {
367            "preToolUse": [
368                {
369                    "matcher": "Shell",
370                    "command": rewrite_cmd
371                },
372                {
373                    "matcher": "Read|Grep",
374                    "command": redirect_cmd
375                }
376            ]
377        }
378    });
379
380    let content = if hooks_json.exists() {
381        std::fs::read_to_string(&hooks_json).unwrap_or_default()
382    } else {
383        String::new()
384    };
385
386    let has_correct_matchers = content.contains("\"Shell\"")
387        && (content.contains("\"Read|Grep\"") || content.contains("\"Read\""));
388    let has_correct_format = content.contains("\"version\"") && content.contains("\"preToolUse\"");
389    if has_correct_format
390        && has_correct_matchers
391        && content.contains("hook rewrite")
392        && content.contains("hook redirect")
393    {
394        return;
395    }
396
397    if content.is_empty() || !content.contains("\"version\"") {
398        write_file(
399            &hooks_json,
400            &serde_json::to_string_pretty(&hook_config).unwrap(),
401        );
402    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&content) {
403        if let Some(obj) = existing.as_object_mut() {
404            obj.insert("version".to_string(), serde_json::json!(1));
405            obj.insert("hooks".to_string(), hook_config["hooks"].clone());
406            write_file(
407                &hooks_json,
408                &serde_json::to_string_pretty(&existing).unwrap(),
409            );
410        }
411    } else {
412        write_file(
413            &hooks_json,
414            &serde_json::to_string_pretty(&hook_config).unwrap(),
415        );
416    }
417
418    if !mcp_server_quiet_mode() {
419        println!("Installed Cursor hooks at {}", hooks_json.display());
420    }
421}
422
423pub(super) fn install_gemini_hook() {
424    let home = match dirs::home_dir() {
425        Some(h) => h,
426        None => {
427            eprintln!("Cannot resolve home directory");
428            return;
429        }
430    };
431
432    install_gemini_hook_scripts(&home);
433    install_gemini_hook_config(&home);
434}
435
436fn install_standard_hook_scripts(
437    hooks_dir: &std::path::Path,
438    rewrite_name: &str,
439    redirect_name: &str,
440) {
441    let _ = std::fs::create_dir_all(hooks_dir);
442
443    let binary = resolve_binary_path_for_bash();
444    let rewrite_path = hooks_dir.join(rewrite_name);
445    let rewrite_script = generate_compact_rewrite_script(&binary);
446    write_file(&rewrite_path, &rewrite_script);
447    make_executable(&rewrite_path);
448
449    let redirect_path = hooks_dir.join(redirect_name);
450    write_file(&redirect_path, REDIRECT_SCRIPT_GENERIC);
451    make_executable(&redirect_path);
452}
453
454pub(super) fn install_gemini_hook_scripts(home: &std::path::Path) {
455    let hooks_dir = home.join(".gemini").join("hooks");
456    install_standard_hook_scripts(
457        &hooks_dir,
458        "lean-ctx-rewrite-gemini.sh",
459        "lean-ctx-redirect-gemini.sh",
460    );
461}
462
463pub(super) fn install_gemini_hook_config(home: &std::path::Path) {
464    let binary = resolve_binary_path();
465    let rewrite_cmd = format!("{binary} hook rewrite");
466    let redirect_cmd = format!("{binary} hook redirect");
467
468    let settings_path = home.join(".gemini").join("settings.json");
469    let settings_content = if settings_path.exists() {
470        std::fs::read_to_string(&settings_path).unwrap_or_default()
471    } else {
472        String::new()
473    };
474
475    let has_new_format = settings_content.contains("hook rewrite")
476        && settings_content.contains("hook redirect")
477        && settings_content.contains("\"type\"")
478        && settings_content.contains("\"matcher\"");
479    let has_old_hooks = settings_content.contains("lean-ctx-rewrite")
480        || settings_content.contains("lean-ctx-redirect")
481        || (settings_content.contains("hook rewrite") && !settings_content.contains("\"matcher\""));
482
483    if has_new_format && !has_old_hooks {
484        return;
485    }
486
487    let hook_config = serde_json::json!({
488        "hooks": {
489            "BeforeTool": [
490                {
491                    "matcher": "shell|execute_command|run_shell_command",
492                    "hooks": [{
493                        "type": "command",
494                        "command": rewrite_cmd
495                    }]
496                },
497                {
498                    "matcher": "read_file|read_many_files|grep|search|list_dir",
499                    "hooks": [{
500                        "type": "command",
501                        "command": redirect_cmd
502                    }]
503                }
504            ]
505        }
506    });
507
508    if settings_content.is_empty() {
509        write_file(
510            &settings_path,
511            &serde_json::to_string_pretty(&hook_config).unwrap(),
512        );
513    } else if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(&settings_content) {
514        if let Some(obj) = existing.as_object_mut() {
515            obj.insert("hooks".to_string(), hook_config["hooks"].clone());
516            write_file(
517                &settings_path,
518                &serde_json::to_string_pretty(&existing).unwrap(),
519            );
520        }
521    }
522    if !mcp_server_quiet_mode() {
523        println!(
524            "Installed Gemini CLI hooks at {}",
525            settings_path.parent().unwrap_or(&settings_path).display()
526        );
527    }
528}
529
530pub fn install_codex_hook() {
531    let home = match dirs::home_dir() {
532        Some(h) => h,
533        None => {
534            eprintln!("Cannot resolve home directory");
535            return;
536        }
537    };
538
539    let codex_dir = home.join(".codex");
540    let _ = std::fs::create_dir_all(&codex_dir);
541
542    let hook_config_changed = install_codex_hook_config(&home);
543    let installed_docs = install_codex_instruction_docs(&codex_dir);
544
545    if !mcp_server_quiet_mode() {
546        if hook_config_changed {
547            eprintln!(
548                "Installed Codex-compatible SessionStart/PreToolUse hooks at {}",
549                codex_dir.display()
550            );
551        }
552        if installed_docs {
553            eprintln!("Installed Codex instructions at {}", codex_dir.display());
554        } else {
555            eprintln!("Codex AGENTS.md already configured.");
556        }
557    }
558}
559
560fn install_codex_hook_config(home: &std::path::Path) -> bool {
561    let binary = resolve_binary_path();
562    let session_start_cmd = format!("{binary} hook codex-session-start");
563    let pre_tool_use_cmd = format!("{binary} hook codex-pretooluse");
564    let codex_dir = home.join(".codex");
565    let hooks_json_path = codex_dir.join("hooks.json");
566
567    let mut changed = false;
568    let mut root = if hooks_json_path.exists() {
569        match std::fs::read_to_string(&hooks_json_path)
570            .ok()
571            .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
572        {
573            Some(parsed) => parsed,
574            None => {
575                changed = true;
576                serde_json::json!({ "hooks": {} })
577            }
578        }
579    } else {
580        changed = true;
581        serde_json::json!({ "hooks": {} })
582    };
583
584    if upsert_lean_ctx_codex_hook_entries(&mut root, &session_start_cmd, &pre_tool_use_cmd) {
585        changed = true;
586    }
587    if changed {
588        write_file(
589            &hooks_json_path,
590            &serde_json::to_string_pretty(&root).unwrap(),
591        );
592    }
593
594    let rewrite_path = codex_dir.join("hooks").join("lean-ctx-rewrite-codex.sh");
595    if rewrite_path.exists() && std::fs::remove_file(&rewrite_path).is_ok() {
596        changed = true;
597    }
598
599    let config_toml_path = codex_dir.join("config.toml");
600    let config_content = std::fs::read_to_string(&config_toml_path).unwrap_or_default();
601    if let Some(updated) = ensure_codex_hooks_enabled(&config_content) {
602        write_file(&config_toml_path, &updated);
603        changed = true;
604        if !mcp_server_quiet_mode() {
605            eprintln!(
606                "Enabled codex_hooks feature in {}",
607                config_toml_path.display()
608            );
609        }
610    }
611
612    changed
613}
614
615fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
616    shared_ensure_codex_hooks_enabled(config_content)
617}
618
619pub(super) fn install_windsurf_rules(global: bool) {
620    let Some(rules_path) = prepare_project_rules_path(global, ".windsurfrules") else {
621        return;
622    };
623
624    let rules = include_str!("../templates/windsurfrules.txt");
625    write_file(&rules_path, rules);
626    println!("Installed .windsurfrules in current project.");
627}
628
629pub(super) fn install_cline_rules(global: bool) {
630    let Some(rules_path) = prepare_project_rules_path(global, ".clinerules") else {
631        return;
632    };
633
634    let binary = resolve_binary_path();
635    let rules = format!(
636        r#"# lean-ctx Shell Optimization
637# Prefix all shell commands with `{binary} -c` for 60-90% token savings.
638
639When running shell commands, always prefix them with `{binary} -c` to compress output. For example:
640- `{binary} -c git status` instead of `git status`
641- `{binary} -c cargo test` instead of `cargo test`
642- `{binary} -c ls src/` instead of `ls src/`
643
644Supported commands: git, cargo, npm, pnpm, docker, kubectl, pip, ruff, go, curl, grep, find, ls, aws, helm, and 90+ more.
645"#
646    );
647
648    write_file(&rules_path, &rules);
649    println!("Installed .clinerules in current project.");
650}
651
652fn prepare_project_rules_path(global: bool, file_name: &str) -> Option<PathBuf> {
653    let scope = crate::core::config::Config::load().rules_scope_effective();
654    if global || scope == crate::core::config::RulesScope::Global {
655        println!(
656            "Global mode: skipping project-local {file_name} (use without --global in a project)."
657        );
658        return None;
659    }
660
661    let cwd = std::env::current_dir().unwrap_or_default();
662    if !is_inside_git_repo(&cwd) || cwd == dirs::home_dir().unwrap_or_default() {
663        eprintln!("  Skipping {file_name}: not inside a git repository or in home directory.");
664        return None;
665    }
666
667    let rules_path = PathBuf::from(file_name);
668    if rules_path.exists() {
669        let content = std::fs::read_to_string(&rules_path).unwrap_or_default();
670        if content.contains("lean-ctx") {
671            println!("{file_name} already configured.");
672            return None;
673        }
674    }
675
676    Some(rules_path)
677}
678
679pub(super) fn install_pi_hook(global: bool) {
680    let has_pi = std::process::Command::new("pi")
681        .arg("--version")
682        .output()
683        .is_ok();
684
685    if !has_pi {
686        println!("Pi Coding Agent not found in PATH.");
687        println!("Install Pi first: npm install -g @mariozechner/pi-coding-agent");
688        println!();
689    }
690
691    println!("Installing pi-lean-ctx Pi Package...");
692    println!();
693
694    let install_result = std::process::Command::new("pi")
695        .args(["install", "npm:pi-lean-ctx"])
696        .status();
697
698    match install_result {
699        Ok(status) if status.success() => {
700            println!("Installed pi-lean-ctx Pi Package.");
701        }
702        _ => {
703            println!("Could not auto-install pi-lean-ctx. Install manually:");
704            println!("  pi install npm:pi-lean-ctx");
705            println!();
706        }
707    }
708
709    write_pi_mcp_config();
710
711    let scope = crate::core::config::Config::load().rules_scope_effective();
712    let skip_project = global || scope == crate::core::config::RulesScope::Global;
713
714    if !skip_project {
715        let agents_md = PathBuf::from("AGENTS.md");
716        if !agents_md.exists()
717            || !std::fs::read_to_string(&agents_md)
718                .unwrap_or_default()
719                .contains("lean-ctx")
720        {
721            let content = include_str!("../templates/PI_AGENTS.md");
722            write_file(&agents_md, content);
723            println!("Created AGENTS.md in current project directory.");
724        } else {
725            println!("AGENTS.md already contains lean-ctx configuration.");
726        }
727    } else {
728        println!(
729            "Global mode: skipping project-local AGENTS.md (use without --global in a project)."
730        );
731    }
732
733    println!();
734    println!("Setup complete. All Pi tools (bash, read, grep, find, ls) route through lean-ctx.");
735    println!("MCP tools (ctx_session, ctx_knowledge, ctx_semantic_search, ...) also available.");
736    println!("Use /lean-ctx in Pi to verify the binary path and MCP status.");
737}
738
739fn write_pi_mcp_config() {
740    let home = match dirs::home_dir() {
741        Some(h) => h,
742        None => return,
743    };
744
745    let mcp_config_path = home.join(".pi/agent/mcp.json");
746
747    if !home.join(".pi/agent").exists() {
748        println!("  \x1b[2m○ ~/.pi/agent/ not found — skipping MCP config\x1b[0m");
749        return;
750    }
751
752    if mcp_config_path.exists() {
753        let content = match std::fs::read_to_string(&mcp_config_path) {
754            Ok(c) => c,
755            Err(_) => return,
756        };
757        if content.contains("lean-ctx") {
758            println!("  \x1b[32m✓\x1b[0m Pi MCP config already contains lean-ctx");
759            return;
760        }
761
762        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
763            if let Some(obj) = json.as_object_mut() {
764                let servers = obj
765                    .entry("mcpServers")
766                    .or_insert_with(|| serde_json::json!({}));
767                if let Some(servers_obj) = servers.as_object_mut() {
768                    servers_obj.insert("lean-ctx".to_string(), pi_mcp_server_entry());
769                }
770                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
771                    let _ = std::fs::write(&mcp_config_path, formatted);
772                    println!(
773                        "  \x1b[32m✓\x1b[0m Added lean-ctx to Pi MCP config (~/.pi/agent/mcp.json)"
774                    );
775                }
776            }
777        }
778        return;
779    }
780
781    let content = serde_json::json!({
782        "mcpServers": {
783            "lean-ctx": pi_mcp_server_entry()
784        }
785    });
786    if let Ok(formatted) = serde_json::to_string_pretty(&content) {
787        let _ = std::fs::write(&mcp_config_path, formatted);
788        println!("  \x1b[32m✓\x1b[0m Created Pi MCP config (~/.pi/agent/mcp.json)");
789    }
790}
791
792fn pi_mcp_server_entry() -> serde_json::Value {
793    let binary = resolve_binary_path();
794    let mut entry = full_server_entry(&binary);
795    if let Some(obj) = entry.as_object_mut() {
796        obj.insert("lifecycle".to_string(), serde_json::json!("lazy"));
797        obj.insert("directTools".to_string(), serde_json::json!(true));
798    }
799    entry
800}
801
802pub(super) fn install_copilot_hook(global: bool) {
803    let binary = resolve_binary_path();
804
805    if global {
806        let mcp_path = crate::core::editor_registry::vscode_mcp_path();
807        if mcp_path.as_os_str() == "/nonexistent" {
808            println!("  \x1b[2mVS Code not found — skipping global Copilot config\x1b[0m");
809            return;
810        }
811        write_vscode_mcp_file(&mcp_path, &binary, "global VS Code User MCP");
812        install_copilot_pretooluse_hook(true);
813    } else {
814        let vscode_dir = PathBuf::from(".vscode");
815        let _ = std::fs::create_dir_all(&vscode_dir);
816        let mcp_path = vscode_dir.join("mcp.json");
817        write_vscode_mcp_file(&mcp_path, &binary, ".vscode/mcp.json");
818        install_copilot_pretooluse_hook(false);
819    }
820}
821
822fn install_copilot_pretooluse_hook(global: bool) {
823    let binary = resolve_binary_path();
824    let rewrite_cmd = format!("{binary} hook rewrite");
825    let redirect_cmd = format!("{binary} hook redirect");
826
827    let hook_config = serde_json::json!({
828        "version": 1,
829        "hooks": {
830            "preToolUse": [
831                {
832                    "type": "command",
833                    "bash": rewrite_cmd,
834                    "timeoutSec": 15
835                },
836                {
837                    "type": "command",
838                    "bash": redirect_cmd,
839                    "timeoutSec": 5
840                }
841            ]
842        }
843    });
844
845    let hook_path = if global {
846        let Some(home) = dirs::home_dir() else { return };
847        let dir = home.join(".github").join("hooks");
848        let _ = std::fs::create_dir_all(&dir);
849        dir.join("hooks.json")
850    } else {
851        let dir = PathBuf::from(".github").join("hooks");
852        let _ = std::fs::create_dir_all(&dir);
853        dir.join("hooks.json")
854    };
855
856    let needs_write = if hook_path.exists() {
857        let content = std::fs::read_to_string(&hook_path).unwrap_or_default();
858        !content.contains("hook rewrite") || content.contains("\"PreToolUse\"")
859    } else {
860        true
861    };
862
863    if !needs_write {
864        return;
865    }
866
867    if hook_path.exists() {
868        if let Ok(mut existing) = serde_json::from_str::<serde_json::Value>(
869            &std::fs::read_to_string(&hook_path).unwrap_or_default(),
870        ) {
871            if let Some(obj) = existing.as_object_mut() {
872                obj.insert("version".to_string(), serde_json::json!(1));
873                obj.insert("hooks".to_string(), hook_config["hooks"].clone());
874                write_file(
875                    &hook_path,
876                    &serde_json::to_string_pretty(&existing).unwrap(),
877                );
878                if !mcp_server_quiet_mode() {
879                    println!("Updated Copilot hooks at {}", hook_path.display());
880                }
881                return;
882            }
883        }
884    }
885
886    write_file(
887        &hook_path,
888        &serde_json::to_string_pretty(&hook_config).unwrap(),
889    );
890    if !mcp_server_quiet_mode() {
891        println!("Installed Copilot hooks at {}", hook_path.display());
892    }
893}
894
895fn write_vscode_mcp_file(mcp_path: &PathBuf, binary: &str, label: &str) {
896    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
897        .map(|d| d.to_string_lossy().to_string())
898        .unwrap_or_default();
899    let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [], "env": { "LEAN_CTX_DATA_DIR": data_dir } });
900    if mcp_path.exists() {
901        let content = std::fs::read_to_string(mcp_path).unwrap_or_default();
902        match serde_json::from_str::<serde_json::Value>(&content) {
903            Ok(mut json) => {
904                if let Some(obj) = json.as_object_mut() {
905                    let servers = obj
906                        .entry("servers")
907                        .or_insert_with(|| serde_json::json!({}));
908                    if let Some(servers_obj) = servers.as_object_mut() {
909                        if servers_obj.get("lean-ctx") == Some(&desired) {
910                            println!("  \x1b[32m✓\x1b[0m Copilot already configured in {label}");
911                            return;
912                        }
913                        servers_obj.insert("lean-ctx".to_string(), desired);
914                    }
915                    write_file(
916                        mcp_path,
917                        &serde_json::to_string_pretty(&json).unwrap_or_default(),
918                    );
919                    println!("  \x1b[32m✓\x1b[0m Added lean-ctx to {label}");
920                    return;
921                }
922            }
923            Err(e) => {
924                eprintln!(
925                    "Could not parse VS Code MCP config at {}: {e}\nAdd to \"servers\": \"lean-ctx\": {{ \"command\": \"{}\", \"args\": [] }}",
926                    mcp_path.display(),
927                    binary
928                );
929                return;
930            }
931        };
932    }
933
934    if let Some(parent) = mcp_path.parent() {
935        let _ = std::fs::create_dir_all(parent);
936    }
937
938    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
939        .map(|d| d.to_string_lossy().to_string())
940        .unwrap_or_default();
941    let config = serde_json::json!({
942        "servers": {
943            "lean-ctx": {
944                "type": "stdio",
945                "command": binary,
946                "args": [],
947                "env": { "LEAN_CTX_DATA_DIR": data_dir }
948            }
949        }
950    });
951
952    write_file(
953        mcp_path,
954        &serde_json::to_string_pretty(&config).unwrap_or_default(),
955    );
956    println!("  \x1b[32m✓\x1b[0m Created {label} with lean-ctx MCP server");
957}
958
959pub(super) fn install_amp_hook() {
960    let binary = resolve_binary_path();
961    let home = dirs::home_dir().unwrap_or_default();
962    let config_path = home.join(".config/amp/settings.json");
963    let display_path = "~/.config/amp/settings.json";
964
965    if let Some(parent) = config_path.parent() {
966        let _ = std::fs::create_dir_all(parent);
967    }
968
969    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
970        .map(|d| d.to_string_lossy().to_string())
971        .unwrap_or_default();
972    let entry = serde_json::json!({
973        "command": binary,
974        "env": { "LEAN_CTX_DATA_DIR": data_dir }
975    });
976    install_named_json_server("Amp", display_path, &config_path, "amp.mcpServers", entry);
977}
978
979pub(super) fn install_jetbrains_hook() {
980    let binary = resolve_binary_path();
981    let home = dirs::home_dir().unwrap_or_default();
982    let config_path = home.join(".jb-mcp.json");
983    let display_path = "~/.jb-mcp.json";
984
985    let entry = serde_json::json!({
986        "name": "lean-ctx",
987        "command": binary,
988        "args": [],
989        "env": {
990            "LEAN_CTX_DATA_DIR": crate::core::data_dir::lean_ctx_data_dir()
991                .map(|d| d.to_string_lossy().to_string())
992                .unwrap_or_default()
993        }
994    });
995
996    if config_path.exists() {
997        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
998        if content.contains("lean-ctx") {
999            println!("JetBrains MCP already configured at {display_path}");
1000            return;
1001        }
1002
1003        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1004            if let Some(obj) = json.as_object_mut() {
1005                let servers = obj
1006                    .entry("servers")
1007                    .or_insert_with(|| serde_json::json!([]));
1008                if let Some(arr) = servers.as_array_mut() {
1009                    arr.push(entry.clone());
1010                }
1011                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1012                    let _ = std::fs::write(&config_path, formatted);
1013                    println!("  \x1b[32m✓\x1b[0m JetBrains MCP configured at {display_path}");
1014                    return;
1015                }
1016            }
1017        }
1018    }
1019
1020    let config = serde_json::json!({ "servers": [entry] });
1021    if let Ok(json_str) = serde_json::to_string_pretty(&config) {
1022        let _ = std::fs::write(&config_path, json_str);
1023        println!("  \x1b[32m✓\x1b[0m JetBrains MCP configured at {display_path}");
1024    } else {
1025        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure JetBrains");
1026    }
1027}
1028
1029pub(super) fn install_opencode_hook() {
1030    let binary = resolve_binary_path();
1031    let home = dirs::home_dir().unwrap_or_default();
1032    let config_path = home.join(".config/opencode/opencode.json");
1033    let display_path = "~/.config/opencode/opencode.json";
1034
1035    if let Some(parent) = config_path.parent() {
1036        let _ = std::fs::create_dir_all(parent);
1037    }
1038
1039    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
1040        .map(|d| d.to_string_lossy().to_string())
1041        .unwrap_or_default();
1042    let desired = serde_json::json!({
1043        "type": "local",
1044        "command": [&binary],
1045        "enabled": true,
1046        "environment": { "LEAN_CTX_DATA_DIR": data_dir }
1047    });
1048
1049    if config_path.exists() {
1050        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1051        if content.contains("lean-ctx") {
1052            println!("OpenCode MCP already configured at {display_path}");
1053        } else if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1054            if let Some(obj) = json.as_object_mut() {
1055                let mcp = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1056                if let Some(mcp_obj) = mcp.as_object_mut() {
1057                    mcp_obj.insert("lean-ctx".to_string(), desired.clone());
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 OpenCode MCP configured at {display_path}");
1062                }
1063            }
1064        }
1065    } else {
1066        let content = serde_json::to_string_pretty(&serde_json::json!({
1067            "$schema": "https://opencode.ai/config.json",
1068            "mcp": {
1069                "lean-ctx": desired
1070            }
1071        }));
1072
1073        if let Ok(json_str) = content {
1074            let _ = std::fs::write(&config_path, json_str);
1075            println!("  \x1b[32m✓\x1b[0m OpenCode MCP configured at {display_path}");
1076        } else {
1077            eprintln!("  \x1b[31m✗\x1b[0m Failed to configure OpenCode");
1078        }
1079    }
1080
1081    install_opencode_plugin(&home);
1082}
1083
1084fn install_opencode_plugin(home: &std::path::Path) {
1085    let plugin_dir = home.join(".config/opencode/plugins");
1086    let _ = std::fs::create_dir_all(&plugin_dir);
1087    let plugin_path = plugin_dir.join("lean-ctx.ts");
1088
1089    let plugin_content = include_str!("../templates/opencode-plugin.ts");
1090    let _ = std::fs::write(&plugin_path, plugin_content);
1091
1092    if !mcp_server_quiet_mode() {
1093        println!(
1094            "  \x1b[32m✓\x1b[0m OpenCode plugin installed at {}",
1095            plugin_path.display()
1096        );
1097    }
1098}
1099
1100pub(super) fn install_crush_hook() {
1101    let binary = resolve_binary_path();
1102    let home = dirs::home_dir().unwrap_or_default();
1103    let config_path = home.join(".config/crush/crush.json");
1104    let display_path = "~/.config/crush/crush.json";
1105
1106    if let Some(parent) = config_path.parent() {
1107        let _ = std::fs::create_dir_all(parent);
1108    }
1109
1110    if config_path.exists() {
1111        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
1112        if content.contains("lean-ctx") {
1113            println!("Crush MCP already configured at {display_path}");
1114            return;
1115        }
1116
1117        if let Ok(mut json) = serde_json::from_str::<serde_json::Value>(&content) {
1118            if let Some(obj) = json.as_object_mut() {
1119                let servers = obj.entry("mcp").or_insert_with(|| serde_json::json!({}));
1120                if let Some(servers_obj) = servers.as_object_mut() {
1121                    servers_obj.insert(
1122                        "lean-ctx".to_string(),
1123                        serde_json::json!({ "type": "stdio", "command": binary }),
1124                    );
1125                }
1126                if let Ok(formatted) = serde_json::to_string_pretty(&json) {
1127                    let _ = std::fs::write(&config_path, formatted);
1128                    println!("  \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1129                    return;
1130                }
1131            }
1132        }
1133    }
1134
1135    let content = serde_json::to_string_pretty(&serde_json::json!({
1136        "mcp": {
1137            "lean-ctx": {
1138                "type": "stdio",
1139                "command": binary
1140            }
1141        }
1142    }));
1143
1144    if let Ok(json_str) = content {
1145        let _ = std::fs::write(&config_path, json_str);
1146        println!("  \x1b[32m✓\x1b[0m Crush MCP configured at {display_path}");
1147    } else {
1148        eprintln!("  \x1b[31m✗\x1b[0m Failed to configure Crush");
1149    }
1150}
1151
1152pub(super) fn install_kiro_hook() {
1153    let home = dirs::home_dir().unwrap_or_default();
1154
1155    install_mcp_json_agent(
1156        "AWS Kiro",
1157        "~/.kiro/settings/mcp.json",
1158        &home.join(".kiro/settings/mcp.json"),
1159    );
1160
1161    let cwd = std::env::current_dir().unwrap_or_default();
1162    let steering_dir = cwd.join(".kiro").join("steering");
1163    let steering_file = steering_dir.join("lean-ctx.md");
1164
1165    if steering_file.exists()
1166        && std::fs::read_to_string(&steering_file)
1167            .unwrap_or_default()
1168            .contains("lean-ctx")
1169    {
1170        println!("  Kiro steering file already exists at .kiro/steering/lean-ctx.md");
1171    } else {
1172        let _ = std::fs::create_dir_all(&steering_dir);
1173        write_file(&steering_file, KIRO_STEERING_TEMPLATE);
1174        println!("  \x1b[32m✓\x1b[0m Created .kiro/steering/lean-ctx.md (Kiro will now prefer lean-ctx tools)");
1175    }
1176}
1177
1178pub(super) fn install_hermes_hook(global: bool) {
1179    let home = match dirs::home_dir() {
1180        Some(h) => h,
1181        None => {
1182            eprintln!("Cannot resolve home directory");
1183            return;
1184        }
1185    };
1186
1187    let binary = resolve_binary_path();
1188    let config_path = home.join(".hermes/config.yaml");
1189    let target = crate::core::editor_registry::EditorTarget {
1190        name: "Hermes Agent",
1191        agent_key: "hermes".to_string(),
1192        config_path: config_path.clone(),
1193        detect_path: home.join(".hermes"),
1194        config_type: crate::core::editor_registry::ConfigType::HermesYaml,
1195    };
1196
1197    match crate::core::editor_registry::write_config_with_options(
1198        &target,
1199        &binary,
1200        crate::core::editor_registry::WriteOptions {
1201            overwrite_invalid: true,
1202        },
1203    ) {
1204        Ok(res) => match res.action {
1205            crate::core::editor_registry::WriteAction::Created => {
1206                println!("  \x1b[32m✓\x1b[0m Hermes Agent MCP configured at ~/.hermes/config.yaml");
1207            }
1208            crate::core::editor_registry::WriteAction::Updated => {
1209                println!("  \x1b[32m✓\x1b[0m Hermes Agent MCP updated at ~/.hermes/config.yaml");
1210            }
1211            crate::core::editor_registry::WriteAction::Already => {
1212                println!("  Hermes Agent MCP already configured at ~/.hermes/config.yaml");
1213            }
1214        },
1215        Err(e) => {
1216            eprintln!("  \x1b[31m✗\x1b[0m Failed to configure Hermes Agent MCP: {e}");
1217        }
1218    }
1219
1220    let scope = crate::core::config::Config::load().rules_scope_effective();
1221
1222    match scope {
1223        crate::core::config::RulesScope::Global => {
1224            install_hermes_rules(&home);
1225        }
1226        crate::core::config::RulesScope::Project => {
1227            if !global {
1228                install_project_hermes_rules();
1229                install_project_rules();
1230            }
1231        }
1232        crate::core::config::RulesScope::Both => {
1233            if global {
1234                install_hermes_rules(&home);
1235            } else {
1236                install_hermes_rules(&home);
1237                install_project_hermes_rules();
1238                install_project_rules();
1239            }
1240        }
1241    }
1242}
1243
1244fn install_hermes_rules(home: &std::path::Path) {
1245    let rules_path = home.join(".hermes/HERMES.md");
1246    let content = HERMES_RULES_TEMPLATE;
1247
1248    if rules_path.exists() {
1249        let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
1250        if existing.contains("lean-ctx") {
1251            println!("  Hermes rules already present in ~/.hermes/HERMES.md");
1252            return;
1253        }
1254        let mut updated = existing;
1255        if !updated.ends_with('\n') {
1256            updated.push('\n');
1257        }
1258        updated.push('\n');
1259        updated.push_str(content);
1260        let _ = std::fs::write(&rules_path, updated);
1261        println!("  \x1b[32m✓\x1b[0m Appended lean-ctx rules to ~/.hermes/HERMES.md");
1262    } else {
1263        let _ = std::fs::create_dir_all(rules_path.parent().unwrap());
1264        let _ = std::fs::write(&rules_path, content);
1265        println!("  \x1b[32m✓\x1b[0m Created ~/.hermes/HERMES.md with lean-ctx rules");
1266    }
1267}
1268
1269fn install_project_hermes_rules() {
1270    let cwd = match std::env::current_dir() {
1271        Ok(d) => d,
1272        Err(_) => return,
1273    };
1274    let rules_path = cwd.join(".hermes.md");
1275    if rules_path.exists() {
1276        let existing = std::fs::read_to_string(&rules_path).unwrap_or_default();
1277        if existing.contains("lean-ctx") {
1278            println!("  .hermes.md already contains lean-ctx rules");
1279            return;
1280        }
1281        let mut updated = existing;
1282        if !updated.ends_with('\n') {
1283            updated.push('\n');
1284        }
1285        updated.push('\n');
1286        updated.push_str(HERMES_RULES_TEMPLATE);
1287        let _ = std::fs::write(&rules_path, updated);
1288        println!("  \x1b[32m✓\x1b[0m Appended lean-ctx rules to .hermes.md");
1289    } else {
1290        let _ = std::fs::write(&rules_path, HERMES_RULES_TEMPLATE);
1291        println!("  \x1b[32m✓\x1b[0m Created .hermes.md with lean-ctx rules");
1292    }
1293}
1294
1295const HERMES_RULES_TEMPLATE: &str = "\
1296# lean-ctx — Context Engineering Layer
1297
1298PREFER lean-ctx MCP tools over native equivalents for token savings:
1299
1300| PREFER | OVER | Why |
1301|--------|------|-----|
1302| `ctx_read(path, mode)` | `Read` / `cat` | Cached, 10 read modes, re-reads ~13 tokens |
1303| `ctx_shell(command)` | `Shell` / `bash` | Pattern compression for git/npm/cargo output |
1304| `ctx_search(pattern, path)` | `Grep` / `rg` | Compact search results |
1305| `ctx_tree(path, depth)` | `ls` / `find` | Compact directory maps |
1306
1307- Native Edit/StrReplace stay unchanged. If Edit requires Read and Read is unavailable, use `ctx_edit(path, old_string, new_string)`.
1308- Write, Delete, Glob — use normally.
1309
1310ctx_read modes: full|map|signatures|diff|task|reference|aggressive|entropy|lines:N-M. Auto-selects optimal mode.
1311Re-reads cost ~13 tokens (cached).
1312
1313Available tools: ctx_overview, ctx_preload, ctx_dedup, ctx_compress, ctx_session, ctx_knowledge, ctx_semantic_search.
1314Multi-agent: ctx_agent(action=handoff|sync). Diary: ctx_agent(action=diary, category=discovery|decision|blocker|progress|insight).
1315";
1316
1317#[cfg(test)]
1318mod tests {
1319    use super::{ensure_codex_hooks_enabled, upsert_lean_ctx_codex_hook_entries};
1320    use serde_json::json;
1321
1322    #[test]
1323    fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
1324        let mut input = json!({
1325            "hooks": {
1326                "PreToolUse": [
1327                    {
1328                        "matcher": "Bash",
1329                        "hooks": [{
1330                            "type": "command",
1331                            "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
1332                            "timeout": 15
1333                        }]
1334                    },
1335                    {
1336                        "matcher": "Bash",
1337                        "hooks": [{
1338                            "type": "command",
1339                            "command": "echo keep-me",
1340                            "timeout": 5
1341                        }]
1342                    }
1343                ],
1344                "SessionStart": [
1345                    {
1346                        "matcher": "startup|resume|clear",
1347                        "hooks": [{
1348                            "type": "command",
1349                            "command": "lean-ctx hook codex-session-start",
1350                            "timeout": 15
1351                        }]
1352                    }
1353                ],
1354                "PostToolUse": [
1355                    {
1356                        "matcher": "Bash",
1357                        "hooks": [{
1358                            "type": "command",
1359                            "command": "echo keep-post",
1360                            "timeout": 5
1361                        }]
1362                    }
1363                ]
1364            }
1365        });
1366
1367        let changed = upsert_lean_ctx_codex_hook_entries(
1368            &mut input,
1369            "lean-ctx hook codex-session-start",
1370            "lean-ctx hook codex-pretooluse",
1371        );
1372        assert!(changed, "legacy hooks should be migrated");
1373
1374        let pre_tool_use = input["hooks"]["PreToolUse"]
1375            .as_array()
1376            .expect("PreToolUse array should remain");
1377        assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
1378        assert_eq!(
1379            pre_tool_use[0]["hooks"][0]["command"].as_str(),
1380            Some("echo keep-me")
1381        );
1382        assert_eq!(
1383            pre_tool_use[1]["hooks"][0]["command"].as_str(),
1384            Some("lean-ctx hook codex-pretooluse")
1385        );
1386        assert_eq!(
1387            input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
1388            Some("lean-ctx hook codex-session-start")
1389        );
1390        assert_eq!(
1391            input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
1392            Some("echo keep-post")
1393        );
1394    }
1395
1396    #[test]
1397    fn ignores_non_lean_ctx_codex_entries() {
1398        let custom = json!({
1399            "matcher": "Bash",
1400            "hooks": [{
1401                "type": "command",
1402                "command": "echo keep-me",
1403                "timeout": 5
1404            }]
1405        });
1406        assert!(
1407            !super::super::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
1408            "custom Codex hooks must be preserved"
1409        );
1410    }
1411
1412    #[test]
1413    fn detects_managed_codex_session_start_entry() {
1414        let managed = json!({
1415            "matcher": "startup|resume|clear",
1416            "hooks": [{
1417                "type": "command",
1418                "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
1419                "timeout": 15
1420            }]
1421        });
1422        assert!(super::super::support::is_lean_ctx_codex_managed_entry(
1423            "SessionStart",
1424            &managed
1425        ));
1426    }
1427
1428    #[test]
1429    fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
1430        let input = "\
1431[features]
1432other = true
1433codex_hooks = false
1434
1435[mcp_servers.other]
1436command = \"other\"
1437";
1438
1439        let output =
1440            ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
1441
1442        assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
1443        assert!(!output.contains("codex_hooks = false"));
1444    }
1445
1446    #[test]
1447    fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
1448        let input = "\
1449[features]
1450other = true
1451
1452[mcp_servers.lean-ctx]
1453command = \"lean-ctx\"
1454codex_hooks = true
1455";
1456
1457        let output = ensure_codex_hooks_enabled(input)
1458            .expect("stray codex_hooks assignment should be normalized");
1459
1460        assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
1461        assert_eq!(output.matches("codex_hooks = true").count(), 1);
1462        assert!(
1463            !output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\ncodex_hooks = true")
1464        );
1465    }
1466
1467    #[test]
1468    fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
1469        let input = "\
1470[mcp_servers.lean-ctx]
1471command = \"lean-ctx\"
1472";
1473
1474        let output =
1475            ensure_codex_hooks_enabled(input).expect("missing features section should be added");
1476
1477        assert!(output.ends_with("\n[features]\ncodex_hooks = true\n"));
1478    }
1479}