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