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, PlannedChange, 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, toml_patch,
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 hooks_path = Self::hooks_path(scope)?;
117        let config_path = Self::config_toml_path(scope)?;
118
119        let presence_json = json_patch::tagged_hook_presence(&hooks_path, &["hooks"], tag)?;
120
121        let presence_toml = if config_path.exists() {
122            let doc = toml_patch::read_or_empty(&config_path)?;
123            if toml_patch::contains_named_table(&doc, &["hooks"], tag) {
124                crate::status::ConfigPresence::Single
125            } else {
126                crate::status::ConfigPresence::Absent
127            }
128        } else {
129            crate::status::ConfigPresence::Absent
130        };
131
132        let json_installed = matches!(
133            presence_json,
134            crate::status::ConfigPresence::Single | crate::status::ConfigPresence::Duplicate { .. }
135        );
136        let toml_installed = matches!(presence_toml, crate::status::ConfigPresence::Single);
137
138        let mut report = if json_installed {
139            StatusReport::for_tagged_hook(tag, hooks_path.clone(), presence_json)
140        } else if toml_installed {
141            let target = crate::status::PlanTarget::Hook {
142                tag: tag.to_string(),
143            };
144            let files = vec![crate::status::PathStatus::Exists {
145                path: config_path.clone(),
146            }];
147            crate::status::StatusReport {
148                target,
149                status: crate::status::InstallStatus::InstalledOwned {
150                    owner: tag.to_string(),
151                },
152                config_path: Some(config_path.clone()),
153                ledger_path: None,
154                files,
155                warnings: Vec::new(),
156            }
157        } else {
158            StatusReport::for_tagged_hook(tag, hooks_path.clone(), presence_json)
159        };
160
161        if config_path.exists()
162            && !report.files.iter().any(
163                |f| matches!(f, crate::status::PathStatus::Exists { path } if path == &config_path),
164            )
165        {
166            report.files.push(crate::status::PathStatus::Exists {
167                path: config_path.clone(),
168            });
169        }
170        if hooks_path.exists()
171            && !report.files.iter().any(
172                |f| matches!(f, crate::status::PathStatus::Exists { path } if path == &hooks_path),
173            )
174        {
175            report.files.push(crate::status::PathStatus::Exists {
176                path: hooks_path.clone(),
177            });
178        }
179
180        if json_installed && toml_installed {
181            report
182                .warnings
183                .push(crate::status::StatusWarning::DualHooksExist {
184                    hooks_json: hooks_path,
185                    config_toml: config_path,
186                });
187        }
188
189        Ok(report)
190    }
191
192    fn plan_install(
193        &self,
194        scope: &Scope,
195        spec: &HookSpec,
196    ) -> Result<InstallPlan, AgentConfigError> {
197        HookSpec::validate_tag(&spec.tag)?;
198        let target = PlanTarget::Hook {
199            integration_id: Integration::id(self),
200            scope: scope.clone(),
201            tag: spec.tag.clone(),
202        };
203
204        if validate_codex_event(&spec.event, &spec.matcher).is_err() {
205            return Ok(InstallPlan::refused(
206                target,
207                None,
208                crate::plan::RefusalReason::UnsupportedSpecField,
209            ));
210        }
211
212        let hooks_path = Self::hooks_path(scope)?;
213        let config_path = Self::config_toml_path(scope)?;
214        let inline_toml = spec.options.codex_inline_toml.unwrap_or(false);
215
216        let mut changes = Vec::new();
217
218        if inline_toml {
219            let mut doc = match toml_patch::read_or_empty(&config_path) {
220                Ok(doc) => doc,
221                Err(AgentConfigError::TomlInvalid { .. }) => {
222                    changes.push(PlannedChange::Refuse {
223                        path: Some(config_path.clone()),
224                        reason: crate::plan::RefusalReason::InvalidConfig,
225                    });
226                    return Ok(InstallPlan::from_changes(target, changes));
227                }
228                Err(e) => return Err(e),
229            };
230            let table = build_toml_hook_table(spec);
231            let changed = toml_patch::upsert_named_table(&mut doc, &["hooks"], &spec.tag, table)?;
232            if changed {
233                let bytes = toml_patch::to_string(&doc);
234                planning::plan_write_file(&mut changes, &config_path, &bytes, true)?;
235            }
236        } else {
237            let event_key = event_to_string(&spec.event);
238            let matcher_str = matcher_to_codex(&spec.matcher);
239            let command_str = if cfg!(windows) {
240                spec.options
241                    .windows_command
242                    .as_ref()
243                    .unwrap_or(&spec.command)
244                    .render_shell()
245            } else {
246                spec.command.render_shell()
247            };
248
249            let mut hook_obj = serde_json::Map::new();
250            hook_obj.insert("type".to_string(), json!("command"));
251            hook_obj.insert("command".to_string(), json!(command_str));
252            if let Some(t) = spec.options.timeout_seconds {
253                hook_obj.insert("timeout".to_string(), json!(t));
254            }
255            if let Some(msg) = &spec.options.status_message {
256                hook_obj.insert("statusMessage".to_string(), json!(msg));
257            }
258
259            let entry = json!({
260                "matcher": matcher_str,
261                "hooks": vec![serde_json::Value::Object(hook_obj)],
262            });
263            planning::plan_tagged_json_upsert(
264                &mut changes,
265                &hooks_path,
266                &["hooks", event_key.as_str()],
267                &spec.tag,
268                entry,
269                |_| {},
270            )?;
271        }
272
273        if has_refusal(&changes) {
274            return Ok(InstallPlan::from_changes(target, changes));
275        }
276
277        if let Some(rules) = &spec.rules {
278            let agents = Self::agents_path(scope)?;
279            planning::plan_markdown_upsert(&mut changes, &agents, &spec.tag, &rules.content)?;
280        }
281
282        let mut plan = InstallPlan::from_changes(target, changes);
283
284        let has_json_hook = if hooks_path.exists() {
285            json_patch::tagged_hook_presence(&hooks_path, &["hooks"], &spec.tag)
286                .map(|presence| {
287                    matches!(
288                        presence,
289                        crate::status::ConfigPresence::Single
290                            | crate::status::ConfigPresence::Duplicate { .. }
291                    )
292                })
293                .unwrap_or(false)
294        } else {
295            false
296        };
297        let has_toml_hook = if config_path.exists() {
298            if let Ok(doc) = toml_patch::read_or_empty(&config_path) {
299                toml_patch::contains_named_table(&doc, &["hooks"], &spec.tag)
300            } else {
301                false
302            }
303        } else {
304            false
305        };
306
307        if inline_toml && has_json_hook {
308            plan.warnings.push(crate::plan::PlanWarning {
309                path: Some(hooks_path),
310                message: "Legacy hooks.json hook still exists, which may coexist or conflict with config.toml hooks".to_string(),
311            });
312        } else if !inline_toml && has_toml_hook {
313            plan.warnings.push(crate::plan::PlanWarning {
314                path: Some(config_path),
315                message: "Inline config.toml hook still exists, which may coexist or conflict with hooks.json hooks".to_string(),
316            });
317        }
318
319        Ok(plan)
320    }
321
322    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
323        HookSpec::validate_tag(tag)?;
324        let target = PlanTarget::Hook {
325            integration_id: Integration::id(self),
326            scope: scope.clone(),
327            tag: tag.to_string(),
328        };
329        let mut changes = Vec::new();
330        let hooks_path = Self::hooks_path(scope)?;
331        let config_path = Self::config_toml_path(scope)?;
332
333        planning::plan_tagged_json_remove_under(
334            &mut changes,
335            &hooks_path,
336            &["hooks"],
337            tag,
338            planning::json_object_empty,
339            true,
340        )?;
341
342        if config_path.exists() {
343            let mut doc = match toml_patch::read_or_empty(&config_path) {
344                Ok(doc) => doc,
345                Err(AgentConfigError::TomlInvalid { .. }) => {
346                    changes.push(PlannedChange::Refuse {
347                        path: Some(config_path.clone()),
348                        reason: crate::plan::RefusalReason::InvalidConfig,
349                    });
350                    return Ok(UninstallPlan::from_changes(target, changes));
351                }
352                Err(e) => return Err(e),
353            };
354            if toml_patch::contains_named_table(&doc, &["hooks"], tag) {
355                let removed = toml_patch::remove_named_table(&mut doc, &["hooks"], tag)?;
356                if removed {
357                    let empty = doc.as_table().is_empty();
358                    let bytes = toml_patch::to_string(&doc);
359                    if empty {
360                        planning::plan_restore_backup_or_remove(
361                            &mut changes,
362                            &config_path,
363                            &bytes,
364                        )?;
365                    } else {
366                        planning::plan_write_file(&mut changes, &config_path, &bytes, false)?;
367                    }
368                }
369            }
370        }
371
372        if has_refusal(&changes) {
373            return Ok(UninstallPlan::from_changes(target, changes));
374        }
375
376        let agents = Self::agents_path(scope)?;
377        planning::plan_markdown_remove(&mut changes, &agents, tag)?;
378        Ok(UninstallPlan::from_changes(target, changes))
379    }
380
381    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
382        HookSpec::validate_tag(&spec.tag)?;
383        validate_codex_event(&spec.event, &spec.matcher)?;
384        let mut report = InstallReport::default();
385
386        let inline_toml = spec.options.codex_inline_toml.unwrap_or(false);
387        if inline_toml {
388            let p = Self::config_toml_path(scope)?;
389            scope.ensure_contained(&p)?;
390            file_lock::with_lock(&p, || {
391                let mut doc = toml_patch::read_or_empty(&p)?;
392                let table = build_toml_hook_table(spec);
393                let changed =
394                    toml_patch::upsert_named_table(&mut doc, &["hooks"], &spec.tag, table)?;
395                if changed {
396                    let bytes = toml_patch::to_string(&doc);
397                    let outcome = safe_fs::write(scope, &p, &bytes, true)?;
398                    if outcome.existed && !outcome.no_change {
399                        report.patched.push(outcome.path.clone());
400                    } else if !outcome.existed {
401                        report.created.push(outcome.path.clone());
402                    }
403                    if let Some(b) = outcome.backup {
404                        report.backed_up.push(b);
405                    }
406                } else {
407                    report.already_installed = true;
408                }
409                Ok::<(), AgentConfigError>(())
410            })?;
411        } else {
412            let p = Self::hooks_path(scope)?;
413            scope.ensure_contained(&p)?;
414            file_lock::with_lock(&p, || {
415                let mut root = json_patch::read_or_empty(&p)?;
416
417                let event_key = event_to_string(&spec.event);
418                let matcher_str = matcher_to_codex(&spec.matcher);
419                let command_str = if cfg!(windows) {
420                    spec.options
421                        .windows_command
422                        .as_ref()
423                        .unwrap_or(&spec.command)
424                        .render_shell()
425                } else {
426                    spec.command.render_shell()
427                };
428
429                let mut hook_obj = serde_json::Map::new();
430                hook_obj.insert("type".to_string(), json!("command"));
431                hook_obj.insert("command".to_string(), json!(command_str));
432                if let Some(t) = spec.options.timeout_seconds {
433                    hook_obj.insert("timeout".to_string(), json!(t));
434                }
435                if let Some(msg) = &spec.options.status_message {
436                    hook_obj.insert("statusMessage".to_string(), json!(msg));
437                }
438
439                let entry = json!({
440                    "matcher": matcher_str,
441                    "hooks": vec![serde_json::Value::Object(hook_obj)],
442                });
443
444                let changed = json_patch::upsert_tagged_array_entry(
445                    &mut root,
446                    &["hooks", &event_key],
447                    &spec.tag,
448                    entry,
449                )?;
450
451                if changed {
452                    let bytes = json_patch::to_pretty(&root);
453                    let outcome = safe_fs::write(scope, &p, &bytes, true)?;
454                    if outcome.existed {
455                        report.patched.push(outcome.path.clone());
456                    } else {
457                        report.created.push(outcome.path.clone());
458                    }
459                    if let Some(b) = outcome.backup {
460                        report.backed_up.push(b);
461                    }
462                } else {
463                    report.already_installed = true;
464                }
465                Ok::<(), AgentConfigError>(())
466            })?;
467        }
468
469        if let Some(rules) = &spec.rules {
470            let agents = Self::agents_path(scope)?;
471            scope.ensure_contained(&agents)?;
472            file_lock::with_lock(&agents, || {
473                let host = fs_atomic::read_to_string_or_empty(&agents)?;
474                let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
475                let outcome = safe_fs::write(scope, &agents, new_host.as_bytes(), true)?;
476                if outcome.existed && !outcome.no_change {
477                    report.patched.push(outcome.path.clone());
478                    report.already_installed = false;
479                } else if !outcome.existed {
480                    report.created.push(outcome.path.clone());
481                    report.already_installed = false;
482                }
483                if let Some(b) = outcome.backup {
484                    report.backed_up.push(b);
485                }
486                Ok::<(), AgentConfigError>(())
487            })?;
488        }
489
490        Ok(report)
491    }
492
493    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
494        HookSpec::validate_tag(tag)?;
495        let mut report = UninstallReport::default();
496
497        let p = Self::hooks_path(scope)?;
498        scope.ensure_contained(&p)?;
499        if p.exists() {
500            file_lock::with_lock(&p, || {
501                let mut root = json_patch::read_or_empty(&p)?;
502                let changed =
503                    json_patch::remove_tagged_array_entries_under(&mut root, &["hooks"], tag)?;
504                if changed {
505                    let empty = root.as_object().map(|o| o.is_empty()).unwrap_or(true);
506                    let bytes = json_patch::to_pretty(&root);
507                    if empty && safe_fs::restore_backup_if_matches(scope, &p, &bytes)? {
508                        report.restored.push(p.clone());
509                    } else if empty {
510                        safe_fs::remove_file(scope, &p)?;
511                        report.removed.push(p.clone());
512                    } else {
513                        safe_fs::write(scope, &p, &bytes, false)?;
514                        report.patched.push(p.clone());
515                    }
516                }
517                Ok::<(), AgentConfigError>(())
518            })?;
519        }
520
521        let config_path = Self::config_toml_path(scope)?;
522        if config_path.exists() {
523            scope.ensure_contained(&config_path)?;
524            file_lock::with_lock(&config_path, || {
525                let mut doc = toml_patch::read_or_empty(&config_path)?;
526                if toml_patch::contains_named_table(&doc, &["hooks"], tag) {
527                    let removed = toml_patch::remove_named_table(&mut doc, &["hooks"], tag)?;
528                    if removed {
529                        let empty = doc.as_table().is_empty();
530                        let bytes = toml_patch::to_string(&doc);
531                        if empty && safe_fs::restore_backup_if_matches(scope, &config_path, &bytes)?
532                        {
533                            report.restored.push(config_path.clone());
534                        } else if empty {
535                            safe_fs::remove_file(scope, &config_path)?;
536                            report.removed.push(config_path.clone());
537                        } else {
538                            safe_fs::write(scope, &config_path, &bytes, false)?;
539                            report.patched.push(config_path.clone());
540                        }
541                    }
542                }
543                Ok::<(), AgentConfigError>(())
544            })?;
545        }
546
547        let agents = Self::agents_path(scope)?;
548        scope.ensure_contained(&agents)?;
549        file_lock::with_lock(&agents, || {
550            let host = fs_atomic::read_to_string_or_empty(&agents)?;
551            let (stripped, removed) = md_block::remove(&host, tag);
552            if removed {
553                if stripped.trim().is_empty() {
554                    if safe_fs::restore_backup_if_matches(scope, &agents, stripped.as_bytes())? {
555                        report.restored.push(agents.clone());
556                    } else {
557                        safe_fs::remove_file(scope, &agents)?;
558                        report.removed.push(agents.clone());
559                    }
560                } else {
561                    safe_fs::write(scope, &agents, stripped.as_bytes(), false)?;
562                    report.patched.push(agents.clone());
563                }
564            }
565            Ok::<(), AgentConfigError>(())
566        })?;
567
568        if report.removed.is_empty() && report.patched.is_empty() && report.restored.is_empty() {
569            report.not_installed = true;
570        }
571        Ok(report)
572    }
573}
574
575impl SkillSurface for CodexAgent {
576    fn id(&self) -> &'static str {
577        "codex"
578    }
579
580    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
581        &[ScopeKind::Global, ScopeKind::Local]
582    }
583
584    fn skill_status(
585        &self,
586        scope: &Scope,
587        name: &str,
588        expected_owner: &str,
589    ) -> Result<StatusReport, AgentConfigError> {
590        SkillSpec::validate_name(name)?;
591        let root = Self::skills_root(scope)?;
592        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
593        let recorded = ownership::owner_of(&ledger, name)?;
594        Ok(StatusReport::for_skill(
595            name,
596            dir,
597            manifest,
598            ledger,
599            expected_owner,
600            recorded,
601        ))
602    }
603
604    fn plan_install_skill(
605        &self,
606        scope: &Scope,
607        spec: &SkillSpec,
608    ) -> Result<InstallPlan, AgentConfigError> {
609        agent_planning::skill_install(
610            SkillSurface::id(self),
611            scope,
612            spec,
613            Self::skills_root(scope),
614        )
615    }
616
617    fn plan_uninstall_skill(
618        &self,
619        scope: &Scope,
620        name: &str,
621        owner_tag: &str,
622    ) -> Result<UninstallPlan, AgentConfigError> {
623        agent_planning::skill_uninstall(
624            SkillSurface::id(self),
625            scope,
626            name,
627            owner_tag,
628            Self::skills_root(scope),
629        )
630    }
631
632    fn install_skill(
633        &self,
634        scope: &Scope,
635        spec: &SkillSpec,
636    ) -> Result<InstallReport, AgentConfigError> {
637        let root = Self::skills_root(scope)?;
638        scope.ensure_contained(&root)?;
639        skills_dir::install(&root, spec)
640    }
641
642    fn uninstall_skill(
643        &self,
644        scope: &Scope,
645        name: &str,
646        owner_tag: &str,
647    ) -> Result<UninstallReport, AgentConfigError> {
648        let root = Self::skills_root(scope)?;
649        scope.ensure_contained(&root)?;
650        skills_dir::uninstall(&root, name, owner_tag)
651    }
652}
653
654impl InstructionSurface for CodexAgent {
655    fn id(&self) -> &'static str {
656        "codex"
657    }
658
659    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
660        &[ScopeKind::Global, ScopeKind::Local]
661    }
662
663    fn instruction_status(
664        &self,
665        scope: &Scope,
666        name: &str,
667        expected_owner: &str,
668    ) -> Result<StatusReport, AgentConfigError> {
669        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
670    }
671
672    fn plan_install_instruction(
673        &self,
674        scope: &Scope,
675        spec: &InstructionSpec,
676    ) -> Result<InstallPlan, AgentConfigError> {
677        instructions_dir::inline_plan_install(
678            InstructionSurface::id(self),
679            scope,
680            self.inline_layout(scope),
681            spec,
682        )
683    }
684
685    fn plan_uninstall_instruction(
686        &self,
687        scope: &Scope,
688        name: &str,
689        owner_tag: &str,
690    ) -> Result<UninstallPlan, AgentConfigError> {
691        instructions_dir::inline_plan_uninstall(
692            InstructionSurface::id(self),
693            scope,
694            self.inline_layout(scope),
695            name,
696            owner_tag,
697        )
698    }
699
700    fn install_instruction(
701        &self,
702        scope: &Scope,
703        spec: &InstructionSpec,
704    ) -> Result<InstallReport, AgentConfigError> {
705        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
706    }
707
708    fn uninstall_instruction(
709        &self,
710        scope: &Scope,
711        name: &str,
712        owner_tag: &str,
713    ) -> Result<UninstallReport, AgentConfigError> {
714        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
715    }
716}
717
718fn matcher_to_codex(m: &Matcher) -> String {
719    match m {
720        Matcher::All => "*".to_string(),
721        Matcher::Bash => "Bash".to_string(),
722        Matcher::Exact(s) => s.clone(),
723        Matcher::AnyOf(names) => names.join("|"),
724        Matcher::Regex(s) => s.clone(),
725    }
726}
727
728fn event_to_string(e: &Event) -> String {
729    match e {
730        Event::PreToolUse => "PreToolUse".into(),
731        Event::PostToolUse => "PostToolUse".into(),
732        Event::PermissionRequest => "PermissionRequest".into(),
733        Event::PreCompact => "PreCompact".into(),
734        Event::SessionStart => "SessionStart".into(),
735        Event::SubagentStop => "SubagentStop".into(),
736        Event::UserPromptSubmit => "UserPromptSubmit".into(),
737        Event::Stop => "Stop".into(),
738        Event::Custom(s) => s.clone(),
739        other => other.as_str().into(),
740    }
741}
742
743fn validate_codex_event(event: &Event, matcher: &Matcher) -> Result<(), AgentConfigError> {
744    let s = match event {
745        Event::PreToolUse => "PreToolUse",
746        Event::PostToolUse => "PostToolUse",
747        Event::PermissionRequest => "PermissionRequest",
748        Event::PreCompact => "PreCompact",
749        Event::SessionStart => "SessionStart",
750        Event::SubagentStop => "SubagentStop",
751        Event::UserPromptSubmit => "UserPromptSubmit",
752        Event::Stop => "Stop",
753        Event::Custom(c) => c.as_str(),
754        other => other.as_str(),
755    };
756    let allowed = [
757        "PreToolUse",
758        "PostToolUse",
759        "PermissionRequest",
760        "PreCompact",
761        "SessionStart",
762        "SubagentStop",
763        "UserPromptSubmit",
764        "Stop",
765    ];
766    let is_valid = allowed.contains(&s) || matches!(event, Event::Custom(_));
767    if !is_valid {
768        return Err(AgentConfigError::UnsupportedSpecField {
769            id: "codex",
770            field: "event",
771            value: format!("{:?}", event),
772        });
773    }
774
775    if !matches!(matcher, Matcher::All)
776        && s != "PreToolUse"
777        && s != "PostToolUse"
778        && s != "PermissionRequest"
779    {
780        return Err(AgentConfigError::UnsupportedSpecField {
781            id: "codex",
782            field: "matcher",
783            value: format!("{:?}", matcher),
784        });
785    }
786    Ok(())
787}
788
789fn build_toml_hook_table(spec: &HookSpec) -> toml_edit::Table {
790    let mut table = toml_edit::Table::new();
791    let command_str = if cfg!(windows) {
792        spec.options
793            .windows_command
794            .as_ref()
795            .unwrap_or(&spec.command)
796            .render_shell()
797    } else {
798        spec.command.render_shell()
799    };
800    table["event"] = toml_edit::value(event_to_string(&spec.event));
801    table["matcher"] = toml_edit::value(matcher_to_codex(&spec.matcher));
802    table["command"] = toml_edit::value(command_str);
803    if let Some(t) = spec.options.timeout_seconds {
804        table["timeout"] = toml_edit::value(t as i64);
805    }
806    if let Some(msg) = &spec.options.status_message {
807        table["statusMessage"] = toml_edit::value(msg.clone());
808    }
809    table
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815    use serde_json::{json, Value};
816    use tempfile::tempdir;
817
818    fn read_json(p: &std::path::Path) -> Value {
819        serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
820    }
821
822    fn local_spec(tag: &str) -> HookSpec {
823        HookSpec::builder(tag)
824            .command_program("myapp", ["hook"])
825            .matcher(Matcher::Bash)
826            .event(Event::PreToolUse)
827            .build()
828    }
829
830    #[test]
831    fn writes_pre_tool_use_pascalcase() {
832        let dir = tempdir().unwrap();
833        let agent = CodexAgent::new();
834        let scope = Scope::Local(dir.path().to_path_buf());
835        agent.install(&scope, &local_spec("alpha")).unwrap();
836
837        let v = read_json(&dir.path().join(".codex/hooks.json"));
838        assert_eq!(v["hooks"]["PreToolUse"][0]["matcher"], json!("Bash"));
839        assert_eq!(
840            v["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
841            json!("myapp hook")
842        );
843    }
844
845    #[test]
846    fn install_uninstall_round_trip() {
847        let dir = tempdir().unwrap();
848        let agent = CodexAgent::new();
849        let scope = Scope::Local(dir.path().to_path_buf());
850        agent.install(&scope, &local_spec("alpha")).unwrap();
851        agent.uninstall(&scope, "alpha").unwrap();
852        assert!(!dir.path().join(".codex/hooks.json").exists());
853    }
854
855    #[test]
856    fn matcher_mapping() {
857        assert_eq!(matcher_to_codex(&Matcher::All), "*");
858        assert_eq!(matcher_to_codex(&Matcher::Bash), "Bash");
859        assert_eq!(matcher_to_codex(&Matcher::Exact("Edit".into())), "Edit");
860        assert_eq!(
861            matcher_to_codex(&Matcher::AnyOf(vec!["Read".into(), "Write".into()])),
862            "Read|Write"
863        );
864        assert_eq!(
865            matcher_to_codex(&Matcher::Regex("Bash|Edit".into())),
866            "Bash|Edit"
867        );
868    }
869
870    #[test]
871    fn post_tool_use_pascal_case() {
872        let dir = tempdir().unwrap();
873        let agent = CodexAgent::new();
874        let scope = Scope::Local(dir.path().to_path_buf());
875        let spec = HookSpec::builder("alpha")
876            .command_program("noop", [] as [&str; 0])
877            .event(Event::PostToolUse)
878            .build();
879        agent.install(&scope, &spec).unwrap();
880        let v = read_json(&dir.path().join(".codex/hooks.json"));
881        assert!(v["hooks"]["PostToolUse"].is_array());
882    }
883
884    #[test]
885    fn rules_injects_into_agents_md() {
886        let dir = tempdir().unwrap();
887        let agent = CodexAgent::new();
888        let scope = Scope::Local(dir.path().to_path_buf());
889        let spec = HookSpec::builder("alpha")
890            .command_program("noop", [] as [&str; 0])
891            .rules("Use strict mode.")
892            .build();
893        agent.install(&scope, &spec).unwrap();
894        let md = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
895        assert!(md.contains("Use strict mode."));
896        assert!(md.contains("AGENT-CONFIG:alpha"));
897    }
898
899    #[test]
900    fn install_idempotent() {
901        let dir = tempdir().unwrap();
902        let agent = CodexAgent::new();
903        let scope = Scope::Local(dir.path().to_path_buf());
904        agent.install(&scope, &local_spec("alpha")).unwrap();
905        let r2 = agent.install(&scope, &local_spec("alpha")).unwrap();
906        assert!(r2.already_installed);
907    }
908}