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