Skip to main content

lean_ctx/hooks/agents/
codex.rs

1use super::super::{
2    ensure_codex_hooks_enabled as shared_ensure_codex_hooks_enabled,
3    install_codex_instruction_docs, mcp_server_quiet_mode, resolve_binary_path,
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    let _ = std::fs::create_dir_all(&codex_dir);
13
14    let hook_config_changed = install_codex_hook_config(&codex_dir);
15    let installed_docs = install_codex_instruction_docs(&codex_dir);
16
17    if !mcp_server_quiet_mode() {
18        if hook_config_changed {
19            eprintln!(
20                "Installed Codex-compatible SessionStart/PreToolUse hooks at {}",
21                codex_dir.display()
22            );
23        }
24        if installed_docs {
25            eprintln!("Installed Codex instructions at {}", codex_dir.display());
26        } else {
27            eprintln!("Codex AGENTS.md already configured.");
28        }
29    }
30}
31
32fn install_codex_hook_config(codex_dir: &std::path::Path) -> bool {
33    let binary = resolve_binary_path();
34    let session_start_cmd = format!("{binary} hook codex-session-start");
35    let pre_tool_use_cmd = format!("{binary} hook codex-pretooluse");
36    let hooks_json_path = codex_dir.join("hooks.json");
37
38    let mut changed = false;
39    let mut root = if hooks_json_path.exists() {
40        if let Some(parsed) = std::fs::read_to_string(&hooks_json_path)
41            .ok()
42            .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
43        {
44            parsed
45        } else {
46            changed = true;
47            serde_json::json!({ "hooks": {} })
48        }
49    } else {
50        changed = true;
51        serde_json::json!({ "hooks": {} })
52    };
53
54    if upsert_lean_ctx_codex_hook_entries(&mut root, &session_start_cmd, &pre_tool_use_cmd) {
55        changed = true;
56    }
57
58    // Observe hooks for context awareness
59    let observe_cmd = format!("{binary} hook observe");
60    if ensure_codex_observe_hooks(&mut root, &observe_cmd) {
61        changed = true;
62    }
63
64    if changed {
65        write_file(
66            &hooks_json_path,
67            &serde_json::to_string_pretty(&root).unwrap_or_default(),
68        );
69    }
70
71    let rewrite_path = codex_dir.join("hooks").join("lean-ctx-rewrite-codex.sh");
72    if rewrite_path.exists() && std::fs::remove_file(&rewrite_path).is_ok() {
73        changed = true;
74    }
75
76    let config_toml_path = codex_dir.join("config.toml");
77    let config_content = std::fs::read_to_string(&config_toml_path).unwrap_or_default();
78
79    // Hybrid mode: ensure MCP server entry exists in config.toml so Codex
80    // Desktop/Cloud can reach lean-ctx even without CLI hooks.
81    let mcp_updated = ensure_codex_mcp_server(
82        &config_content,
83        &binary,
84        &super::super::mcp_server_env_pairs(),
85    );
86    let hooks_updated =
87        ensure_codex_hooks_enabled(mcp_updated.as_deref().unwrap_or(&config_content));
88
89    let final_content = hooks_updated
90        .or(mcp_updated)
91        .unwrap_or_else(|| config_content.clone());
92    if final_content != config_content {
93        write_file(&config_toml_path, &final_content);
94        changed = true;
95        if !mcp_server_quiet_mode() {
96            eprintln!(
97                "Updated Codex config (MCP server + hooks) in {}",
98                config_toml_path.display()
99            );
100        }
101    }
102
103    changed
104}
105
106fn ensure_codex_observe_hooks(root: &mut serde_json::Value, observe_cmd: &str) -> bool {
107    let original = root.clone();
108    let Some(hooks_obj) = root
109        .as_object_mut()
110        .and_then(|r| r.get_mut("hooks"))
111        .and_then(|h| h.as_object_mut())
112    else {
113        return false;
114    };
115
116    let observe_events = ["PostToolUse", "SessionStart", "SessionEnd"];
117    for event in observe_events {
118        let arr = hooks_obj
119            .entry(event.to_string())
120            .or_insert_with(|| serde_json::json!([]));
121        let Some(entries) = arr.as_array_mut() else {
122            continue;
123        };
124        let already = entries.iter().any(|e| {
125            e.get("hooks")
126                .and_then(|h| h.as_array())
127                .is_some_and(|hooks| {
128                    hooks.iter().any(|hook| {
129                        hook.get("command")
130                            .and_then(|c| c.as_str())
131                            .is_some_and(|c| c.contains("hook observe"))
132                    })
133                })
134        });
135        if !already {
136            entries.push(serde_json::json!({
137                "matcher": ".*",
138                "hooks": [{ "type": "command", "command": observe_cmd, "timeout": 5 }]
139            }));
140        }
141    }
142
143    *root != original
144}
145
146/// Idempotent upsert of the `[mcp_servers.lean-ctx]` entry in Codex `config.toml`.
147///
148/// Uses a format-preserving TOML editor so existing user content/comments and an
149/// orphaned `[mcp_servers.lean-ctx.env]` (issue #189) are normalized into a
150/// single valid section instead of producing a duplicate table header.
151///
152/// `env_pairs` is injected (not read from global state) so the pure
153/// config-rewriting logic is hermetically testable; the caller passes
154/// [`crate::hooks::mcp_server_env_pairs`]. Existing `command`/`args` are left
155/// untouched to respect user customization; env keys are upserted so a stale
156/// install gains `LEAN_CTX_PROJECT_ROOT`/`LEAN_CTX_EXTRA_ROOTS` (#403). Returns
157/// `None` when nothing changed, or when the file is not valid TOML (never
158/// clobbers an unparseable user config).
159fn ensure_codex_mcp_server(
160    config_content: &str,
161    binary: &str,
162    env_pairs: &[(String, String)],
163) -> Option<String> {
164    let mut doc = config_content.parse::<toml_edit::DocumentMut>().ok()?;
165    let original = doc.to_string();
166
167    // `[mcp_servers]` stays implicit so we never emit a bare parent header.
168    let servers = doc["mcp_servers"].or_insert(toml_edit::table());
169    if let Some(t) = servers.as_table_mut() {
170        t.set_implicit(true);
171    }
172
173    // `[mcp_servers.lean-ctx]` must be explicit so its header is rendered before
174    // the `.env` child table (fixes the orphaned-env ordering from #189).
175    let lean = servers["lean-ctx"].or_insert(toml_edit::table());
176    let lean_tbl = lean.as_table_mut()?;
177    lean_tbl.set_implicit(false);
178
179    // Respect user customization: only fill `command`/`args` when absent.
180    if !lean_tbl.contains_key("command") {
181        lean_tbl["command"] = toml_edit::value(binary);
182    }
183    if !lean_tbl.contains_key("args") {
184        lean_tbl["args"] = toml_edit::value(toml_edit::Array::new());
185    }
186
187    let env = lean_tbl["env"].or_insert(toml_edit::table());
188    if let Some(env_tbl) = env.as_table_mut() {
189        for (key, val) in env_pairs {
190            let key = key.as_str();
191            if env_tbl.get(key).and_then(toml_edit::Item::as_str) != Some(val.as_str()) {
192                env_tbl[key] = toml_edit::value(val.as_str());
193            }
194        }
195    }
196
197    let updated = doc.to_string();
198    (updated != original).then_some(updated)
199}
200
201fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
202    shared_ensure_codex_hooks_enabled(config_content)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::{
208        ensure_codex_hooks_enabled, ensure_codex_mcp_server, upsert_lean_ctx_codex_hook_entries,
209    };
210    use serde_json::json;
211
212    /// Minimal env block (data dir only) for the config-rewrite tests that do
213    /// not exercise project-root/extra-roots propagation.
214    fn data_dir_pairs() -> Vec<(String, String)> {
215        vec![(
216            "LEAN_CTX_DATA_DIR".to_string(),
217            "/Users/user/.lean-ctx".to_string(),
218        )]
219    }
220
221    #[test]
222    fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
223        let mut input = json!({
224            "hooks": {
225                "PreToolUse": [
226                    {
227                        "matcher": "Bash",
228                        "hooks": [{
229                            "type": "command",
230                            "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
231                            "timeout": 15
232                        }]
233                    },
234                    {
235                        "matcher": "Bash",
236                        "hooks": [{
237                            "type": "command",
238                            "command": "echo keep-me",
239                            "timeout": 5
240                        }]
241                    }
242                ],
243                "SessionStart": [
244                    {
245                        "matcher": "startup|resume|clear",
246                        "hooks": [{
247                            "type": "command",
248                            "command": "lean-ctx hook codex-session-start",
249                            "timeout": 15
250                        }]
251                    }
252                ],
253                "PostToolUse": [
254                    {
255                        "matcher": "Bash",
256                        "hooks": [{
257                            "type": "command",
258                            "command": "echo keep-post",
259                            "timeout": 5
260                        }]
261                    }
262                ]
263            }
264        });
265
266        let changed = upsert_lean_ctx_codex_hook_entries(
267            &mut input,
268            "lean-ctx hook codex-session-start",
269            "lean-ctx hook codex-pretooluse",
270        );
271        assert!(changed, "legacy hooks should be migrated");
272
273        let pre_tool_use = input["hooks"]["PreToolUse"]
274            .as_array()
275            .expect("PreToolUse array should remain");
276        assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
277        assert_eq!(
278            pre_tool_use[0]["hooks"][0]["command"].as_str(),
279            Some("echo keep-me")
280        );
281        assert_eq!(
282            pre_tool_use[1]["hooks"][0]["command"].as_str(),
283            Some("lean-ctx hook codex-pretooluse")
284        );
285        assert_eq!(
286            input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
287            Some("lean-ctx hook codex-session-start")
288        );
289        assert_eq!(
290            input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
291            Some("echo keep-post")
292        );
293    }
294
295    #[test]
296    fn ignores_non_lean_ctx_codex_entries() {
297        let custom = json!({
298            "matcher": "Bash",
299            "hooks": [{
300                "type": "command",
301                "command": "echo keep-me",
302                "timeout": 5
303            }]
304        });
305        assert!(
306            !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
307            "custom Codex hooks must be preserved"
308        );
309    }
310
311    #[test]
312    fn detects_managed_codex_session_start_entry() {
313        let managed = json!({
314            "matcher": "startup|resume|clear",
315            "hooks": [{
316                "type": "command",
317                "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
318                "timeout": 15
319            }]
320        });
321        assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
322            "SessionStart",
323            &managed
324        ));
325    }
326
327    #[test]
328    fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
329        let input = "\
330[features]
331other = true
332codex_hooks = false
333
334[mcp_servers.other]
335command = \"other\"
336";
337
338        let output =
339            ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
340
341        assert!(output.contains("[features]\nother = true\nhooks = true\n"));
342        assert!(!output.contains("codex_hooks = false"));
343    }
344
345    #[test]
346    fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
347        let input = "\
348[features]
349other = true
350
351[mcp_servers.lean-ctx]
352command = \"lean-ctx\"
353codex_hooks = true
354";
355
356        let output = ensure_codex_hooks_enabled(input)
357            .expect("stray codex_hooks assignment should be normalized");
358
359        assert!(output.contains("[features]\nother = true\nhooks = true\n"));
360        assert_eq!(output.matches("hooks = true").count(), 1);
361        assert!(!output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nhooks = true"));
362    }
363
364    #[test]
365    fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
366        let input = "\
367[mcp_servers.lean-ctx]
368command = \"lean-ctx\"
369";
370
371        let output =
372            ensure_codex_hooks_enabled(input).expect("missing features section should be added");
373
374        assert!(output.ends_with("\n[features]\nhooks = true\n"));
375    }
376
377    #[test]
378    fn codex_docs_steer_to_reliable_mcp_path_without_false_hook_claim() {
379        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-desktop-note");
380        let _ = std::fs::remove_dir_all(&tmp);
381        std::fs::create_dir_all(&tmp).unwrap();
382
383        crate::hooks::support::install_codex_instruction_docs(&tmp);
384
385        let lean_ctx_md = std::fs::read_to_string(tmp.join("LEAN-CTX.md")).unwrap();
386        assert!(
387            lean_ctx_md.contains("ctx_shell") && lean_ctx_md.contains("ctx_read"),
388            "LEAN-CTX.md must steer the agent to the MCP tools"
389        );
390        // Regression guard for #350: never assert as fact that Desktop/Cloud hooks
391        // do not run — they can (gated by trust via /hooks, varies by version).
392        let normalized = lean_ctx_md.replace('\n', " ");
393        assert!(
394            !normalized.contains("hooks do not run")
395                && !normalized.contains("no automatic compression"),
396            "LEAN-CTX.md must not make the false blanket claim that Codex Desktop hooks never run (#350)"
397        );
398
399        let agents_md = std::fs::read_to_string(tmp.join("AGENTS.md")).unwrap();
400        assert!(
401            agents_md.contains("ctx_shell") && agents_md.contains("ctx_search"),
402            "AGENTS.md block must steer to the reliable MCP tools"
403        );
404        let agents_norm = agents_md.replace('\n', " ");
405        assert!(
406            !agents_norm.contains("hooks do not run"),
407            "AGENTS.md must not claim Codex hooks never run (#350)"
408        );
409
410        let _ = std::fs::remove_dir_all(&tmp);
411    }
412
413    #[test]
414    fn install_codex_docs_preserves_existing_user_instructions() {
415        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
416        let _ = std::fs::remove_dir_all(&tmp);
417        std::fs::create_dir_all(&tmp).unwrap();
418
419        let agents_md = tmp.join("AGENTS.md");
420        let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
421        std::fs::write(&agents_md, user_content).unwrap();
422
423        crate::hooks::support::install_codex_instruction_docs(&tmp);
424
425        let result = std::fs::read_to_string(&agents_md).unwrap();
426        assert!(
427            result.contains("My Custom Instructions"),
428            "user content must be preserved"
429        );
430        assert!(
431            result.contains("Always use tabs"),
432            "user rules must be preserved"
433        );
434        assert!(
435            result.contains("<!-- lean-ctx -->"),
436            "lean-ctx block must be appended"
437        );
438        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
439        assert!(
440            result.contains(&expected_ref),
441            "lean-ctx reference must use codex_dir path"
442        );
443
444        let _ = std::fs::remove_dir_all(&tmp);
445    }
446
447    #[test]
448    fn install_codex_docs_updates_only_marked_block() {
449        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
450        let _ = std::fs::remove_dir_all(&tmp);
451        std::fs::create_dir_all(&tmp).unwrap();
452
453        let agents_md = tmp.join("AGENTS.md");
454        let content_with_block = "# My Instructions\n\nCustom rule here.\n\n<!-- lean-ctx -->\n## lean-ctx\n\n@OLD-LEAN-CTX.md\n<!-- /lean-ctx -->\n\n## Other Section\nKeep this.\n";
455        std::fs::write(&agents_md, content_with_block).unwrap();
456
457        crate::hooks::support::install_codex_instruction_docs(&tmp);
458
459        let result = std::fs::read_to_string(&agents_md).unwrap();
460        assert!(
461            result.contains("Custom rule here."),
462            "user content before block preserved"
463        );
464        assert!(
465            result.contains("Other Section"),
466            "user content after block preserved"
467        );
468        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
469        assert!(
470            result.contains(&expected_ref),
471            "block updated to current reference"
472        );
473        assert!(
474            !result.contains("OLD-LEAN-CTX"),
475            "old block content replaced"
476        );
477
478        let _ = std::fs::remove_dir_all(&tmp);
479    }
480
481    #[test]
482    fn ensure_mcp_server_adds_section_when_missing() {
483        let input = "[features]\ncodex_hooks = true\n";
484        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
485            .expect("should add MCP section");
486        assert!(result.contains("[mcp_servers.lean-ctx]"));
487        assert!(result.contains("command = \"lean-ctx\""));
488        assert!(result.contains("args = []"));
489        assert!(result.contains("[features]\ncodex_hooks = true\n"));
490    }
491
492    #[test]
493    fn ensure_mcp_server_noop_when_already_complete() {
494        // Parent + args + an env block already carrying every desired key: the
495        // upsert must be a true no-op (no churn on every session start).
496        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
497                     [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"\n";
498        assert!(
499            ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs()).is_none(),
500            "should not modify config when MCP section already has all keys"
501        );
502    }
503
504    #[test]
505    fn ensure_mcp_server_preserves_existing_sections() {
506        let input = "[mcp_servers.other]\ncommand = \"other\"\n";
507        let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx", &data_dir_pairs())
508            .expect("should add lean-ctx section");
509        assert!(result.contains("[mcp_servers.other]"));
510        assert!(result.contains("[mcp_servers.lean-ctx]"));
511        assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
512    }
513
514    #[test]
515    fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
516        let input = "\
517[mcp_servers.lean-ctx.env]
518LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
519";
520        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
521            .expect("should insert parent section before orphaned env");
522        let parent_pos = result
523            .find("[mcp_servers.lean-ctx]")
524            .expect("parent section must exist");
525        let env_pos = result
526            .find("[mcp_servers.lean-ctx.env]")
527            .expect("env sub-table must be preserved");
528        assert!(
529            parent_pos < env_pos,
530            "parent section must come before env sub-table"
531        );
532        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
533        assert!(result.contains("LEAN_CTX_DATA_DIR"));
534        assert_eq!(
535            result.matches("[mcp_servers.lean-ctx.env]").count(),
536            1,
537            "must not duplicate the env table (would be invalid TOML)"
538        );
539    }
540
541    #[test]
542    fn ensure_mcp_server_handles_issue_189_scenario() {
543        let input = "\
544source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
545source_type = \"local\"
546
547[mcp_servers.lean-ctx.env]
548LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
549";
550        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
551            .expect("should fix orphaned config from issue #189");
552        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
553        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
554        assert!(result.contains("[mcp_servers.lean-ctx.env]"));
555        assert!(result.contains("LEAN_CTX_DATA_DIR"));
556
557        let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
558        let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
559        assert!(parent_pos < env_pos);
560        assert_eq!(
561            result.matches("[mcp_servers.lean-ctx.env]").count(),
562            1,
563            "issue #189 fix must merge into one env table, not duplicate it"
564        );
565        // Original sibling content must survive the normalization.
566        assert!(result.contains("source_type = \"local\""));
567    }
568
569    #[test]
570    fn ensure_mcp_server_quotes_windows_backslash_paths() {
571        let input = "[features]\ncodex_hooks = true\n";
572        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
573        let result = ensure_codex_mcp_server(input, win_path, &data_dir_pairs())
574            .expect("should add MCP section");
575        // Quote style is the TOML editor's concern; what matters is that the
576        // backslash path round-trips to exactly the same string and stays valid.
577        let doc = result
578            .parse::<toml_edit::DocumentMut>()
579            .expect("output must be valid TOML");
580        assert_eq!(
581            doc["mcp_servers"]["lean-ctx"]["command"].as_str(),
582            Some(win_path),
583            "Windows backslash path must round-trip exactly: {result}"
584        );
585    }
586
587    #[test]
588    fn ensure_mcp_server_does_not_match_similarly_named_section() {
589        let input = "\
590[mcp_servers.lean-ctx-other]
591command = \"other\"
592";
593        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
594            .expect("should add lean-ctx section despite similarly-named section");
595        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
596        assert!(result.contains("[mcp_servers.lean-ctx-other]"));
597    }
598
599    #[test]
600    fn ensure_mcp_server_writes_project_and_extra_roots() {
601        // #403: when init captured a project root + sibling worktrees, those
602        // must be propagated into the env block so the long-lived MCP server
603        // resolves explicit paths under every root.
604        let pairs = vec![
605            (
606                "LEAN_CTX_DATA_DIR".to_string(),
607                "/home/u/.lean-ctx".to_string(),
608            ),
609            (
610                "LEAN_CTX_PROJECT_ROOT".to_string(),
611                "/work/main".to_string(),
612            ),
613            (
614                "LEAN_CTX_EXTRA_ROOTS".to_string(),
615                "/work/wt-a:/work/wt-b".to_string(),
616            ),
617        ];
618        let result =
619            ensure_codex_mcp_server("", "lean-ctx", &pairs).expect("fresh config must be created");
620
621        let doc = result
622            .parse::<toml_edit::DocumentMut>()
623            .expect("output must be valid TOML");
624        let env = &doc["mcp_servers"]["lean-ctx"]["env"];
625        assert_eq!(env["LEAN_CTX_PROJECT_ROOT"].as_str(), Some("/work/main"));
626        assert_eq!(
627            env["LEAN_CTX_EXTRA_ROOTS"].as_str(),
628            Some("/work/wt-a:/work/wt-b")
629        );
630        assert_eq!(env["LEAN_CTX_DATA_DIR"].as_str(), Some("/home/u/.lean-ctx"));
631    }
632
633    #[test]
634    fn ensure_mcp_server_upserts_missing_keys_into_existing_env() {
635        // Pre-existing install (only DATA_DIR) must gain the new roots without
636        // duplicating the section, and the operation must be idempotent.
637        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
638                     [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/home/u/.lean-ctx\"\n";
639        let pairs = vec![
640            (
641                "LEAN_CTX_DATA_DIR".to_string(),
642                "/home/u/.lean-ctx".to_string(),
643            ),
644            (
645                "LEAN_CTX_PROJECT_ROOT".to_string(),
646                "/work/main".to_string(),
647            ),
648        ];
649
650        let result = ensure_codex_mcp_server(input, "lean-ctx", &pairs)
651            .expect("should upsert the missing project root");
652        assert_eq!(
653            result.matches("[mcp_servers.lean-ctx]").count(),
654            1,
655            "must not duplicate the parent section"
656        );
657        let doc = result
658            .parse::<toml_edit::DocumentMut>()
659            .expect("output must be valid TOML");
660        assert_eq!(
661            doc["mcp_servers"]["lean-ctx"]["env"]["LEAN_CTX_PROJECT_ROOT"].as_str(),
662            Some("/work/main")
663        );
664
665        // Second pass over the upserted config is a no-op.
666        assert!(
667            ensure_codex_mcp_server(&result, "lean-ctx", &pairs).is_none(),
668            "upsert must be idempotent"
669        );
670    }
671}