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 ensure_codex_mcp_server(config_content: &str, binary: &str) -> Option<String> {
99 if config_content.contains("[mcp_servers.lean-ctx]") {
100 return None;
101 }
102 let mut out = config_content.to_string();
103 if !out.is_empty() && !out.ends_with('\n') {
104 out.push('\n');
105 }
106 out.push_str(&format!(
107 "\n[mcp_servers.lean-ctx]\ncommand = \"{binary}\"\nargs = []\n"
108 ));
109 Some(out)
110}
111
112fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
113 shared_ensure_codex_hooks_enabled(config_content)
114}
115
116#[cfg(test)]
117mod tests {
118 use super::{
119 ensure_codex_hooks_enabled, ensure_codex_mcp_server, upsert_lean_ctx_codex_hook_entries,
120 };
121 use serde_json::json;
122
123 #[test]
124 fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
125 let mut input = json!({
126 "hooks": {
127 "PreToolUse": [
128 {
129 "matcher": "Bash",
130 "hooks": [{
131 "type": "command",
132 "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
133 "timeout": 15
134 }]
135 },
136 {
137 "matcher": "Bash",
138 "hooks": [{
139 "type": "command",
140 "command": "echo keep-me",
141 "timeout": 5
142 }]
143 }
144 ],
145 "SessionStart": [
146 {
147 "matcher": "startup|resume|clear",
148 "hooks": [{
149 "type": "command",
150 "command": "lean-ctx hook codex-session-start",
151 "timeout": 15
152 }]
153 }
154 ],
155 "PostToolUse": [
156 {
157 "matcher": "Bash",
158 "hooks": [{
159 "type": "command",
160 "command": "echo keep-post",
161 "timeout": 5
162 }]
163 }
164 ]
165 }
166 });
167
168 let changed = upsert_lean_ctx_codex_hook_entries(
169 &mut input,
170 "lean-ctx hook codex-session-start",
171 "lean-ctx hook codex-pretooluse",
172 );
173 assert!(changed, "legacy hooks should be migrated");
174
175 let pre_tool_use = input["hooks"]["PreToolUse"]
176 .as_array()
177 .expect("PreToolUse array should remain");
178 assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
179 assert_eq!(
180 pre_tool_use[0]["hooks"][0]["command"].as_str(),
181 Some("echo keep-me")
182 );
183 assert_eq!(
184 pre_tool_use[1]["hooks"][0]["command"].as_str(),
185 Some("lean-ctx hook codex-pretooluse")
186 );
187 assert_eq!(
188 input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
189 Some("lean-ctx hook codex-session-start")
190 );
191 assert_eq!(
192 input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
193 Some("echo keep-post")
194 );
195 }
196
197 #[test]
198 fn ignores_non_lean_ctx_codex_entries() {
199 let custom = json!({
200 "matcher": "Bash",
201 "hooks": [{
202 "type": "command",
203 "command": "echo keep-me",
204 "timeout": 5
205 }]
206 });
207 assert!(
208 !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
209 "custom Codex hooks must be preserved"
210 );
211 }
212
213 #[test]
214 fn detects_managed_codex_session_start_entry() {
215 let managed = json!({
216 "matcher": "startup|resume|clear",
217 "hooks": [{
218 "type": "command",
219 "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
220 "timeout": 15
221 }]
222 });
223 assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
224 "SessionStart",
225 &managed
226 ));
227 }
228
229 #[test]
230 fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
231 let input = "\
232[features]
233other = true
234codex_hooks = false
235
236[mcp_servers.other]
237command = \"other\"
238";
239
240 let output =
241 ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
242
243 assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
244 assert!(!output.contains("codex_hooks = false"));
245 }
246
247 #[test]
248 fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
249 let input = "\
250[features]
251other = true
252
253[mcp_servers.lean-ctx]
254command = \"lean-ctx\"
255codex_hooks = true
256";
257
258 let output = ensure_codex_hooks_enabled(input)
259 .expect("stray codex_hooks assignment should be normalized");
260
261 assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
262 assert_eq!(output.matches("codex_hooks = true").count(), 1);
263 assert!(
264 !output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\ncodex_hooks = true")
265 );
266 }
267
268 #[test]
269 fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
270 let input = "\
271[mcp_servers.lean-ctx]
272command = \"lean-ctx\"
273";
274
275 let output =
276 ensure_codex_hooks_enabled(input).expect("missing features section should be added");
277
278 assert!(output.ends_with("\n[features]\ncodex_hooks = true\n"));
279 }
280
281 #[test]
282 fn install_codex_docs_preserves_existing_user_instructions() {
283 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
284 let _ = std::fs::remove_dir_all(&tmp);
285 std::fs::create_dir_all(&tmp).unwrap();
286
287 let agents_md = tmp.join("AGENTS.md");
288 let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
289 std::fs::write(&agents_md, user_content).unwrap();
290
291 crate::hooks::support::install_codex_instruction_docs(&tmp);
292
293 let result = std::fs::read_to_string(&agents_md).unwrap();
294 assert!(
295 result.contains("My Custom Instructions"),
296 "user content must be preserved"
297 );
298 assert!(
299 result.contains("Always use tabs"),
300 "user rules must be preserved"
301 );
302 assert!(
303 result.contains("<!-- lean-ctx -->"),
304 "lean-ctx block must be appended"
305 );
306 assert!(
307 result.contains("LEAN-CTX.md (same directory)"),
308 "lean-ctx reference must be present"
309 );
310
311 let _ = std::fs::remove_dir_all(&tmp);
312 }
313
314 #[test]
315 fn install_codex_docs_updates_only_marked_block() {
316 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
317 let _ = std::fs::remove_dir_all(&tmp);
318 std::fs::create_dir_all(&tmp).unwrap();
319
320 let agents_md = tmp.join("AGENTS.md");
321 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";
322 std::fs::write(&agents_md, content_with_block).unwrap();
323
324 crate::hooks::support::install_codex_instruction_docs(&tmp);
325
326 let result = std::fs::read_to_string(&agents_md).unwrap();
327 assert!(
328 result.contains("Custom rule here."),
329 "user content before block preserved"
330 );
331 assert!(
332 result.contains("Other Section"),
333 "user content after block preserved"
334 );
335 assert!(
336 result.contains("LEAN-CTX.md (same directory)"),
337 "block updated to current reference"
338 );
339 assert!(
340 !result.contains("OLD-LEAN-CTX"),
341 "old block content replaced"
342 );
343
344 let _ = std::fs::remove_dir_all(&tmp);
345 }
346
347 #[test]
348 fn ensure_mcp_server_adds_section_when_missing() {
349 let input = "[features]\ncodex_hooks = true\n";
350 let result = ensure_codex_mcp_server(input, "lean-ctx").expect("should add MCP section");
351 assert!(result.contains("[mcp_servers.lean-ctx]"));
352 assert!(result.contains("command = \"lean-ctx\""));
353 assert!(result.contains("args = []"));
354 assert!(result.contains("[features]\ncodex_hooks = true\n"));
355 }
356
357 #[test]
358 fn ensure_mcp_server_noop_when_present() {
359 let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n";
360 assert!(
361 ensure_codex_mcp_server(input, "lean-ctx").is_none(),
362 "should not modify config when MCP section already exists"
363 );
364 }
365
366 #[test]
367 fn ensure_mcp_server_preserves_existing_sections() {
368 let input = "[mcp_servers.other]\ncommand = \"other\"\n";
369 let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx")
370 .expect("should add lean-ctx section");
371 assert!(result.contains("[mcp_servers.other]"));
372 assert!(result.contains("[mcp_servers.lean-ctx]"));
373 assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
374 }
375}