1use super::{ConfigFormat, FrameworkAdapter, ParsedHookInput, ParsedHookInputBuilder, common};
25use crate::model::EventType;
26use std::path::PathBuf;
27
28pub struct CodexAdapter;
32
33impl FrameworkAdapter for CodexAdapter {
34 fn name(&self) -> &'static str {
35 "codex"
36 }
37
38 fn display_name(&self) -> &'static str {
39 "Codex CLI"
40 }
41
42 fn project_config_path(&self) -> PathBuf {
43 PathBuf::from(".codex/config.toml")
44 }
45
46 fn user_config_path(&self) -> Option<PathBuf> {
47 dirs::home_dir().map(|h| h.join(".codex/config.toml"))
48 }
49
50 fn config_format(&self) -> ConfigFormat {
51 ConfigFormat::Toml
52 }
53
54 fn generate_hooks_config(
55 &self,
56 _enabled_events: &[EventType],
57 mi6_bin: &str,
58 otel_enabled: bool,
59 otel_port: u16,
60 ) -> serde_json::Value {
61 let notify_command = vec![
70 mi6_bin.to_string(),
71 "ingest".to_string(),
72 "event".to_string(),
73 "--framework".to_string(),
74 "codex".to_string(),
75 ];
76
77 let mut config = serde_json::json!({
78 "notify": notify_command
79 });
80
81 if otel_enabled {
84 config["otel"] = serde_json::json!({
85 "exporter": {
86 "otlp-http": {
87 "endpoint": format!("http://127.0.0.1:{}", otel_port),
88 "protocol": "json"
89 }
90 }
91 });
92 }
93
94 config
95 }
96
97 fn merge_config(
98 &self,
99 generated: serde_json::Value,
100 existing: Option<serde_json::Value>,
101 ) -> serde_json::Value {
102 let mut settings = existing.unwrap_or_else(|| serde_json::json!({}));
103
104 if let Some(notify) = generated.get("notify") {
106 settings["notify"] = notify.clone();
107 }
108
109 if let Some(new_otel) = generated.get("otel") {
111 if let Some(existing_otel) = settings.get_mut("otel") {
112 if let (Some(existing_obj), Some(new_obj)) =
113 (existing_otel.as_object_mut(), new_otel.as_object())
114 {
115 for (key, value) in new_obj {
116 existing_obj.insert(key.clone(), value.clone());
117 }
118 }
119 } else {
120 settings["otel"] = new_otel.clone();
121 }
122 }
123
124 settings
125 }
126
127 fn parse_hook_input(&self, _event_type: &str, json: &serde_json::Value) -> ParsedHookInput {
128 ParsedHookInputBuilder::new(json)
137 .session_id("thread-id")
138 .tool_use_id("turn-id")
139 .cwd("cwd")
140 .build()
141 }
144
145 fn map_event_type(&self, framework_event: &str) -> EventType {
146 match framework_event {
147 "approval-requested" => EventType::PermissionRequest,
149 "agent-turn-complete" => EventType::Custom("TurnComplete".to_string()),
152 other => other
154 .parse()
155 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
156 }
157 }
158
159 fn supported_events(&self) -> Vec<&'static str> {
160 vec!["agent-turn-complete", "approval-requested"]
162 }
163
164 fn framework_specific_events(&self) -> Vec<&'static str> {
165 vec!["agent-turn-complete"]
167 }
168
169 fn detection_env_vars(&self) -> &[&'static str] {
170 &["CODEX_SESSION_ID", "CODEX_PROJECT_DIR", "CODEX_THREAD_ID"]
175 }
176
177 fn is_installed(&self) -> bool {
178 common::is_framework_installed(self.user_config_path(), "codex")
179 }
180
181 fn otel_support(&self) -> super::OtelSupport {
182 use super::OtelSupport;
183 let Some(config_path) = self.user_config_path() else {
185 return OtelSupport::Disabled;
186 };
187 let Ok(contents) = std::fs::read_to_string(&config_path) else {
188 return OtelSupport::Disabled;
189 };
190 let Ok(toml_val) = toml::from_str::<toml::Value>(&contents) else {
191 return OtelSupport::Disabled;
192 };
193
194 if let Some(otel) = toml_val.get("otel") {
196 if otel.get("exporter").is_some() {
198 return OtelSupport::Enabled;
199 }
200 }
201 OtelSupport::Disabled
202 }
203
204 fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
205 let mut settings = existing;
206 let mut modified = false;
207
208 if let Some(notify) = settings.get("notify")
210 && let Some(arr) = notify.as_array()
211 {
212 let has_mi6_hook = arr
214 .iter()
215 .any(|v| v.as_str().is_some_and(|s| s.contains("mi6")));
216 if has_mi6_hook && let Some(obj) = settings.as_object_mut() {
217 obj.remove("notify");
218 modified = true;
219 }
220 }
221
222 if let Some(obj) = settings.as_object_mut()
224 && obj.remove("otel").is_some()
225 {
226 modified = true;
227 }
228
229 if modified { Some(settings) } else { None }
230 }
231
232 fn resume_command(&self, session_id: &str) -> Option<String> {
233 Some(format!("codex resume {}", session_id))
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_name() {
243 let adapter = CodexAdapter;
244 assert_eq!(adapter.name(), "codex");
245 assert_eq!(adapter.display_name(), "Codex CLI");
246 }
247
248 #[test]
249 fn test_config_format() {
250 let adapter = CodexAdapter;
251 assert_eq!(adapter.config_format(), ConfigFormat::Toml);
252 }
253
254 #[test]
255 fn test_project_config_path() {
256 let adapter = CodexAdapter;
257 assert_eq!(
258 adapter.project_config_path(),
259 PathBuf::from(".codex/config.toml")
260 );
261 }
262
263 #[test]
264 fn test_map_event_type() {
265 let adapter = CodexAdapter;
266
267 assert_eq!(
269 adapter.map_event_type("approval-requested"),
270 EventType::PermissionRequest
271 );
272 assert_eq!(
273 adapter.map_event_type("agent-turn-complete"),
274 EventType::Custom("TurnComplete".to_string())
275 );
276
277 assert_eq!(
279 adapter.map_event_type("unknown-event"),
280 EventType::Custom("unknown-event".to_string())
281 );
282 }
283
284 #[test]
285 fn test_parse_hook_input() {
286 let adapter = CodexAdapter;
287 let input = serde_json::json!({
288 "type": "agent-turn-complete",
289 "thread-id": "codex-thread-123",
290 "turn-id": "turn-456",
291 "cwd": "/projects/test"
292 });
293
294 let parsed = adapter.parse_hook_input("agent-turn-complete", &input);
295
296 assert_eq!(parsed.session_id, Some("codex-thread-123".to_string()));
297 assert_eq!(parsed.tool_use_id, Some("turn-456".to_string()));
298 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
299 assert_eq!(parsed.tool_name, None);
301 assert_eq!(parsed.permission_mode, None);
302 assert_eq!(parsed.transcript_path, None);
303 }
304
305 #[test]
306 fn test_generate_hooks_config() -> Result<(), String> {
307 let adapter = CodexAdapter;
308 let events = vec![EventType::PermissionRequest];
309
310 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
311
312 let notify = config
314 .get("notify")
315 .ok_or("missing notify")?
316 .as_array()
317 .ok_or("notify not an array")?;
318 assert_eq!(notify[0], "mi6");
319 assert_eq!(notify[1], "ingest");
320 assert_eq!(notify[2], "event");
321 assert_eq!(notify[3], "--framework");
322 assert_eq!(notify[4], "codex");
323
324 assert!(config.get("otel").is_none());
326 Ok(())
327 }
328
329 #[test]
330 fn test_generate_hooks_config_with_otel() -> Result<(), String> {
331 let adapter = CodexAdapter;
332 let events = vec![];
333
334 let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
335
336 let otel = config.get("otel").ok_or("missing otel")?;
338 assert_eq!(
339 otel["exporter"]["otlp-http"]["endpoint"],
340 "http://127.0.0.1:4318"
341 );
342 assert_eq!(otel["exporter"]["otlp-http"]["protocol"], "json");
343 Ok(())
344 }
345
346 #[test]
347 fn test_merge_config_new() -> Result<(), String> {
348 let adapter = CodexAdapter;
349 let generated = serde_json::json!({
350 "notify": ["mi6", "ingest", "event", "--framework", "codex"]
351 });
352
353 let merged = adapter.merge_config(generated, None);
354
355 let notify = merged
356 .get("notify")
357 .ok_or("missing notify")?
358 .as_array()
359 .ok_or("notify not an array")?;
360 assert_eq!(notify.len(), 5);
361 Ok(())
362 }
363
364 #[test]
365 fn test_merge_config_existing() {
366 let adapter = CodexAdapter;
367 let generated = serde_json::json!({
368 "notify": ["mi6", "ingest", "event", "--framework", "codex"],
369 "otel": {
370 "exporter": {
371 "otlp-http": {
372 "endpoint": "http://127.0.0.1:4318",
373 "protocol": "json"
374 }
375 }
376 }
377 });
378 let existing = serde_json::json!({
379 "model": "gpt-4",
380 "otel": {
381 "headers": { "x-api-key": "secret" }
382 }
383 });
384
385 let merged = adapter.merge_config(generated, Some(existing));
386
387 assert_eq!(merged["model"], "gpt-4");
389 assert!(merged.get("notify").is_some());
391 assert_eq!(
393 merged["otel"]["exporter"]["otlp-http"]["endpoint"],
394 "http://127.0.0.1:4318"
395 );
396 assert_eq!(merged["otel"]["headers"]["x-api-key"], "secret");
398 }
399
400 #[test]
401 fn test_supported_events() {
402 let adapter = CodexAdapter;
403 let events = adapter.supported_events();
404
405 assert!(events.contains(&"agent-turn-complete"));
406 assert!(events.contains(&"approval-requested"));
407 assert_eq!(events.len(), 2);
408 }
409
410 #[test]
411 fn test_framework_specific_events() {
412 let adapter = CodexAdapter;
413 let events = adapter.framework_specific_events();
414
415 assert!(events.contains(&"agent-turn-complete"));
416 assert_eq!(events.len(), 1);
417 }
418
419 #[test]
420 fn test_detection_env_vars() {
421 let adapter = CodexAdapter;
422 let vars = adapter.detection_env_vars();
423
424 assert!(vars.contains(&"CODEX_SESSION_ID"));
425 assert!(vars.contains(&"CODEX_PROJECT_DIR"));
426 assert!(vars.contains(&"CODEX_THREAD_ID"));
427 }
428}