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