1use std::path::PathBuf;
23
24use anyhow::{Context, Result};
25
26use crate::fs::write_file_atomic;
27
28pub 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
41pub struct OpencodeMcp {
53 pub config_path: PathBuf,
55}
56
57impl OpencodeMcp {
58 pub fn global_path() -> Result<PathBuf> {
60 Ok(user_home()?.join(".config").join("opencode").join("opencode.json"))
61 }
62
63 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 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 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 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
163pub struct ClaudeMcp {
182 pub path: PathBuf,
184}
185
186impl ClaudeMcp {
187 pub fn user_global_path() -> Result<PathBuf> {
189 Ok(user_home()?.join(".claude.json"))
190 }
191
192 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 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
285pub struct CodexMcp {
299 pub config_path: PathBuf,
301}
302
303fn 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 pub fn global_path() -> Result<PathBuf> {
322 Ok(user_home()?.join(".codex").join("config.toml"))
323 }
324
325 fn table_header(server: &str) -> String {
327 format!("[mcp_servers.{server}]")
328 }
329
330 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 if trimmed.starts_with('[') && line.trim() == header {
343 start = Some(i);
344 }
345 }
346 Some(s) => {
347 if trimmed.starts_with('[') {
349 return Some((s, i));
350 }
351 }
352 }
353 }
354 start.map(|s| (s, lines.len()))
355 }
356
357 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 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 let mut out = String::with_capacity(content.len() + body.len());
390 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 for _ in start..end {
399 let _ = line_iter.next();
400 }
401 out.push_str(&body);
402 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 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 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 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 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 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 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 #[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()); 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()); 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 #[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 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()); }
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 #[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 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::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}