Skip to main content

code_repo_wiki/config/
mcp.rs

1//! 多 Agent MCP 配置读写模块(v33;v39 落点统一为用户级)
2//!
3//! 合并 install 的多 Agent 支持:`install` 默认注册 opencode(用户级全局
4//! `~/.config/opencode/opencode.json` 的 `mcp` 块),`--claude` 额外写
5//! 用户级 `~/.claude.json` 顶层 `mcpServers`(User scope——command 绑定
6//! 本机 exe 路径=用户级内容,v39 起不再写项目根 `.mcp.json`),`--codex`
7//! 额外写用户级 `~/.codex/config.toml` 的 `[mcp_servers.<name>]` 表。
8//!
9//! 三个 writer(`OpencodeMcp`/`ClaudeMcp`/`CodexMcp`)各自封装一种
10//! 格式,共同契约:
11//! - `install(server)`:条目已存在且命令一致 → 返回 `false`(跳过);
12//!   存在但命令不同(升级,如二进制路径变化)→ 更新返回 `true`;
13//!   不存在 → 新建返回 `true`
14//! - `remove(server)`:文件缺失/无条目 → 幂等返回 `false`;移除后空容器
15//!   的清理语义各格式不同(opencode 删 `mcp` 键、Claude 保留空 `mcpServers`
16//!   对象——`~/.claude.json` 承载 OAuth 会话,绝不删整个文件、Codex 删表)
17//! - 只动本 server 的条目,绝不触碰其他 server/配置键(多 Agent 共存;
18//!   Claude/opencode 的用户配置键——OAuth、provider 等——原样保留)
19//! - 畸形文件(JSON 非对象/TOML 解析失败)→ 显式报错,拒绝静默处理
20//!   (契约与 v32 commands::remove_mcp_config 一致:损坏的配置文件不能被静默吞掉)
21
22use std::path::PathBuf;
23
24use anyhow::{Context, Result};
25
26use crate::fs::write_file_atomic;
27
28/// 用户主目录(Codex/opencode 用户级配置的基础)
29///
30/// Windows 语义(N11 先例):USERPROFILE 优先于 HOME——Windows 构建工具
31/// (Git Bash/Cygwin/MSYS)常把 HOME 指向临时值,USERPROFILE 才是用户
32/// 真实主目录。两者都缺失时显式报错,不静默写当前目录(与
33/// [`crate::config::global_config_dir`] 的「写错位置比报错更隐蔽」同语义)。
34pub fn user_home() -> Result<PathBuf> {
35    std::env::var("USERPROFILE")
36        .or_else(|_| std::env::var("HOME"))
37        .map(PathBuf::from)
38        .map_err(|_| anyhow::anyhow!("无法确定用户主目录(USERPROFILE 与 HOME 均未设置)"))
39}
40
41// ---------------------------------------------------------------------------
42// opencode(用户级全局 `mcp` 块)
43// ---------------------------------------------------------------------------
44
45/// opencode MCP 配置读写(`~/.config/opencode/opencode.json`)
46///
47/// 官方格式(opencode.ai/docs/mcp-servers,V1 稳定版,V2 beta 兼容):
48/// 顶层 `mcp` 键 → 服务器名 → `{ "type": "local", "command": [...],
49/// "enabled": true }`。全局与项目配置按服务器名逐条合并、项目覆盖全局;
50/// 本 writer 只写用户级(install 拍板:一次注册所有仓库可用,
51/// server 以工作区为 cwd,无需 --root)。
52pub struct OpencodeMcp {
53    /// 用户级 opencode.json 路径
54    pub config_path: PathBuf,
55}
56
57impl OpencodeMcp {
58    /// 用户级 opencode 配置路径(`~/.config/opencode/opencode.json`)
59    pub fn global_path() -> Result<PathBuf> {
60        Ok(user_home()?.join(".config").join("opencode").join("opencode.json"))
61    }
62
63    /// 注册/更新 MCP server;返回是否实际变更
64    ///
65    /// - 文件缺失 → 视为空 `{}` 新建(opencode 支持全空配置)
66    /// - `mcp` 键缺失 → 新建;已有其他 server → 保留(多 server 共存)
67    /// - 本 server 已存在且 command 一致 → `false`(幂等跳过)
68    /// - 本 server 已存在但 command 不同 → 整体替换该条目(升级)
69    /// - 顶层非 JSON 对象 → 显式报错(N12 规则:数组/标量是损坏配置)
70    pub fn install(&self, server: &str, command: &[String]) -> Result<bool> {
71        let content = std::fs::read_to_string(&self.config_path)
72            .unwrap_or_else(|_| "{}".to_string());
73        let mut value: serde_json::Value = serde_json::from_str(&content)
74            .with_context(|| format!("解析 opencode 配置失败: {}", self.config_path.display()))?;
75        if !value.is_object() {
76            anyhow::bail!(
77                "opencode.json 顶层应为 JSON 对象: {}",
78                self.config_path.display()
79            );
80        }
81
82        // mcp 块:只读旧条目做变更判定,随后整体重建该 server 条目
83        let mcp = value.get("mcp").and_then(|v| v.as_object());
84        let unchanged = mcp
85            .and_then(|m| m.get(server))
86            .and_then(|v| v.get("command"))
87            .and_then(|v| v.as_array())
88            .is_some_and(|cur| {
89                cur.iter()
90                    .filter_map(|v| v.as_str())
91                    .eq(command.iter().map(String::as_str))
92            });
93        if unchanged {
94            return Ok(false);
95        }
96
97        let entry = serde_json::json!({
98            "type": "local",
99            "command": command,
100            "enabled": true
101        });
102        let obj = value.as_object_mut().unwrap();
103        let mcp_block = obj
104            .entry("mcp")
105            .or_insert_with(|| serde_json::Value::Object(Default::default()));
106        mcp_block
107            .as_object_mut()
108            .with_context(|| format!("opencode.json 的 mcp 键应为对象: {}", self.config_path.display()))?
109            .insert(server.to_string(), entry);
110
111        let output = serde_json::to_string_pretty(&value).context("序列化 opencode 配置失败")?;
112        if let Some(parent) = self.config_path.parent() {
113            std::fs::create_dir_all(parent)
114                .with_context(|| format!("创建配置目录失败: {}", parent.display()))?;
115        }
116        write_file_atomic(&self.config_path, &output)?;
117        Ok(true)
118    }
119
120    /// 移除 MCP server;返回是否实际移除
121    ///
122    /// - 文件缺失/无 mcp 键/无该 server → `false`(幂等)
123    /// - 移除后 mcp 块为空 → 整个删掉 `mcp` 键(不留空容器)
124    /// - 其他键与其他 server 保留
125    pub fn remove(&self, server: &str) -> Result<bool> {
126        if !self.config_path.exists() {
127            return Ok(false);
128        }
129        let content = std::fs::read_to_string(&self.config_path)
130            .with_context(|| format!("读取 opencode 配置失败: {}", self.config_path.display()))?;
131        let mut value: serde_json::Value = serde_json::from_str(&content)
132            .with_context(|| format!("解析 opencode 配置失败: {}", self.config_path.display()))?;
133        if !value.is_object() {
134            anyhow::bail!(
135                "opencode.json 顶层应为 JSON 对象: {}",
136                self.config_path.display()
137            );
138        }
139
140        let removed = {
141            let mcp = value.get_mut("mcp").and_then(|v| v.as_object_mut());
142            match mcp {
143                None => false,
144                Some(m) => {
145                    let hit = m.remove(server).is_some();
146                    if hit && m.is_empty() {
147                        // mcp 块已无任何 server → 整个键失去价值,删除
148                        value.as_object_mut().unwrap().remove("mcp");
149                    }
150                    hit
151                }
152            }
153        };
154        if !removed {
155            return Ok(false);
156        }
157        let output = serde_json::to_string_pretty(&value).context("序列化 opencode 配置失败")?;
158        write_file_atomic(&self.config_path, &output)?;
159        Ok(true)
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Claude Code(用户级 `~/.claude.json` 顶层 `mcpServers`,User scope)
165// ---------------------------------------------------------------------------
166
167/// Claude Code MCP 配置读写(用户级 `~/.claude.json` 顶层 `mcpServers`)
168///
169/// Claude Code 官方格式(code.claude.com/docs/en/mcp):User scope 的 MCP
170/// 服务器注册在 `~/.claude.json` 顶层 `mcpServers` → 服务器名 →
171/// `{ "type": "stdio", "command": "<string>", "args": [...] }`,对所有项目生效。
172///
173/// 落点论证(v39):MCP server 的 `command` 绑定本机可执行文件绝对路径
174/// (机器相关=用户级内容),与 opencode 全局配置、Codex `~/.codex/config.toml`
175/// 同语义——一次 install 所有仓库的 Claude 会话可用;项目根 `.mcp.json`
176/// (Project scope,团队共享入 git)不再写入。
177///
178/// 与 opencode 格式不兼容(键名/command 形态/env 变量语法均不同),
179/// 故独立 writer。`~/.claude.json` 承载 OAuth 会话等大量用户配置:
180/// 只动顶层 `mcpServers` 本 server 条目,其他键与人工修改一律保留。
181pub struct ClaudeMcp {
182    /// 用户级 `~/.claude.json` 路径
183    pub path: PathBuf,
184}
185
186impl ClaudeMcp {
187    /// 用户级 `~/.claude.json` 路径(`%USERPROFILE%/.claude.json`)
188    pub fn user_global_path() -> Result<PathBuf> {
189        Ok(user_home()?.join(".claude.json"))
190    }
191
192    /// 注册/更新 MCP server;返回是否实际变更
193    ///
194    /// - 文件缺失 → 新建(仅含 mcpServers 容器,其余键留给 Claude 会话写入)
195    /// - 本 server 存在且 command+args 一致 → `false`
196    /// - 本 server 存在但命令不同 → 更新(升级)
197    /// - OAuth 会话与其他 server 条目原样保留
198    pub fn install(
199        &self,
200        server: &str,
201        command: &str,
202        args: &[String],
203    ) -> Result<bool> {
204        let content = std::fs::read_to_string(&self.path).unwrap_or_default();
205        let mut value: serde_json::Value = if content.trim().is_empty() {
206            serde_json::Value::Object(Default::default())
207        } else {
208            serde_json::from_str(&content)
209                .with_context(|| format!("解析 Claude 用户配置失败: {}", self.path.display()))?
210        };
211        if !value.is_object() {
212            anyhow::bail!("Claude 用户配置顶层应为 JSON 对象: {}", self.path.display());
213        }
214
215        let unchanged = value
216            .get("mcpServers")
217            .and_then(|v| v.get(server))
218            .and_then(|v| v.get("command"))
219            .and_then(|v| v.as_str())
220            .is_some_and(|cur| {
221                cur == command
222                    && value
223                        .get("mcpServers")
224                        .and_then(|v| v.get(server))
225                        .and_then(|v| v.get("args"))
226                        .and_then(|v| v.as_array())
227                        .is_some_and(|a| {
228                            a.iter().filter_map(|v| v.as_str()).eq(args.iter().map(String::as_str))
229                        })
230            });
231        if unchanged {
232            return Ok(false);
233        }
234
235        let entry = serde_json::json!({
236            "type": "stdio",
237            "command": command,
238            "args": args
239        });
240        let obj = value.as_object_mut().unwrap();
241        let servers = obj
242            .entry("mcpServers")
243            .or_insert_with(|| serde_json::Value::Object(Default::default()));
244        servers
245            .as_object_mut()
246            .with_context(|| format!("Claude 用户配置的 mcpServers 键应为对象: {}", self.path.display()))?
247            .insert(server.to_string(), entry);
248
249        let output = serde_json::to_string_pretty(&value).context("序列化 Claude 用户配置失败")?;
250        write_file_atomic(&self.path, &output)?;
251        Ok(true)
252    }
253
254    /// 移除 MCP server;返回是否实际移除(语义与 v32 commands::remove_mcp_config
255    /// 相同,迁入本模块统一管理;v39 落点改用户级 `~/.claude.json`)
256    ///
257    /// - 文件缺失 → `false`(幂等)
258    /// - 移除后 mcpServers 为空 → 保留空对象写回(`~/.claude.json` 承载 OAuth
259    ///   会话等用户配置,**绝不删除整个文件**;与 opencode.json 同语义)
260    /// - 移除后仍有其他 server → 原子写回保留
261    /// - JSON 解析失败 → 显式报错(拒绝静默清理损坏配置)
262    pub fn remove(&self, server: &str) -> Result<bool> {
263        if !self.path.exists() {
264            return Ok(false);
265        }
266        let content = std::fs::read_to_string(&self.path)
267            .with_context(|| format!("读取 Claude 用户配置失败: {}", self.path.display()))?;
268        let mut value: serde_json::Value = serde_json::from_str(&content)
269            .with_context(|| format!("解析 Claude 用户配置失败(拒绝静默跳过): {}", self.path.display()))?;
270
271        let removed = {
272            let servers = value.get_mut("mcpServers").and_then(|v| v.as_object_mut());
273            match servers {
274                None => false,
275                Some(s) => s.remove(server).is_some(),
276            }
277        };
278        if !removed {
279            return Ok(false);
280        }
281        write_file_atomic(&self.path, &serde_json::to_string_pretty(&value)?)?;
282        Ok(true)
283    }}
284
285// ---------------------------------------------------------------------------
286// Codex CLI(用户级 `~/.codex/config.toml` 的 `[mcp_servers.<name>]` 表)
287// ---------------------------------------------------------------------------
288
289/// Codex CLI MCP 配置读写(`~/.codex/config.toml`)
290///
291/// 官方格式(developers.openai.com/codex/mcp):`[mcp_servers.<name>]` 表,
292/// stdio 用 `command`(必填)+ `args`。项目级 `.codex/config.toml` 只在
293/// 项目被信任时加载,install 场景写全局文件(一次注册所有仓库可用)。
294///
295/// 用**文本级表编辑**而非 toml::Value 往返:Codex 配置可能含用户手写的
296/// 注释与其他表(provider/auth 等),toml::Value 序列化会重排整文件丢注释;
297/// 本 writer 只替换/追加本 server 的虚线表块,其余内容原样保留。
298pub struct CodexMcp {
299    /// 用户级 Codex 配置路径
300    pub config_path: PathBuf,
301}
302
303/// TOML basic string 转义(路径含 Windows 反斜杠必须转义,否则解析失败)
304fn toml_basic_escape(s: &str) -> String {
305    let mut out = String::with_capacity(s.len() + 8);
306    for ch in s.chars() {
307        match ch {
308            '\\' => out.push_str("\\\\"),
309            '"' => out.push_str("\\\""),
310            '\n' => out.push_str("\\n"),
311            '\r' => out.push_str("\\r"),
312            '\t' => out.push_str("\\t"),
313            _ => out.push(ch),
314        }
315    }
316    out
317}
318
319impl CodexMcp {
320    /// 用户级 Codex 配置路径(`~/.codex/config.toml`)
321    pub fn global_path() -> Result<PathBuf> {
322        Ok(user_home()?.join(".codex").join("config.toml"))
323    }
324
325    /// 本 server 的虚线表头(`[mcp_servers.<name>]`)
326    fn table_header(server: &str) -> String {
327        format!("[mcp_servers.{server}]")
328    }
329
330    /// 定位虚线表块([header] 起始行到下一个表头/EOF 的行区间)
331    ///
332    /// 返回 (起始行号 0-based, 结束行号不包含, 是否命中)。表头行要求
333    /// 行首 `[`(TOML 表头必须行首)且精确匹配 header 字符串。
334    fn find_table(content: &str, header: &str) -> Option<(usize, usize)> {
335        let lines: Vec<&str> = content.lines().collect();
336        let mut start: Option<usize> = None;
337        for (i, line) in lines.iter().enumerate() {
338            let trimmed = line.trim_start();
339            match start {
340                None => {
341                    // 未找到目标表:目标表头必须精确匹配(行首 [ 且整行等于 header)
342                    if trimmed.starts_with('[') && line.trim() == header {
343                        start = Some(i);
344                    }
345                }
346                Some(s) => {
347                    // 已找到目标表:下一个行首 [ 的表头(非注释内)即表块结束
348                    if trimmed.starts_with('[') {
349                        return Some((s, i));
350                    }
351                }
352            }
353        }
354        start.map(|s| (s, lines.len()))
355    }
356
357    /// 注册/更新 MCP server;返回是否实际变更
358    ///
359    /// - 文件缺失 → 新建(内容仅本表)
360    /// - 表已存在且 command 相同 → `false`;command 不同 → 整块替换(升级)
361    /// - 其他表/注释原样保留
362    pub fn install(&self, server: &str, command: &str, args: &[String]) -> Result<bool> {
363        let header = Self::table_header(server);
364        let body = format!(
365            "{}\ncommand = \"{}\"\nargs = [{}]\n",
366            header,
367            toml_basic_escape(command),
368            args.iter()
369                .map(|a| format!("\"{}\"", toml_basic_escape(a)))
370                .collect::<Vec<_>>()
371                .join(", ")
372        );
373
374        let content = match std::fs::read_to_string(&self.config_path) {
375            Ok(c) => c,
376            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
377            Err(e) => return Err(e).context("读取 Codex 配置失败"),
378        };
379
380        match Self::find_table(&content, &header) {
381            Some((start, end)) => {
382                // 表已存在:比对 command 是否一致(一致 → 跳过,否则替换块)
383                let current: Vec<&str> = content.lines().collect();
384                let block = current[start..end].join("\n");
385                if block.contains(&format!("command = \"{}\"", toml_basic_escape(command))) {
386                    return Ok(false);
387                }
388                // 组装替换:保留行尾换行一致性——用原始行重建(含换行符)
389                let mut out = String::with_capacity(content.len() + body.len());
390                // content 逐行重建直到 start
391                let mut line_iter = content.split_inclusive('\n');
392                for _ in 0..start {
393                    if let Some(l) = line_iter.next() {
394                        out.push_str(l);
395                    }
396                }
397                // 跳过旧块(start..end),写入新块(表体以换行结尾)
398                for _ in start..end {
399                    let _ = line_iter.next();
400                }
401                out.push_str(&body);
402                // 旧块之后的行:若紧跟旧块末尾的是空行,保留其原有前缀逻辑
403                for l in line_iter {
404                    out.push_str(l);
405                }
406                write_file_atomic(&self.config_path, &out)?;
407                Ok(true)
408            }
409            None => {
410                // 不存在:文件尾追加(空文件直接写;有内容则补一个空行分隔)
411                let separator = if content.is_empty() || content.ends_with("\n\n") {
412                    ""
413                } else if content.ends_with('\n') {
414                    "\n"
415                } else {
416                    "\n\n"
417                };
418                if let Some(parent) = self.config_path.parent() {
419                    std::fs::create_dir_all(parent)
420                        .with_context(|| format!("创建配置目录失败: {}", parent.display()))?;
421                }
422                write_file_atomic(&self.config_path, &format!("{content}{separator}{body}"))?;
423                Ok(true)
424            }
425        }
426    }
427
428    /// 移除 MCP server 表;返回是否实际移除
429    ///
430    /// - 文件缺失/无该表 → `false`(幂等)
431    /// - 移除后其他表/注释保留
432    pub fn remove(&self, server: &str) -> Result<bool> {
433        if !self.config_path.exists() {
434            return Ok(false);
435        }
436        let content = std::fs::read_to_string(&self.config_path)
437            .with_context(|| format!("读取 Codex 配置失败: {}", self.config_path.display()))?;
438        let header = Self::table_header(server);
439        match Self::find_table(&content, &header) {
440            Some((start, end)) => {
441                // 重建:跳过 start..end 区间(含表头行与块内全部行)。
442                // 块后可能残留空行:若移除后出现连续空行,压缩为单空行。
443                let mut out = String::with_capacity(content.len());
444                let mut line_iter = content.split_inclusive('\n');
445                for _ in 0..start {
446                    if let Some(l) = line_iter.next() {
447                        out.push_str(l);
448                    }
449                }
450                for _ in start..end {
451                    let _ = line_iter.next();
452                }
453                // 剩余行:跳过块后紧跟的一个空行(块删除后留下双空行)
454                let mut skip_blank = true;
455                for l in line_iter {
456                    if skip_blank && l.trim().is_empty() {
457                        skip_blank = false;
458                        continue;
459                    }
460                    skip_blank = false;
461                    out.push_str(l);
462                }
463                // 尾部若以多个空行结尾,压缩为单换行
464                while out.ends_with("\n\n") {
465                    out.pop();
466                }
467                write_file_atomic(&self.config_path, &out)?;
468                Ok(true)
469            }
470            None => Ok(false),
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use std::path::Path;
479    use std::sync::atomic::{AtomicU64, Ordering};
480
481    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
482
483    /// 独立临时目录(防并行测试冲突),返回目录路径
484    fn temp_dir(tag: &str) -> PathBuf {
485        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
486        let dir = std::env::temp_dir().join(format!("code-repo-wiki-mcp-test-{tag}-{}-{id}", std::process::id()));
487        let _ = std::fs::remove_dir_all(&dir);
488        std::fs::create_dir_all(&dir).expect("创建临时目录失败");
489        dir
490    }
491
492    fn write(path: &Path, content: &str) {
493        std::fs::write(path, content).expect("写入临时文件失败");
494    }
495
496    // ---- OpencodeMcp ----
497
498    #[test]
499    fn opencode_install_creates_mcp_block() {
500        let dir = temp_dir("oc-create");
501        let path = dir.join("opencode.json");
502        let mcp = OpencodeMcp { config_path: path.clone() };
503        let cmd = vec!["/usr/bin/code-repo-wiki".to_string(), "mcp".to_string()];
504        assert!(mcp.install("code-repo-wiki", &cmd).unwrap());
505        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
506        let entry = &parsed["mcp"]["code-repo-wiki"];
507        assert_eq!(entry["type"], "local");
508        assert_eq!(entry["command"][0], "/usr/bin/code-repo-wiki");
509        assert_eq!(entry["command"][1], "mcp");
510        assert_eq!(entry["enabled"], true);
511    }
512
513    #[test]
514    fn opencode_install_idempotent_skips_unchanged() {
515        let dir = temp_dir("oc-idem");
516        let path = dir.join("opencode.json");
517        let mcp = OpencodeMcp { config_path: path.clone() };
518        let cmd = vec!["code-repo-wiki".to_string(), "mcp".to_string()];
519        assert!(mcp.install("code-repo-wiki", &cmd).unwrap());
520        assert!(!mcp.install("code-repo-wiki", &cmd).unwrap());
521    }
522
523    #[test]
524    fn opencode_install_upgrades_changed_command() {
525        let dir = temp_dir("oc-upgrade");
526        let path = dir.join("opencode.json");
527        let mcp = OpencodeMcp { config_path: path.clone() };
528        let old = vec!["/old/code-repo-wiki".to_string(), "mcp".to_string()];
529        let new = vec!["/new/code-repo-wiki".to_string(), "mcp".to_string()];
530        assert!(mcp.install("code-repo-wiki", &old).unwrap());
531        assert!(mcp.install("code-repo-wiki", &new).unwrap());
532        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
533        assert_eq!(parsed["mcp"]["code-repo-wiki"]["command"][0], "/new/code-repo-wiki");
534    }
535
536    #[test]
537    fn opencode_install_preserves_other_servers() {
538        let dir = temp_dir("oc-preserve");
539        let path = dir.join("opencode.json");
540        write(&path, r#"{"mcp": {"other": {"type": "local", "command": ["npx", "x"]}}}"#);
541        let mcp = OpencodeMcp { config_path: path.clone() };
542        mcp.install("code-repo-wiki", &["rw".to_string(), "mcp".to_string()]).unwrap();
543        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
544        assert!(parsed["mcp"]["other"].is_object());
545        assert!(parsed["mcp"]["code-repo-wiki"].is_object());
546    }
547
548    #[test]
549    fn opencode_install_rejects_non_object_top_level() {
550        let dir = temp_dir("oc-malformed");
551        let path = dir.join("opencode.json");
552        write(&path, "[1, 2, 3]");
553        let mcp = OpencodeMcp { config_path: path.clone() };
554        assert!(mcp.install("code-repo-wiki", &["x".to_string()]).is_err());
555    }
556
557    #[test]
558    fn opencode_remove_idempotent_and_cleanup() {
559        let dir = temp_dir("oc-remove");
560        let path = dir.join("opencode.json");
561        let mcp = OpencodeMcp { config_path: path.clone() };
562        assert!(!mcp.remove("code-repo-wiki").unwrap()); // 文件缺失 → false
563        mcp.install("code-repo-wiki", &["rw".to_string()]).unwrap();
564        assert!(mcp.remove("code-repo-wiki").unwrap());
565        assert!(!mcp.remove("code-repo-wiki").unwrap()); // 已删 → false
566        // mcp 块已空 → 整个 mcp 键删除
567        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
568        assert!(parsed.get("mcp").is_none());
569    }
570
571    #[test]
572    fn opencode_remove_preserves_other_servers() {
573        let dir = temp_dir("oc-remove-preserve");
574        let path = dir.join("opencode.json");
575        write(&path, r#"{"mcp": {"code-repo-wiki": {"type": "local", "command": ["rw"]}, "other": {"type": "local", "command": ["npx", "y"]}}, "provider": {"x": 1}}"#);
576        let mcp = OpencodeMcp { config_path: path.clone() };
577        assert!(mcp.remove("code-repo-wiki").unwrap());
578        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
579        assert!(parsed["mcp"]["other"].is_object());
580        assert!(parsed.get("mcp").unwrap().get("code-repo-wiki").is_none());
581        assert_eq!(parsed["provider"]["x"], 1);
582    }
583
584    // ---- ClaudeMcp ----
585
586    #[test]
587    fn claude_install_creates_servers_block() {
588        let dir = temp_dir("cl-create");
589        let path = dir.join("claude.json");
590        let mcp = ClaudeMcp { path: path.clone() };
591        assert!(mcp.install("code-repo-wiki", "rw", &["mcp".to_string()]).unwrap());
592        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
593        let entry = &parsed["mcpServers"]["code-repo-wiki"];
594        assert_eq!(entry["type"], "stdio");
595        assert_eq!(entry["command"], "rw");
596        assert_eq!(entry["args"][0], "mcp");
597    }
598
599    #[test]
600    fn claude_install_preserves_other_servers_and_idempotent() {
601        let dir = temp_dir("cl-preserve");
602        let path = dir.join("claude.json");
603        write(&path, r#"{"oauthAccount": {}, "mcpServers": {"other": {"command": "npx", "args": ["x"]}}}"#);
604        let mcp = ClaudeMcp { path: path.clone() };
605        let cmd = ("rw", vec!["mcp".to_string()]);
606        assert!(mcp.install("code-repo-wiki", cmd.0, &cmd.1).unwrap());
607        assert!(!mcp.install("code-repo-wiki", cmd.0, &cmd.1).unwrap());
608        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
609        assert!(parsed["oauthAccount"].is_object(), "OAuth 会话等用户键必须保留");
610        assert!(parsed["mcpServers"]["other"].is_object());
611        assert!(parsed["mcpServers"]["code-repo-wiki"].is_object());
612    }
613
614    #[test]
615    fn claude_remove_keeps_file_with_empty_servers() {
616        let dir = temp_dir("cl-remove");
617        let path = dir.join("claude.json");
618        let mcp = ClaudeMcp { path: path.clone() };
619        mcp.install("code-repo-wiki", "rw", &[]).unwrap();
620        assert!(mcp.remove("code-repo-wiki").unwrap());
621        // ~/.claude.json 承载 OAuth 会话等用户配置:空 mcpServers 保留文件
622        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
623        assert!(parsed["mcpServers"].as_object().unwrap().is_empty());
624        assert!(!mcp.remove("code-repo-wiki").unwrap()); // 条目缺失 → 幂等
625    }
626
627    #[test]
628    fn claude_remove_preserves_other_servers() {
629        let dir = temp_dir("cl-remove-preserve");
630        let path = dir.join("claude.json");
631        write(&path, r#"{"mcpServers": {"code-repo-wiki": {"command": "rw"}, "other": {"command": "npx"}}}"#);
632        let mcp = ClaudeMcp { path: path.clone() };
633        assert!(mcp.remove("code-repo-wiki").unwrap());
634        let parsed: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
635        assert!(parsed["mcpServers"]["other"].is_object());
636        assert!(parsed["mcpServers"].get("code-repo-wiki").is_none());
637    }
638
639    #[test]
640    fn claude_remove_rejects_malformed_json() {
641        let dir = temp_dir("cl-malformed");
642        let path = dir.join("claude.json");
643        write(&path, "{not json");
644        let mcp = ClaudeMcp { path: path.clone() };
645        assert!(mcp.remove("code-repo-wiki").is_err());
646    }
647
648    // ---- CodexMcp ----
649
650    #[test]
651    fn codex_install_creates_table_and_roundtrips() {
652        let dir = temp_dir("cx-create");
653        let path = dir.join("config.toml");
654        let mcp = CodexMcp { config_path: path.clone() };
655        let exe = r"C:\RustProjects\code-repo-wiki\target\release\code-repo-wiki.exe";
656        assert!(mcp.install("code-repo-wiki", exe, &["mcp".to_string()]).unwrap());
657        let content = std::fs::read_to_string(&path).unwrap();
658        assert!(content.contains("[mcp_servers.code-repo-wiki]"));
659        assert!(content.contains("command = \"C:\\\\RustProjects\\\\code-repo-wiki"));
660        // toml 解析回读验证
661        let parsed: toml::Value = toml::from_str(&content).unwrap();
662        assert_eq!(parsed["mcp_servers"]["code-repo-wiki"]["command"].as_str(), Some(exe));
663        assert_eq!(parsed["mcp_servers"]["code-repo-wiki"]["args"][0].as_str(), Some("mcp"));
664    }
665
666    #[test]
667    fn codex_install_preserves_other_tables_and_comments() {
668        let dir = temp_dir("cx-preserve");
669        let path = dir.join("config.toml");
670        write(&path, "# 我的注释\n[model]\nname = \"gpt-5\"\n\n[provider.openai]\nkey = \"x\"\n");
671        let mcp = CodexMcp { config_path: path.clone() };
672        mcp.install("code-repo-wiki", "rw", &["mcp".to_string()]).unwrap();
673        let content = std::fs::read_to_string(&path).unwrap();
674        assert!(content.contains("# 我的注释"));
675        assert!(content.contains("[model]"));
676        assert!(content.contains("[provider.openai]"));
677        let parsed: toml::Value = toml::from_str(&content).unwrap();
678        assert!(parsed["mcp_servers"]["code-repo-wiki"].is_table());
679        assert_eq!(parsed["model"]["name"].as_str(), Some("gpt-5"));
680    }
681
682    #[test]
683    fn codex_install_upgrades_existing_table() {
684        let dir = temp_dir("cx-upgrade");
685        let path = dir.join("config.toml");
686        write(&path, "[mcp_servers.code-repo-wiki]\ncommand = \"/old/rw\"\nargs = [\"mcp\"]\n");
687        let mcp = CodexMcp { config_path: path.clone() };
688        assert!(mcp.install("code-repo-wiki", "/new/rw", &["mcp".to_string()]).unwrap());
689        let parsed: toml::Value = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
690        assert_eq!(parsed["mcp_servers"]["code-repo-wiki"]["command"].as_str(), Some("/new/rw"));
691    }
692
693    #[test]
694    fn codex_install_idempotent() {
695        let dir = temp_dir("cx-idem");
696        let path = dir.join("config.toml");
697        let mcp = CodexMcp { config_path: path.clone() };
698        assert!(mcp.install("code-repo-wiki", "/rw", &["mcp".to_string()]).unwrap());
699        assert!(!mcp.install("code-repo-wiki", "/rw", &["mcp".to_string()]).unwrap());
700    }
701
702    #[test]
703    fn codex_remove_table_preserves_rest() {
704        let dir = temp_dir("cx-remove");
705        let path = dir.join("config.toml");
706        write(&path, "[model]\nname = \"gpt-5\"\n\n[mcp_servers.code-repo-wiki]\ncommand = \"/rw\"\n\n[provider.openai]\nkey = \"x\"\n");
707        let mcp = CodexMcp { config_path: path.clone() };
708        assert!(mcp.remove("code-repo-wiki").unwrap());
709        assert!(!mcp.remove("code-repo-wiki").unwrap());
710        let content = std::fs::read_to_string(&path).unwrap();
711        assert!(!content.contains("mcp_servers"));
712        assert!(content.contains("[model]"));
713        assert!(content.contains("[provider.openai]"));
714        // 移除后仍可被 toml 解析(无残留双空行/孤儿行)
715        toml::from_str::<toml::Value>(&content).unwrap();
716    }
717
718    #[test]
719    fn codex_remove_missing_is_idempotent() {
720        let dir = temp_dir("cx-remove-miss");
721        let path = dir.join("config.toml");
722        write(&path, "[model]\nname = \"x\"\n");
723        let mcp = CodexMcp { config_path: path.clone() };
724        assert!(!mcp.remove("code-repo-wiki").unwrap());
725    }
726}