use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::fs::write_file_atomic;
pub fn user_home() -> Result<PathBuf> {
std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.map(PathBuf::from)
.map_err(|_| anyhow::anyhow!("无法确定用户主目录(USERPROFILE 与 HOME 均未设置)"))
}
pub struct OpencodeMcp {
pub config_path: PathBuf,
}
impl OpencodeMcp {
pub fn global_path() -> Result<PathBuf> {
Ok(user_home()?.join(".config").join("opencode").join("opencode.json"))
}
pub fn install(&self, server: &str, command: &[String]) -> Result<bool> {
let content = std::fs::read_to_string(&self.config_path)
.unwrap_or_else(|_| "{}".to_string());
let mut value: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("解析 opencode 配置失败: {}", self.config_path.display()))?;
if !value.is_object() {
anyhow::bail!(
"opencode.json 顶层应为 JSON 对象: {}",
self.config_path.display()
);
}
let mcp = value.get("mcp").and_then(|v| v.as_object());
let unchanged = mcp
.and_then(|m| m.get(server))
.and_then(|v| v.get("command"))
.and_then(|v| v.as_array())
.is_some_and(|cur| {
cur.iter()
.filter_map(|v| v.as_str())
.eq(command.iter().map(String::as_str))
});
if unchanged {
return Ok(false);
}
let entry = serde_json::json!({
"type": "local",
"command": command,
"enabled": true
});
let obj = value.as_object_mut().unwrap();
let mcp_block = obj
.entry("mcp")
.or_insert_with(|| serde_json::Value::Object(Default::default()));
mcp_block
.as_object_mut()
.with_context(|| format!("opencode.json 的 mcp 键应为对象: {}", self.config_path.display()))?
.insert(server.to_string(), entry);
let output = serde_json::to_string_pretty(&value).context("序列化 opencode 配置失败")?;
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("创建配置目录失败: {}", parent.display()))?;
}
write_file_atomic(&self.config_path, &output)?;
Ok(true)
}
pub fn remove(&self, server: &str) -> Result<bool> {
if !self.config_path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&self.config_path)
.with_context(|| format!("读取 opencode 配置失败: {}", self.config_path.display()))?;
let mut value: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("解析 opencode 配置失败: {}", self.config_path.display()))?;
if !value.is_object() {
anyhow::bail!(
"opencode.json 顶层应为 JSON 对象: {}",
self.config_path.display()
);
}
let removed = {
let mcp = value.get_mut("mcp").and_then(|v| v.as_object_mut());
match mcp {
None => false,
Some(m) => {
let hit = m.remove(server).is_some();
if hit && m.is_empty() {
value.as_object_mut().unwrap().remove("mcp");
}
hit
}
}
};
if !removed {
return Ok(false);
}
let output = serde_json::to_string_pretty(&value).context("序列化 opencode 配置失败")?;
write_file_atomic(&self.config_path, &output)?;
Ok(true)
}
}
pub struct ClaudeMcp {
pub path: PathBuf,
}
impl ClaudeMcp {
pub fn user_global_path() -> Result<PathBuf> {
Ok(user_home()?.join(".claude.json"))
}
pub fn install(
&self,
server: &str,
command: &str,
args: &[String],
) -> Result<bool> {
let content = std::fs::read_to_string(&self.path).unwrap_or_default();
let mut value: serde_json::Value = if content.trim().is_empty() {
serde_json::Value::Object(Default::default())
} else {
serde_json::from_str(&content)
.with_context(|| format!("解析 Claude 用户配置失败: {}", self.path.display()))?
};
if !value.is_object() {
anyhow::bail!("Claude 用户配置顶层应为 JSON 对象: {}", self.path.display());
}
let unchanged = value
.get("mcpServers")
.and_then(|v| v.get(server))
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str())
.is_some_and(|cur| {
cur == command
&& value
.get("mcpServers")
.and_then(|v| v.get(server))
.and_then(|v| v.get("args"))
.and_then(|v| v.as_array())
.is_some_and(|a| {
a.iter().filter_map(|v| v.as_str()).eq(args.iter().map(String::as_str))
})
});
if unchanged {
return Ok(false);
}
let entry = serde_json::json!({
"type": "stdio",
"command": command,
"args": args
});
let obj = value.as_object_mut().unwrap();
let servers = obj
.entry("mcpServers")
.or_insert_with(|| serde_json::Value::Object(Default::default()));
servers
.as_object_mut()
.with_context(|| format!("Claude 用户配置的 mcpServers 键应为对象: {}", self.path.display()))?
.insert(server.to_string(), entry);
let output = serde_json::to_string_pretty(&value).context("序列化 Claude 用户配置失败")?;
write_file_atomic(&self.path, &output)?;
Ok(true)
}
pub fn remove(&self, server: &str) -> Result<bool> {
if !self.path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&self.path)
.with_context(|| format!("读取 Claude 用户配置失败: {}", self.path.display()))?;
let mut value: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("解析 Claude 用户配置失败(拒绝静默跳过): {}", self.path.display()))?;
let removed = {
let servers = value.get_mut("mcpServers").and_then(|v| v.as_object_mut());
match servers {
None => false,
Some(s) => s.remove(server).is_some(),
}
};
if !removed {
return Ok(false);
}
write_file_atomic(&self.path, &serde_json::to_string_pretty(&value)?)?;
Ok(true)
}}
pub struct CodexMcp {
pub config_path: PathBuf,
}
fn toml_basic_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
out
}
impl CodexMcp {
pub fn global_path() -> Result<PathBuf> {
Ok(user_home()?.join(".codex").join("config.toml"))
}
fn table_header(server: &str) -> String {
format!("[mcp_servers.{server}]")
}
fn find_table(content: &str, header: &str) -> Option<(usize, usize)> {
let lines: Vec<&str> = content.lines().collect();
let mut start: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
match start {
None => {
if trimmed.starts_with('[') && line.trim() == header {
start = Some(i);
}
}
Some(s) => {
if trimmed.starts_with('[') {
return Some((s, i));
}
}
}
}
start.map(|s| (s, lines.len()))
}
pub fn install(&self, server: &str, command: &str, args: &[String]) -> Result<bool> {
let header = Self::table_header(server);
let body = format!(
"{}\ncommand = \"{}\"\nargs = [{}]\n",
header,
toml_basic_escape(command),
args.iter()
.map(|a| format!("\"{}\"", toml_basic_escape(a)))
.collect::<Vec<_>>()
.join(", ")
);
let content = match std::fs::read_to_string(&self.config_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).context("读取 Codex 配置失败"),
};
match Self::find_table(&content, &header) {
Some((start, end)) => {
let current: Vec<&str> = content.lines().collect();
let block = current[start..end].join("\n");
if block.contains(&format!("command = \"{}\"", toml_basic_escape(command))) {
return Ok(false);
}
let mut out = String::with_capacity(content.len() + body.len());
let mut line_iter = content.split_inclusive('\n');
for _ in 0..start {
if let Some(l) = line_iter.next() {
out.push_str(l);
}
}
for _ in start..end {
let _ = line_iter.next();
}
out.push_str(&body);
for l in line_iter {
out.push_str(l);
}
write_file_atomic(&self.config_path, &out)?;
Ok(true)
}
None => {
let separator = if content.is_empty() || content.ends_with("\n\n") {
""
} else if content.ends_with('\n') {
"\n"
} else {
"\n\n"
};
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("创建配置目录失败: {}", parent.display()))?;
}
write_file_atomic(&self.config_path, &format!("{content}{separator}{body}"))?;
Ok(true)
}
}
}
pub fn remove(&self, server: &str) -> Result<bool> {
if !self.config_path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&self.config_path)
.with_context(|| format!("读取 Codex 配置失败: {}", self.config_path.display()))?;
let header = Self::table_header(server);
match Self::find_table(&content, &header) {
Some((start, end)) => {
let mut out = String::with_capacity(content.len());
let mut line_iter = content.split_inclusive('\n');
for _ in 0..start {
if let Some(l) = line_iter.next() {
out.push_str(l);
}
}
for _ in start..end {
let _ = line_iter.next();
}
let mut skip_blank = true;
for l in line_iter {
if skip_blank && l.trim().is_empty() {
skip_blank = false;
continue;
}
skip_blank = false;
out.push_str(l);
}
while out.ends_with("\n\n") {
out.pop();
}
write_file_atomic(&self.config_path, &out)?;
Ok(true)
}
None => Ok(false),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(tag: &str) -> PathBuf {
let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("code-repo-wiki-mcp-test-{tag}-{}-{id}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("创建临时目录失败");
dir
}
fn write(path: &Path, content: &str) {
std::fs::write(path, content).expect("写入临时文件失败");
}
#[test]
fn opencode_install_creates_mcp_block() {
let dir = temp_dir("oc-create");
let path = dir.join("opencode.json");
let mcp = OpencodeMcp { config_path: path.clone() };
let cmd = vec!["/usr/bin/code-repo-wiki".to_string(), "mcp".to_string()];
assert!(mcp.install("code-repo-wiki", &cmd).unwrap());
let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let entry = &parsed["mcp"]["code-repo-wiki"];
assert_eq!(entry["type"], "local");
assert_eq!(entry["command"][0], "/usr/bin/code-repo-wiki");
assert_eq!(entry["command"][1], "mcp");
assert_eq!(entry["enabled"], true);
}
#[test]
fn opencode_install_idempotent_skips_unchanged() {
let dir = temp_dir("oc-idem");
let path = dir.join("opencode.json");
let mcp = OpencodeMcp { config_path: path.clone() };
let cmd = vec!["code-repo-wiki".to_string(), "mcp".to_string()];
assert!(mcp.install("code-repo-wiki", &cmd).unwrap());
assert!(!mcp.install("code-repo-wiki", &cmd).unwrap());
}
#[test]
fn opencode_install_upgrades_changed_command() {
let dir = temp_dir("oc-upgrade");
let path = dir.join("opencode.json");
let mcp = OpencodeMcp { config_path: path.clone() };
let old = vec!["/old/code-repo-wiki".to_string(), "mcp".to_string()];
let new = vec!["/new/code-repo-wiki".to_string(), "mcp".to_string()];
assert!(mcp.install("code-repo-wiki", &old).unwrap());
assert!(mcp.install("code-repo-wiki", &new).unwrap());
let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(parsed["mcp"]["code-repo-wiki"]["command"][0], "/new/code-repo-wiki");
}
#[test]
fn opencode_install_preserves_other_servers() {
let dir = temp_dir("oc-preserve");
let path = dir.join("opencode.json");
write(&path, r#"{"mcp": {"other": {"type": "local", "command": ["npx", "x"]}}}"#);
let mcp = OpencodeMcp { config_path: path.clone() };
mcp.install("code-repo-wiki", &["rw".to_string(), "mcp".to_string()]).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(parsed["mcp"]["other"].is_object());
assert!(parsed["mcp"]["code-repo-wiki"].is_object());
}
#[test]
fn opencode_install_rejects_non_object_top_level() {
let dir = temp_dir("oc-malformed");
let path = dir.join("opencode.json");
write(&path, "[1, 2, 3]");
let mcp = OpencodeMcp { config_path: path.clone() };
assert!(mcp.install("code-repo-wiki", &["x".to_string()]).is_err());
}
#[test]
fn opencode_remove_idempotent_and_cleanup() {
let dir = temp_dir("oc-remove");
let path = dir.join("opencode.json");
let mcp = OpencodeMcp { config_path: path.clone() };
assert!(!mcp.remove("code-repo-wiki").unwrap()); mcp.install("code-repo-wiki", &["rw".to_string()]).unwrap();
assert!(mcp.remove("code-repo-wiki").unwrap());
assert!(!mcp.remove("code-repo-wiki").unwrap()); let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(parsed.get("mcp").is_none());
}
#[test]
fn opencode_remove_preserves_other_servers() {
let dir = temp_dir("oc-remove-preserve");
let path = dir.join("opencode.json");
write(&path, r#"{"mcp": {"code-repo-wiki": {"type": "local", "command": ["rw"]}, "other": {"type": "local", "command": ["npx", "y"]}}, "provider": {"x": 1}}"#);
let mcp = OpencodeMcp { config_path: path.clone() };
assert!(mcp.remove("code-repo-wiki").unwrap());
let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(parsed["mcp"]["other"].is_object());
assert!(parsed.get("mcp").unwrap().get("code-repo-wiki").is_none());
assert_eq!(parsed["provider"]["x"], 1);
}
#[test]
fn claude_install_creates_servers_block() {
let dir = temp_dir("cl-create");
let path = dir.join("claude.json");
let mcp = ClaudeMcp { path: path.clone() };
assert!(mcp.install("code-repo-wiki", "rw", &["mcp".to_string()]).unwrap());
let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let entry = &parsed["mcpServers"]["code-repo-wiki"];
assert_eq!(entry["type"], "stdio");
assert_eq!(entry["command"], "rw");
assert_eq!(entry["args"][0], "mcp");
}
#[test]
fn claude_install_preserves_other_servers_and_idempotent() {
let dir = temp_dir("cl-preserve");
let path = dir.join("claude.json");
write(&path, r#"{"oauthAccount": {}, "mcpServers": {"other": {"command": "npx", "args": ["x"]}}}"#);
let mcp = ClaudeMcp { path: path.clone() };
let cmd = ("rw", vec!["mcp".to_string()]);
assert!(mcp.install("code-repo-wiki", cmd.0, &cmd.1).unwrap());
assert!(!mcp.install("code-repo-wiki", cmd.0, &cmd.1).unwrap());
let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(parsed["oauthAccount"].is_object(), "OAuth 会话等用户键必须保留");
assert!(parsed["mcpServers"]["other"].is_object());
assert!(parsed["mcpServers"]["code-repo-wiki"].is_object());
}
#[test]
fn claude_remove_keeps_file_with_empty_servers() {
let dir = temp_dir("cl-remove");
let path = dir.join("claude.json");
let mcp = ClaudeMcp { path: path.clone() };
mcp.install("code-repo-wiki", "rw", &[]).unwrap();
assert!(mcp.remove("code-repo-wiki").unwrap());
let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
assert!(!mcp.remove("code-repo-wiki").unwrap()); }
#[test]
fn claude_remove_preserves_other_servers() {
let dir = temp_dir("cl-remove-preserve");
let path = dir.join("claude.json");
write(&path, r#"{"mcpServers": {"code-repo-wiki": {"command": "rw"}, "other": {"command": "npx"}}}"#);
let mcp = ClaudeMcp { path: path.clone() };
assert!(mcp.remove("code-repo-wiki").unwrap());
let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert!(parsed["mcpServers"]["other"].is_object());
assert!(parsed["mcpServers"].get("code-repo-wiki").is_none());
}
#[test]
fn claude_remove_rejects_malformed_json() {
let dir = temp_dir("cl-malformed");
let path = dir.join("claude.json");
write(&path, "{not json");
let mcp = ClaudeMcp { path: path.clone() };
assert!(mcp.remove("code-repo-wiki").is_err());
}
#[test]
fn codex_install_creates_table_and_roundtrips() {
let dir = temp_dir("cx-create");
let path = dir.join("config.toml");
let mcp = CodexMcp { config_path: path.clone() };
let exe = r"C:\RustProjects\code-repo-wiki\target\release\code-repo-wiki.exe";
assert!(mcp.install("code-repo-wiki", exe, &["mcp".to_string()]).unwrap());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("[mcp_servers.code-repo-wiki]"));
assert!(content.contains("command = \"C:\\\\RustProjects\\\\code-repo-wiki"));
let parsed: toml::Value = toml::from_str(&content).unwrap();
assert_eq!(parsed["mcp_servers"]["code-repo-wiki"]["command"].as_str(), Some(exe));
assert_eq!(parsed["mcp_servers"]["code-repo-wiki"]["args"][0].as_str(), Some("mcp"));
}
#[test]
fn codex_install_preserves_other_tables_and_comments() {
let dir = temp_dir("cx-preserve");
let path = dir.join("config.toml");
write(&path, "# 我的注释\n[model]\nname = \"gpt-5\"\n\n[provider.openai]\nkey = \"x\"\n");
let mcp = CodexMcp { config_path: path.clone() };
mcp.install("code-repo-wiki", "rw", &["mcp".to_string()]).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("# 我的注释"));
assert!(content.contains("[model]"));
assert!(content.contains("[provider.openai]"));
let parsed: toml::Value = toml::from_str(&content).unwrap();
assert!(parsed["mcp_servers"]["code-repo-wiki"].is_table());
assert_eq!(parsed["model"]["name"].as_str(), Some("gpt-5"));
}
#[test]
fn codex_install_upgrades_existing_table() {
let dir = temp_dir("cx-upgrade");
let path = dir.join("config.toml");
write(&path, "[mcp_servers.code-repo-wiki]\ncommand = \"/old/rw\"\nargs = [\"mcp\"]\n");
let mcp = CodexMcp { config_path: path.clone() };
assert!(mcp.install("code-repo-wiki", "/new/rw", &["mcp".to_string()]).unwrap());
let parsed: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(parsed["mcp_servers"]["code-repo-wiki"]["command"].as_str(), Some("/new/rw"));
}
#[test]
fn codex_install_idempotent() {
let dir = temp_dir("cx-idem");
let path = dir.join("config.toml");
let mcp = CodexMcp { config_path: path.clone() };
assert!(mcp.install("code-repo-wiki", "/rw", &["mcp".to_string()]).unwrap());
assert!(!mcp.install("code-repo-wiki", "/rw", &["mcp".to_string()]).unwrap());
}
#[test]
fn codex_remove_table_preserves_rest() {
let dir = temp_dir("cx-remove");
let path = dir.join("config.toml");
write(&path, "[model]\nname = \"gpt-5\"\n\n[mcp_servers.code-repo-wiki]\ncommand = \"/rw\"\n\n[provider.openai]\nkey = \"x\"\n");
let mcp = CodexMcp { config_path: path.clone() };
assert!(mcp.remove("code-repo-wiki").unwrap());
assert!(!mcp.remove("code-repo-wiki").unwrap());
let content = std::fs::read_to_string(&path).unwrap();
assert!(!content.contains("mcp_servers"));
assert!(content.contains("[model]"));
assert!(content.contains("[provider.openai]"));
toml::from_str::<toml::Value>(&content).unwrap();
}
#[test]
fn codex_remove_missing_is_idempotent() {
let dir = temp_dir("cx-remove-miss");
let path = dir.join("config.toml");
write(&path, "[model]\nname = \"x\"\n");
let mcp = CodexMcp { config_path: path.clone() };
assert!(!mcp.remove("code-repo-wiki").unwrap());
}
}