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> {
212 let path = target_dir.join("hooks.json");
213
214 let mut root: serde_json::Value = if path.is_file() {
215 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
216 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
217 } else {
218 serde_json::json!({})
219 };
220
221 let hooks_section = root
222 .as_object_mut()
223 .ok_or_else(|| {
224 MarsError::Config(crate::error::ConfigError::Invalid {
225 message: format!("{} is not a JSON object", path.display()),
226 })
227 })?
228 .entry("hooks")
229 .or_insert_with(|| serde_json::json!({}));
230
231 let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
232 MarsError::Config(crate::error::ConfigError::Invalid {
233 message: format!("{}: hooks is not an object", path.display()),
234 })
235 })?;
236
237 for hook in hooks {
238 let native_event = hook.native_event.clone();
239 let command_entry = serde_json::json!({
240 "type": "command",
241 "command": hook_command(&hook.script_path),
242 });
243 let hook_binding = serde_json::json!({
244 "matcher": codex_hook_matcher(&native_event),
245 "hooks": [command_entry],
246 });
247 let event_hooks = hooks_map
248 .entry(native_event.clone())
249 .or_insert_with(|| serde_json::json!([]))
250 .as_array_mut()
251 .ok_or_else(|| {
252 MarsError::Config(crate::error::ConfigError::Invalid {
253 message: format!("{}: hooks.{native_event} is not an array", path.display()),
254 })
255 })?;
256 remove_managed_hook_bindings(event_hooks, &hook.name);
257 event_hooks.push(hook_binding);
258 }
259
260 let content = serde_json::to_string_pretty(&root).map_err(|e| {
261 MarsError::Config(crate::error::ConfigError::Invalid {
262 message: format!("failed to serialize {}: {e}", path.display()),
263 })
264 })?;
265 crate::fs::atomic_write(&path, content.as_bytes())?;
266
267 remove_stale_codex_hooks_json(target_dir, hooks)?;
268
269 Ok(path)
270}
271
272fn codex_hook_matcher(native_event: &str) -> &'static str {
273 match native_event {
274 "PreToolUse" | "PostToolUse" | "PermissionRequest" => "Bash",
275 _ => "",
276 }
277}
278
279fn remove_managed_hook_bindings(bindings: &mut Vec<serde_json::Value>, hook_name: &str) {
280 bindings.retain_mut(|binding| {
281 let Some(hooks) = binding.get_mut("hooks").and_then(|v| v.as_array_mut()) else {
282 return true;
283 };
284 hooks.retain(|hook| {
285 hook.get("command")
286 .and_then(|v| v.as_str())
287 .map(|command| !is_managed_hook_command_for(command, hook_name))
288 .unwrap_or(true)
289 });
290 !hooks.is_empty()
291 });
292}
293
294fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
295 let normalized = command.replace('\\', "/").replace("//", "/");
296 normalized.contains(&format!("/hooks/{hook_name}/"))
297}
298
299fn remove_codex_hook_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
300 let hook_keys: Vec<(String, &str)> = entry_keys
301 .iter()
302 .filter_map(|k| {
303 let rest = k.strip_prefix("hook:")?;
304 let (event, name) = rest.split_once(':')?;
305 Some((codex_hook_event(event)?.to_string(), name))
306 })
307 .collect();
308
309 if hook_keys.is_empty() {
310 return Ok(());
311 }
312
313 remove_codex_hook_entries_from_hooks_json(&hook_keys, target_dir)?;
314 remove_codex_hook_entries_from_legacy_codex_hooks_json(&hook_keys, target_dir)?;
315 Ok(())
316}
317
318fn remove_codex_hook_entries_from_hooks_json(
319 hook_keys: &[(String, &str)],
320 target_dir: &Path,
321) -> Result<(), MarsError> {
322 let path = target_dir.join("hooks.json");
323 if !path.is_file() {
324 return Ok(());
325 }
326
327 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
328 let mut root: serde_json::Value =
329 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
330
331 if let Some(hooks_map) = root
332 .as_object_mut()
333 .and_then(|o| o.get_mut("hooks"))
334 .and_then(|v| v.as_object_mut())
335 {
336 for (event, name) in hook_keys {
337 if let Some(arr) = hooks_map.get_mut(event).and_then(|v| v.as_array_mut()) {
338 remove_managed_hook_bindings(arr, name);
339 }
340 }
341 }
342
343 let content = serde_json::to_string_pretty(&root).map_err(|e| {
344 MarsError::Config(crate::error::ConfigError::Invalid {
345 message: format!("failed to serialize {}: {e}", path.display()),
346 })
347 })?;
348 crate::fs::atomic_write(&path, content.as_bytes())?;
349 Ok(())
350}
351
352fn remove_codex_hook_entries_from_legacy_codex_hooks_json(
353 hook_keys: &[(String, &str)],
354 target_dir: &Path,
355) -> Result<(), MarsError> {
356 let path = target_dir.join("codex_hooks.json");
357 if !path.is_file() {
358 return Ok(());
359 }
360
361 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
362 let mut root: serde_json::Value =
363 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
364
365 if let Some(hooks_map) = root
366 .as_object_mut()
367 .and_then(|o| o.get_mut("hooks"))
368 .and_then(|v| v.as_object_mut())
369 {
370 for (event, name) in hook_keys {
371 let legacy_event = legacy_codex_hook_event(event);
372 if let Some(arr) = hooks_map
373 .get_mut(legacy_event)
374 .and_then(|v| v.as_array_mut())
375 {
376 arr.retain(|cmd| {
377 let cmd_str = cmd.as_str().unwrap_or("");
378 !is_managed_hook_command_for(cmd_str, name)
379 });
380 }
381 }
382 }
383
384 let content = serde_json::to_string_pretty(&root).map_err(|e| {
385 MarsError::Config(crate::error::ConfigError::Invalid {
386 message: format!("failed to serialize {}: {e}", path.display()),
387 })
388 })?;
389 crate::fs::atomic_write(&path, content.as_bytes())?;
390 Ok(())
391}
392
393fn remove_stale_codex_hooks_json(target_dir: &Path, hooks: &[&HookEntry]) -> Result<(), MarsError> {
394 let hook_keys: Vec<(String, &str)> = hooks
395 .iter()
396 .map(|hook| (hook.native_event.clone(), hook.name.as_str()))
397 .collect();
398 remove_codex_hook_entries_from_legacy_codex_hooks_json(&hook_keys, target_dir)
399}
400
401fn legacy_codex_hook_event(event: &str) -> &str {
402 match event {
403 "SessionStart" => "start",
404 "Stop" => "stop",
405 "PreToolUse" => "pre-exec",
406 "PostToolUse" => "post-exec",
407 other => other,
408 }
409}
410
411fn codex_hook_event(event: &str) -> Option<&'static str> {
412 match event {
413 "session.start" => Some("SessionStart"),
414 "session.end" => Some("Stop"),
415 "tool.pre" => Some("PreToolUse"),
416 "tool.post" => Some("PostToolUse"),
417 _ => None,
418 }
419}
420
421#[cfg(test)]
426mod tests {
427 use super::*;
428 use indexmap::IndexMap;
429 use tempfile::TempDir;
430
431 fn make_mcp_entry(name: &str) -> ConfigEntry {
432 ConfigEntry::McpServer(McpServerEntry {
433 name: name.to_string(),
434 command: "npx".to_string(),
435 args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
436 env: IndexMap::new(),
437 })
438 }
439
440 fn make_mcp_entry_with_env(name: &str) -> ConfigEntry {
441 let mut env = IndexMap::new();
442 env.insert("API_KEY".to_string(), "MY_SECRET".to_string());
443 ConfigEntry::McpServer(McpServerEntry {
444 name: name.to_string(),
445 command: "npx".to_string(),
446 args: vec![],
447 env,
448 })
449 }
450
451 fn make_hook_entry(name: &str, native: &str) -> ConfigEntry {
452 ConfigEntry::Hook(HookEntry {
453 name: name.to_string(),
454 event: "tool.pre".to_string(),
455 native_event: native.to_string(),
456 script_path: format!("/hooks/{name}/run.sh"),
457 order: 0,
458 })
459 }
460
461 fn make_hook_entry_with_path(name: &str, native: &str, script_path: &str) -> ConfigEntry {
462 ConfigEntry::Hook(HookEntry {
463 name: name.to_string(),
464 event: "tool.pre".to_string(),
465 native_event: native.to_string(),
466 script_path: script_path.to_string(),
467 order: 0,
468 })
469 }
470
471 #[test]
472 fn write_mcp_creates_codex_mcp_json() {
473 let tmp = TempDir::new().unwrap();
474 let adapter = CodexAdapter;
475 let entries = vec![make_mcp_entry("context7")];
476 let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
477 assert_eq!(written.len(), 1);
478 assert!(tmp.path().join("codex_mcp.json").exists());
479
480 let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
481 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
482 assert!(json["mcpServers"]["context7"].is_object());
483 }
484
485 #[test]
486 fn write_mcp_env_as_list_of_var_names() {
487 let tmp = TempDir::new().unwrap();
488 let adapter = CodexAdapter;
489 let entries = vec![make_mcp_entry_with_env("server")];
490 adapter.write_config_entries(&entries, tmp.path()).unwrap();
491
492 let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
493 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
494 assert!(json["mcpServers"]["server"]["env"].is_array());
496 let env_arr = json["mcpServers"]["server"]["env"].as_array().unwrap();
497 assert!(env_arr.iter().any(|v| v.as_str() == Some("MY_SECRET")));
498 }
499
500 #[test]
501 fn write_hooks_creates_hooks_json() {
502 let tmp = TempDir::new().unwrap();
503 let adapter = CodexAdapter;
504 let entries = vec![make_hook_entry("audit", "PreToolUse")];
505 adapter.write_config_entries(&entries, tmp.path()).unwrap();
506
507 let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
508 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
509 let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
510 assert_eq!(hooks[0]["matcher"], "Bash");
511 assert_eq!(hooks[0]["hooks"][0]["type"], "command");
512 assert!(
513 hooks[0]["hooks"][0]["command"]
514 .as_str()
515 .unwrap()
516 .contains("/hooks/audit/")
517 );
518 }
519
520 #[test]
521 fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
522 let tmp = TempDir::new().unwrap();
523 let adapter = CodexAdapter;
524 adapter
525 .write_config_entries(
526 &[make_hook_entry_with_path(
527 "audit",
528 "PreToolUse",
529 "/old/hooks/audit/run.sh",
530 )],
531 tmp.path(),
532 )
533 .unwrap();
534 adapter
535 .write_config_entries(
536 &[make_hook_entry_with_path(
537 "audit",
538 "PreToolUse",
539 "/new/hooks/audit/run.sh",
540 )],
541 tmp.path(),
542 )
543 .unwrap();
544
545 let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
546 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
547 let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
548 assert_eq!(hooks.len(), 1);
549 assert!(
550 hooks[0]["hooks"][0]["command"]
551 .as_str()
552 .unwrap()
553 .contains("/new/hooks/audit/")
554 );
555 }
556
557 #[test]
558 fn remove_mcp_entries_removes_by_name() {
559 let tmp = TempDir::new().unwrap();
560 let adapter = CodexAdapter;
561 let entries = vec![make_mcp_entry("to-remove"), make_mcp_entry("to-keep")];
562 adapter.write_config_entries(&entries, tmp.path()).unwrap();
563
564 adapter
565 .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
566 .unwrap();
567
568 let raw = std::fs::read_to_string(tmp.path().join("codex_mcp.json")).unwrap();
569 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
570 assert!(json["mcpServers"]["to-remove"].is_null());
571 assert!(json["mcpServers"]["to-keep"].is_object());
572 }
573
574 #[test]
575 fn remove_hook_entries_matches_backslash_commands() {
576 let tmp = TempDir::new().unwrap();
577 let existing = serde_json::json!({
578 "hooks": {
579 "PreToolUse": [
580 {
581 "matcher": "Bash",
582 "hooks": [
583 { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit\\\\run.sh\"" }
584 ]
585 },
586 {
587 "matcher": "Bash",
588 "hooks": [
589 { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit-extended\\\\run.sh\"" }
590 ]
591 }
592 ]
593 }
594 });
595 std::fs::write(
596 tmp.path().join("hooks.json"),
597 serde_json::to_string_pretty(&existing).unwrap(),
598 )
599 .unwrap();
600
601 remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
602
603 let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
604 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
605 let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
606 assert_eq!(hooks.len(), 1);
607 assert!(
608 hooks[0]["hooks"][0]["command"]
609 .as_str()
610 .unwrap()
611 .contains("audit-extended")
612 );
613 }
614
615 #[test]
616 fn remove_hook_entries_preserves_unmanaged_handler_in_same_binding() {
617 let tmp = TempDir::new().unwrap();
618 let existing = serde_json::json!({
619 "hooks": {
620 "PreToolUse": [
621 {
622 "matcher": "Bash",
623 "hooks": [
624 { "type": "command", "command": "bash \"/pkg/hooks/audit/run.sh\"" },
625 { "type": "command", "command": "bash \"/user/hooks/custom.sh\"" }
626 ]
627 }
628 ]
629 }
630 });
631 std::fs::write(
632 tmp.path().join("hooks.json"),
633 serde_json::to_string_pretty(&existing).unwrap(),
634 )
635 .unwrap();
636
637 remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
638
639 let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
640 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
641 let bindings = json["hooks"]["PreToolUse"].as_array().unwrap();
642 assert_eq!(bindings.len(), 1);
643 let hooks = bindings[0]["hooks"].as_array().unwrap();
644 assert_eq!(hooks.len(), 1);
645 assert!(
646 hooks[0]["command"]
647 .as_str()
648 .unwrap()
649 .contains("/user/hooks/custom.sh")
650 );
651 }
652
653 #[test]
654 fn remove_hook_entries_scopes_by_universal_event() {
655 let tmp = TempDir::new().unwrap();
656 let existing = serde_json::json!({
657 "hooks": {
658 "PreToolUse": [
659 {
660 "matcher": "Bash",
661 "hooks": [
662 { "type": "command", "command": "bash \"/pkg/hooks/audit/run.sh\"" }
663 ]
664 }
665 ],
666 "PostToolUse": [
667 {
668 "matcher": "Bash",
669 "hooks": [
670 { "type": "command", "command": "bash \"/pkg/hooks/audit/run.sh\"" }
671 ]
672 }
673 ]
674 }
675 });
676 std::fs::write(
677 tmp.path().join("hooks.json"),
678 serde_json::to_string_pretty(&existing).unwrap(),
679 )
680 .unwrap();
681
682 remove_codex_hook_entries(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
683
684 let raw = std::fs::read_to_string(tmp.path().join("hooks.json")).unwrap();
685 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
686 assert!(json["hooks"]["PreToolUse"].as_array().unwrap().is_empty());
687 assert_eq!(json["hooks"]["PostToolUse"].as_array().unwrap().len(), 1);
688 }
689}