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