Skip to main content

lean_ctx/hooks/agents/
codex.rs

1use super::super::{
2    ensure_codex_hooks_enabled as shared_ensure_codex_hooks_enabled, ensure_state_dir,
3    install_codex_instruction_docs, mcp_server_quiet_mode, resolve_hook_command_binary,
4    upsert_lean_ctx_codex_hook_entries, write_file,
5};
6
7pub fn install_codex_hook() {
8    let Some(codex_dir) = crate::core::home::resolve_codex_dir() else {
9        tracing::error!("Cannot resolve codex directory");
10        return;
11    };
12    if !ensure_state_dir(&codex_dir) {
13        return;
14    }
15
16    let hook_config_changed = install_codex_hook_config(&codex_dir);
17    let installed_docs = install_codex_instruction_docs(&codex_dir);
18
19    if !mcp_server_quiet_mode() {
20        if hook_config_changed {
21            eprintln!(
22                "Installed Codex-compatible SessionStart/PreToolUse hooks at {}",
23                codex_dir.display()
24            );
25        }
26        if installed_docs {
27            eprintln!("Installed Codex instructions at {}", codex_dir.display());
28        } else {
29            eprintln!("Codex AGENTS.md already configured.");
30        }
31    }
32}
33
34fn install_codex_hook_config(codex_dir: &std::path::Path) -> bool {
35    let binary = resolve_hook_command_binary();
36    let session_start_cmd = format!("{binary} hook codex-session-start");
37    let pre_tool_use_cmd = format!("{binary} hook codex-pretooluse");
38    let hooks_json_path = codex_dir.join("hooks.json");
39
40    let mut changed = false;
41    let mut root = if hooks_json_path.exists() {
42        if let Some(parsed) = std::fs::read_to_string(&hooks_json_path)
43            .ok()
44            .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
45        {
46            parsed
47        } else {
48            changed = true;
49            serde_json::json!({ "hooks": {} })
50        }
51    } else {
52        changed = true;
53        serde_json::json!({ "hooks": {} })
54    };
55
56    if upsert_lean_ctx_codex_hook_entries(&mut root, &session_start_cmd, &pre_tool_use_cmd) {
57        changed = true;
58    }
59
60    // Observe hooks for context awareness
61    let observe_cmd = format!("{binary} hook observe");
62    if ensure_codex_observe_hooks(&mut root, &observe_cmd) {
63        changed = true;
64    }
65
66    if changed {
67        write_file(
68            &hooks_json_path,
69            &serde_json::to_string_pretty(&root).unwrap_or_default(),
70        );
71    }
72
73    let rewrite_path = codex_dir.join("hooks").join("lean-ctx-rewrite-codex.sh");
74    if rewrite_path.exists() && std::fs::remove_file(&rewrite_path).is_ok() {
75        changed = true;
76    }
77
78    let config_toml_path = crate::core::home::resolve_codex_config_path()
79        .unwrap_or_else(|| codex_dir.join("config.toml"));
80    let config_content = std::fs::read_to_string(&config_toml_path).unwrap_or_default();
81
82    // Hybrid mode: ensure MCP server entry exists in config.toml so Codex
83    // Desktop/Cloud can reach lean-ctx even without CLI hooks.
84    let mcp_updated = ensure_codex_mcp_server(
85        &config_content,
86        &binary,
87        &super::super::mcp_server_env_pairs(),
88    );
89    let hooks_updated =
90        ensure_codex_hooks_enabled(mcp_updated.as_deref().unwrap_or(&config_content));
91
92    let final_content = hooks_updated
93        .or(mcp_updated)
94        .unwrap_or_else(|| config_content.clone());
95    if final_content != config_content {
96        write_file(&config_toml_path, &final_content);
97        changed = true;
98        if !mcp_server_quiet_mode() {
99            eprintln!(
100                "Updated Codex config (MCP server + hooks) in {}",
101                config_toml_path.display()
102            );
103        }
104    }
105
106    changed
107}
108
109fn ensure_codex_observe_hooks(root: &mut serde_json::Value, observe_cmd: &str) -> bool {
110    let original = root.clone();
111    let Some(hooks_obj) = root
112        .as_object_mut()
113        .and_then(|r| r.get_mut("hooks"))
114        .and_then(|h| h.as_object_mut())
115    else {
116        return false;
117    };
118
119    let observe_hook = serde_json::json!({
120        "type": "command", "command": observe_cmd, "timeout": 5
121    });
122
123    let observe_events = ["PostToolUse", "SessionStart", "SessionEnd"];
124    for event in observe_events {
125        let arr = hooks_obj
126            .entry(event.to_string())
127            .or_insert_with(|| serde_json::json!([]));
128        let Some(entries) = arr.as_array_mut() else {
129            continue;
130        };
131
132        // Migration: if observe exists as a standalone entry (separate from the
133        // lean-ctx session-start entry), merge it into the lean-ctx entry and
134        // remove the standalone to prevent duplicate 'hook: <Event>' log lines.
135        let observe_standalone_idx = entries.iter().position(|e| {
136            let hooks = e.get("hooks").and_then(|h| h.as_array());
137            let Some(hooks) = hooks else { return false };
138            // Standalone = only has observe, no other lean-ctx commands
139            hooks.len() == 1
140                && hooks[0]
141                    .get("command")
142                    .and_then(|c| c.as_str())
143                    .is_some_and(|c| c.contains("hook observe"))
144        });
145        let leanctx_entry_idx = entries.iter().position(|e| {
146            e.get("hooks")
147                .and_then(|h| h.as_array())
148                .is_some_and(|hooks| {
149                    hooks.iter().any(|hook| {
150                        hook.get("command")
151                            .and_then(|c| c.as_str())
152                            .is_some_and(|c| c.contains("lean-ctx") && !c.contains("hook observe"))
153                    })
154                })
155        });
156        if let (Some(standalone), Some(target)) = (observe_standalone_idx, leanctx_entry_idx) {
157            // Remove standalone first (shifts indices if standalone < target)
158            entries.remove(standalone);
159            let target = if standalone < target {
160                target - 1
161            } else {
162                target
163            };
164            // Merge observe into the lean-ctx entry
165            if let Some(hooks_arr) = entries[target]
166                .as_object_mut()
167                .and_then(|e| e.get_mut("hooks"))
168                .and_then(|h| h.as_array_mut())
169            {
170                let already = hooks_arr.iter().any(|h| {
171                    h.get("command")
172                        .and_then(|c| c.as_str())
173                        .is_some_and(|c| c.contains("hook observe"))
174                });
175                if !already {
176                    hooks_arr.push(observe_hook.clone());
177                }
178            }
179            continue;
180        }
181
182        // Already properly merged — nothing to do.
183        let already_has_observe = entries.iter().any(|e| {
184            e.get("hooks")
185                .and_then(|h| h.as_array())
186                .is_some_and(|hooks| {
187                    hooks.iter().any(|hook| {
188                        hook.get("command")
189                            .and_then(|c| c.as_str())
190                            .is_some_and(|c| c.contains("hook observe"))
191                    })
192                })
193        });
194        if already_has_observe {
195            continue;
196        }
197
198        // Merge into existing lean-ctx entry instead of creating a duplicate
199        // that fires a separate `hook: <Event>` log line (GL #1398).
200        let merged = entries.iter_mut().any(|entry| {
201            let dominated = entry
202                .get("hooks")
203                .and_then(|h| h.as_array())
204                .is_some_and(|hooks| {
205                    hooks.iter().any(|hook| {
206                        hook.get("command")
207                            .and_then(|c| c.as_str())
208                            .is_some_and(|c| c.contains("lean-ctx"))
209                    })
210                });
211            if !dominated {
212                return false;
213            }
214            if let Some(hooks_arr) = entry
215                .as_object_mut()
216                .and_then(|e| e.get_mut("hooks"))
217                .and_then(|h| h.as_array_mut())
218            {
219                hooks_arr.push(observe_hook.clone());
220                return true;
221            }
222            false
223        });
224
225        if !merged {
226            entries.push(serde_json::json!({
227                "matcher": ".*",
228                "hooks": [observe_hook.clone()]
229            }));
230        }
231    }
232
233    *root != original
234}
235
236/// Idempotent upsert of the `[mcp_servers.lean-ctx]` entry in Codex `config.toml`.
237///
238/// Uses a format-preserving TOML editor so existing user content/comments and an
239/// orphaned `[mcp_servers.lean-ctx.env]` (issue #189) are normalized into a
240/// single valid section instead of producing a duplicate table header.
241///
242/// `env_pairs` is injected (not read from global state) so the pure
243/// config-rewriting logic is hermetically testable; the caller passes
244/// [`crate::hooks::mcp_server_env_pairs`]. Existing `command`/`args` are left
245/// untouched to respect user customization; env keys are upserted so a stale
246/// install gains `LEAN_CTX_PROJECT_ROOT`/`LEAN_CTX_EXTRA_ROOTS` (#403). Returns
247/// `None` when nothing changed, or when the file is not valid TOML (never
248/// clobbers an unparseable user config).
249fn ensure_codex_mcp_server(
250    config_content: &str,
251    binary: &str,
252    env_pairs: &[(String, String)],
253) -> Option<String> {
254    let mut doc = config_content.parse::<toml_edit::DocumentMut>().ok()?;
255    let original = doc.to_string();
256
257    // `[mcp_servers]` stays implicit so we never emit a bare parent header.
258    let servers = doc["mcp_servers"].or_insert(toml_edit::table());
259    if let Some(t) = servers.as_table_mut() {
260        t.set_implicit(true);
261    }
262
263    // `[mcp_servers.lean-ctx]` must be explicit so its header is rendered before
264    // the `.env` child table (fixes the orphaned-env ordering from #189).
265    let lean = servers["lean-ctx"].or_insert(toml_edit::table());
266    let lean_tbl = lean.as_table_mut()?;
267    lean_tbl.set_implicit(false);
268
269    // Respect user customization: only fill `command`/`args` when absent.
270    if !lean_tbl.contains_key("command") {
271        lean_tbl["command"] = toml_edit::value(binary);
272    }
273    if !lean_tbl.contains_key("args") {
274        lean_tbl["args"] = toml_edit::value(toml_edit::Array::new());
275    }
276
277    // Ensure adequate timeouts: lean-ctx tools like ctx_compose can take
278    // >60s on first invocation (tree-sitter parsing, index building).
279    if !lean_tbl.contains_key("startup_timeout_sec") {
280        lean_tbl["startup_timeout_sec"] = toml_edit::value(30);
281    }
282    if !lean_tbl.contains_key("tool_timeout_sec") {
283        lean_tbl["tool_timeout_sec"] = toml_edit::value(120);
284    }
285
286    let env = lean_tbl["env"].or_insert(toml_edit::table());
287    if let Some(env_tbl) = env.as_table_mut() {
288        for (key, val) in env_pairs {
289            let key = key.as_str();
290            if env_tbl.get(key).and_then(toml_edit::Item::as_str) != Some(val.as_str()) {
291                env_tbl[key] = toml_edit::value(val.as_str());
292            }
293        }
294    }
295
296    let updated = doc.to_string();
297    (updated != original).then_some(updated)
298}
299
300fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
301    shared_ensure_codex_hooks_enabled(config_content)
302}
303
304// Codex deny is handled mode-aware by the codex-pretooluse handler at runtime:
305// non-rewritable Bash calls are denied in Replace mode, rewritten in Hybrid.
306// A separate deny hook is not needed (Codex PreToolUse only fires for Bash).
307
308#[cfg(test)]
309mod tests {
310    use super::{
311        ensure_codex_hooks_enabled, ensure_codex_mcp_server, ensure_codex_observe_hooks,
312        upsert_lean_ctx_codex_hook_entries,
313    };
314    use serde_json::json;
315
316    /// Minimal env block (data dir only) for the config-rewrite tests that do
317    /// not exercise project-root/extra-roots propagation.
318    fn data_dir_pairs() -> Vec<(String, String)> {
319        vec![(
320            "LEAN_CTX_DATA_DIR".to_string(),
321            "/Users/user/.lean-ctx".to_string(),
322        )]
323    }
324
325    #[test]
326    fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
327        let mut input = json!({
328            "hooks": {
329                "PreToolUse": [
330                    {
331                        "matcher": "Bash",
332                        "hooks": [{
333                            "type": "command",
334                            "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
335                            "timeout": 15
336                        }]
337                    },
338                    {
339                        "matcher": "Bash",
340                        "hooks": [{
341                            "type": "command",
342                            "command": "echo keep-me",
343                            "timeout": 5
344                        }]
345                    }
346                ],
347                "SessionStart": [
348                    {
349                        "matcher": "startup|resume|clear",
350                        "hooks": [{
351                            "type": "command",
352                            "command": "lean-ctx hook codex-session-start",
353                            "timeout": 15
354                        }]
355                    }
356                ],
357                "PostToolUse": [
358                    {
359                        "matcher": "Bash",
360                        "hooks": [{
361                            "type": "command",
362                            "command": "echo keep-post",
363                            "timeout": 5
364                        }]
365                    }
366                ]
367            }
368        });
369
370        let changed = upsert_lean_ctx_codex_hook_entries(
371            &mut input,
372            "lean-ctx hook codex-session-start",
373            "lean-ctx hook codex-pretooluse",
374        );
375        assert!(changed, "legacy hooks should be migrated");
376
377        let pre_tool_use = input["hooks"]["PreToolUse"]
378            .as_array()
379            .expect("PreToolUse array should remain");
380        assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
381        assert_eq!(
382            pre_tool_use[0]["hooks"][0]["command"].as_str(),
383            Some("echo keep-me")
384        );
385        assert_eq!(
386            pre_tool_use[1]["hooks"][0]["command"].as_str(),
387            Some("lean-ctx hook codex-pretooluse")
388        );
389        assert_eq!(
390            input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
391            Some("lean-ctx hook codex-session-start")
392        );
393        assert_eq!(
394            input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
395            Some("echo keep-post")
396        );
397    }
398
399    #[test]
400    fn ignores_non_lean_ctx_codex_entries() {
401        let custom = json!({
402            "matcher": "Bash",
403            "hooks": [{
404                "type": "command",
405                "command": "echo keep-me",
406                "timeout": 5
407            }]
408        });
409        assert!(
410            !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
411            "custom Codex hooks must be preserved"
412        );
413    }
414
415    #[test]
416    fn detects_managed_codex_session_start_entry() {
417        let managed = json!({
418            "matcher": "startup|resume|clear",
419            "hooks": [{
420                "type": "command",
421                "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
422                "timeout": 15
423            }]
424        });
425        assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
426            "SessionStart",
427            &managed
428        ));
429    }
430
431    #[test]
432    fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
433        let input = "\
434[features]
435other = true
436codex_hooks = false
437
438[mcp_servers.other]
439command = \"other\"
440";
441
442        let output =
443            ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
444
445        assert!(output.contains("[features]\nother = true\nhooks = true\n"));
446        assert!(!output.contains("codex_hooks = false"));
447    }
448
449    #[test]
450    fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
451        let input = "\
452[features]
453other = true
454
455[mcp_servers.lean-ctx]
456command = \"lean-ctx\"
457codex_hooks = true
458";
459
460        let output = ensure_codex_hooks_enabled(input)
461            .expect("stray codex_hooks assignment should be normalized");
462
463        assert!(output.contains("[features]\nother = true\nhooks = true\n"));
464        assert_eq!(output.matches("hooks = true").count(), 1);
465        assert!(!output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nhooks = true"));
466    }
467
468    #[test]
469    fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
470        let input = "\
471[mcp_servers.lean-ctx]
472command = \"lean-ctx\"
473";
474
475        let output =
476            ensure_codex_hooks_enabled(input).expect("missing features section should be added");
477
478        assert!(output.ends_with("\n[features]\nhooks = true\n"));
479    }
480
481    #[test]
482    fn codex_docs_steer_to_reliable_mcp_path_without_false_hook_claim() {
483        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-desktop-note");
484        let _ = std::fs::remove_dir_all(&tmp);
485        std::fs::create_dir_all(&tmp).unwrap();
486
487        crate::hooks::support::install_codex_instruction_docs(&tmp);
488
489        let lean_ctx_md = std::fs::read_to_string(tmp.join("LEAN-CTX.md")).unwrap();
490        assert!(
491            lean_ctx_md.contains("ctx_shell") && lean_ctx_md.contains("ctx_read"),
492            "LEAN-CTX.md must steer the agent to the MCP tools"
493        );
494        // Regression guard for #350: never assert as fact that Desktop/Cloud hooks
495        // do not run — they can (gated by trust via /hooks, varies by version).
496        let normalized = lean_ctx_md.replace('\n', " ");
497        assert!(
498            !normalized.contains("hooks do not run")
499                && !normalized.contains("no automatic compression"),
500            "LEAN-CTX.md must not make the false blanket claim that Codex Desktop hooks never run (#350)"
501        );
502
503        let agents_md = std::fs::read_to_string(tmp.join("AGENTS.md")).unwrap();
504        assert!(
505            agents_md.contains("ctx_shell") && agents_md.contains("ctx_search"),
506            "AGENTS.md block must steer to the reliable MCP tools"
507        );
508        let agents_norm = agents_md.replace('\n', " ");
509        assert!(
510            !agents_norm.contains("hooks do not run"),
511            "AGENTS.md must not claim Codex hooks never run (#350)"
512        );
513
514        let _ = std::fs::remove_dir_all(&tmp);
515    }
516
517    #[test]
518    fn install_codex_docs_preserves_existing_user_instructions() {
519        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
520        let _ = std::fs::remove_dir_all(&tmp);
521        std::fs::create_dir_all(&tmp).unwrap();
522
523        let agents_md = tmp.join("AGENTS.md");
524        let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
525        std::fs::write(&agents_md, user_content).unwrap();
526
527        crate::hooks::support::install_codex_instruction_docs(&tmp);
528
529        let result = std::fs::read_to_string(&agents_md).unwrap();
530        assert!(
531            result.contains("My Custom Instructions"),
532            "user content must be preserved"
533        );
534        assert!(
535            result.contains("Always use tabs"),
536            "user rules must be preserved"
537        );
538        assert!(
539            result.contains(crate::core::rules_canonical::AGENTS_BLOCK_START),
540            "lean-ctx block must be appended"
541        );
542        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
543        assert!(
544            result.contains(&expected_ref),
545            "lean-ctx reference must use codex_dir path"
546        );
547
548        let _ = std::fs::remove_dir_all(&tmp);
549    }
550
551    #[test]
552    fn install_codex_docs_updates_only_marked_block() {
553        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
554        let _ = std::fs::remove_dir_all(&tmp);
555        std::fs::create_dir_all(&tmp).unwrap();
556
557        let agents_md = tmp.join("AGENTS.md");
558        let content_with_block = format!(
559            "# My Instructions\n\nCustom rule here.\n\n{}\n## lean-ctx\n\n@OLD-LEAN-CTX.md\n{}\n\n## Other Section\nKeep this.\n",
560            crate::core::rules_canonical::AGENTS_BLOCK_START,
561            crate::core::rules_canonical::AGENTS_BLOCK_END,
562        );
563        std::fs::write(&agents_md, content_with_block).unwrap();
564
565        crate::hooks::support::install_codex_instruction_docs(&tmp);
566
567        let result = std::fs::read_to_string(&agents_md).unwrap();
568        assert!(
569            result.contains("Custom rule here."),
570            "user content before block preserved"
571        );
572        assert!(
573            result.contains("Other Section"),
574            "user content after block preserved"
575        );
576        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
577        assert!(
578            result.contains(&expected_ref),
579            "block updated to current reference"
580        );
581        assert!(
582            !result.contains("OLD-LEAN-CTX"),
583            "old block content replaced"
584        );
585
586        let _ = std::fs::remove_dir_all(&tmp);
587    }
588
589    #[test]
590    fn ensure_mcp_server_adds_section_when_missing() {
591        let input = "[features]\ncodex_hooks = true\n";
592        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
593            .expect("should add MCP section");
594        assert!(result.contains("[mcp_servers.lean-ctx]"));
595        assert!(result.contains("command = \"lean-ctx\""));
596        assert!(result.contains("args = []"));
597        assert!(result.contains("[features]\ncodex_hooks = true\n"));
598    }
599
600    #[test]
601    fn ensure_mcp_server_noop_when_already_complete() {
602        // Parent + args + timeouts + an env block already carrying every desired
603        // key: the upsert must be a true no-op (no churn on every session start).
604        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\
605                     startup_timeout_sec = 30\ntool_timeout_sec = 120\n\n\
606                     [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"\n";
607        assert!(
608            ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs()).is_none(),
609            "should not modify config when MCP section already has all keys"
610        );
611    }
612
613    #[test]
614    fn ensure_mcp_server_adds_timeouts() {
615        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n";
616        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
617            .expect("should add timeouts");
618        assert!(
619            result.contains("startup_timeout_sec = 30"),
620            "must set startup_timeout_sec"
621        );
622        assert!(
623            result.contains("tool_timeout_sec = 120"),
624            "must set tool_timeout_sec"
625        );
626    }
627
628    #[test]
629    fn ensure_mcp_server_preserves_existing_sections() {
630        let input = "[mcp_servers.other]\ncommand = \"other\"\n";
631        let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx", &data_dir_pairs())
632            .expect("should add lean-ctx section");
633        assert!(result.contains("[mcp_servers.other]"));
634        assert!(result.contains("[mcp_servers.lean-ctx]"));
635        assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
636    }
637
638    #[test]
639    fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
640        let input = "\
641[mcp_servers.lean-ctx.env]
642LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
643";
644        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
645            .expect("should insert parent section before orphaned env");
646        let parent_pos = result
647            .find("[mcp_servers.lean-ctx]")
648            .expect("parent section must exist");
649        let env_pos = result
650            .find("[mcp_servers.lean-ctx.env]")
651            .expect("env sub-table must be preserved");
652        assert!(
653            parent_pos < env_pos,
654            "parent section must come before env sub-table"
655        );
656        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
657        assert!(result.contains("LEAN_CTX_DATA_DIR"));
658        assert_eq!(
659            result.matches("[mcp_servers.lean-ctx.env]").count(),
660            1,
661            "must not duplicate the env table (would be invalid TOML)"
662        );
663    }
664
665    #[test]
666    fn ensure_mcp_server_handles_issue_189_scenario() {
667        let input = "\
668source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
669source_type = \"local\"
670
671[mcp_servers.lean-ctx.env]
672LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
673";
674        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
675            .expect("should fix orphaned config from issue #189");
676        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
677        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
678        assert!(result.contains("[mcp_servers.lean-ctx.env]"));
679        assert!(result.contains("LEAN_CTX_DATA_DIR"));
680
681        let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
682        let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
683        assert!(parent_pos < env_pos);
684        assert_eq!(
685            result.matches("[mcp_servers.lean-ctx.env]").count(),
686            1,
687            "issue #189 fix must merge into one env table, not duplicate it"
688        );
689        // Original sibling content must survive the normalization.
690        assert!(result.contains("source_type = \"local\""));
691    }
692
693    #[test]
694    fn ensure_mcp_server_quotes_windows_backslash_paths() {
695        let input = "[features]\ncodex_hooks = true\n";
696        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
697        let result = ensure_codex_mcp_server(input, win_path, &data_dir_pairs())
698            .expect("should add MCP section");
699        // Quote style is the TOML editor's concern; what matters is that the
700        // backslash path round-trips to exactly the same string and stays valid.
701        let doc = result
702            .parse::<toml_edit::DocumentMut>()
703            .expect("output must be valid TOML");
704        assert_eq!(
705            doc["mcp_servers"]["lean-ctx"]["command"].as_str(),
706            Some(win_path),
707            "Windows backslash path must round-trip exactly: {result}"
708        );
709    }
710
711    #[test]
712    fn ensure_mcp_server_does_not_match_similarly_named_section() {
713        let input = "\
714[mcp_servers.lean-ctx-other]
715command = \"other\"
716";
717        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
718            .expect("should add lean-ctx section despite similarly-named section");
719        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
720        assert!(result.contains("[mcp_servers.lean-ctx-other]"));
721    }
722
723    #[test]
724    fn ensure_mcp_server_writes_project_and_extra_roots() {
725        // #403: when init captured a project root + sibling worktrees, those
726        // must be propagated into the env block so the long-lived MCP server
727        // resolves explicit paths under every root.
728        let pairs = vec![
729            (
730                "LEAN_CTX_DATA_DIR".to_string(),
731                "/home/u/.lean-ctx".to_string(),
732            ),
733            (
734                "LEAN_CTX_PROJECT_ROOT".to_string(),
735                "/work/main".to_string(),
736            ),
737            (
738                "LEAN_CTX_EXTRA_ROOTS".to_string(),
739                "/work/wt-a:/work/wt-b".to_string(),
740            ),
741        ];
742        let result =
743            ensure_codex_mcp_server("", "lean-ctx", &pairs).expect("fresh config must be created");
744
745        let doc = result
746            .parse::<toml_edit::DocumentMut>()
747            .expect("output must be valid TOML");
748        let env = &doc["mcp_servers"]["lean-ctx"]["env"];
749        assert_eq!(env["LEAN_CTX_PROJECT_ROOT"].as_str(), Some("/work/main"));
750        assert_eq!(
751            env["LEAN_CTX_EXTRA_ROOTS"].as_str(),
752            Some("/work/wt-a:/work/wt-b")
753        );
754        assert_eq!(env["LEAN_CTX_DATA_DIR"].as_str(), Some("/home/u/.lean-ctx"));
755    }
756
757    #[test]
758    fn ensure_mcp_server_upserts_missing_keys_into_existing_env() {
759        // Pre-existing install (only DATA_DIR) must gain the new roots without
760        // duplicating the section, and the operation must be idempotent.
761        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
762                     [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/home/u/.lean-ctx\"\n";
763        let pairs = vec![
764            (
765                "LEAN_CTX_DATA_DIR".to_string(),
766                "/home/u/.lean-ctx".to_string(),
767            ),
768            (
769                "LEAN_CTX_PROJECT_ROOT".to_string(),
770                "/work/main".to_string(),
771            ),
772        ];
773
774        let result = ensure_codex_mcp_server(input, "lean-ctx", &pairs)
775            .expect("should upsert the missing project root");
776        assert_eq!(
777            result.matches("[mcp_servers.lean-ctx]").count(),
778            1,
779            "must not duplicate the parent section"
780        );
781        let doc = result
782            .parse::<toml_edit::DocumentMut>()
783            .expect("output must be valid TOML");
784        assert_eq!(
785            doc["mcp_servers"]["lean-ctx"]["env"]["LEAN_CTX_PROJECT_ROOT"].as_str(),
786            Some("/work/main")
787        );
788
789        // Second pass over the upserted config is a no-op.
790        assert!(
791            ensure_codex_mcp_server(&result, "lean-ctx", &pairs).is_none(),
792            "upsert must be idempotent"
793        );
794    }
795
796    #[test]
797    fn observe_merges_into_existing_session_start_entry() {
798        let mut root = serde_json::json!({
799            "hooks": {
800                "SessionStart": [{
801                    "matcher": "startup|resume|clear",
802                    "hooks": [{
803                        "type": "command",
804                        "command": "/bin/lean-ctx hook codex-session-start",
805                        "timeout": 15
806                    }]
807                }]
808            }
809        });
810        let changed = ensure_codex_observe_hooks(&mut root, "/bin/lean-ctx hook observe");
811        assert!(changed);
812        let entries = root["hooks"]["SessionStart"].as_array().unwrap();
813        assert_eq!(entries.len(), 1, "must be single entry, not two");
814        let hooks = entries[0]["hooks"].as_array().unwrap();
815        assert_eq!(hooks.len(), 2, "session-start + observe in one entry");
816        assert!(hooks[1]["command"].as_str().unwrap().contains("observe"));
817    }
818
819    #[test]
820    fn observe_creates_new_entry_when_no_lean_ctx_exists() {
821        let mut root = serde_json::json!({ "hooks": {} });
822        let changed = ensure_codex_observe_hooks(&mut root, "/bin/lean-ctx hook observe");
823        assert!(changed);
824        let entries = root["hooks"]["PostToolUse"].as_array().unwrap();
825        assert_eq!(entries.len(), 1);
826        assert_eq!(entries[0]["matcher"].as_str().unwrap(), ".*");
827    }
828
829    #[test]
830    fn observe_standalone_migrated_into_lean_ctx_entry() {
831        // Reproduces the bug: observe exists as a separate entry from
832        // codex-session-start, causing duplicate "hook: SessionStart" logs.
833        let mut root = serde_json::json!({
834            "hooks": {
835                "SessionStart": [
836                    {
837                        "matcher": ".*",
838                        "hooks": [{
839                            "type": "command",
840                            "command": "/bin/lean-ctx hook observe",
841                            "timeout": 5
842                        }]
843                    },
844                    {
845                        "matcher": "startup|resume|clear",
846                        "hooks": [{
847                            "type": "command",
848                            "command": "/bin/lean-ctx hook codex-session-start",
849                            "timeout": 15
850                        }]
851                    }
852                ]
853            }
854        });
855        let changed = ensure_codex_observe_hooks(&mut root, "/bin/lean-ctx hook observe");
856        assert!(changed, "migration must report a change");
857        let entries = root["hooks"]["SessionStart"].as_array().unwrap();
858        assert_eq!(
859            entries.len(),
860            1,
861            "standalone must be removed, leaving 1 entry"
862        );
863        let hooks = entries[0]["hooks"].as_array().unwrap();
864        assert_eq!(
865            hooks.len(),
866            2,
867            "session-start + observe merged in one entry"
868        );
869        assert!(
870            hooks.iter().any(|h| h["command"]
871                .as_str()
872                .unwrap()
873                .contains("codex-session-start")),
874            "must keep codex-session-start"
875        );
876        assert!(
877            hooks
878                .iter()
879                .any(|h| h["command"].as_str().unwrap().contains("hook observe")),
880            "must keep observe"
881        );
882    }
883}