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(codex_dir) = crate::core::home::resolve_codex_dir() else {
9        tracing::error!("Cannot resolve codex directory");
10        return;
11    };
12    let _ = std::fs::create_dir_all(&codex_dir);
13
14    let hook_config_changed = install_codex_hook_config(&codex_dir);
15    let installed_docs = install_codex_instruction_docs(&codex_dir);
16
17    if !mcp_server_quiet_mode() {
18        if hook_config_changed {
19            eprintln!(
20                "Installed Codex-compatible SessionStart/PreToolUse hooks at {}",
21                codex_dir.display()
22            );
23        }
24        if installed_docs {
25            eprintln!("Installed Codex instructions at {}", codex_dir.display());
26        } else {
27            eprintln!("Codex AGENTS.md already configured.");
28        }
29    }
30}
31
32fn install_codex_hook_config(codex_dir: &std::path::Path) -> bool {
33    let binary = resolve_binary_path();
34    let session_start_cmd = format!("{binary} hook codex-session-start");
35    let pre_tool_use_cmd = format!("{binary} hook codex-pretooluse");
36    let hooks_json_path = codex_dir.join("hooks.json");
37
38    let mut changed = false;
39    let mut root = if hooks_json_path.exists() {
40        if let Some(parsed) = std::fs::read_to_string(&hooks_json_path)
41            .ok()
42            .and_then(|content| crate::core::jsonc::parse_jsonc(&content).ok())
43        {
44            parsed
45        } else {
46            changed = true;
47            serde_json::json!({ "hooks": {} })
48        }
49    } else {
50        changed = true;
51        serde_json::json!({ "hooks": {} })
52    };
53
54    if upsert_lean_ctx_codex_hook_entries(&mut root, &session_start_cmd, &pre_tool_use_cmd) {
55        changed = true;
56    }
57
58    // Observe hooks for context awareness
59    let observe_cmd = format!("{binary} hook observe");
60    if ensure_codex_observe_hooks(&mut root, &observe_cmd) {
61        changed = true;
62    }
63
64    if changed {
65        write_file(
66            &hooks_json_path,
67            &serde_json::to_string_pretty(&root).unwrap_or_default(),
68        );
69    }
70
71    let rewrite_path = codex_dir.join("hooks").join("lean-ctx-rewrite-codex.sh");
72    if rewrite_path.exists() && std::fs::remove_file(&rewrite_path).is_ok() {
73        changed = true;
74    }
75
76    let config_toml_path = codex_dir.join("config.toml");
77    let config_content = std::fs::read_to_string(&config_toml_path).unwrap_or_default();
78
79    // Hybrid mode: ensure MCP server entry exists in config.toml so Codex
80    // Desktop/Cloud can reach lean-ctx even without CLI hooks.
81    let mcp_updated = ensure_codex_mcp_server(&config_content, &binary);
82    let hooks_updated =
83        ensure_codex_hooks_enabled(mcp_updated.as_deref().unwrap_or(&config_content));
84
85    let final_content = hooks_updated
86        .or(mcp_updated)
87        .unwrap_or_else(|| config_content.clone());
88    if final_content != config_content {
89        write_file(&config_toml_path, &final_content);
90        changed = true;
91        if !mcp_server_quiet_mode() {
92            eprintln!(
93                "Updated Codex config (MCP server + hooks) in {}",
94                config_toml_path.display()
95            );
96        }
97    }
98
99    changed
100}
101
102fn ensure_codex_observe_hooks(root: &mut serde_json::Value, observe_cmd: &str) -> bool {
103    let original = root.clone();
104    let Some(hooks_obj) = root
105        .as_object_mut()
106        .and_then(|r| r.get_mut("hooks"))
107        .and_then(|h| h.as_object_mut())
108    else {
109        return false;
110    };
111
112    let observe_events = ["PostToolUse", "SessionStart", "SessionEnd"];
113    for event in observe_events {
114        let arr = hooks_obj
115            .entry(event.to_string())
116            .or_insert_with(|| serde_json::json!([]));
117        let Some(entries) = arr.as_array_mut() else {
118            continue;
119        };
120        let already = entries.iter().any(|e| {
121            e.get("hooks")
122                .and_then(|h| h.as_array())
123                .is_some_and(|hooks| {
124                    hooks.iter().any(|hook| {
125                        hook.get("command")
126                            .and_then(|c| c.as_str())
127                            .is_some_and(|c| c.contains("hook observe"))
128                    })
129                })
130        });
131        if !already {
132            entries.push(serde_json::json!({
133                "matcher": ".*",
134                "hooks": [{ "type": "command", "command": observe_cmd, "timeout": 5 }]
135            }));
136        }
137    }
138
139    *root != original
140}
141
142fn toml_quote_value(value: &str) -> String {
143    if value.contains('\\') {
144        format!("'{value}'")
145    } else {
146        format!("\"{value}\"")
147    }
148}
149
150fn ensure_codex_mcp_server(config_content: &str, binary: &str) -> Option<String> {
151    if config_content.contains("[mcp_servers.lean-ctx]") {
152        return None;
153    }
154
155    let quoted = toml_quote_value(binary);
156    let data_dir = crate::core::data_dir::lean_ctx_data_dir()
157        .map(|d| d.to_string_lossy().to_string())
158        .unwrap_or_default();
159    let section = format!(
160        "[mcp_servers.lean-ctx]\ncommand = {quoted}\nargs = []\n\n\
161         [mcp_servers.lean-ctx.env]\n\
162         LEAN_CTX_DATA_DIR = {data_dir_q}\n",
163        data_dir_q = toml_quote_value(&data_dir),
164    );
165
166    if let Some(pos) = config_content.find("[mcp_servers.lean-ctx.") {
167        let insert_at = config_content[..pos].rfind('\n').map_or(0, |nl| nl + 1);
168        let mut out = String::with_capacity(config_content.len() + section.len() + 2);
169        out.push_str(&config_content[..insert_at]);
170        out.push_str(&section);
171        out.push('\n');
172        out.push_str(&config_content[insert_at..]);
173        return Some(out);
174    }
175
176    let mut out = config_content.to_string();
177    if !out.is_empty() && !out.ends_with('\n') {
178        out.push('\n');
179    }
180    out.push_str(&format!("\n{section}"));
181    Some(out)
182}
183
184fn ensure_codex_hooks_enabled(config_content: &str) -> Option<String> {
185    shared_ensure_codex_hooks_enabled(config_content)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::{
191        ensure_codex_hooks_enabled, ensure_codex_mcp_server, upsert_lean_ctx_codex_hook_entries,
192    };
193    use serde_json::json;
194
195    #[test]
196    fn upsert_replaces_legacy_codex_rewrite_but_keeps_custom_hooks() {
197        let mut input = json!({
198            "hooks": {
199                "PreToolUse": [
200                    {
201                        "matcher": "Bash",
202                        "hooks": [{
203                            "type": "command",
204                            "command": "/opt/homebrew/bin/lean-ctx hook rewrite",
205                            "timeout": 15
206                        }]
207                    },
208                    {
209                        "matcher": "Bash",
210                        "hooks": [{
211                            "type": "command",
212                            "command": "echo keep-me",
213                            "timeout": 5
214                        }]
215                    }
216                ],
217                "SessionStart": [
218                    {
219                        "matcher": "startup|resume|clear",
220                        "hooks": [{
221                            "type": "command",
222                            "command": "lean-ctx hook codex-session-start",
223                            "timeout": 15
224                        }]
225                    }
226                ],
227                "PostToolUse": [
228                    {
229                        "matcher": "Bash",
230                        "hooks": [{
231                            "type": "command",
232                            "command": "echo keep-post",
233                            "timeout": 5
234                        }]
235                    }
236                ]
237            }
238        });
239
240        let changed = upsert_lean_ctx_codex_hook_entries(
241            &mut input,
242            "lean-ctx hook codex-session-start",
243            "lean-ctx hook codex-pretooluse",
244        );
245        assert!(changed, "legacy hooks should be migrated");
246
247        let pre_tool_use = input["hooks"]["PreToolUse"]
248            .as_array()
249            .expect("PreToolUse array should remain");
250        assert_eq!(pre_tool_use.len(), 2, "custom hook should be preserved");
251        assert_eq!(
252            pre_tool_use[0]["hooks"][0]["command"].as_str(),
253            Some("echo keep-me")
254        );
255        assert_eq!(
256            pre_tool_use[1]["hooks"][0]["command"].as_str(),
257            Some("lean-ctx hook codex-pretooluse")
258        );
259        assert_eq!(
260            input["hooks"]["SessionStart"][0]["hooks"][0]["command"].as_str(),
261            Some("lean-ctx hook codex-session-start")
262        );
263        assert_eq!(
264            input["hooks"]["PostToolUse"][0]["hooks"][0]["command"].as_str(),
265            Some("echo keep-post")
266        );
267    }
268
269    #[test]
270    fn ignores_non_lean_ctx_codex_entries() {
271        let custom = json!({
272            "matcher": "Bash",
273            "hooks": [{
274                "type": "command",
275                "command": "echo keep-me",
276                "timeout": 5
277            }]
278        });
279        assert!(
280            !crate::hooks::support::is_lean_ctx_codex_managed_entry("PreToolUse", &custom),
281            "custom Codex hooks must be preserved"
282        );
283    }
284
285    #[test]
286    fn detects_managed_codex_session_start_entry() {
287        let managed = json!({
288            "matcher": "startup|resume|clear",
289            "hooks": [{
290                "type": "command",
291                "command": "/opt/homebrew/bin/lean-ctx hook codex-session-start",
292                "timeout": 15
293            }]
294        });
295        assert!(crate::hooks::support::is_lean_ctx_codex_managed_entry(
296            "SessionStart",
297            &managed
298        ));
299    }
300
301    #[test]
302    fn ensure_codex_hooks_enabled_updates_existing_features_flag() {
303        let input = "\
304[features]
305other = true
306codex_hooks = false
307
308[mcp_servers.other]
309command = \"other\"
310";
311
312        let output =
313            ensure_codex_hooks_enabled(input).expect("codex_hooks=false should be migrated");
314
315        assert!(output.contains("[features]\nother = true\nhooks = true\n"));
316        assert!(!output.contains("codex_hooks = false"));
317    }
318
319    #[test]
320    fn ensure_codex_hooks_enabled_moves_stray_assignment_into_features_section() {
321        let input = "\
322[features]
323other = true
324
325[mcp_servers.lean-ctx]
326command = \"lean-ctx\"
327codex_hooks = true
328";
329
330        let output = ensure_codex_hooks_enabled(input)
331            .expect("stray codex_hooks assignment should be normalized");
332
333        assert!(output.contains("[features]\nother = true\nhooks = true\n"));
334        assert_eq!(output.matches("hooks = true").count(), 1);
335        assert!(!output.contains("[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nhooks = true"));
336    }
337
338    #[test]
339    fn ensure_codex_hooks_enabled_adds_features_section_when_missing() {
340        let input = "\
341[mcp_servers.lean-ctx]
342command = \"lean-ctx\"
343";
344
345        let output =
346            ensure_codex_hooks_enabled(input).expect("missing features section should be added");
347
348        assert!(output.ends_with("\n[features]\nhooks = true\n"));
349    }
350
351    #[test]
352    fn install_codex_docs_preserves_existing_user_instructions() {
353        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-preserve");
354        let _ = std::fs::remove_dir_all(&tmp);
355        std::fs::create_dir_all(&tmp).unwrap();
356
357        let agents_md = tmp.join("AGENTS.md");
358        let user_content = "# My Custom Instructions\n\nDo not change my codebase style.\n\n## Rules\n- Always use tabs\n- No semicolons\n";
359        std::fs::write(&agents_md, user_content).unwrap();
360
361        crate::hooks::support::install_codex_instruction_docs(&tmp);
362
363        let result = std::fs::read_to_string(&agents_md).unwrap();
364        assert!(
365            result.contains("My Custom Instructions"),
366            "user content must be preserved"
367        );
368        assert!(
369            result.contains("Always use tabs"),
370            "user rules must be preserved"
371        );
372        assert!(
373            result.contains("<!-- lean-ctx -->"),
374            "lean-ctx block must be appended"
375        );
376        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
377        assert!(
378            result.contains(&expected_ref),
379            "lean-ctx reference must use codex_dir path"
380        );
381
382        let _ = std::fs::remove_dir_all(&tmp);
383    }
384
385    #[test]
386    fn install_codex_docs_updates_only_marked_block() {
387        let tmp = std::env::temp_dir().join("lean-ctx-test-codex-marked");
388        let _ = std::fs::remove_dir_all(&tmp);
389        std::fs::create_dir_all(&tmp).unwrap();
390
391        let agents_md = tmp.join("AGENTS.md");
392        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";
393        std::fs::write(&agents_md, content_with_block).unwrap();
394
395        crate::hooks::support::install_codex_instruction_docs(&tmp);
396
397        let result = std::fs::read_to_string(&agents_md).unwrap();
398        assert!(
399            result.contains("Custom rule here."),
400            "user content before block preserved"
401        );
402        assert!(
403            result.contains("Other Section"),
404            "user content after block preserved"
405        );
406        let expected_ref = tmp.join("LEAN-CTX.md").display().to_string();
407        assert!(
408            result.contains(&expected_ref),
409            "block updated to current reference"
410        );
411        assert!(
412            !result.contains("OLD-LEAN-CTX"),
413            "old block content replaced"
414        );
415
416        let _ = std::fs::remove_dir_all(&tmp);
417    }
418
419    #[test]
420    fn ensure_mcp_server_adds_section_when_missing() {
421        let input = "[features]\ncodex_hooks = true\n";
422        let result = ensure_codex_mcp_server(input, "lean-ctx").expect("should add MCP section");
423        assert!(result.contains("[mcp_servers.lean-ctx]"));
424        assert!(result.contains("command = \"lean-ctx\""));
425        assert!(result.contains("args = []"));
426        assert!(result.contains("[features]\ncodex_hooks = true\n"));
427    }
428
429    #[test]
430    fn ensure_mcp_server_noop_when_present() {
431        let input = "[mcp_servers.lean-ctx]\ncommand = \"lean-ctx\"\nargs = []\n";
432        assert!(
433            ensure_codex_mcp_server(input, "lean-ctx").is_none(),
434            "should not modify config when MCP section already exists"
435        );
436    }
437
438    #[test]
439    fn ensure_mcp_server_preserves_existing_sections() {
440        let input = "[mcp_servers.other]\ncommand = \"other\"\n";
441        let result = ensure_codex_mcp_server(input, "/usr/bin/lean-ctx")
442            .expect("should add lean-ctx section");
443        assert!(result.contains("[mcp_servers.other]"));
444        assert!(result.contains("[mcp_servers.lean-ctx]"));
445        assert!(result.contains("command = \"/usr/bin/lean-ctx\""));
446    }
447
448    #[test]
449    fn ensure_mcp_server_inserts_before_orphaned_env_subtable() {
450        let input = "\
451[mcp_servers.lean-ctx.env]
452LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
453";
454        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx")
455            .expect("should insert parent section before orphaned env");
456        let parent_pos = result
457            .find("[mcp_servers.lean-ctx]")
458            .expect("parent section must exist");
459        let env_pos = result
460            .find("[mcp_servers.lean-ctx.env]")
461            .expect("env sub-table must be preserved");
462        assert!(
463            parent_pos < env_pos,
464            "parent section must come before env sub-table"
465        );
466        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
467        assert!(result.contains("LEAN_CTX_DATA_DIR"));
468    }
469
470    #[test]
471    fn ensure_mcp_server_handles_issue_189_scenario() {
472        let input = "\
473source = \"/Users/user/.cache/codex-runtimes/codex-primary-runtime/plugins/openai-primary-runtime\"
474source_type = \"local\"
475
476[mcp_servers.lean-ctx.env]
477LEAN_CTX_DATA_DIR = \"/Users/user/.lean-ctx\"
478";
479        let result = ensure_codex_mcp_server(input, "/usr/local/bin/lean-ctx")
480            .expect("should fix orphaned config from issue #189");
481        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
482        assert!(result.contains("command = \"/usr/local/bin/lean-ctx\""));
483        assert!(result.contains("[mcp_servers.lean-ctx.env]"));
484        assert!(result.contains("LEAN_CTX_DATA_DIR"));
485
486        let parent_pos = result.find("[mcp_servers.lean-ctx]\n").unwrap();
487        let env_pos = result.find("[mcp_servers.lean-ctx.env]").unwrap();
488        assert!(parent_pos < env_pos);
489    }
490
491    #[test]
492    fn ensure_mcp_server_quotes_windows_backslash_paths() {
493        let input = "[features]\ncodex_hooks = true\n";
494        let win_path = r"C:\Users\Foo\AppData\Roaming\npm\lean-ctx.cmd";
495        let result = ensure_codex_mcp_server(input, win_path).expect("should add MCP section");
496        assert!(
497            result.contains(&format!("command = '{win_path}'")),
498            "Windows paths must use TOML single quotes: {result}"
499        );
500    }
501
502    #[test]
503    fn ensure_mcp_server_does_not_match_similarly_named_section() {
504        let input = "\
505[mcp_servers.lean-ctx-other]
506command = \"other\"
507";
508        let result = ensure_codex_mcp_server(input, "lean-ctx")
509            .expect("should add lean-ctx section despite similarly-named section");
510        assert!(result.contains("[mcp_servers.lean-ctx]\n"));
511        assert!(result.contains("[mcp_servers.lean-ctx-other]"));
512    }
513}