mcp_sync/targets/
codex.rs1use crate::{
4 canon::{canon_names, Canon},
5 target::{SyncOptions, Target},
6 utils::{backup, ensure_parent, home, log_verbose},
7};
8use anyhow::{anyhow, Context, Result};
9use std::{
10 fs,
11 path::{Path, PathBuf},
12};
13use toml_edit::{value, Array, DocumentMut, InlineTable, Item, Table};
14
15pub struct CodexTarget;
19
20impl Target for CodexTarget {
21 fn name(&self) -> &'static str {
22 "Codex"
23 }
24
25 fn global_path(&self) -> Result<PathBuf> {
26 let home = home()?;
27
28 #[cfg(target_os = "windows")]
30 let path = home.join("AppData").join("Roaming").join("codex").join("config.toml");
31
32 #[cfg(not(target_os = "windows"))]
33 let path = home.join(".codex").join("config.toml");
34
35 Ok(path)
36 }
37
38 fn project_path(&self, project_root: &Path) -> PathBuf {
39 project_root.join(".codex").join("config.toml")
40 }
41
42 fn sync(&self, path: &Path, canon: &Canon, opts: &SyncOptions) -> Result<()> {
43 let mut doc: DocumentMut = if path.exists() {
44 let s = fs::read_to_string(path).with_context(|| format!("read {:?}", path))?;
45 s.parse::<DocumentMut>().with_context(|| format!("parse TOML {:?}", path))?
46 } else {
47 DocumentMut::new()
48 };
49
50 if !doc.as_table().contains_key("mcp_servers") {
51 doc["mcp_servers"] = Item::Table(Table::new());
52 }
53 let mcp_servers = doc["mcp_servers"]
54 .as_table_mut()
55 .ok_or_else(|| anyhow!("mcp_servers is not a table"))?;
56
57 for (name, s) in &canon.servers {
58 let mut t = Table::new();
59 t.set_implicit(true);
60
61 if s.kind() == "http" {
62 let url = s.url.clone().ok_or_else(|| anyhow!("server {}: http missing url", name))?;
63 t["url"] = value(url);
64 t["enabled"] = value(s.enabled());
65 if let Some(h) = &s.headers {
66 let mut inline = InlineTable::new();
67 for (k, v) in h {
68 inline.insert(k, v.clone().into());
69 }
70 t["http_headers"] = Item::Value(inline.into());
71 }
72 if let Some(b) = &s.bearer_token_env_var {
73 t["bearer_token_env_var"] = value(b.clone());
74 }
75 } else {
76 let cmd = s.command.clone().ok_or_else(|| anyhow!("server {}: stdio missing command", name))?;
77 t["command"] = value(cmd);
78 let mut arr = Array::new();
79 for arg in s.args.clone().unwrap_or_default() {
80 arr.push(arg);
81 }
82 t["args"] = Item::Value(arr.into());
83 t["enabled"] = value(s.enabled());
84 if let Some(cwd) = &s.cwd {
85 t["cwd"] = value(cwd.clone());
86 }
87 if let Some(env) = &s.env {
88 let mut inline = InlineTable::new();
89 for (k, v) in env {
90 inline.insert(k, v.clone().into());
91 }
92 t["env"] = Item::Value(inline.into());
93 }
94 }
95
96 mcp_servers[name] = Item::Table(t);
97 }
98
99 if opts.clean {
100 let canon_set = canon_names(canon);
101 let existing: Vec<String> = mcp_servers.iter().map(|(k, _)| k.to_string()).collect();
102 for k in existing {
103 if !canon_set.contains(&k) {
104 mcp_servers.remove(&k);
105 }
106 }
107 }
108
109 let label = if path.to_string_lossy().contains(".codex/config.toml") && path.starts_with(home().unwrap_or_default()) {
110 "global"
111 } else {
112 "project"
113 };
114 log_verbose(opts.verbose, format!("{} {} -> {:?}", self.name(), label, path));
115
116 if opts.dry_run {
117 return Ok(());
118 }
119
120 ensure_parent(path)?;
121 backup(path)?;
122 fs::write(path, doc.to_string()).with_context(|| format!("write {:?}", path))?;
123 Ok(())
124 }
125}