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, 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#[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 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}