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 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 = codex_dir.join("config.toml");
79 let config_content = std::fs::read_to_string(&config_toml_path).unwrap_or_default();
80
81 let mcp_updated = ensure_codex_mcp_server(
84 &config_content,
85 &binary,
86 &super::super::mcp_server_env_pairs(),
87 );
88 let hooks_updated =
89 ensure_codex_hooks_enabled(mcp_updated.as_deref().unwrap_or(&config_content));
90
91 let final_content = hooks_updated
92 .or(mcp_updated)
93 .unwrap_or_else(|| config_content.clone());
94 if final_content != config_content {
95 write_file(&config_toml_path, &final_content);
96 changed = true;
97 if !mcp_server_quiet_mode() {
98 eprintln!(
99 "Updated Codex config (MCP server + hooks) in {}",
100 config_toml_path.display()
101 );
102 }
103 }
104
105 changed
106}
107
108fn ensure_codex_observe_hooks(root: &mut serde_json::Value, observe_cmd: &str) -> bool {
109 let original = root.clone();
110 let Some(hooks_obj) = root
111 .as_object_mut()
112 .and_then(|r| r.get_mut("hooks"))
113 .and_then(|h| h.as_object_mut())
114 else {
115 return false;
116 };
117
118 let observe_events = ["PostToolUse", "SessionStart", "SessionEnd"];
119 for event in observe_events {
120 let arr = hooks_obj
121 .entry(event.to_string())
122 .or_insert_with(|| serde_json::json!([]));
123 let Some(entries) = arr.as_array_mut() else {
124 continue;
125 };
126 let already = entries.iter().any(|e| {
127 e.get("hooks")
128 .and_then(|h| h.as_array())
129 .is_some_and(|hooks| {
130 hooks.iter().any(|hook| {
131 hook.get("command")
132 .and_then(|c| c.as_str())
133 .is_some_and(|c| c.contains("hook observe"))
134 })
135 })
136 });
137 if !already {
138 entries.push(serde_json::json!({
139 "matcher": ".*",
140 "hooks": [{ "type": "command", "command": observe_cmd, "timeout": 5 }]
141 }));
142 }
143 }
144
145 *root != original
146}
147
148fn ensure_codex_mcp_server(
162 config_content: &str,
163 binary: &str,
164 env_pairs: &[(String, String)],
165) -> Option<String> {
166 let mut doc = config_content.parse::<toml_edit::DocumentMut>().ok()?;
167 let original = doc.to_string();
168
169 let servers = doc["mcp_servers"].or_insert(toml_edit::table());
171 if let Some(t) = servers.as_table_mut() {
172 t.set_implicit(true);
173 }
174
175 let lean = servers["lean-ctx"].or_insert(toml_edit::table());
178 let lean_tbl = lean.as_table_mut()?;
179 lean_tbl.set_implicit(false);
180
181 if !lean_tbl.contains_key("command") {
183 lean_tbl["command"] = toml_edit::value(binary);
184 }
185 if !lean_tbl.contains_key("args") {
186 lean_tbl["args"] = toml_edit::value(toml_edit::Array::new());
187 }
188
189 let env = lean_tbl["env"].or_insert(toml_edit::table());
190 if let Some(env_tbl) = env.as_table_mut() {
191 for (key, val) in env_pairs {
192 let key = key.as_str();
193 if env_tbl.get(key).and_then(toml_edit::Item::as_str) != Some(val.as_str()) {
194 env_tbl[key] = toml_edit::value(val.as_str());
195 }
196 }
197 }
198
199 let updated = doc.to_string();
200 (updated != original).then_some(updated)
201}
202
203fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
204 shared_ensure_codex_hooks_enabled(config_content)
205}
206
207pub(crate) fn install_codex_deny_hook() {
210 let Some(codex_dir) = crate::core::home::resolve_codex_dir() else {
211 return;
212 };
213 let binary = resolve_hook_command_binary();
214 let deny_cmd = format!("{binary} hook deny");
215
216 let hooks_json_path = codex_dir.join("hooks.json");
217 let mut root = if hooks_json_path.exists() {
218 std::fs::read_to_string(&hooks_json_path)
219 .ok()
220 .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
221 .unwrap_or_else(|| serde_json::json!({ "hooks": {} }))
222 } else {
223 serde_json::json!({ "hooks": {} })
224 };
225
226 let hooks = root
227 .as_object_mut()
228 .unwrap()
229 .entry("hooks")
230 .or_insert_with(|| serde_json::json!({}));
231 let hooks_obj = hooks.as_object_mut().unwrap();
232
233 let pre = hooks_obj
234 .entry("PreToolUse")
235 .or_insert_with(|| serde_json::json!([]));
236 if let Some(arr) = pre.as_array_mut() {
237 let has_deny = arr.iter().any(|e| {
238 e.get("command")
239 .and_then(|c| c.as_str())
240 .is_some_and(|c| c.contains("hook deny"))
241 });
242 if !has_deny {
243 arr.push(serde_json::json!({
244 "matcher": "Read|Grep|Glob",
245 "command": deny_cmd
246 }));
247 }
248 }
249
250 write_file(
251 &hooks_json_path,
252 &serde_json::to_string_pretty(&root).unwrap_or_default(),
253 );
254
255 if !mcp_server_quiet_mode() {
256 eprintln!(" \x1b[32m✓\x1b[0m Codex deny hook installed (Replace mode)");
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::{
263 ensure_codex_hooks_enabled, ensure_codex_mcp_server, upsert_lean_ctx_codex_hook_entries,
264 };
265 use serde_json::json;
266
267 fn data_dir_pairs() -> Vec<(String, String)> {
270 vec![(
271 "LEAN_CTX_DATA_DIR".to_string(),
272 "/Users/user/.lean-ctx".to_string(),
273 )]
274 }
275
276 #[test]
277 fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
278 let mut input = json!({
279 "hooks": {
280 "PreToolUse": [
281 {
282 "matcher": "Bash",
283 "hooks": [{
284 "type": "command",
285 "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
286 "timeout": 15
287 }]
288 },
289 {
290 "matcher": "Bash",
291 "hooks": [{
292 "type": "command",
293 "command": "echo keep-me",
294 "timeout": 5
295 }]
296 }
297 ],
298 "SessionStart": [
299 {
300 "matcher": "startup|resume|clear",
301 "hooks": [{
302 "type": "command",
303 "command": "lean-ctx hook codex-session-start",
304 "timeout": 15
305 }]
306 }
307 ],
308 "PostToolUse": [
309 {
310 "matcher": "Bash",
311 "hooks": [{
312 "type": "command",
313 "command": "echo keep-post",
314 "timeout": 5
315 }]
316 }
317 ]
318 }
319 });
320
321 let changed = upsert_lean_ctx_codex_hook_entries(
322 &mut input,
323 "lean-ctx hook codex-session-start",
324 "lean-ctx hook codex-pretooluse",
325 );
326 assert!(changed, "legacy hooks should be migrated");
327
328 let pre_tool_use = input["hooks"]["PreToolUse"]
329 .as_array()
330 .expect("PreToolUse array should remain");
331 assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
332 assert_eq!(
333 pre_tool_use[0]["hooks"][0]["command"].as_str(),
334 Some("echo keep-me")
335 );
336 assert_eq!(
337 pre_tool_use[1]["hooks"][0]["command"].as_str(),
338 Some("lean-ctx hook codex-pretooluse")
339 );
340 assert_eq!(
341 input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
342 Some("lean-ctx hook codex-session-start")
343 );
344 assert_eq!(
345 input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
346 Some("echo keep-post")
347 );
348 }
349
350 #[test]
351 fn ignores_non_lean_ctx_codex_entries() {
352 let custom = json!({
353 "matcher": "Bash",
354 "hooks": [{
355 "type": "command",
356 "command": "echo keep-me",
357 "timeout": 5
358 }]
359 });
360 assert!(
361 !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
362 "custom Codex hooks must be preserved"
363 );
364 }
365
366 #[test]
367 fn detects_managed_codex_session_start_entry() {
368 let managed = json!({
369 "matcher": "startup|resume|clear",
370 "hooks": [{
371 "type": "command",
372 "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
373 "timeout": 15
374 }]
375 });
376 assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
377 "SessionStart",
378 &managed
379 ));
380 }
381
382 #[test]
383 fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
384 let input = "\
385[features]
386other = true
387codex_hooks = false
388
389[mcp_servers.other]
390command = \"other\"
391";
392
393 let output =
394 ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
395
396 assert!(output.contains("[features]\nother = true\nhooks = true\n"));
397 assert!(!output.contains("codex_hooks = false"));
398 }
399
400 #[test]
401 fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
402 let input = "\
403[features]
404other = true
405
406[mcp_servers.lean-ctx]
407command = \"lean-ctx\"
408codex_hooks = true
409";
410
411 let output = ensure_codex_hooks_enabled(input)
412 .expect("stray codex_hooks assignment should be normalized");
413
414 assert!(output.contains("[features]\nother = true\nhooks = true\n"));
415 assert_eq!(output.matches("hooks = true").count(), 1);
416 assert!(!output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nhooks = true"));
417 }
418
419 #[test]
420 fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
421 let input = "\
422[mcp_servers.lean-ctx]
423command = \"lean-ctx\"
424";
425
426 let output =
427 ensure_codex_hooks_enabled(input).expect("missing features section should be added");
428
429 assert!(output.ends_with("\n[features]\nhooks = true\n"));
430 }
431
432 #[test]
433 fn codex_docs_steer_to_reliable_mcp_path_without_false_hook_claim() {
434 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-desktop-note");
435 let _ = std::fs::remove_dir_all(&tmp);
436 std::fs::create_dir_all(&tmp).unwrap();
437
438 crate::hooks::support::install_codex_instruction_docs(&tmp);
439
440 let lean_ctx_md = std::fs::read_to_string(tmp.join("LEAN-CTX.md")).unwrap();
441 assert!(
442 lean_ctx_md.contains("ctx_shell") && lean_ctx_md.contains("ctx_read"),
443 "LEAN-CTX.md must steer the agent to the MCP tools"
444 );
445 let normalized = lean_ctx_md.replace('\n', " ");
448 assert!(
449 !normalized.contains("hooks do not run")
450 && !normalized.contains("no automatic compression"),
451 "LEAN-CTX.md must not make the false blanket claim that Codex Desktop hooks never run (#350)"
452 );
453
454 let agents_md = std::fs::read_to_string(tmp.join("AGENTS.md")).unwrap();
455 assert!(
456 agents_md.contains("ctx_shell") && agents_md.contains("ctx_search"),
457 "AGENTS.md block must steer to the reliable MCP tools"
458 );
459 let agents_norm = agents_md.replace('\n', " ");
460 assert!(
461 !agents_norm.contains("hooks do not run"),
462 "AGENTS.md must not claim Codex hooks never run (#350)"
463 );
464
465 let _ = std::fs::remove_dir_all(&tmp);
466 }
467
468 #[test]
469 fn install_codex_docs_preserves_existing_user_instructions() {
470 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
471 let _ = std::fs::remove_dir_all(&tmp);
472 std::fs::create_dir_all(&tmp).unwrap();
473
474 let agents_md = tmp.join("AGENTS.md");
475 let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
476 std::fs::write(&agents_md, user_content).unwrap();
477
478 crate::hooks::support::install_codex_instruction_docs(&tmp);
479
480 let result = std::fs::read_to_string(&agents_md).unwrap();
481 assert!(
482 result.contains("My Custom Instructions"),
483 "user content must be preserved"
484 );
485 assert!(
486 result.contains("Always use tabs"),
487 "user rules must be preserved"
488 );
489 assert!(
490 result.contains(crate::core::rules_canonical::AGENTS_BLOCK_START),
491 "lean-ctx block must be appended"
492 );
493 let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
494 assert!(
495 result.contains(&expected_ref),
496 "lean-ctx reference must use codex_dir path"
497 );
498
499 let _ = std::fs::remove_dir_all(&tmp);
500 }
501
502 #[test]
503 fn install_codex_docs_updates_only_marked_block() {
504 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
505 let _ = std::fs::remove_dir_all(&tmp);
506 std::fs::create_dir_all(&tmp).unwrap();
507
508 let agents_md = tmp.join("AGENTS.md");
509 let content_with_block = format!(
510 "# 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",
511 crate::core::rules_canonical::AGENTS_BLOCK_START,
512 crate::core::rules_canonical::AGENTS_BLOCK_END,
513 );
514 std::fs::write(&agents_md, content_with_block).unwrap();
515
516 crate::hooks::support::install_codex_instruction_docs(&tmp);
517
518 let result = std::fs::read_to_string(&agents_md).unwrap();
519 assert!(
520 result.contains("Custom rule here."),
521 "user content before block preserved"
522 );
523 assert!(
524 result.contains("Other Section"),
525 "user content after block preserved"
526 );
527 let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
528 assert!(
529 result.contains(&expected_ref),
530 "block updated to current reference"
531 );
532 assert!(
533 !result.contains("OLD-LEAN-CTX"),
534 "old block content replaced"
535 );
536
537 let _ = std::fs::remove_dir_all(&tmp);
538 }
539
540 #[test]
541 fn ensure_mcp_server_adds_section_when_missing() {
542 let input = "[features]\ncodex_hooks = true\n";
543 let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
544 .expect("should add MCP section");
545 assert!(result.contains("[mcp_servers.lean-ctx]"));
546 assert!(result.contains("command = \"lean-ctx\""));
547 assert!(result.contains("args = []"));
548 assert!(result.contains("[features]\ncodex_hooks = true\n"));
549 }
550
551 #[test]
552 fn ensure_mcp_server_noop_when_already_complete() {
553 let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
556 [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"\n";
557 assert!(
558 ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs()).is_none(),
559 "should not modify config when MCP section already has all keys"
560 );
561 }
562
563 #[test]
564 fn ensure_mcp_server_preserves_existing_sections() {
565 let input = "[mcp_servers.other]\ncommand = \"other\"\n";
566 let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx", &data_dir_pairs())
567 .expect("should add lean-ctx section");
568 assert!(result.contains("[mcp_servers.other]"));
569 assert!(result.contains("[mcp_servers.lean-ctx]"));
570 assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
571 }
572
573 #[test]
574 fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
575 let input = "\
576[mcp_servers.lean-ctx.env]
577LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
578";
579 let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
580 .expect("should insert parent section before orphaned env");
581 let parent_pos = result
582 .find("[mcp_servers.lean-ctx]")
583 .expect("parent section must exist");
584 let env_pos = result
585 .find("[mcp_servers.lean-ctx.env]")
586 .expect("env sub-table must be preserved");
587 assert!(
588 parent_pos < env_pos,
589 "parent section must come before env sub-table"
590 );
591 assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
592 assert!(result.contains("LEAN_CTX_DATA_DIR"));
593 assert_eq!(
594 result.matches("[mcp_servers.lean-ctx.env]").count(),
595 1,
596 "must not duplicate the env table (would be invalid TOML)"
597 );
598 }
599
600 #[test]
601 fn ensure_mcp_server_handles_issue_189_scenario() {
602 let input = "\
603source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
604source_type = \"local\"
605
606[mcp_servers.lean-ctx.env]
607LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
608";
609 let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
610 .expect("should fix orphaned config from issue #189");
611 assert!(result.contains("[mcp_servers.lean-ctx]\n"));
612 assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
613 assert!(result.contains("[mcp_servers.lean-ctx.env]"));
614 assert!(result.contains("LEAN_CTX_DATA_DIR"));
615
616 let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
617 let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
618 assert!(parent_pos < env_pos);
619 assert_eq!(
620 result.matches("[mcp_servers.lean-ctx.env]").count(),
621 1,
622 "issue #189 fix must merge into one env table, not duplicate it"
623 );
624 assert!(result.contains("source_type = \"local\""));
626 }
627
628 #[test]
629 fn ensure_mcp_server_quotes_windows_backslash_paths() {
630 let input = "[features]\ncodex_hooks = true\n";
631 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
632 let result = ensure_codex_mcp_server(input, win_path, &data_dir_pairs())
633 .expect("should add MCP section");
634 let doc = result
637 .parse::<toml_edit::DocumentMut>()
638 .expect("output must be valid TOML");
639 assert_eq!(
640 doc["mcp_servers"]["lean-ctx"]["command"].as_str(),
641 Some(win_path),
642 "Windows backslash path must round-trip exactly: {result}"
643 );
644 }
645
646 #[test]
647 fn ensure_mcp_server_does_not_match_similarly_named_section() {
648 let input = "\
649[mcp_servers.lean-ctx-other]
650command = \"other\"
651";
652 let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
653 .expect("should add lean-ctx section despite similarly-named section");
654 assert!(result.contains("[mcp_servers.lean-ctx]\n"));
655 assert!(result.contains("[mcp_servers.lean-ctx-other]"));
656 }
657
658 #[test]
659 fn ensure_mcp_server_writes_project_and_extra_roots() {
660 let pairs = vec![
664 (
665 "LEAN_CTX_DATA_DIR".to_string(),
666 "/home/u/.lean-ctx".to_string(),
667 ),
668 (
669 "LEAN_CTX_PROJECT_ROOT".to_string(),
670 "/work/main".to_string(),
671 ),
672 (
673 "LEAN_CTX_EXTRA_ROOTS".to_string(),
674 "/work/wt-a:/work/wt-b".to_string(),
675 ),
676 ];
677 let result =
678 ensure_codex_mcp_server("", "lean-ctx", &pairs).expect("fresh config must be created");
679
680 let doc = result
681 .parse::<toml_edit::DocumentMut>()
682 .expect("output must be valid TOML");
683 let env = &doc["mcp_servers"]["lean-ctx"]["env"];
684 assert_eq!(env["LEAN_CTX_PROJECT_ROOT"].as_str(), Some("/work/main"));
685 assert_eq!(
686 env["LEAN_CTX_EXTRA_ROOTS"].as_str(),
687 Some("/work/wt-a:/work/wt-b")
688 );
689 assert_eq!(env["LEAN_CTX_DATA_DIR"].as_str(), Some("/home/u/.lean-ctx"));
690 }
691
692 #[test]
693 fn ensure_mcp_server_upserts_missing_keys_into_existing_env() {
694 let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
697 [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/home/u/.lean-ctx\"\n";
698 let pairs = vec![
699 (
700 "LEAN_CTX_DATA_DIR".to_string(),
701 "/home/u/.lean-ctx".to_string(),
702 ),
703 (
704 "LEAN_CTX_PROJECT_ROOT".to_string(),
705 "/work/main".to_string(),
706 ),
707 ];
708
709 let result = ensure_codex_mcp_server(input, "lean-ctx", &pairs)
710 .expect("should upsert the missing project root");
711 assert_eq!(
712 result.matches("[mcp_servers.lean-ctx]").count(),
713 1,
714 "must not duplicate the parent section"
715 );
716 let doc = result
717 .parse::<toml_edit::DocumentMut>()
718 .expect("output must be valid TOML");
719 assert_eq!(
720 doc["mcp_servers"]["lean-ctx"]["env"]["LEAN_CTX_PROJECT_ROOT"].as_str(),
721 Some("/work/main")
722 );
723
724 assert!(
726 ensure_codex_mcp_server(&result, "lean-ctx", &pairs).is_none(),
727 "upsert must be idempotent"
728 );
729 }
730}