mcp_sync/targets/
codex.rs

1//! OpenAI Codex target implementation (TOML format).
2
3use 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
15/// Codex target for OpenAI Codex CLI.
16///
17/// File format: TOML with `[mcp_servers.<name>]` tables
18pub 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        // Codex uses ~/.codex on Unix, %APPDATA%\codex on Windows
29        #[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}