1use std::path::Path;
2
3use tuff_hooks_spec::{
4 CompatibilityEntry, CompatibilityMatrix, CoverageLevel, HookEvent, SPEC_VERSION,
5};
6
7use tuff_core::adapter::{AgentAdapter, HookSettingsShape};
8use tuff_core::manifest::CapabilityType;
9
10pub const ID: &str = "codex";
11pub const DISPLAY_NAME: &str = "Codex";
12pub const SUPPORTED_TYPES: &[CapabilityType] = &[
13 CapabilityType::Skill,
14 CapabilityType::Tool,
15 CapabilityType::Hook,
16 CapabilityType::Workflow,
17 CapabilityType::McpServer,
18];
19
20pub const SUPPORTED_AGENTS: &[&str] = &["Codex"];
21
22pub const HOOK_SETTINGS_RELPATH: &str = ".agents/hook.json";
23
24pub struct Codex;
25
26pub const HOOK_COMPATIBILITY: CompatibilityMatrix = CompatibilityMatrix {
27 spec_version: SPEC_VERSION,
28 adapter: ID,
29 events: &[
30 CompatibilityEntry {
31 event: HookEvent::BeforeFinish,
32 native_event: Some("before_finish"),
33 aliases: &[],
34 coverage: CoverageLevel::Full,
35 scope: &[],
36 caveat: None,
37 source: None,
38 since_harness_version: None,
39 until_harness_version: None,
40 },
41 CompatibilityEntry {
42 event: HookEvent::AfterSave,
43 native_event: Some("after_save"),
44 aliases: &[],
45 coverage: CoverageLevel::Full,
46 scope: &[],
47 caveat: None,
48 source: None,
49 since_harness_version: None,
50 until_harness_version: None,
51 },
52 CompatibilityEntry {
53 event: HookEvent::PreToolUse,
54 native_event: Some("pre_tool_execution"),
55 aliases: &["pre_tool_execution"],
56 coverage: CoverageLevel::Partial,
57 scope: &["local function tools", "Bash", "Edit", "Write", "MCP"],
58 caveat: Some("Codex hosted tools do not use the local function-tool hook path."),
59 source: Some("https://learn.chatgpt.com/docs/hooks.md"),
60 since_harness_version: None,
61 until_harness_version: None,
62 },
63 CompatibilityEntry {
64 event: HookEvent::PostToolUse,
65 native_event: Some("post_tool_execution"),
66 aliases: &["post_tool_execution"],
67 coverage: CoverageLevel::Partial,
68 scope: &["local function tools", "Bash", "Edit", "Write", "MCP"],
69 caveat: Some("Codex hosted tools do not use the local function-tool hook path."),
70 source: Some("https://learn.chatgpt.com/docs/hooks.md"),
71 since_harness_version: None,
72 until_harness_version: None,
73 },
74 CompatibilityEntry {
75 event: HookEvent::SessionStart,
76 native_event: None,
77 aliases: &[],
78 coverage: CoverageLevel::Unsupported,
79 scope: &[],
80 caveat: Some("Codex hook.json does not currently define a session-start event."),
81 source: None,
82 since_harness_version: None,
83 until_harness_version: None,
84 },
85 CompatibilityEntry {
86 event: HookEvent::SessionEnd,
87 native_event: None,
88 aliases: &[],
89 coverage: CoverageLevel::Unsupported,
90 scope: &[],
91 caveat: Some("Codex hook.json does not currently define a session-end event."),
92 source: None,
93 since_harness_version: None,
94 until_harness_version: None,
95 },
96 CompatibilityEntry {
97 event: HookEvent::Stop,
98 native_event: None,
99 aliases: &[],
100 coverage: CoverageLevel::Unsupported,
101 scope: &[],
102 caveat: Some("Codex hook.json does not currently define a stop event."),
103 source: None,
104 since_harness_version: None,
105 until_harness_version: None,
106 },
107 ],
108};
109
110impl AgentAdapter for Codex {
111 fn id(&self) -> &'static str {
112 ID
113 }
114
115 fn display_name(&self) -> &'static str {
116 DISPLAY_NAME
117 }
118
119 fn dir_prefix(&self) -> &'static str {
120 ".agents"
121 }
122
123 fn mcp_config_relpath(&self) -> &'static str {
124 ".agents/mcp.json"
125 }
126
127 fn supported_agents(&self) -> &[&'static str] {
128 SUPPORTED_AGENTS
129 }
130
131 fn kinds_supported(&self) -> &[CapabilityType] {
132 SUPPORTED_TYPES
133 }
134
135 fn hook_compatibility(&self) -> &'static CompatibilityMatrix {
136 &HOOK_COMPATIBILITY
137 }
138
139 fn hook_settings_relpath(&self) -> &'static str {
140 HOOK_SETTINGS_RELPATH
141 }
142
143 fn scaffold_hook_event(&self) -> &'static str {
144 "before_finish"
145 }
146
147 fn hook_settings_shape(&self) -> HookSettingsShape {
148 HookSettingsShape::Grouped
149 }
150
151 fn detect(&self, repo_root: &Path) -> bool {
152 repo_root.join(".agents").exists() || repo_root.join("AGENTS.md").exists()
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
165 fn a_remote_server_entry_declares_type_and_renders_headers() {
166 let server = tuff_core::manifest::McpServerConfig {
167 transport: tuff_core::manifest::McpTransport::Http,
168 command: None,
169 args: Vec::new(),
170 url: Some("https://mcp.example.test/mcp".to_string()),
171 env: Default::default(),
172 headers: [(
173 "Authorization".to_string(),
174 tuff_core::manifest::HeaderRef {
175 from_env: "EXAMPLE_TOKEN".to_string(),
176 format: Some("Bearer {}".to_string()),
177 },
178 )]
179 .into_iter()
180 .collect(),
181 metadata: None,
182 };
183
184 let entry = Codex.mcp_server_entry(&server);
185
186 assert_eq!(
187 entry,
188 serde_json::json!({
189 "type": "http",
190 "url": "https://mcp.example.test/mcp",
191 "headers": {"Authorization": "Bearer ${EXAMPLE_TOKEN}"},
192 })
193 );
194 }
195
196 #[test]
197 fn id_and_display_name_are_not_empty() {
198 assert!(!ID.is_empty());
199 assert!(!DISPLAY_NAME.is_empty());
200 }
201
202 #[test]
203 fn supported_types_covers_all_capability_types() {
204 assert_eq!(SUPPORTED_TYPES.len(), 5);
205 }
206
207 #[test]
208 fn merging_the_same_fragment_twice_does_not_duplicate_the_hook() {
209 let fragment = serde_json::json!({
210 "hooks": {
211 "before_finish": [{"hooks": [{"type": "command", "command": "sh .agents/hooks/demo/run.sh"}]}]
212 }
213 });
214
215 let once = Codex
216 .merge_hook_fragment(None, &fragment)
217 .expect("first merge");
218 let twice = Codex
219 .merge_hook_fragment(Some(&once), &fragment)
220 .expect("second merge");
221
222 let settings: serde_json::Value = serde_json::from_slice(&twice).expect("valid json");
223 let groups = settings["hooks"]["before_finish"]
224 .as_array()
225 .expect("event array");
226 assert_eq!(
227 groups.len(),
228 1,
229 "re-adding a hook must not register it twice"
230 );
231 assert_eq!(
232 once, twice,
233 "a redundant merge must leave the file unchanged"
234 );
235 }
236}