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(&config_content, &binary);
82 let hooks_updated =
83 ensure_codex_hooks_enabled(mcp_updated.as_deref().unwrap_or(&config_content));
84
85 let final_content = hooks_updated
86 .or(mcp_updated)
87 .unwrap_or_else(|| config_content.clone());
88 if final_content != config_content {
89 write_file(&config_toml_path, &final_content);
90 changed = true;
91 if !mcp_server_quiet_mode() {
92 eprintln!(
93 "Updated Codex config (MCP server + hooks) in {}",
94 config_toml_path.display()
95 );
96 }
97 }
98
99 changed
100}
101
102fn ensure_codex_observe_hooks(root: &mut serde_json::Value, observe_cmd: &str) -> bool {
103 let original = root.clone();
104 let Some(hooks_obj) = root
105 .as_object_mut()
106 .and_then(|r| r.get_mut("hooks"))
107 .and_then(|h| h.as_object_mut())
108 else {
109 return false;
110 };
111
112 let observe_events = ["PostToolUse", "SessionStart", "SessionEnd"];
113 for event in observe_events {
114 let arr = hooks_obj
115 .entry(event.to_string())
116 .or_insert_with(|| serde_json::json!([]));
117 let Some(entries) = arr.as_array_mut() else {
118 continue;
119 };
120 let already = entries.iter().any(|e| {
121 e.get("hooks")
122 .and_then(|h| h.as_array())
123 .is_some_and(|hooks| {
124 hooks.iter().any(|hook| {
125 hook.get("command")
126 .and_then(|c| c.as_str())
127 .is_some_and(|c| c.contains("hook observe"))
128 })
129 })
130 });
131 if !already {
132 entries.push(serde_json::json!({
133 "matcher": ".*",
134 "hooks": [{ "type": "command", "command": observe_cmd, "timeout": 5 }]
135 }));
136 }
137 }
138
139 *root != original
140}
141
142fn toml_quote_value(value: &str) -> String {
143 if value.contains('\\') {
144 format!("'{value}'")
145 } else {
146 format!("\"{value}\"")
147 }
148}
149
150fn ensure_codex_mcp_server(config_content: &str, binary: &str) -> Option<String> {
151 if config_content.contains("[mcp_servers.lean-ctx]") {
152 return None;
153 }
154
155 let quoted = toml_quote_value(binary);
156 let data_dir = crate::core::data_dir::lean_ctx_data_dir()
157 .map(|d| d.to_string_lossy().to_string())
158 .unwrap_or_default();
159 let section = format!(
160 "[mcp_servers.lean-ctx]\ncommand = {quoted}\nargs = []\n\n\
161 [mcp_servers.lean-ctx.env]\n\
162 LEAN_CTX_DATA_DIR = {data_dir_q}\n",
163 data_dir_q = toml_quote_value(&data_dir),
164 );
165
166 if let Some(pos) = config_content.find("[mcp_servers.lean-ctx.") {
167 let insert_at = config_content[..pos].rfind('\n').map_or(0, |nl| nl + 1);
168 let mut out = String::with_capacity(config_content.len() + section.len() + 2);
169 out.push_str(&config_content[..insert_at]);
170 out.push_str(§ion);
171 out.push('\n');
172 out.push_str(&config_content[insert_at..]);
173 return Some(out);
174 }
175
176 let mut out = config_content.to_string();
177 if !out.is_empty() && !out.ends_with('\n') {
178 out.push('\n');
179 }
180 out.push_str(&format!("\n{section}"));
181 Some(out)
182}
183
184fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
185 shared_ensure_codex_hooks_enabled(config_content)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::{
191 ensure_codex_hooks_enabled, ensure_codex_mcp_server, upsert_lean_ctx_codex_hook_entries,
192 };
193 use serde_json::json;
194
195 #[test]
196 fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
197 let mut input = json!({
198 "hooks": {
199 "PreToolUse": [
200 {
201 "matcher": "Bash",
202 "hooks": [{
203 "type": "command",
204 "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
205 "timeout": 15
206 }]
207 },
208 {
209 "matcher": "Bash",
210 "hooks": [{
211 "type": "command",
212 "command": "echo keep-me",
213 "timeout": 5
214 }]
215 }
216 ],
217 "SessionStart": [
218 {
219 "matcher": "startup|resume|clear",
220 "hooks": [{
221 "type": "command",
222 "command": "lean-ctx hook codex-session-start",
223 "timeout": 15
224 }]
225 }
226 ],
227 "PostToolUse": [
228 {
229 "matcher": "Bash",
230 "hooks": [{
231 "type": "command",
232 "command": "echo keep-post",
233 "timeout": 5
234 }]
235 }
236 ]
237 }
238 });
239
240 let changed = upsert_lean_ctx_codex_hook_entries(
241 &mut input,
242 "lean-ctx hook codex-session-start",
243 "lean-ctx hook codex-pretooluse",
244 );
245 assert!(changed, "legacy hooks should be migrated");
246
247 let pre_tool_use = input["hooks"]["PreToolUse"]
248 .as_array()
249 .expect("PreToolUse array should remain");
250 assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
251 assert_eq!(
252 pre_tool_use[0]["hooks"][0]["command"].as_str(),
253 Some("echo keep-me")
254 );
255 assert_eq!(
256 pre_tool_use[1]["hooks"][0]["command"].as_str(),
257 Some("lean-ctx hook codex-pretooluse")
258 );
259 assert_eq!(
260 input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
261 Some("lean-ctx hook codex-session-start")
262 );
263 assert_eq!(
264 input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
265 Some("echo keep-post")
266 );
267 }
268
269 #[test]
270 fn ignores_non_lean_ctx_codex_entries() {
271 let custom = json!({
272 "matcher": "Bash",
273 "hooks": [{
274 "type": "command",
275 "command": "echo keep-me",
276 "timeout": 5
277 }]
278 });
279 assert!(
280 !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
281 "custom Codex hooks must be preserved"
282 );
283 }
284
285 #[test]
286 fn detects_managed_codex_session_start_entry() {
287 let managed = json!({
288 "matcher": "startup|resume|clear",
289 "hooks": [{
290 "type": "command",
291 "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
292 "timeout": 15
293 }]
294 });
295 assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
296 "SessionStart",
297 &managed
298 ));
299 }
300
301 #[test]
302 fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
303 let input = "\
304[features]
305other = true
306codex_hooks = false
307
308[mcp_servers.other]
309command = \"other\"
310";
311
312 let output =
313 ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
314
315 assert!(output.contains("[features]\nother = true\nhooks = true\n"));
316 assert!(!output.contains("codex_hooks = false"));
317 }
318
319 #[test]
320 fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
321 let input = "\
322[features]
323other = true
324
325[mcp_servers.lean-ctx]
326command = \"lean-ctx\"
327codex_hooks = true
328";
329
330 let output = ensure_codex_hooks_enabled(input)
331 .expect("stray codex_hooks assignment should be normalized");
332
333 assert!(output.contains("[features]\nother = true\nhooks = true\n"));
334 assert_eq!(output.matches("hooks = true").count(), 1);
335 assert!(!output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nhooks = true"));
336 }
337
338 #[test]
339 fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
340 let input = "\
341[mcp_servers.lean-ctx]
342command = \"lean-ctx\"
343";
344
345 let output =
346 ensure_codex_hooks_enabled(input).expect("missing features section should be added");
347
348 assert!(output.ends_with("\n[features]\nhooks = true\n"));
349 }
350
351 #[test]
352 fn codex_docs_steer_to_reliable_mcp_path_without_false_hook_claim() {
353 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-desktop-note");
354 let _ = std::fs::remove_dir_all(&tmp);
355 std::fs::create_dir_all(&tmp).unwrap();
356
357 crate::hooks::support::install_codex_instruction_docs(&tmp);
358
359 let lean_ctx_md = std::fs::read_to_string(tmp.join("LEAN-CTX.md")).unwrap();
360 assert!(
361 lean_ctx_md.contains("ctx_shell") && lean_ctx_md.contains("ctx_read"),
362 "LEAN-CTX.md must steer the agent to the MCP tools"
363 );
364 let normalized = lean_ctx_md.replace('\n', " ");
367 assert!(
368 !normalized.contains("hooks do not run")
369 && !normalized.contains("no automatic compression"),
370 "LEAN-CTX.md must not make the false blanket claim that Codex Desktop hooks never run (#350)"
371 );
372
373 let agents_md = std::fs::read_to_string(tmp.join("AGENTS.md")).unwrap();
374 assert!(
375 agents_md.contains("ctx_shell") && agents_md.contains("ctx_search"),
376 "AGENTS.md block must steer to the reliable MCP tools"
377 );
378 let agents_norm = agents_md.replace('\n', " ");
379 assert!(
380 !agents_norm.contains("hooks do not run"),
381 "AGENTS.md must not claim Codex hooks never run (#350)"
382 );
383
384 let _ = std::fs::remove_dir_all(&tmp);
385 }
386
387 #[test]
388 fn install_codex_docs_preserves_existing_user_instructions() {
389 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
390 let _ = std::fs::remove_dir_all(&tmp);
391 std::fs::create_dir_all(&tmp).unwrap();
392
393 let agents_md = tmp.join("AGENTS.md");
394 let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
395 std::fs::write(&agents_md, user_content).unwrap();
396
397 crate::hooks::support::install_codex_instruction_docs(&tmp);
398
399 let result = std::fs::read_to_string(&agents_md).unwrap();
400 assert!(
401 result.contains("My Custom Instructions"),
402 "user content must be preserved"
403 );
404 assert!(
405 result.contains("Always use tabs"),
406 "user rules must be preserved"
407 );
408 assert!(
409 result.contains("<!-- lean-ctx -->"),
410 "lean-ctx block must be appended"
411 );
412 let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
413 assert!(
414 result.contains(&expected_ref),
415 "lean-ctx reference must use codex_dir path"
416 );
417
418 let _ = std::fs::remove_dir_all(&tmp);
419 }
420
421 #[test]
422 fn install_codex_docs_updates_only_marked_block() {
423 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
424 let _ = std::fs::remove_dir_all(&tmp);
425 std::fs::create_dir_all(&tmp).unwrap();
426
427 let agents_md = tmp.join("AGENTS.md");
428 let content_with_block = "# My Instructions\n\nCustom rule here.\n\n<!-- lean-ctx -->\n## lean-ctx\n\n@OLD-LEAN-CTX.md\n<!-- /lean-ctx -->\n\n## Other Section\nKeep this.\n";
429 std::fs::write(&agents_md, content_with_block).unwrap();
430
431 crate::hooks::support::install_codex_instruction_docs(&tmp);
432
433 let result = std::fs::read_to_string(&agents_md).unwrap();
434 assert!(
435 result.contains("Custom rule here."),
436 "user content before block preserved"
437 );
438 assert!(
439 result.contains("Other Section"),
440 "user content after block preserved"
441 );
442 let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
443 assert!(
444 result.contains(&expected_ref),
445 "block updated to current reference"
446 );
447 assert!(
448 !result.contains("OLD-LEAN-CTX"),
449 "old block content replaced"
450 );
451
452 let _ = std::fs::remove_dir_all(&tmp);
453 }
454
455 #[test]
456 fn ensure_mcp_server_adds_section_when_missing() {
457 let input = "[features]\ncodex_hooks = true\n";
458 let result = ensure_codex_mcp_server(input, "lean-ctx").expect("should add MCP section");
459 assert!(result.contains("[mcp_servers.lean-ctx]"));
460 assert!(result.contains("command = \"lean-ctx\""));
461 assert!(result.contains("args = []"));
462 assert!(result.contains("[features]\ncodex_hooks = true\n"));
463 }
464
465 #[test]
466 fn ensure_mcp_server_noop_when_present() {
467 let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n";
468 assert!(
469 ensure_codex_mcp_server(input, "lean-ctx").is_none(),
470 "should not modify config when MCP section already exists"
471 );
472 }
473
474 #[test]
475 fn ensure_mcp_server_preserves_existing_sections() {
476 let input = "[mcp_servers.other]\ncommand = \"other\"\n";
477 let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx")
478 .expect("should add lean-ctx section");
479 assert!(result.contains("[mcp_servers.other]"));
480 assert!(result.contains("[mcp_servers.lean-ctx]"));
481 assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
482 }
483
484 #[test]
485 fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
486 let input = "\
487[mcp_servers.lean-ctx.env]
488LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
489";
490 let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx")
491 .expect("should insert parent section before orphaned env");
492 let parent_pos = result
493 .find("[mcp_servers.lean-ctx]")
494 .expect("parent section must exist");
495 let env_pos = result
496 .find("[mcp_servers.lean-ctx.env]")
497 .expect("env sub-table must be preserved");
498 assert!(
499 parent_pos < env_pos,
500 "parent section must come before env sub-table"
501 );
502 assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
503 assert!(result.contains("LEAN_CTX_DATA_DIR"));
504 }
505
506 #[test]
507 fn ensure_mcp_server_handles_issue_189_scenario() {
508 let input = "\
509source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
510source_type = \"local\"
511
512[mcp_servers.lean-ctx.env]
513LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
514";
515 let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx")
516 .expect("should fix orphaned config from issue #189");
517 assert!(result.contains("[mcp_servers.lean-ctx]\n"));
518 assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
519 assert!(result.contains("[mcp_servers.lean-ctx.env]"));
520 assert!(result.contains("LEAN_CTX_DATA_DIR"));
521
522 let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
523 let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
524 assert!(parent_pos < env_pos);
525 }
526
527 #[test]
528 fn ensure_mcp_server_quotes_windows_backslash_paths() {
529 let input = "[features]\ncodex_hooks = true\n";
530 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
531 let result = ensure_codex_mcp_server(input, win_path).expect("should add MCP section");
532 assert!(
533 result.contains(&format!("command = '{win_path}'")),
534 "Windows paths must use TOML single quotes: {result}"
535 );
536 }
537
538 #[test]
539 fn ensure_mcp_server_does_not_match_similarly_named_section() {
540 let input = "\
541[mcp_servers.lean-ctx-other]
542command = \"other\"
543";
544 let result = ensure_codex_mcp_server(input, "lean-ctx")
545 .expect("should add lean-ctx section despite similarly-named section");
546 assert!(result.contains("[mcp_servers.lean-ctx]\n"));
547 assert!(result.contains("[mcp_servers.lean-ctx-other]"));
548 }
549}