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