Skip to main content

lean_ctx/hooks/agents/
codex.rs

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(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    // Hybrid mode: ensure MCP server entry exists in config.toml so Codex
76    // Desktop/Cloud can reach lean-ctx even without CLI hooks.
77    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}