1use super::{ConfigFormat, FrameworkAdapter, ParsedHookInput, 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 ParsedHookInput {
137 session_id: json
139 .get("thread-id")
140 .and_then(|v| v.as_str())
141 .map(String::from),
142 tool_use_id: json
144 .get("turn-id")
145 .and_then(|v| v.as_str())
146 .map(String::from),
147 cwd: json.get("cwd").and_then(|v| v.as_str()).map(String::from),
148 tool_name: None,
150 subagent_type: None,
151 spawned_agent_id: None,
152 permission_mode: None,
153 transcript_path: None,
154 session_source: None,
155 agent_id: None,
156 agent_transcript_path: None,
157 compact_trigger: None,
158 model: None,
159 duration_ms: None,
160 tokens_input: None,
161 tokens_output: None,
162 tokens_cache_read: None,
163 tokens_cache_write: None,
164 cost_usd: None,
165 prompt: None,
166 }
167 }
168
169 fn map_event_type(&self, framework_event: &str) -> EventType {
170 match framework_event {
171 "approval-requested" => EventType::PermissionRequest,
173 "agent-turn-complete" => EventType::Custom("TurnComplete".to_string()),
176 other => other
178 .parse()
179 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
180 }
181 }
182
183 fn supported_events(&self) -> Vec<&'static str> {
184 vec!["agent-turn-complete", "approval-requested"]
186 }
187
188 fn framework_specific_events(&self) -> Vec<&'static str> {
189 vec!["agent-turn-complete"]
191 }
192
193 fn detection_env_vars(&self) -> &[&'static str] {
194 &["CODEX_SESSION_ID", "CODEX_PROJECT_DIR", "CODEX_THREAD_ID"]
199 }
200
201 fn is_installed(&self) -> bool {
202 common::is_framework_installed(self.user_config_path(), "codex")
203 }
204
205 fn otel_support(&self) -> super::OtelSupport {
206 use super::OtelSupport;
207 let Some(config_path) = self.user_config_path() else {
209 return OtelSupport::Disabled;
210 };
211 let Ok(contents) = std::fs::read_to_string(&config_path) else {
212 return OtelSupport::Disabled;
213 };
214 let Ok(toml_val) = toml::from_str::<toml::Value>(&contents) else {
215 return OtelSupport::Disabled;
216 };
217
218 if let Some(otel) = toml_val.get("otel") {
220 if otel.get("exporter").is_some() {
222 return OtelSupport::Enabled;
223 }
224 }
225 OtelSupport::Disabled
226 }
227
228 fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
229 let mut settings = existing;
230 let mut modified = false;
231
232 if let Some(notify) = settings.get("notify")
234 && let Some(arr) = notify.as_array()
235 {
236 let has_mi6_hook = arr
238 .iter()
239 .any(|v| v.as_str().is_some_and(|s| s.contains("mi6")));
240 if has_mi6_hook && let Some(obj) = settings.as_object_mut() {
241 obj.remove("notify");
242 modified = true;
243 }
244 }
245
246 if let Some(obj) = settings.as_object_mut()
248 && obj.remove("otel").is_some()
249 {
250 modified = true;
251 }
252
253 if modified { Some(settings) } else { None }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_name() {
263 let adapter = CodexAdapter;
264 assert_eq!(adapter.name(), "codex");
265 assert_eq!(adapter.display_name(), "Codex CLI");
266 }
267
268 #[test]
269 fn test_config_format() {
270 let adapter = CodexAdapter;
271 assert_eq!(adapter.config_format(), ConfigFormat::Toml);
272 }
273
274 #[test]
275 fn test_project_config_path() {
276 let adapter = CodexAdapter;
277 assert_eq!(
278 adapter.project_config_path(),
279 PathBuf::from(".codex/config.toml")
280 );
281 }
282
283 #[test]
284 fn test_map_event_type() {
285 let adapter = CodexAdapter;
286
287 assert_eq!(
289 adapter.map_event_type("approval-requested"),
290 EventType::PermissionRequest
291 );
292 assert_eq!(
293 adapter.map_event_type("agent-turn-complete"),
294 EventType::Custom("TurnComplete".to_string())
295 );
296
297 assert_eq!(
299 adapter.map_event_type("unknown-event"),
300 EventType::Custom("unknown-event".to_string())
301 );
302 }
303
304 #[test]
305 fn test_parse_hook_input() {
306 let adapter = CodexAdapter;
307 let input = serde_json::json!({
308 "type": "agent-turn-complete",
309 "thread-id": "codex-thread-123",
310 "turn-id": "turn-456",
311 "cwd": "/projects/test"
312 });
313
314 let parsed = adapter.parse_hook_input("agent-turn-complete", &input);
315
316 assert_eq!(parsed.session_id, Some("codex-thread-123".to_string()));
317 assert_eq!(parsed.tool_use_id, Some("turn-456".to_string()));
318 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
319 assert_eq!(parsed.tool_name, None);
321 assert_eq!(parsed.permission_mode, None);
322 assert_eq!(parsed.transcript_path, None);
323 }
324
325 #[test]
326 fn test_generate_hooks_config() -> Result<(), String> {
327 let adapter = CodexAdapter;
328 let events = vec![EventType::PermissionRequest];
329
330 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
331
332 let notify = config
334 .get("notify")
335 .ok_or("missing notify")?
336 .as_array()
337 .ok_or("notify not an array")?;
338 assert_eq!(notify[0], "mi6");
339 assert_eq!(notify[1], "ingest");
340 assert_eq!(notify[2], "event");
341 assert_eq!(notify[3], "--framework");
342 assert_eq!(notify[4], "codex");
343
344 assert!(config.get("otel").is_none());
346 Ok(())
347 }
348
349 #[test]
350 fn test_generate_hooks_config_with_otel() -> Result<(), String> {
351 let adapter = CodexAdapter;
352 let events = vec![];
353
354 let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
355
356 let otel = config.get("otel").ok_or("missing otel")?;
358 assert_eq!(
359 otel["exporter"]["otlp-http"]["endpoint"],
360 "http://127.0.0.1:4318"
361 );
362 assert_eq!(otel["exporter"]["otlp-http"]["protocol"], "json");
363 Ok(())
364 }
365
366 #[test]
367 fn test_merge_config_new() -> Result<(), String> {
368 let adapter = CodexAdapter;
369 let generated = serde_json::json!({
370 "notify": ["mi6", "ingest", "event", "--framework", "codex"]
371 });
372
373 let merged = adapter.merge_config(generated, None);
374
375 let notify = merged
376 .get("notify")
377 .ok_or("missing notify")?
378 .as_array()
379 .ok_or("notify not an array")?;
380 assert_eq!(notify.len(), 5);
381 Ok(())
382 }
383
384 #[test]
385 fn test_merge_config_existing() {
386 let adapter = CodexAdapter;
387 let generated = serde_json::json!({
388 "notify": ["mi6", "ingest", "event", "--framework", "codex"],
389 "otel": {
390 "exporter": {
391 "otlp-http": {
392 "endpoint": "http://127.0.0.1:4318",
393 "protocol": "json"
394 }
395 }
396 }
397 });
398 let existing = serde_json::json!({
399 "model": "gpt-4",
400 "otel": {
401 "headers": { "x-api-key": "secret" }
402 }
403 });
404
405 let merged = adapter.merge_config(generated, Some(existing));
406
407 assert_eq!(merged["model"], "gpt-4");
409 assert!(merged.get("notify").is_some());
411 assert_eq!(
413 merged["otel"]["exporter"]["otlp-http"]["endpoint"],
414 "http://127.0.0.1:4318"
415 );
416 assert_eq!(merged["otel"]["headers"]["x-api-key"], "secret");
418 }
419
420 #[test]
421 fn test_supported_events() {
422 let adapter = CodexAdapter;
423 let events = adapter.supported_events();
424
425 assert!(events.contains(&"agent-turn-complete"));
426 assert!(events.contains(&"approval-requested"));
427 assert_eq!(events.len(), 2);
428 }
429
430 #[test]
431 fn test_framework_specific_events() {
432 let adapter = CodexAdapter;
433 let events = adapter.framework_specific_events();
434
435 assert!(events.contains(&"agent-turn-complete"));
436 assert_eq!(events.len(), 1);
437 }
438
439 #[test]
440 fn test_detection_env_vars() {
441 let adapter = CodexAdapter;
442 let vars = adapter.detection_env_vars();
443
444 assert!(vars.contains(&"CODEX_SESSION_ID"));
445 assert!(vars.contains(&"CODEX_PROJECT_DIR"));
446 assert!(vars.contains(&"CODEX_THREAD_ID"));
447 }
448}