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    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"),
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"),
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}