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 if let Some(updated) = ensure_codex_hooks_enabled(&config_content) {
75 write_file(&config_toml_path, &updated);
76 changed = true;
77 if !mcp_server_quiet_mode() {
78 eprintln!(
79 "Enabled codex_hooks feature in {}",
80 config_toml_path.display()
81 );
82 }
83 }
84
85 changed
86}
87
88fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
89 shared_ensure_codex_hooks_enabled(config_content)
90}
91
92#[cfg(test)]
93mod tests {
94 use super::{ensure_codex_hooks_enabled, upsert_lean_ctx_codex_hook_entries};
95 use serde_json::json;
96
97 #[test]
98 fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
99 let mut input = json!({
100 "hooks": {
101 "PreToolUse": [
102 {
103 "matcher": "Bash",
104 "hooks": [{
105 "type": "command",
106 "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
107 "timeout": 15
108 }]
109 },
110 {
111 "matcher": "Bash",
112 "hooks": [{
113 "type": "command",
114 "command": "echo keep-me",
115 "timeout": 5
116 }]
117 }
118 ],
119 "SessionStart": [
120 {
121 "matcher": "startup|resume|clear",
122 "hooks": [{
123 "type": "command",
124 "command": "lean-ctx hook codex-session-start",
125 "timeout": 15
126 }]
127 }
128 ],
129 "PostToolUse": [
130 {
131 "matcher": "Bash",
132 "hooks": [{
133 "type": "command",
134 "command": "echo keep-post",
135 "timeout": 5
136 }]
137 }
138 ]
139 }
140 });
141
142 let changed = upsert_lean_ctx_codex_hook_entries(
143 &mut input,
144 "lean-ctx hook codex-session-start",
145 "lean-ctx hook codex-pretooluse",
146 );
147 assert!(changed, "legacy hooks should be migrated");
148
149 let pre_tool_use = input["hooks"]["PreToolUse"]
150 .as_array()
151 .expect("PreToolUse array should remain");
152 assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
153 assert_eq!(
154 pre_tool_use[0]["hooks"][0]["command"].as_str(),
155 Some("echo keep-me")
156 );
157 assert_eq!(
158 pre_tool_use[1]["hooks"][0]["command"].as_str(),
159 Some("lean-ctx hook codex-pretooluse")
160 );
161 assert_eq!(
162 input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
163 Some("lean-ctx hook codex-session-start")
164 );
165 assert_eq!(
166 input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
167 Some("echo keep-post")
168 );
169 }
170
171 #[test]
172 fn ignores_non_lean_ctx_codex_entries() {
173 let custom = json!({
174 "matcher": "Bash",
175 "hooks": [{
176 "type": "command",
177 "command": "echo keep-me",
178 "timeout": 5
179 }]
180 });
181 assert!(
182 !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
183 "custom Codex hooks must be preserved"
184 );
185 }
186
187 #[test]
188 fn detects_managed_codex_session_start_entry() {
189 let managed = json!({
190 "matcher": "startup|resume|clear",
191 "hooks": [{
192 "type": "command",
193 "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
194 "timeout": 15
195 }]
196 });
197 assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
198 "SessionStart",
199 &managed
200 ));
201 }
202
203 #[test]
204 fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
205 let input = "\
206[features]
207other = true
208codex_hooks = false
209
210[mcp_servers.other]
211command = \"other\"
212";
213
214 let output =
215 ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
216
217 assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
218 assert!(!output.contains("codex_hooks = false"));
219 }
220
221 #[test]
222 fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
223 let input = "\
224[features]
225other = true
226
227[mcp_servers.lean-ctx]
228command = \"lean-ctx\"
229codex_hooks = true
230";
231
232 let output = ensure_codex_hooks_enabled(input)
233 .expect("stray codex_hooks assignment should be normalized");
234
235 assert!(output.contains("[features]\nother = true\ncodex_hooks = true\n"));
236 assert_eq!(output.matches("codex_hooks = true").count(), 1);
237 assert!(
238 !output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\ncodex_hooks = true")
239 );
240 }
241
242 #[test]
243 fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
244 let input = "\
245[mcp_servers.lean-ctx]
246command = \"lean-ctx\"
247";
248
249 let output =
250 ensure_codex_hooks_enabled(input).expect("missing features section should be added");
251
252 assert!(output.ends_with("\n[features]\ncodex_hooks = true\n"));
253 }
254
255 #[test]
256 fn install_codex_docs_preserves_existing_user_instructions() {
257 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
258 let _ = std::fs::remove_dir_all(&tmp);
259 std::fs::create_dir_all(&tmp).unwrap();
260
261 let agents_md = tmp.join("AGENTS.md");
262 let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
263 std::fs::write(&agents_md, user_content).unwrap();
264
265 crate::hooks::support::install_codex_instruction_docs(&tmp);
266
267 let result = std::fs::read_to_string(&agents_md).unwrap();
268 assert!(
269 result.contains("My Custom Instructions"),
270 "user content must be preserved"
271 );
272 assert!(
273 result.contains("Always use tabs"),
274 "user rules must be preserved"
275 );
276 assert!(
277 result.contains("<!-- lean-ctx -->"),
278 "lean-ctx block must be appended"
279 );
280 assert!(
281 result.contains("LEAN-CTX.md (same directory)"),
282 "lean-ctx reference must be present"
283 );
284
285 let _ = std::fs::remove_dir_all(&tmp);
286 }
287
288 #[test]
289 fn install_codex_docs_updates_only_marked_block() {
290 let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
291 let _ = std::fs::remove_dir_all(&tmp);
292 std::fs::create_dir_all(&tmp).unwrap();
293
294 let agents_md = tmp.join("AGENTS.md");
295 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";
296 std::fs::write(&agents_md, content_with_block).unwrap();
297
298 crate::hooks::support::install_codex_instruction_docs(&tmp);
299
300 let result = std::fs::read_to_string(&agents_md).unwrap();
301 assert!(
302 result.contains("Custom rule here."),
303 "user content before block preserved"
304 );
305 assert!(
306 result.contains("Other Section"),
307 "user content after block preserved"
308 );
309 assert!(
310 result.contains("LEAN-CTX.md (same directory)"),
311 "block updated to current reference"
312 );
313 assert!(
314 !result.contains("OLD-LEAN-CTX"),
315 "old block content replaced"
316 );
317
318 let _ = std::fs::remove_dir_all(&tmp);
319 }
320}