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_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
149fn 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 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 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 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#[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 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 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 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 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 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 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 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 assert!(
678 ensure_codex_mcp_server(&result, "lean-ctx", &pairs).is_none(),
679 "upsert must be idempotent"
680 );
681 }
682}