lean_ctx/hooks/agents/
codex.rs1use 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(home) = crate::core::home::resolve_home_dir() else {
9 tracing::error!("Cannot resolve home directory");
10 return;
11 };
12
13 let codex_dir = home.join(".codex");
14 let _ = std::fs::create_dir_all(&codex_dir);
15
16 let hook_config_changed = install_codex_hook_config(&home);
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(home: &std::path::Path) -> bool {
35 let binary = resolve_binary_path();
36 let session_start_cmd = format!("{binary} hook codex-session-start");
37 let pre_tool_use_cmd = format!("{binary} hook codex-pretooluse");
38 let codex_dir = home.join(".codex");
39 let hooks_json_path = codex_dir.join("hooks.json");
40
41 let mut changed = false;
42 let mut root = if hooks_json_path.exists() {
43 if let Some(parsed) = std::fs::read_to_string(&hooks_json_path)
44 .ok()
45 .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
46 {
47 parsed
48 } else {
49 changed = true;
50 serde_json::json!({ "hooks": {} })
51 }
52 } else {
53 changed = true;
54 serde_json::json!({ "hooks": {} })
55 };
56
57 if upsert_lean_ctx_codex_hook_entries(&mut root, &session_start_cmd, &pre_tool_use_cmd) {
58 changed = true;
59 }
60 if changed {
61 write_file(
62 &hooks_json_path,
63 &serde_json::to_string_pretty(&root).unwrap_or_default(),
64 );
65 }
66
67 let rewrite_path = codex_dir.join("hooks").join("lean-ctx-rewrite-codex.sh");
68 if rewrite_path.exists() && std::fs::remove_file(&rewrite_path).is_ok() {
69 changed = true;
70 }
71
72 let config_toml_path = codex_dir.join("config.toml");
73 let config_content = std::fs::read_to_string(&config_toml_path).unwrap_or_default();
74
75 let mcp_updated = ensure_codex_mcp_server(&config_content, &binary);
78 let hooks_updated =
79 ensure_codex_hooks_enabled(mcp_updated.as_deref().unwrap_or(&config_content));
80
81 let final_content = hooks_updated
82 .or(mcp_updated)
83 .unwrap_or_else(|| config_content.clone());
84 if final_content != config_content {
85 write_file(&config_toml_path, &final_content);
86 changed = true;
87 if !mcp_server_quiet_mode() {
88 eprintln!(
89 "Updated Codex config (MCP server + hooks) in {}",
90 config_toml_path.display()
91 );
92 }
93 }
94
95 changed
96}
97
98fn toml_quote_value(value: &str) -> String {
99 if value.contains('\\') {
100 format!("'{value}'")
101 } else {
102 format!("\"{value}\"")
103 }
104}
105
106fn ensure_codex_mcp_server(config_content: &str, binary: &str) -> Option<String> {
107 if config_content.contains("[mcp_servers.lean-ctx]") {
108 return None;
109 }
110
111 let quoted = toml_quote_value(binary);
112 let section = format!("[mcp_servers.lean-ctx]\ncommand = {quoted}\nargs = []\n");
113
114 if let Some(pos) = config_content.find("[mcp_servers.lean-ctx.") {
115 let insert_at = config_content[..pos].rfind('\n').map_or(0, |nl| nl + 1);
116 let mut out = String::with_capacity(config_content.len() + section.len() + 2);
117 out.push_str(&config_content[..insert_at]);
118 out.push_str(§ion);
119 out.push('\n');
120 out.push_str(&config_content[insert_at..]);
121 return Some(out);
122 }
123
124 let mut out = config_content.to_string();
125 if !out.is_empty() && !out.ends_with('\n') {
126 out.push('\n');
127 }
128 out.push_str(&format!("\n{section}"));
129 Some(out)
130}
131
132fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
133 shared_ensure_codex_hooks_enabled(config_content)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::{
139 ensure_codex_hooks_enabled, ensure_codex_mcp_server, upsert_lean_ctx_codex_hook_entries,
140 };
141 use serde_json::json;
142
143 #[test]
144 fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
145 let mut input = json!({
146 "hooks": {
147 "PreToolUse": [
148 {
149 "matcher": "Bash",
150 "hooks": [{
151 "type": "command",
152 "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
153 "timeout": 15
154 }]
155 },
156 {
157 "matcher": "Bash",
158 "hooks": [{
159 "type": "command",
160 "command": "echo keep-me",
161 "timeout": 5
162 }]
163 }
164 ],
165 "SessionStart": [
166 {
167 "matcher": "startup|resume|clear",
168 "hooks": [{
169 "type": "command",
170 "command": "lean-ctx hook codex-session-start",
171 "timeout": 15
172 }]
173 }
174 ],
175 "PostToolUse": [
176 {
177 "matcher": "Bash",
178 "hooks": [{
179 "type": "command",
180 "command": "echo keep-post",
181 "timeout": 5
182 }]
183 }
184 ]
185 }
186 });
187
188 let changed = upsert_lean_ctx_codex_hook_entries(
189 &mut input,
190 "lean-ctx hook codex-session-start",
191 "lean-ctx hook codex-pretooluse",
192 );
193 assert!(changed, "legacy hooks should be migrated");
194
195 let pre_tool_use = input["hooks"]["PreToolUse"]
196 .as_array()
197 .expect("PreToolUse array should remain");
198 assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
199 assert_eq!(
200 pre_tool_use[0]["hooks"][0]["command"].as_str(),
201 Some("echo keep-me")
202 );
203 assert_eq!(
204 pre_tool_use[1]["hooks"][0]["command"].as_str(),
205 Some("lean-ctx hook codex-pretooluse")
206 );
207 assert_eq!(
208 input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
209 Some("lean-ctx hook codex-session-start")
210 );
211 assert_eq!(
212 input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
213 Some("echo keep-post")
214 );
215 }
216
217 #[test]
218 fn ignores_non_lean_ctx_codex_entries() {
219 let custom = json!({
220 "matcher": "Bash",
221 "hooks": [{
222 "type": "command",
223 "command": "echo keep-me",
224 "timeout": 5
225 }]
226 });
227 assert!(
228 !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
229 "custom Codex hooks must be preserved"
230 );
231 }
232
233 #[test]
234 fn detects_managed_codex_session_start_entry() {
235 let managed = json!({
236 "matcher": "startup|resume|clear",
237 "hooks": [{
238 "type": "command",
239 "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
240 "timeout": 15
241 }]
242 });
243 assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
244 "SessionStart",
245 &managed
246 ));
247 }
248
249 #[test]
250 fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
251 let input = "\
252[features]
253other = true
254codex_hooks = false
255
256[mcp_servers.other]
257command = \"other\"
258";
259
260 let output =
261 ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
262
263 assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
264 assert!(!output.contains("codex_hooks = false"));
265 }
266
267 #[test]
268 fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
269 let input = "\
270[features]
271other = true
272
273[mcp_servers.lean-ctx]
274command = \"lean-ctx\"
275codex_hooks = true
276";
277
278 let output = ensure_codex_hooks_enabled(input)
279 .expect("stray codex_hooks assignment should be normalized");
280
281 assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
282 assert_eq!(output.matches("codex_hooks = true").count(), 1);
283 assert!(
284 !output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\ncodex_hooks = true")
285 );
286 }
287
288 #[test]
289 fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
290 let input = "\
291[mcp_servers.lean-ctx]
292command = \"lean-ctx\"
293";
294
295 let output =
296 ensure_codex_hooks_enabled(input).expect("missing features section should be added");
297
298 assert!(output.ends_with("\n[features]\ncodex_hooks = true\n"));
299 }
300
301 #[test]
302 fn install_codex_docs_preserves_existing_user_instructions() {
303 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
304 let _ = std::fs::remove_dir_all(&tmp);
305 std::fs::create_dir_all(&tmp).unwrap();
306
307 let agents_md = tmp.join("AGENTS.md");
308 let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
309 std::fs::write(&agents_md, user_content).unwrap();
310
311 crate::hooks::support::install_codex_instruction_docs(&tmp);
312
313 let result = std::fs::read_to_string(&agents_md).unwrap();
314 assert!(
315 result.contains("My Custom Instructions"),
316 "user content must be preserved"
317 );
318 assert!(
319 result.contains("Always use tabs"),
320 "user rules must be preserved"
321 );
322 assert!(
323 result.contains("<!-- lean-ctx -->"),
324 "lean-ctx block must be appended"
325 );
326 assert!(
327 result.contains("LEAN-CTX.md (same directory)"),
328 "lean-ctx reference must be present"
329 );
330
331 let _ = std::fs::remove_dir_all(&tmp);
332 }
333
334 #[test]
335 fn install_codex_docs_updates_only_marked_block() {
336 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
337 let _ = std::fs::remove_dir_all(&tmp);
338 std::fs::create_dir_all(&tmp).unwrap();
339
340 let agents_md = tmp.join("AGENTS.md");
341 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";
342 std::fs::write(&agents_md, content_with_block).unwrap();
343
344 crate::hooks::support::install_codex_instruction_docs(&tmp);
345
346 let result = std::fs::read_to_string(&agents_md).unwrap();
347 assert!(
348 result.contains("Custom rule here."),
349 "user content before block preserved"
350 );
351 assert!(
352 result.contains("Other Section"),
353 "user content after block preserved"
354 );
355 assert!(
356 result.contains("LEAN-CTX.md (same directory)"),
357 "block updated to current reference"
358 );
359 assert!(
360 !result.contains("OLD-LEAN-CTX"),
361 "old block content replaced"
362 );
363
364 let _ = std::fs::remove_dir_all(&tmp);
365 }
366
367 #[test]
368 fn ensure_mcp_server_adds_section_when_missing() {
369 let input = "[features]\ncodex_hooks = true\n";
370 let result = ensure_codex_mcp_server(input, "lean-ctx").expect("should add MCP section");
371 assert!(result.contains("[mcp_servers.lean-ctx]"));
372 assert!(result.contains("command = \"lean-ctx\""));
373 assert!(result.contains("args = []"));
374 assert!(result.contains("[features]\ncodex_hooks = true\n"));
375 }
376
377 #[test]
378 fn ensure_mcp_server_noop_when_present() {
379 let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n";
380 assert!(
381 ensure_codex_mcp_server(input, "lean-ctx").is_none(),
382 "should not modify config when MCP section already exists"
383 );
384 }
385
386 #[test]
387 fn ensure_mcp_server_preserves_existing_sections() {
388 let input = "[mcp_servers.other]\ncommand = \"other\"\n";
389 let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx")
390 .expect("should add lean-ctx section");
391 assert!(result.contains("[mcp_servers.other]"));
392 assert!(result.contains("[mcp_servers.lean-ctx]"));
393 assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
394 }
395
396 #[test]
397 fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
398 let input = "\
399[mcp_servers.lean-ctx.env]
400LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
401";
402 let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx")
403 .expect("should insert parent section before orphaned env");
404 let parent_pos = result
405 .find("[mcp_servers.lean-ctx]")
406 .expect("parent section must exist");
407 let env_pos = result
408 .find("[mcp_servers.lean-ctx.env]")
409 .expect("env sub-table must be preserved");
410 assert!(
411 parent_pos < env_pos,
412 "parent section must come before env sub-table"
413 );
414 assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
415 assert!(result.contains("LEAN_CTX_DATA_DIR"));
416 }
417
418 #[test]
419 fn ensure_mcp_server_handles_issue_189_scenario() {
420 let input = "\
421source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
422source_type = \"local\"
423
424[mcp_servers.lean-ctx.env]
425LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
426";
427 let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx")
428 .expect("should fix orphaned config from issue #189");
429 assert!(result.contains("[mcp_servers.lean-ctx]\n"));
430 assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
431 assert!(result.contains("[mcp_servers.lean-ctx.env]"));
432 assert!(result.contains("LEAN_CTX_DATA_DIR"));
433
434 let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
435 let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
436 assert!(parent_pos < env_pos);
437 }
438
439 #[test]
440 fn ensure_mcp_server_quotes_windows_backslash_paths() {
441 let input = "[features]\ncodex_hooks = true\n";
442 let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
443 let result = ensure_codex_mcp_server(input, win_path).expect("should add MCP section");
444 assert!(
445 result.contains(&format!("command = '{win_path}'")),
446 "Windows paths must use TOML single quotes: {result}"
447 );
448 }
449
450 #[test]
451 fn ensure_mcp_server_does_not_match_similarly_named_section() {
452 let input = "\
453[mcp_servers.lean-ctx-other]
454command = \"other\"
455";
456 let result = ensure_codex_mcp_server(input, "lean-ctx")
457 .expect("should add lean-ctx section despite similarly-named section");
458 assert!(result.contains("[mcp_servers.lean-ctx]\n"));
459 assert!(result.contains("[mcp_servers.lean-ctx-other]"));
460 }
461}