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