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(crate::core::rules_canonical::AGENTS_BLOCK_START),
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 = format!(
455            "# 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",
456            crate::core::rules_canonical::AGENTS_BLOCK_START,
457            crate::core::rules_canonical::AGENTS_BLOCK_END,
458        );
459        std::fs::write(&agents_md, content_with_block).unwrap();
460
461        crate::hooks::support::install_codex_instruction_docs(&tmp);
462
463        let result = std::fs::read_to_string(&agents_md).unwrap();
464        assert!(
465            result.contains("Custom rule here."),
466            "user content before block preserved"
467        );
468        assert!(
469            result.contains("Other Section"),
470            "user content after block preserved"
471        );
472        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
473        assert!(
474            result.contains(&expected_ref),
475            "block updated to current reference"
476        );
477        assert!(
478            !result.contains("OLD-LEAN-CTX"),
479            "old block content replaced"
480        );
481
482        let _ = std::fs::remove_dir_all(&tmp);
483    }
484
485    #[test]
486    fn ensure_mcp_server_adds_section_when_missing() {
487        let input = "[features]\ncodex_hooks = true\n";
488        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
489            .expect("should add MCP section");
490        assert!(result.contains("[mcp_servers.lean-ctx]"));
491        assert!(result.contains("command = \"lean-ctx\""));
492        assert!(result.contains("args = []"));
493        assert!(result.contains("[features]\ncodex_hooks = true\n"));
494    }
495
496    #[test]
497    fn ensure_mcp_server_noop_when_already_complete() {
498        // Parent + args + an env block already carrying every desired key: the
499        // upsert must be a true no-op (no churn on every session start).
500        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
501                     [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"\n";
502        assert!(
503            ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs()).is_none(),
504            "should not modify config when MCP section already has all keys"
505        );
506    }
507
508    #[test]
509    fn ensure_mcp_server_preserves_existing_sections() {
510        let input = "[mcp_servers.other]\ncommand = \"other\"\n";
511        let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx", &data_dir_pairs())
512            .expect("should add lean-ctx section");
513        assert!(result.contains("[mcp_servers.other]"));
514        assert!(result.contains("[mcp_servers.lean-ctx]"));
515        assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
516    }
517
518    #[test]
519    fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
520        let input = "\
521[mcp_servers.lean-ctx.env]
522LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
523";
524        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
525            .expect("should insert parent section before orphaned env");
526        let parent_pos = result
527            .find("[mcp_servers.lean-ctx]")
528            .expect("parent section must exist");
529        let env_pos = result
530            .find("[mcp_servers.lean-ctx.env]")
531            .expect("env sub-table must be preserved");
532        assert!(
533            parent_pos < env_pos,
534            "parent section must come before env sub-table"
535        );
536        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
537        assert!(result.contains("LEAN_CTX_DATA_DIR"));
538        assert_eq!(
539            result.matches("[mcp_servers.lean-ctx.env]").count(),
540            1,
541            "must not duplicate the env table (would be invalid TOML)"
542        );
543    }
544
545    #[test]
546    fn ensure_mcp_server_handles_issue_189_scenario() {
547        let input = "\
548source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
549source_type = \"local\"
550
551[mcp_servers.lean-ctx.env]
552LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
553";
554        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
555            .expect("should fix orphaned config from issue #189");
556        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
557        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
558        assert!(result.contains("[mcp_servers.lean-ctx.env]"));
559        assert!(result.contains("LEAN_CTX_DATA_DIR"));
560
561        let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
562        let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
563        assert!(parent_pos < env_pos);
564        assert_eq!(
565            result.matches("[mcp_servers.lean-ctx.env]").count(),
566            1,
567            "issue #189 fix must merge into one env table, not duplicate it"
568        );
569        // Original sibling content must survive the normalization.
570        assert!(result.contains("source_type = \"local\""));
571    }
572
573    #[test]
574    fn ensure_mcp_server_quotes_windows_backslash_paths() {
575        let input = "[features]\ncodex_hooks = true\n";
576        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
577        let result = ensure_codex_mcp_server(input, win_path, &data_dir_pairs())
578            .expect("should add MCP section");
579        // Quote style is the TOML editor's concern; what matters is that the
580        // backslash path round-trips to exactly the same string and stays valid.
581        let doc = result
582            .parse::<toml_edit::DocumentMut>()
583            .expect("output must be valid TOML");
584        assert_eq!(
585            doc["mcp_servers"]["lean-ctx"]["command"].as_str(),
586            Some(win_path),
587            "Windows backslash path must round-trip exactly: {result}"
588        );
589    }
590
591    #[test]
592    fn ensure_mcp_server_does_not_match_similarly_named_section() {
593        let input = "\
594[mcp_servers.lean-ctx-other]
595command = \"other\"
596";
597        let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
598            .expect("should add lean-ctx section despite similarly-named section");
599        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
600        assert!(result.contains("[mcp_servers.lean-ctx-other]"));
601    }
602
603    #[test]
604    fn ensure_mcp_server_writes_project_and_extra_roots() {
605        // #403: when init captured a project root + sibling worktrees, those
606        // must be propagated into the env block so the long-lived MCP server
607        // resolves explicit paths under every root.
608        let pairs = vec![
609            (
610                "LEAN_CTX_DATA_DIR".to_string(),
611                "/home/u/.lean-ctx".to_string(),
612            ),
613            (
614                "LEAN_CTX_PROJECT_ROOT".to_string(),
615                "/work/main".to_string(),
616            ),
617            (
618                "LEAN_CTX_EXTRA_ROOTS".to_string(),
619                "/work/wt-a:/work/wt-b".to_string(),
620            ),
621        ];
622        let result =
623            ensure_codex_mcp_server("", "lean-ctx", &pairs).expect("fresh config must be created");
624
625        let doc = result
626            .parse::<toml_edit::DocumentMut>()
627            .expect("output must be valid TOML");
628        let env = &doc["mcp_servers"]["lean-ctx"]["env"];
629        assert_eq!(env["LEAN_CTX_PROJECT_ROOT"].as_str(), Some("/work/main"));
630        assert_eq!(
631            env["LEAN_CTX_EXTRA_ROOTS"].as_str(),
632            Some("/work/wt-a:/work/wt-b")
633        );
634        assert_eq!(env["LEAN_CTX_DATA_DIR"].as_str(), Some("/home/u/.lean-ctx"));
635    }
636
637    #[test]
638    fn ensure_mcp_server_upserts_missing_keys_into_existing_env() {
639        // Pre-existing install (only DATA_DIR) must gain the new roots without
640        // duplicating the section, and the operation must be idempotent.
641        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
642                     [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/home/u/.lean-ctx\"\n";
643        let pairs = vec![
644            (
645                "LEAN_CTX_DATA_DIR".to_string(),
646                "/home/u/.lean-ctx".to_string(),
647            ),
648            (
649                "LEAN_CTX_PROJECT_ROOT".to_string(),
650                "/work/main".to_string(),
651            ),
652        ];
653
654        let result = ensure_codex_mcp_server(input, "lean-ctx", &pairs)
655            .expect("should upsert the missing project root");
656        assert_eq!(
657            result.matches("[mcp_servers.lean-ctx]").count(),
658            1,
659            "must not duplicate the parent section"
660        );
661        let doc = result
662            .parse::<toml_edit::DocumentMut>()
663            .expect("output must be valid TOML");
664        assert_eq!(
665            doc["mcp_servers"]["lean-ctx"]["env"]["LEAN_CTX_PROJECT_ROOT"].as_str(),
666            Some("/work/main")
667        );
668
669        // Second pass over the upserted config is a no-op.
670        assert!(
671            ensure_codex_mcp_server(&result, "lean-ctx", &pairs).is_none(),
672            "upsert must be idempotent"
673        );
674    }
675}