1use std::path::{Path, PathBuf};
9
10use crate::error::MarsError;
11use crate::lock::ItemKind;
12use crate::types::DestPath;
13
14use super::{ConfigEntry, HookEntry, McpServerEntry, TargetAdapter, hook_command};
15
16#[derive(Debug)]
17pub struct CodexAdapter;
18
19impl TargetAdapter for CodexAdapter {
20 fn name(&self) -> &str {
21 ".codex"
22 }
23
24 fn skill_variant_key(&self) -> Option<&str> {
25 Some("codex")
26 }
27
28 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
29 match kind {
30 ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
31 _ => None,
32 }
33 }
34
35 fn write_config_entries(
36 &self,
37 entries: &[ConfigEntry],
38 target_dir: &Path,
39 ) -> Result<Vec<PathBuf>, MarsError> {
40 let mut written = Vec::new();
41
42 let mcp_servers: Vec<&McpServerEntry> = entries
43 .iter()
44 .filter_map(|e| {
45 if let ConfigEntry::McpServer(s) = e {
46 Some(s)
47 } else {
48 None
49 }
50 })
51 .collect();
52
53 let hooks: Vec<&HookEntry> = entries
54 .iter()
55 .filter_map(|e| {
56 if let ConfigEntry::Hook(h) = e {
57 Some(h)
58 } else {
59 None
60 }
61 })
62 .collect();
63
64 if !mcp_servers.is_empty() {
65 let path = write_codex_mcp_json(target_dir, &mcp_servers)?;
66 written.push(path);
67 }
68
69 if !hooks.is_empty() {
70 let path = write_codex_hooks_json(target_dir, &hooks)?;
71 written.push(path);
72 }
73
74 Ok(written)
75 }
76
77 fn remove_config_entries(
78 &self,
79 entry_keys: &[String],
80 target_dir: &Path,
81 ) -> Result<(), MarsError> {
82 remove_codex_mcp_entries(entry_keys, target_dir)?;
83 remove_codex_hook_entries(entry_keys, target_dir)?;
84 Ok(())
85 }
86}
87
88fn write_codex_mcp_json(
105 target_dir: &Path,
106 servers: &[&McpServerEntry],
107) -> Result<PathBuf, MarsError> {
108 let path = target_dir.join("codex_mcp.json");
109
110 let mut root: serde_json::Value = if path.is_file() {
111 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
112 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
113 } else {
114 serde_json::json!({})
115 };
116
117 let mcp_obj = root
118 .as_object_mut()
119 .ok_or_else(|| {
120 MarsError::Config(crate::error::ConfigError::Invalid {
121 message: format!("{} is not a JSON object", path.display()),
122 })
123 })?
124 .entry("mcpServers")
125 .or_insert_with(|| serde_json::json!({}));
126
127 let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
128 MarsError::Config(crate::error::ConfigError::Invalid {
129 message: format!("{}: mcpServers is not an object", path.display()),
130 })
131 })?;
132
133 for server in servers {
134 let mut entry = serde_json::json!({
135 "command": server.command,
136 "args": server.args,
137 });
138
139 if !server.env.is_empty() {
141 let env_list: Vec<serde_json::Value> = server
142 .env
143 .values()
144 .map(|v| serde_json::Value::String(v.clone()))
145 .collect();
146 entry["env"] = serde_json::Value::Array(env_list);
147 }
148
149 mcp_map.insert(server.name.clone(), entry);
150 }
151
152 let content = serde_json::to_string_pretty(&root).map_err(|e| {
153 MarsError::Config(crate::error::ConfigError::Invalid {
154 message: format!("failed to serialize {}: {e}", path.display()),
155 })
156 })?;
157 crate::fs::atomic_write(&path, content.as_bytes())?;
158
159 Ok(path)
160}
161
162fn remove_codex_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
163 let path = target_dir.join("codex_mcp.json");
164 if !path.is_file() {
165 return Ok(());
166 }
167
168 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
169 let mut root: serde_json::Value =
170 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
171
172 if let Some(mcp_map) = root
173 .as_object_mut()
174 .and_then(|o| o.get_mut("mcpServers"))
175 .and_then(|v| v.as_object_mut())
176 {
177 for key in entry_keys {
178 if let Some(name) = key.strip_prefix("mcp:") {
179 mcp_map.remove(name);
180 }
181 }
182 }
183
184 let content = serde_json::to_string_pretty(&root).map_err(|e| {
185 MarsError::Config(crate::error::ConfigError::Invalid {
186 message: format!("failed to serialize {}: {e}", path.display()),
187 })
188 })?;
189 crate::fs::atomic_write(&path, content.as_bytes())?;
190 Ok(())
191}
192
193fn write_codex_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
206 let path = target_dir.join("codex_hooks.json");
207
208 let mut root: serde_json::Value = if path.is_file() {
209 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
210 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
211 } else {
212 serde_json::json!({})
213 };
214
215 let hooks_section = root
216 .as_object_mut()
217 .ok_or_else(|| {
218 MarsError::Config(crate::error::ConfigError::Invalid {
219 message: format!("{} is not a JSON object", path.display()),
220 })
221 })?
222 .entry("hooks")
223 .or_insert_with(|| serde_json::json!({}));
224
225 let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
226 MarsError::Config(crate::error::ConfigError::Invalid {
227 message: format!("{}: hooks is not an object", path.display()),
228 })
229 })?;
230
231 for hook in hooks {
232 let command = hook_command(&hook.script_path);
233 let native_event = hook.native_event.clone();
234 let event_hooks = hooks_map
235 .entry(native_event.clone())
236 .or_insert_with(|| serde_json::json!([]))
237 .as_array_mut()
238 .ok_or_else(|| {
239 MarsError::Config(crate::error::ConfigError::Invalid {
240 message: format!("{}: hooks.{native_event} is not an array", path.display()),
241 })
242 })?;
243 remove_managed_hook_commands(event_hooks, &hook.name);
244 event_hooks.push(serde_json::Value::String(command));
245 }
246
247 let content = serde_json::to_string_pretty(&root).map_err(|e| {
248 MarsError::Config(crate::error::ConfigError::Invalid {
249 message: format!("failed to serialize {}: {e}", path.display()),
250 })
251 })?;
252 crate::fs::atomic_write(&path, content.as_bytes())?;
253
254 Ok(path)
255}
256
257fn remove_managed_hook_commands(commands: &mut Vec<serde_json::Value>, hook_name: &str) {
258 commands.retain(|cmd| {
259 cmd.as_str()
260 .map(|cmd| !is_managed_hook_command_for(cmd, hook_name))
261 .unwrap_or(true)
262 });
263}
264
265fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
266 let normalized = command.replace('\\', "/").replace("//", "/");
267 normalized.contains(&format!("/hooks/{hook_name}/"))
268}
269
270fn remove_codex_hook_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
271 let path = target_dir.join("codex_hooks.json");
272 if !path.is_file() {
273 return Ok(());
274 }
275
276 let hook_keys: Vec<(String, &str)> = entry_keys
277 .iter()
278 .filter_map(|k| {
279 let rest = k.strip_prefix("hook:")?;
280 let (event, name) = rest.split_once(':')?;
281 Some((codex_hook_event(event)?.to_string(), name))
282 })
283 .collect();
284
285 if hook_keys.is_empty() {
286 return Ok(());
287 }
288
289 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
290 let mut root: serde_json::Value =
291 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
292
293 if let Some(hooks_map) = root
294 .as_object_mut()
295 .and_then(|o| o.get_mut("hooks"))
296 .and_then(|v| v.as_object_mut())
297 {
298 for (event, name) in &hook_keys {
299 if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
300 arr.retain(|cmd| {
301 let cmd_str = cmd.as_str().unwrap_or("");
302 !is_managed_hook_command_for(cmd_str, name)
304 });
305 }
306 }
307 }
308
309 let content = serde_json::to_string_pretty(&root).map_err(|e| {
310 MarsError::Config(crate::error::ConfigError::Invalid {
311 message: format!("failed to serialize {}: {e}", path.display()),
312 })
313 })?;
314 crate::fs::atomic_write(&path, content.as_bytes())?;
315 Ok(())
316}
317
318fn codex_hook_event(event: &str) -> Option<&'static str> {
319 match event {
320 "session.start" => Some("start"),
321 "session.end" => Some("stop"),
322 "tool.pre" => Some("pre-exec"),
323 "tool.post" => Some("post-exec"),
324 _ => None,
325 }
326}
327
328#[cfg(test)]
333mod tests {
334 use super::*;
335 use indexmap::IndexMap;
336 use tempfile::TempDir;
337
338 fn make_mcp_entry(name: &str) -> ConfigEntry {
339 ConfigEntry::McpServer(McpServerEntry {
340 name: name.to_string(),
341 command: "npx".to_string(),
342 args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
343 env: IndexMap::new(),
344 })
345 }
346
347 fn make_mcp_entry_with_env(name: &str) -> ConfigEntry {
348 let mut env = IndexMap::new();
349 env.insert("API_KEY".to_string(), "MY_SECRET".to_string());
350 ConfigEntry::McpServer(McpServerEntry {
351 name: name.to_string(),
352 command: "npx".to_string(),
353 args: vec![],
354 env,
355 })
356 }
357
358 fn make_hook_entry(name: &str, native: &str) -> ConfigEntry {
359 ConfigEntry::Hook(HookEntry {
360 name: name.to_string(),
361 event: "tool.pre".to_string(),
362 native_event: native.to_string(),
363 script_path: format!("/hooks/{name}/run.sh"),
364 order: 0,
365 })
366 }
367
368 fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
369 ConfigEntry::Hook(HookEntry {
370 name: name.to_string(),
371 event: "tool.pre".to_string(),
372 native_event: native.to_string(),
373 script_path: script_path.to_string(),
374 order: 0,
375 })
376 }
377
378 #[test]
379 fn write_mcp_creates_codex_mcp_json() {
380 let tmp = TempDir::new().unwrap();
381 let adapter = CodexAdapter;
382 let entries = vec![make_mcp_entry("context7")];
383 let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
384 assert_eq!(written.len(), 1);
385 assert!(tmp.path().join("codex_mcp.json").exists());
386
387 let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
388 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
389 assert!(json["mcpServers"]["context7"].is_object());
390 }
391
392 #[test]
393 fn write_mcp_env_as_list_of_var_names() {
394 let tmp = TempDir::new().unwrap();
395 let adapter = CodexAdapter;
396 let entries = vec![make_mcp_entry_with_env("server")];
397 adapter.write_config_entries(&entries, tmp.path()).unwrap();
398
399 let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
400 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
401 assert!(json["mcpServers"]["server"]["env"].is_array());
403 let env_arr = json["mcpServers"]["server"]["env"].as_array().unwrap();
404 assert!(env_arr.iter().any(|v| v.as_str() == Some("MY_SECRET")));
405 }
406
407 #[test]
408 fn write_hooks_creates_codex_hooks_json() {
409 let tmp = TempDir::new().unwrap();
410 let adapter = CodexAdapter;
411 let entries = vec![make_hook_entry("audit", "pre-exec")];
412 adapter.write_config_entries(&entries, tmp.path()).unwrap();
413
414 let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
415 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
416 assert!(json["hooks"]["pre-exec"].is_array());
417 }
418
419 #[test]
420 fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
421 let tmp = TempDir::new().unwrap();
422 let adapter = CodexAdapter;
423 adapter
424 .write_config_entries(
425 &[make_hook_entry_with_path(
426 "audit",
427 "pre-exec",
428 "/old/hooks/audit/run.sh",
429 )],
430 tmp.path(),
431 )
432 .unwrap();
433 adapter
434 .write_config_entries(
435 &[make_hook_entry_with_path(
436 "audit",
437 "pre-exec",
438 "/new/hooks/audit/run.sh",
439 )],
440 tmp.path(),
441 )
442 .unwrap();
443
444 let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
445 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
446 let hooks = json["hooks"]["pre-exec"].as_array().unwrap();
447 assert_eq!(hooks.len(), 1);
448 assert!(hooks[0].as_str().unwrap().contains("/new/hooks/audit/"));
449 }
450
451 #[test]
452 fn remove_mcp_entries_removes_by_name() {
453 let tmp = TempDir::new().unwrap();
454 let adapter = CodexAdapter;
455 let entries = vec![make_mcp_entry("to-remove"), make_mcp_entry("to-keep")];
456 adapter.write_config_entries(&entries, tmp.path()).unwrap();
457
458 adapter
459 .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
460 .unwrap();
461
462 let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
463 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
464 assert!(json["mcpServers"]["to-remove"].is_null());
465 assert!(json["mcpServers"]["to-keep"].is_object());
466 }
467
468 #[test]
469 fn remove_hook_entries_matches_backslash_commands() {
470 let tmp = TempDir::new().unwrap();
471 let existing = serde_json::json!({
472 "hooks": {
473 "pre-exec": [
474 "bash \"C:\\\\pkg\\\\hooks\\\\audit\\\\run.sh\"",
475 "bash \"C:\\\\pkg\\\\hooks\\\\audit-extended\\\\run.sh\""
476 ]
477 }
478 });
479 std::fs::write(
480 tmp.path().join("codex_hooks.json"),
481 serde_json::to_string_pretty(&existing).unwrap(),
482 )
483 .unwrap();
484
485 remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
486
487 let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
488 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
489 let hooks = json["hooks"]["pre-exec"].as_array().unwrap();
490 assert_eq!(hooks.len(), 1);
491 assert!(hooks[0].as_str().unwrap().contains("audit-extended"));
492 }
493
494 #[test]
495 fn remove_hook_entries_scopes_by_universal_event() {
496 let tmp = TempDir::new().unwrap();
497 let existing = serde_json::json!({
498 "hooks": {
499 "pre-exec": ["bash \"/pkg/hooks/audit/run.sh\""],
500 "post-exec": ["bash \"/pkg/hooks/audit/run.sh\""]
501 }
502 });
503 std::fs::write(
504 tmp.path().join("codex_hooks.json"),
505 serde_json::to_string_pretty(&existing).unwrap(),
506 )
507 .unwrap();
508
509 remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
510
511 let raw = std::fs::read_to_string(tmp.path().join("codex_hooks.json")).unwrap();
512 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
513 assert!(json["hooks"]["pre-exec"].as_array().unwrap().is_empty());
514 assert_eq!(json["hooks"]["post-exec"].as_array().unwrap().len(), 1);
515 }
516}