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 = 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 let mcp_updated = ensure_codex_mcp_server(
85 &config_content,
86 &binary,
87 &super::super::mcp_server_env_pairs(),
88 );
89 let hooks_updated =
90 ensure_codex_hooks_enabled(mcp_updated.as_deref().unwrap_or(&config_content));
91
92 let final_content = hooks_updated
93 .or(mcp_updated)
94 .unwrap_or_else(|| config_content.clone());
95 if final_content != config_content {
96 write_file(&config_toml_path, &final_content);
97 changed = true;
98 if !mcp_server_quiet_mode() {
99 eprintln!(
100 "Updated Codex config (MCP server + hooks) in {}",
101 config_toml_path.display()
102 );
103 }
104 }
105
106 changed
107}
108
109fn ensure_codex_observe_hooks(root: &mut serde_json::Value, observe_cmd: &str) -> bool {
110 let original = root.clone();
111 let Some(hooks_obj) = root
112 .as_object_mut()
113 .and_then(|r| r.get_mut("hooks"))
114 .and_then(|h| h.as_object_mut())
115 else {
116 return false;
117 };
118
119 let observe_hook = serde_json::json!({
120 "type": "command", "command": observe_cmd, "timeout": 5
121 });
122
123 let observe_events = ["PostToolUse", "SessionStart", "SessionEnd"];
124 for event in observe_events {
125 let arr = hooks_obj
126 .entry(event.to_string())
127 .or_insert_with(|| serde_json::json!([]));
128 let Some(entries) = arr.as_array_mut() else {
129 continue;
130 };
131
132 let observe_standalone_idx = entries.iter().position(|e| {
136 let hooks = e.get("hooks").and_then(|h| h.as_array());
137 let Some(hooks) = hooks else { return false };
138 hooks.len() == 1
140 && hooks[0]
141 .get("command")
142 .and_then(|c| c.as_str())
143 .is_some_and(|c| c.contains("hook observe"))
144 });
145 let leanctx_entry_idx = entries.iter().position(|e| {
146 e.get("hooks")
147 .and_then(|h| h.as_array())
148 .is_some_and(|hooks| {
149 hooks.iter().any(|hook| {
150 hook.get("command")
151 .and_then(|c| c.as_str())
152 .is_some_and(|c| c.contains("lean-ctx") && !c.contains("hook observe"))
153 })
154 })
155 });
156 if let (Some(standalone), Some(target)) = (observe_standalone_idx, leanctx_entry_idx) {
157 entries.remove(standalone);
159 let target = if standalone < target {
160 target - 1
161 } else {
162 target
163 };
164 if let Some(hooks_arr) = entries[target]
166 .as_object_mut()
167 .and_then(|e| e.get_mut("hooks"))
168 .and_then(|h| h.as_array_mut())
169 {
170 let already = hooks_arr.iter().any(|h| {
171 h.get("command")
172 .and_then(|c| c.as_str())
173 .is_some_and(|c| c.contains("hook observe"))
174 });
175 if !already {
176 hooks_arr.push(observe_hook.clone());
177 }
178 }
179 continue;
180 }
181
182 let already_has_observe = entries.iter().any(|e| {
184 e.get("hooks")
185 .and_then(|h| h.as_array())
186 .is_some_and(|hooks| {
187 hooks.iter().any(|hook| {
188 hook.get("command")
189 .and_then(|c| c.as_str())
190 .is_some_and(|c| c.contains("hook observe"))
191 })
192 })
193 });
194 if already_has_observe {
195 continue;
196 }
197
198 let merged = entries.iter_mut().any(|entry| {
201 let dominated = entry
202 .get("hooks")
203 .and_then(|h| h.as_array())
204 .is_some_and(|hooks| {
205 hooks.iter().any(|hook| {
206 hook.get("command")
207 .and_then(|c| c.as_str())
208 .is_some_and(|c| c.contains("lean-ctx"))
209 })
210 });
211 if !dominated {
212 return false;
213 }
214 if let Some(hooks_arr) = entry
215 .as_object_mut()
216 .and_then(|e| e.get_mut("hooks"))
217 .and_then(|h| h.as_array_mut())
218 {
219 hooks_arr.push(observe_hook.clone());
220 return true;
221 }
222 false
223 });
224
225 if !merged {
226 entries.push(serde_json::json!({
227 "matcher": ".*",
228 "hooks": [observe_hook.clone()]
229 }));
230 }
231 }
232
233 *root != original
234}
235
236fn ensure_codex_mcp_server(
250 config_content: &str,
251 binary: &str,
252 env_pairs: &[(String, String)],
253) -> Option<String> {
254 let mut doc = config_content.parse::<toml_edit::DocumentMut>().ok()?;
255 let original = doc.to_string();
256
257 let servers = doc["mcp_servers"].or_insert(toml_edit::table());
259 if let Some(t) = servers.as_table_mut() {
260 t.set_implicit(true);
261 }
262
263 let lean = servers["lean-ctx"].or_insert(toml_edit::table());
266 let lean_tbl = lean.as_table_mut()?;
267 lean_tbl.set_implicit(false);
268
269 if !lean_tbl.contains_key("command") {
271 lean_tbl["command"] = toml_edit::value(binary);
272 }
273 if !lean_tbl.contains_key("args") {
274 lean_tbl["args"] = toml_edit::value(toml_edit::Array::new());
275 }
276
277 if !lean_tbl.contains_key("startup_timeout_sec") {
280 lean_tbl["startup_timeout_sec"] = toml_edit::value(30);
281 }
282 if !lean_tbl.contains_key("tool_timeout_sec") {
283 lean_tbl["tool_timeout_sec"] = toml_edit::value(120);
284 }
285
286 let env = lean_tbl["env"].or_insert(toml_edit::table());
287 if let Some(env_tbl) = env.as_table_mut() {
288 for (key, val) in env_pairs {
289 let key = key.as_str();
290 if env_tbl.get(key).and_then(toml_edit::Item::as_str) != Some(val.as_str()) {
291 env_tbl[key] = toml_edit::value(val.as_str());
292 }
293 }
294 }
295
296 let updated = doc.to_string();
297 (updated != original).then_some(updated)
298}
299
300fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
301 shared_ensure_codex_hooks_enabled(config_content)
302}
303
304#[cfg(test)]
309mod tests {
310 use super::{
311 ensure_codex_hooks_enabled, ensure_codex_mcp_server, ensure_codex_observe_hooks,
312 upsert_lean_ctx_codex_hook_entries,
313 };
314 use serde_json::json;
315
316 fn data_dir_pairs() -> Vec<(String, String)> {
319 vec![(
320 "LEAN_CTX_DATA_DIR".to_string(),
321 "/Users/user/.lean-ctx".to_string(),
322 )]
323 }
324
325 #[test]
326 fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
327 let mut input = json!({
328 "hooks": {
329 "PreToolUse": [
330 {
331 "matcher": "Bash",
332 "hooks": [{
333 "type": "command",
334 "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
335 "timeout": 15
336 }]
337 },
338 {
339 "matcher": "Bash",
340 "hooks": [{
341 "type": "command",
342 "command": "echo keep-me",
343 "timeout": 5
344 }]
345 }
346 ],
347 "SessionStart": [
348 {
349 "matcher": "startup|resume|clear",
350 "hooks": [{
351 "type": "command",
352 "command": "lean-ctx hook codex-session-start",
353 "timeout": 15
354 }]
355 }
356 ],
357 "PostToolUse": [
358 {
359 "matcher": "Bash",
360 "hooks": [{
361 "type": "command",
362 "command": "echo keep-post",
363 "timeout": 5
364 }]
365 }
366 ]
367 }
368 });
369
370 let changed = upsert_lean_ctx_codex_hook_entries(
371 &mut input,
372 "lean-ctx hook codex-session-start",
373 "lean-ctx hook codex-pretooluse",
374 );
375 assert!(changed, "legacy hooks should be migrated");
376
377 let pre_tool_use = input["hooks"]["PreToolUse"]
378 .as_array()
379 .expect("PreToolUse array should remain");
380 assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
381 assert_eq!(
382 pre_tool_use[0]["hooks"][0]["command"].as_str(),
383 Some("echo keep-me")
384 );
385 assert_eq!(
386 pre_tool_use[1]["hooks"][0]["command"].as_str(),
387 Some("lean-ctx hook codex-pretooluse")
388 );
389 assert_eq!(
390 input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
391 Some("lean-ctx hook codex-session-start")
392 );
393 assert_eq!(
394 input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
395 Some("echo keep-post")
396 );
397 }
398
399 #[test]
400 fn ignores_non_lean_ctx_codex_entries() {
401 let custom = json!({
402 "matcher": "Bash",
403 "hooks": [{
404 "type": "command",
405 "command": "echo keep-me",
406 "timeout": 5
407 }]
408 });
409 assert!(
410 !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
411 "custom Codex hooks must be preserved"
412 );
413 }
414
415 #[test]
416 fn detects_managed_codex_session_start_entry() {
417 let managed = json!({
418 "matcher": "startup|resume|clear",
419 "hooks": [{
420 "type": "command",
421 "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
422 "timeout": 15
423 }]
424 });
425 assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
426 "SessionStart",
427 &managed
428 ));
429 }
430
431 #[test]
432 fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
433 let input = "\
434[features]
435other = true
436codex_hooks = false
437
438[mcp_servers.other]
439command = \"other\"
440";
441
442 let output =
443 ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
444
445 assert!(output.contains("[features]\nother = true\nhooks = true\n"));
446 assert!(!output.contains("codex_hooks = false"));
447 }
448
449 #[test]
450 fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
451 let input = "\
452[features]
453other = true
454
455[mcp_servers.lean-ctx]
456command = \"lean-ctx\"
457codex_hooks = true
458";
459
460 let output = ensure_codex_hooks_enabled(input)
461 .expect("stray codex_hooks assignment should be normalized");
462
463 assert!(output.contains("[features]\nother = true\nhooks = true\n"));
464 assert_eq!(output.matches("hooks = true").count(), 1);
465 assert!(!output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nhooks = true"));
466 }
467
468 #[test]
469 fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
470 let input = "\
471[mcp_servers.lean-ctx]
472command = \"lean-ctx\"
473";
474
475 let output =
476 ensure_codex_hooks_enabled(input).expect("missing features section should be added");
477
478 assert!(output.ends_with("\n[features]\nhooks = true\n"));
479 }
480
481 #[test]
482 fn codex_docs_steer_to_reliable_mcp_path_without_false_hook_claim() {
483 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-desktop-note");
484 let _ = std::fs::remove_dir_all(&tmp);
485 std::fs::create_dir_all(&tmp).unwrap();
486
487 crate::hooks::support::install_codex_instruction_docs(&tmp);
488
489 let lean_ctx_md = std::fs::read_to_string(tmp.join("LEAN-CTX.md")).unwrap();
490 assert!(
491 lean_ctx_md.contains("ctx_shell") && lean_ctx_md.contains("ctx_read"),
492 "LEAN-CTX.md must steer the agent to the MCP tools"
493 );
494 let normalized = lean_ctx_md.replace('\n', " ");
497 assert!(
498 !normalized.contains("hooks do not run")
499 && !normalized.contains("no automatic compression"),
500 "LEAN-CTX.md must not make the false blanket claim that Codex Desktop hooks never run (#350)"
501 );
502
503 let agents_md = std::fs::read_to_string(tmp.join("AGENTS.md")).unwrap();
504 assert!(
505 agents_md.contains("ctx_shell") && agents_md.contains("ctx_search"),
506 "AGENTS.md block must steer to the reliable MCP tools"
507 );
508 let agents_norm = agents_md.replace('\n', " ");
509 assert!(
510 !agents_norm.contains("hooks do not run"),
511 "AGENTS.md must not claim Codex hooks never run (#350)"
512 );
513
514 let _ = std::fs::remove_dir_all(&tmp);
515 }
516
517 #[test]
518 fn install_codex_docs_preserves_existing_user_instructions() {
519 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
520 let _ = std::fs::remove_dir_all(&tmp);
521 std::fs::create_dir_all(&tmp).unwrap();
522
523 let agents_md = tmp.join("AGENTS.md");
524 let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
525 std::fs::write(&agents_md, user_content).unwrap();
526
527 crate::hooks::support::install_codex_instruction_docs(&tmp);
528
529 let result = std::fs::read_to_string(&agents_md).unwrap();
530 assert!(
531 result.contains("My Custom Instructions"),
532 "user content must be preserved"
533 );
534 assert!(
535 result.contains("Always use tabs"),
536 "user rules must be preserved"
537 );
538 assert!(
539 result.contains(crate::core::rules_canonical::AGENTS_BLOCK_START),
540 "lean-ctx block must be appended"
541 );
542 let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
543 assert!(
544 result.contains(&expected_ref),
545 "lean-ctx reference must use codex_dir path"
546 );
547
548 let _ = std::fs::remove_dir_all(&tmp);
549 }
550
551 #[test]
552 fn install_codex_docs_updates_only_marked_block() {
553 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
554 let _ = std::fs::remove_dir_all(&tmp);
555 std::fs::create_dir_all(&tmp).unwrap();
556
557 let agents_md = tmp.join("AGENTS.md");
558 let content_with_block = format!(
559 "# My Instructions\n\nCustom rule here.\n\n{}\n## lean-ctx\n\n@OLD-LEAN-CTX.md\n{}\n\n## Other Section\nKeep this.\n",
560 crate::core::rules_canonical::AGENTS_BLOCK_START,
561 crate::core::rules_canonical::AGENTS_BLOCK_END,
562 );
563 std::fs::write(&agents_md, content_with_block).unwrap();
564
565 crate::hooks::support::install_codex_instruction_docs(&tmp);
566
567 let result = std::fs::read_to_string(&agents_md).unwrap();
568 assert!(
569 result.contains("Custom rule here."),
570 "user content before block preserved"
571 );
572 assert!(
573 result.contains("Other Section"),
574 "user content after block preserved"
575 );
576 let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
577 assert!(
578 result.contains(&expected_ref),
579 "block updated to current reference"
580 );
581 assert!(
582 !result.contains("OLD-LEAN-CTX"),
583 "old block content replaced"
584 );
585
586 let _ = std::fs::remove_dir_all(&tmp);
587 }
588
589 #[test]
590 fn ensure_mcp_server_adds_section_when_missing() {
591 let input = "[features]\ncodex_hooks = true\n";
592 let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
593 .expect("should add MCP section");
594 assert!(result.contains("[mcp_servers.lean-ctx]"));
595 assert!(result.contains("command = \"lean-ctx\""));
596 assert!(result.contains("args = []"));
597 assert!(result.contains("[features]\ncodex_hooks = true\n"));
598 }
599
600 #[test]
601 fn ensure_mcp_server_noop_when_already_complete() {
602 let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\
605 startup_timeout_sec = 30\ntool_timeout_sec = 120\n\n\
606 [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"\n";
607 assert!(
608 ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs()).is_none(),
609 "should not modify config when MCP section already has all keys"
610 );
611 }
612
613 #[test]
614 fn ensure_mcp_server_adds_timeouts() {
615 let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n";
616 let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
617 .expect("should add timeouts");
618 assert!(
619 result.contains("startup_timeout_sec = 30"),
620 "must set startup_timeout_sec"
621 );
622 assert!(
623 result.contains("tool_timeout_sec = 120"),
624 "must set tool_timeout_sec"
625 );
626 }
627
628 #[test]
629 fn ensure_mcp_server_preserves_existing_sections() {
630 let input = "[mcp_servers.other]\ncommand = \"other\"\n";
631 let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx", &data_dir_pairs())
632 .expect("should add lean-ctx section");
633 assert!(result.contains("[mcp_servers.other]"));
634 assert!(result.contains("[mcp_servers.lean-ctx]"));
635 assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
636 }
637
638 #[test]
639 fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
640 let input = "\
641[mcp_servers.lean-ctx.env]
642LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
643";
644 let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
645 .expect("should insert parent section before orphaned env");
646 let parent_pos = result
647 .find("[mcp_servers.lean-ctx]")
648 .expect("parent section must exist");
649 let env_pos = result
650 .find("[mcp_servers.lean-ctx.env]")
651 .expect("env sub-table must be preserved");
652 assert!(
653 parent_pos < env_pos,
654 "parent section must come before env sub-table"
655 );
656 assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
657 assert!(result.contains("LEAN_CTX_DATA_DIR"));
658 assert_eq!(
659 result.matches("[mcp_servers.lean-ctx.env]").count(),
660 1,
661 "must not duplicate the env table (would be invalid TOML)"
662 );
663 }
664
665 #[test]
666 fn ensure_mcp_server_handles_issue_189_scenario() {
667 let input = "\
668source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
669source_type = \"local\"
670
671[mcp_servers.lean-ctx.env]
672LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
673";
674 let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx", &data_dir_pairs())
675 .expect("should fix orphaned config from issue #189");
676 assert!(result.contains("[mcp_servers.lean-ctx]\n"));
677 assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
678 assert!(result.contains("[mcp_servers.lean-ctx.env]"));
679 assert!(result.contains("LEAN_CTX_DATA_DIR"));
680
681 let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
682 let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
683 assert!(parent_pos < env_pos);
684 assert_eq!(
685 result.matches("[mcp_servers.lean-ctx.env]").count(),
686 1,
687 "issue #189 fix must merge into one env table, not duplicate it"
688 );
689 assert!(result.contains("source_type = \"local\""));
691 }
692
693 #[test]
694 fn ensure_mcp_server_quotes_windows_backslash_paths() {
695 let input = "[features]\ncodex_hooks = true\n";
696 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
697 let result = ensure_codex_mcp_server(input, win_path, &data_dir_pairs())
698 .expect("should add MCP section");
699 let doc = result
702 .parse::<toml_edit::DocumentMut>()
703 .expect("output must be valid TOML");
704 assert_eq!(
705 doc["mcp_servers"]["lean-ctx"]["command"].as_str(),
706 Some(win_path),
707 "Windows backslash path must round-trip exactly: {result}"
708 );
709 }
710
711 #[test]
712 fn ensure_mcp_server_does_not_match_similarly_named_section() {
713 let input = "\
714[mcp_servers.lean-ctx-other]
715command = \"other\"
716";
717 let result = ensure_codex_mcp_server(input, "lean-ctx", &data_dir_pairs())
718 .expect("should add lean-ctx section despite similarly-named section");
719 assert!(result.contains("[mcp_servers.lean-ctx]\n"));
720 assert!(result.contains("[mcp_servers.lean-ctx-other]"));
721 }
722
723 #[test]
724 fn ensure_mcp_server_writes_project_and_extra_roots() {
725 let pairs = vec![
729 (
730 "LEAN_CTX_DATA_DIR".to_string(),
731 "/home/u/.lean-ctx".to_string(),
732 ),
733 (
734 "LEAN_CTX_PROJECT_ROOT".to_string(),
735 "/work/main".to_string(),
736 ),
737 (
738 "LEAN_CTX_EXTRA_ROOTS".to_string(),
739 "/work/wt-a:/work/wt-b".to_string(),
740 ),
741 ];
742 let result =
743 ensure_codex_mcp_server("", "lean-ctx", &pairs).expect("fresh config must be created");
744
745 let doc = result
746 .parse::<toml_edit::DocumentMut>()
747 .expect("output must be valid TOML");
748 let env = &doc["mcp_servers"]["lean-ctx"]["env"];
749 assert_eq!(env["LEAN_CTX_PROJECT_ROOT"].as_str(), Some("/work/main"));
750 assert_eq!(
751 env["LEAN_CTX_EXTRA_ROOTS"].as_str(),
752 Some("/work/wt-a:/work/wt-b")
753 );
754 assert_eq!(env["LEAN_CTX_DATA_DIR"].as_str(), Some("/home/u/.lean-ctx"));
755 }
756
757 #[test]
758 fn ensure_mcp_server_upserts_missing_keys_into_existing_env() {
759 let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n\n\
762 [mcp_servers.lean-ctx.env]\nLEAN_CTX_DATA_DIR = \"/home/u/.lean-ctx\"\n";
763 let pairs = vec![
764 (
765 "LEAN_CTX_DATA_DIR".to_string(),
766 "/home/u/.lean-ctx".to_string(),
767 ),
768 (
769 "LEAN_CTX_PROJECT_ROOT".to_string(),
770 "/work/main".to_string(),
771 ),
772 ];
773
774 let result = ensure_codex_mcp_server(input, "lean-ctx", &pairs)
775 .expect("should upsert the missing project root");
776 assert_eq!(
777 result.matches("[mcp_servers.lean-ctx]").count(),
778 1,
779 "must not duplicate the parent section"
780 );
781 let doc = result
782 .parse::<toml_edit::DocumentMut>()
783 .expect("output must be valid TOML");
784 assert_eq!(
785 doc["mcp_servers"]["lean-ctx"]["env"]["LEAN_CTX_PROJECT_ROOT"].as_str(),
786 Some("/work/main")
787 );
788
789 assert!(
791 ensure_codex_mcp_server(&result, "lean-ctx", &pairs).is_none(),
792 "upsert must be idempotent"
793 );
794 }
795
796 #[test]
797 fn observe_merges_into_existing_session_start_entry() {
798 let mut root = serde_json::json!({
799 "hooks": {
800 "SessionStart": [{
801 "matcher": "startup|resume|clear",
802 "hooks": [{
803 "type": "command",
804 "command": "/bin/lean-ctx hook codex-session-start",
805 "timeout": 15
806 }]
807 }]
808 }
809 });
810 let changed = ensure_codex_observe_hooks(&mut root, "/bin/lean-ctx hook observe");
811 assert!(changed);
812 let entries = root["hooks"]["SessionStart"].as_array().unwrap();
813 assert_eq!(entries.len(), 1, "must be single entry, not two");
814 let hooks = entries[0]["hooks"].as_array().unwrap();
815 assert_eq!(hooks.len(), 2, "session-start + observe in one entry");
816 assert!(hooks[1]["command"].as_str().unwrap().contains("observe"));
817 }
818
819 #[test]
820 fn observe_creates_new_entry_when_no_lean_ctx_exists() {
821 let mut root = serde_json::json!({ "hooks": {} });
822 let changed = ensure_codex_observe_hooks(&mut root, "/bin/lean-ctx hook observe");
823 assert!(changed);
824 let entries = root["hooks"]["PostToolUse"].as_array().unwrap();
825 assert_eq!(entries.len(), 1);
826 assert_eq!(entries[0]["matcher"].as_str().unwrap(), ".*");
827 }
828
829 #[test]
830 fn observe_standalone_migrated_into_lean_ctx_entry() {
831 let mut root = serde_json::json!({
834 "hooks": {
835 "SessionStart": [
836 {
837 "matcher": ".*",
838 "hooks": [{
839 "type": "command",
840 "command": "/bin/lean-ctx hook observe",
841 "timeout": 5
842 }]
843 },
844 {
845 "matcher": "startup|resume|clear",
846 "hooks": [{
847 "type": "command",
848 "command": "/bin/lean-ctx hook codex-session-start",
849 "timeout": 15
850 }]
851 }
852 ]
853 }
854 });
855 let changed = ensure_codex_observe_hooks(&mut root, "/bin/lean-ctx hook observe");
856 assert!(changed, "migration must report a change");
857 let entries = root["hooks"]["SessionStart"].as_array().unwrap();
858 assert_eq!(
859 entries.len(),
860 1,
861 "standalone must be removed, leaving 1 entry"
862 );
863 let hooks = entries[0]["hooks"].as_array().unwrap();
864 assert_eq!(
865 hooks.len(),
866 2,
867 "session-start + observe merged in one entry"
868 );
869 assert!(
870 hooks.iter().any(|h| h["command"]
871 .as_str()
872 .unwrap()
873 .contains("codex-session-start")),
874 "must keep codex-session-start"
875 );
876 assert!(
877 hooks
878 .iter()
879 .any(|h| h["command"].as_str().unwrap().contains("hook observe")),
880 "must keep observe"
881 );
882 }
883}