Skip to main content

agent_config/agents/codex/
mod.rs

1//! Codex CLI integration (OpenAI's official Codex CLI).
2//!
3//! Hook surface: `<scope>/.codex/hooks.json` using PascalCase event names
4//! (`PreToolUse`/`PostToolUse`), JSON shape mirrors Gemini's:
5//!
6//! ```json
7//! {
8//!   "hooks": {
9//!     "PreToolUse": [
10//!       {
11//!         "matcher": "pattern",
12//!         "hooks": [{ "type": "command", "command": "...", "timeout": 600 }],
13//!         "_agent_config_tag": "myapp"
14//!       }
15//!     ]
16//!   }
17//! }
18//! ```
19//!
20//! Optional prompt surface: `$CODEX_HOME/AGENTS.md` (Global, default
21//! `~/.codex/AGENTS.md`) or `<project>/AGENTS.md` (Local). Codex does not yet
22//! support `@import`, so we inject content directly via fenced block.
23//!
24//! MCP surface (TOML at `config.toml`) lives in `mcp.rs`.
25
26use std::path::PathBuf;
27
28use serde_json::json;
29
30use crate::agents::planning as agent_planning;
31use crate::error::AgentConfigError;
32use crate::integration::{
33    InstallReport, InstructionSurface, Integration, SkillSurface, UninstallReport,
34};
35use crate::paths;
36use crate::plan::{has_refusal, InstallPlan, PlanTarget, UninstallPlan};
37use crate::scope::{Scope, ScopeKind};
38use crate::spec::{Event, HookSpec, InstructionSpec, Matcher, SkillSpec};
39use crate::status::StatusReport;
40use crate::util::{
41    file_lock, fs_atomic, instructions_dir, json_patch, md_block, ownership, planning, safe_fs,
42    skills_dir,
43};
44
45mod mcp;
46
47/// Codex CLI.
48#[derive(Debug, Clone, Copy, Default)]
49pub struct CodexAgent {
50    _private: (),
51}
52
53impl CodexAgent {
54    /// Construct an instance. Stateless.
55    pub const fn new() -> Self {
56        Self { _private: () }
57    }
58
59    fn hooks_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
60        Ok(match scope {
61            Scope::Global => paths::codex_home()?.join("hooks.json"),
62            Scope::Local(p) => p.join(".codex").join("hooks.json"),
63        })
64    }
65
66    fn agents_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
67        Ok(match scope {
68            Scope::Global => paths::codex_home()?.join("AGENTS.md"),
69            Scope::Local(p) => p.join("AGENTS.md"),
70        })
71    }
72
73    fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
74        Ok(match scope {
75            Scope::Global => paths::home_dir()?.join(".agents").join("skills"),
76            Scope::Local(p) => p.join(".agents").join("skills"),
77        })
78    }
79
80    /// Directory holding the instruction ownership ledger.
81    /// Global: `$CODEX_HOME` (defaults to `~/.codex`). Local: `<root>/.codex`.
82    fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
83        Ok(match scope {
84            Scope::Global => paths::codex_home()?,
85            Scope::Local(p) => p.join(".codex"),
86        })
87    }
88
89    fn inline_layout(
90        &self,
91        scope: &Scope,
92    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
93        Ok(instructions_dir::InlineLayout {
94            config_dir: Self::instruction_config_dir(scope)?,
95            host_file: Self::agents_path(scope)?,
96        })
97    }
98}
99
100impl Integration for CodexAgent {
101    fn id(&self) -> &'static str {
102        "codex"
103    }
104
105    fn display_name(&self) -> &'static str {
106        "Codex CLI"
107    }
108
109    fn supported_scopes(&self) -> &'static [ScopeKind] {
110        &[ScopeKind::Global, ScopeKind::Local]
111    }
112
113    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
114        HookSpec::validate_tag(tag)?;
115        let p = Self::hooks_path(scope)?;
116        let presence = json_patch::tagged_hook_presence(&p, &["hooks"], tag)?;
117        Ok(StatusReport::for_tagged_hook(tag, p, presence))
118    }
119
120    fn plan_install(
121        &self,
122        scope: &Scope,
123        spec: &HookSpec,
124    ) -> Result<InstallPlan, AgentConfigError> {
125        HookSpec::validate_tag(&spec.tag)?;
126        let target = PlanTarget::Hook {
127            integration_id: Integration::id(self),
128            scope: scope.clone(),
129            tag: spec.tag.clone(),
130        };
131        let p = Self::hooks_path(scope)?;
132        let mut changes = Vec::new();
133        let event_key = event_to_string(&spec.event);
134        let matcher_str = matcher_to_codex(&spec.matcher);
135        let entry = json!({
136            "matcher": matcher_str,
137            "hooks": [{ "type": "command", "command": spec.command.render_shell() }],
138        });
139        planning::plan_tagged_json_upsert(
140            &mut changes,
141            &p,
142            &["hooks", event_key.as_str()],
143            &spec.tag,
144            entry,
145            |_| {},
146        )?;
147        if has_refusal(&changes) {
148            return Ok(InstallPlan::from_changes(target, changes));
149        }
150        if let Some(rules) = &spec.rules {
151            let agents = Self::agents_path(scope)?;
152            planning::plan_markdown_upsert(&mut changes, &agents, &spec.tag, &rules.content)?;
153        }
154        Ok(InstallPlan::from_changes(target, changes))
155    }
156
157    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
158        HookSpec::validate_tag(tag)?;
159        let target = PlanTarget::Hook {
160            integration_id: Integration::id(self),
161            scope: scope.clone(),
162            tag: tag.to_string(),
163        };
164        let mut changes = Vec::new();
165        let p = Self::hooks_path(scope)?;
166        planning::plan_tagged_json_remove_under(
167            &mut changes,
168            &p,
169            &["hooks"],
170            tag,
171            planning::json_object_empty,
172            true,
173        )?;
174        if has_refusal(&changes) {
175            return Ok(UninstallPlan::from_changes(target, changes));
176        }
177        let agents = Self::agents_path(scope)?;
178        planning::plan_markdown_remove(&mut changes, &agents, tag)?;
179        Ok(UninstallPlan::from_changes(target, changes))
180    }
181
182    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
183        HookSpec::validate_tag(&spec.tag)?;
184        let mut report = InstallReport::default();
185
186        let p = Self::hooks_path(scope)?;
187        scope.ensure_contained(&p)?;
188        file_lock::with_lock(&p, || {
189            let mut root = json_patch::read_or_empty(&p)?;
190
191            let event_key = event_to_string(&spec.event);
192            let matcher_str = matcher_to_codex(&spec.matcher);
193
194            let entry = json!({
195                "matcher": matcher_str,
196                "hooks": [{ "type": "command", "command": spec.command.render_shell() }],
197            });
198
199            let changed = json_patch::upsert_tagged_array_entry(
200                &mut root,
201                &["hooks", &event_key],
202                &spec.tag,
203                entry,
204            )?;
205
206            if changed {
207                let bytes = json_patch::to_pretty(&root);
208                let outcome = safe_fs::write(scope, &p, &bytes, true)?;
209                if outcome.existed {
210                    report.patched.push(outcome.path.clone());
211                } else {
212                    report.created.push(outcome.path.clone());
213                }
214                if let Some(b) = outcome.backup {
215                    report.backed_up.push(b);
216                }
217            } else {
218                report.already_installed = true;
219            }
220            Ok::<(), AgentConfigError>(())
221        })?;
222
223        if let Some(rules) = &spec.rules {
224            let agents = Self::agents_path(scope)?;
225            scope.ensure_contained(&agents)?;
226            file_lock::with_lock(&agents, || {
227                let host = fs_atomic::read_to_string_or_empty(&agents)?;
228                let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
229                let outcome = safe_fs::write(scope, &agents, new_host.as_bytes(), true)?;
230                if outcome.existed && !outcome.no_change {
231                    report.patched.push(outcome.path.clone());
232                    report.already_installed = false;
233                } else if !outcome.existed {
234                    report.created.push(outcome.path.clone());
235                    report.already_installed = false;
236                }
237                if let Some(b) = outcome.backup {
238                    report.backed_up.push(b);
239                }
240                Ok::<(), AgentConfigError>(())
241            })?;
242        }
243
244        Ok(report)
245    }
246
247    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
248        HookSpec::validate_tag(tag)?;
249        let mut report = UninstallReport::default();
250
251        let p = Self::hooks_path(scope)?;
252        scope.ensure_contained(&p)?;
253        if p.exists() {
254            file_lock::with_lock(&p, || {
255                let mut root = json_patch::read_or_empty(&p)?;
256                let changed =
257                    json_patch::remove_tagged_array_entries_under(&mut root, &["hooks"], tag)?;
258                if changed {
259                    let empty = root.as_object().map(|o| o.is_empty()).unwrap_or(true);
260                    let bytes = json_patch::to_pretty(&root);
261                    if empty && safe_fs::restore_backup_if_matches(scope, &p, &bytes)? {
262                        report.restored.push(p.clone());
263                    } else if empty {
264                        safe_fs::remove_file(scope, &p)?;
265                        report.removed.push(p.clone());
266                    } else {
267                        safe_fs::write(scope, &p, &bytes, false)?;
268                        report.patched.push(p.clone());
269                    }
270                }
271                Ok::<(), AgentConfigError>(())
272            })?;
273        }
274
275        let agents = Self::agents_path(scope)?;
276        scope.ensure_contained(&agents)?;
277        file_lock::with_lock(&agents, || {
278            let host = fs_atomic::read_to_string_or_empty(&agents)?;
279            let (stripped, removed) = md_block::remove(&host, tag);
280            if removed {
281                if stripped.trim().is_empty() {
282                    if safe_fs::restore_backup_if_matches(scope, &agents, stripped.as_bytes())? {
283                        report.restored.push(agents.clone());
284                    } else {
285                        safe_fs::remove_file(scope, &agents)?;
286                        report.removed.push(agents.clone());
287                    }
288                } else {
289                    safe_fs::write(scope, &agents, stripped.as_bytes(), false)?;
290                    report.patched.push(agents.clone());
291                }
292            }
293            Ok::<(), AgentConfigError>(())
294        })?;
295
296        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
297            report.not_installed = true;
298        }
299        Ok(report)
300    }
301}
302
303impl SkillSurface for CodexAgent {
304    fn id(&self) -> &'static str {
305        "codex"
306    }
307
308    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
309        &[ScopeKind::Global, ScopeKind::Local]
310    }
311
312    fn skill_status(
313        &self,
314        scope: &Scope,
315        name: &str,
316        expected_owner: &str,
317    ) -> Result<StatusReport, AgentConfigError> {
318        SkillSpec::validate_name(name)?;
319        let root = Self::skills_root(scope)?;
320        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
321        let recorded = ownership::owner_of(&ledger, name)?;
322        Ok(StatusReport::for_skill(
323            name,
324            dir,
325            manifest,
326            ledger,
327            expected_owner,
328            recorded,
329        ))
330    }
331
332    fn plan_install_skill(
333        &self,
334        scope: &Scope,
335        spec: &SkillSpec,
336    ) -> Result<InstallPlan, AgentConfigError> {
337        agent_planning::skill_install(
338            SkillSurface::id(self),
339            scope,
340            spec,
341            Self::skills_root(scope),
342        )
343    }
344
345    fn plan_uninstall_skill(
346        &self,
347        scope: &Scope,
348        name: &str,
349        owner_tag: &str,
350    ) -> Result<UninstallPlan, AgentConfigError> {
351        agent_planning::skill_uninstall(
352            SkillSurface::id(self),
353            scope,
354            name,
355            owner_tag,
356            Self::skills_root(scope),
357        )
358    }
359
360    fn install_skill(
361        &self,
362        scope: &Scope,
363        spec: &SkillSpec,
364    ) -> Result<InstallReport, AgentConfigError> {
365        let root = Self::skills_root(scope)?;
366        scope.ensure_contained(&root)?;
367        skills_dir::install(&root, spec)
368    }
369
370    fn uninstall_skill(
371        &self,
372        scope: &Scope,
373        name: &str,
374        owner_tag: &str,
375    ) -> Result<UninstallReport, AgentConfigError> {
376        let root = Self::skills_root(scope)?;
377        scope.ensure_contained(&root)?;
378        skills_dir::uninstall(&root, name, owner_tag)
379    }
380}
381
382impl InstructionSurface for CodexAgent {
383    fn id(&self) -> &'static str {
384        "codex"
385    }
386
387    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
388        &[ScopeKind::Global, ScopeKind::Local]
389    }
390
391    fn instruction_status(
392        &self,
393        scope: &Scope,
394        name: &str,
395        expected_owner: &str,
396    ) -> Result<StatusReport, AgentConfigError> {
397        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
398    }
399
400    fn plan_install_instruction(
401        &self,
402        scope: &Scope,
403        spec: &InstructionSpec,
404    ) -> Result<InstallPlan, AgentConfigError> {
405        instructions_dir::inline_plan_install(
406            InstructionSurface::id(self),
407            scope,
408            self.inline_layout(scope),
409            spec,
410        )
411    }
412
413    fn plan_uninstall_instruction(
414        &self,
415        scope: &Scope,
416        name: &str,
417        owner_tag: &str,
418    ) -> Result<UninstallPlan, AgentConfigError> {
419        instructions_dir::inline_plan_uninstall(
420            InstructionSurface::id(self),
421            scope,
422            self.inline_layout(scope),
423            name,
424            owner_tag,
425        )
426    }
427
428    fn install_instruction(
429        &self,
430        scope: &Scope,
431        spec: &InstructionSpec,
432    ) -> Result<InstallReport, AgentConfigError> {
433        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
434    }
435
436    fn uninstall_instruction(
437        &self,
438        scope: &Scope,
439        name: &str,
440        owner_tag: &str,
441    ) -> Result<UninstallReport, AgentConfigError> {
442        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
443    }
444}
445
446fn matcher_to_codex(m: &Matcher) -> String {
447    match m {
448        Matcher::All => "*".to_string(),
449        Matcher::Bash => "shell".to_string(),
450        Matcher::Exact(s) => s.clone(),
451        Matcher::AnyOf(names) => names.join("|"),
452        Matcher::Regex(s) => s.clone(),
453    }
454}
455
456fn event_to_string(e: &Event) -> String {
457    match e {
458        Event::PreToolUse => "PreToolUse".into(),
459        Event::PostToolUse => "PostToolUse".into(),
460        Event::Custom(s) => s.clone(),
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use serde_json::{json, Value};
468    use tempfile::tempdir;
469
470    fn read_json(p: &std::path::Path) -> Value {
471        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
472    }
473
474    fn local_spec(tag: &str) -> HookSpec {
475        HookSpec::builder(tag)
476            .command_program("myapp", ["hook"])
477            .matcher(Matcher::Bash)
478            .event(Event::PreToolUse)
479            .build()
480    }
481
482    #[test]
483    fn writes_pre_tool_use_pascalcase() {
484        let dir = tempdir().unwrap();
485        let agent = CodexAgent::new();
486        let scope = Scope::Local(dir.path().to_path_buf());
487        agent.install(&scope, &local_spec("alpha")).unwrap();
488
489        let v = read_json(&dir.path().join(".codex/hooks.json"));
490        assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], json!("shell"));
491        assert_eq!(
492            v["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
493            json!("myapp hook")
494        );
495    }
496
497    #[test]
498    fn install_uninstall_round_trip() {
499        let dir = tempdir().unwrap();
500        let agent = CodexAgent::new();
501        let scope = Scope::Local(dir.path().to_path_buf());
502        agent.install(&scope, &local_spec("alpha")).unwrap();
503        agent.uninstall(&scope, "alpha").unwrap();
504        assert!(!dir.path().join(".codex/hooks.json").exists());
505    }
506
507    #[test]
508    fn matcher_mapping() {
509        assert_eq!(matcher_to_codex(&Matcher::All), "*");
510        assert_eq!(matcher_to_codex(&Matcher::Bash), "shell");
511        assert_eq!(matcher_to_codex(&Matcher::Exact("Edit".into())), "Edit");
512        assert_eq!(
513            matcher_to_codex(&Matcher::AnyOf(vec!["Read".into(), "Write".into()])),
514            "Read|Write"
515        );
516        assert_eq!(
517            matcher_to_codex(&Matcher::Regex("Bash|Edit".into())),
518            "Bash|Edit"
519        );
520    }
521
522    #[test]
523    fn post_tool_use_pascal_case() {
524        let dir = tempdir().unwrap();
525        let agent = CodexAgent::new();
526        let scope = Scope::Local(dir.path().to_path_buf());
527        let spec = HookSpec::builder("alpha")
528            .command_program("noop", [] as [&str; 0])
529            .event(Event::PostToolUse)
530            .build();
531        agent.install(&scope, &spec).unwrap();
532        let v = read_json(&dir.path().join(".codex/hooks.json"));
533        assert!(v["hooks"]["PostToolUse"].is_array());
534    }
535
536    #[test]
537    fn rules_injects_into_agents_md() {
538        let dir = tempdir().unwrap();
539        let agent = CodexAgent::new();
540        let scope = Scope::Local(dir.path().to_path_buf());
541        let spec = HookSpec::builder("alpha")
542            .command_program("noop", [] as [&str; 0])
543            .rules("Use strict mode.")
544            .build();
545        agent.install(&scope, &spec).unwrap();
546        let md = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
547        assert!(md.contains("Use strict mode."));
548        assert!(md.contains("AGENT-CONFIG:alpha"));
549    }
550
551    #[test]
552    fn install_idempotent() {
553        let dir = tempdir().unwrap();
554        let agent = CodexAgent::new();
555        let scope = Scope::Local(dir.path().to_path_buf());
556        agent.install(&scope, &local_spec("alpha")).unwrap();
557        let r2 = agent.install(&scope, &local_spec("alpha")).unwrap();
558        assert!(r2.already_installed);
559    }
560}