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