1use 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#[derive(Debug, Clone, Copy, Default)]
50pub struct CodexAgent {
51 _private: (),
52}
53
54impl CodexAgent {
55 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 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}