1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::str::FromStr;
4
5use crate::services::skill::SkillStore;
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
9pub struct McpApps {
10 #[serde(default)]
11 pub claude: bool,
12 #[serde(default)]
13 pub codex: bool,
14 #[serde(default)]
15 pub gemini: bool,
16 #[serde(default)]
17 pub opencode: bool,
18 #[serde(default)]
19 pub hermes: bool,
20}
21
22impl McpApps {
23 pub fn is_enabled_for(&self, app: &AppType) -> bool {
25 match app {
26 AppType::Claude => self.claude,
27 AppType::Codex => self.codex,
28 AppType::Gemini => self.gemini,
29 AppType::OpenCode => self.opencode,
30 AppType::OpenClaw => false,
31 AppType::Hermes => self.hermes,
32 }
33 }
34
35 pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
37 match app {
38 AppType::Claude => self.claude = enabled,
39 AppType::Codex => self.codex = enabled,
40 AppType::Gemini => self.gemini = enabled,
41 AppType::OpenCode => self.opencode = enabled,
42 AppType::OpenClaw => {}
43 AppType::Hermes => self.hermes = enabled,
44 }
45 }
46
47 pub fn enabled_apps(&self) -> Vec<AppType> {
49 let mut apps = Vec::new();
50 if self.claude {
51 apps.push(AppType::Claude);
52 }
53 if self.codex {
54 apps.push(AppType::Codex);
55 }
56 if self.gemini {
57 apps.push(AppType::Gemini);
58 }
59 if self.opencode {
60 apps.push(AppType::OpenCode);
61 }
62 if self.hermes {
63 apps.push(AppType::Hermes);
64 }
65 apps
66 }
67
68 pub fn is_empty(&self) -> bool {
70 !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
76pub struct SkillApps {
77 #[serde(default)]
78 pub claude: bool,
79 #[serde(default)]
80 pub codex: bool,
81 #[serde(default)]
82 pub gemini: bool,
83 #[serde(default)]
84 pub opencode: bool,
85 #[serde(default)]
86 pub hermes: bool,
87}
88
89impl SkillApps {
90 pub fn is_enabled_for(&self, app: &AppType) -> bool {
91 match app {
92 AppType::Claude => self.claude,
93 AppType::Codex => self.codex,
94 AppType::Gemini => self.gemini,
95 AppType::OpenCode => self.opencode,
96 AppType::OpenClaw => false,
97 AppType::Hermes => self.hermes,
98 }
99 }
100
101 pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
102 match app {
103 AppType::Claude => self.claude = enabled,
104 AppType::Codex => self.codex = enabled,
105 AppType::Gemini => self.gemini = enabled,
106 AppType::OpenCode => self.opencode = enabled,
107 AppType::OpenClaw => {}
108 AppType::Hermes => self.hermes = enabled,
109 }
110 }
111
112 pub fn is_empty(&self) -> bool {
113 !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
114 }
115
116 pub fn only(app: &AppType) -> Self {
117 let mut apps = Self::default();
118 apps.set_enabled_for(app, true);
119 apps
120 }
121
122 pub fn from_labels(labels: &[String]) -> Self {
123 let mut apps = Self::default();
124 for label in labels {
125 if let Ok(app) = label.parse::<AppType>() {
126 apps.set_enabled_for(&app, true);
127 }
128 }
129 apps
130 }
131
132 pub fn merge_enabled(&mut self, other: &SkillApps) {
133 self.claude |= other.claude;
134 self.codex |= other.codex;
135 self.gemini |= other.gemini;
136 self.opencode |= other.opencode;
137 self.hermes |= other.hermes;
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct InstalledSkill {
145 pub id: String,
147 pub name: String,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub description: Option<String>,
152 pub directory: String,
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub repo_owner: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub repo_name: Option<String>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub repo_branch: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub readme_url: Option<String>,
166 pub apps: SkillApps,
168 pub installed_at: i64,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct UnmanagedSkill {
176 pub directory: String,
178 pub name: String,
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub description: Option<String>,
183 pub found_in: Vec<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct McpServer {
190 pub id: String,
191 pub name: String,
192 pub server: serde_json::Value,
193 pub apps: McpApps,
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub description: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
197 pub homepage: Option<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub docs: Option<String>,
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
201 pub tags: Vec<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Default)]
206pub struct McpConfig {
207 #[serde(default)]
209 pub servers: HashMap<String, serde_json::Value>,
210}
211
212impl McpConfig {
213 pub fn is_empty(&self) -> bool {
215 self.servers.is_empty()
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct McpRoot {
222 #[serde(skip_serializing_if = "Option::is_none")]
224 pub servers: Option<HashMap<String, McpServer>>,
225
226 #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
228 pub claude: McpConfig,
229 #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
230 pub codex: McpConfig,
231 #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
232 pub gemini: McpConfig,
233 #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
234 pub opencode: McpConfig,
235 #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
236 pub openclaw: McpConfig,
237 #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
238 pub hermes: McpConfig,
239}
240
241impl Default for McpRoot {
242 fn default() -> Self {
243 Self {
244 servers: Some(HashMap::new()),
246 claude: McpConfig::default(),
248 codex: McpConfig::default(),
249 gemini: McpConfig::default(),
250 opencode: McpConfig::default(),
251 openclaw: McpConfig::default(),
252 hermes: McpConfig::default(),
253 }
254 }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, Default)]
259pub struct PromptConfig {
260 #[serde(default)]
261 pub prompts: HashMap<String, crate::prompt::Prompt>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, Default)]
266pub struct PromptRoot {
267 #[serde(default)]
268 pub claude: PromptConfig,
269 #[serde(default)]
270 pub codex: PromptConfig,
271 #[serde(default)]
272 pub gemini: PromptConfig,
273 #[serde(default)]
274 pub opencode: PromptConfig,
275 #[serde(default)]
276 pub openclaw: PromptConfig,
277 #[serde(default)]
278 pub hermes: PromptConfig,
279}
280
281use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
282use crate::error::AppError;
283use crate::prompt_files::prompt_file_path;
284use crate::provider::ProviderManager;
285
286#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, clap::ValueEnum)]
288#[serde(rename_all = "lowercase")]
289pub enum AppType {
290 Claude,
291 Codex,
292 Gemini,
293 OpenCode,
294 OpenClaw,
295 Hermes,
296}
297
298pub const MCP_PICKER_APPS: &[AppType] = &[
300 AppType::Claude,
301 AppType::Codex,
302 AppType::Gemini,
303 AppType::OpenCode,
304 AppType::Hermes,
305];
306
307pub const VISIBLE_PICKER_APPS: &[AppType] = &[
309 AppType::Claude,
310 AppType::Codex,
311 AppType::Gemini,
312 AppType::OpenCode,
313 AppType::OpenClaw,
314 AppType::Hermes,
315];
316
317pub const SKILLS_PICKER_APPS: &[AppType] = &[
319 AppType::Claude,
320 AppType::Codex,
321 AppType::Gemini,
322 AppType::OpenCode,
323 AppType::Hermes,
324];
325
326impl AppType {
327 pub fn as_str(&self) -> &'static str {
328 match self {
329 AppType::Claude => "claude",
330 AppType::Codex => "codex",
331 AppType::Gemini => "gemini",
332 AppType::OpenCode => "opencode",
333 AppType::OpenClaw => "openclaw",
334 AppType::Hermes => "hermes",
335 }
336 }
337
338 pub fn is_additive_mode(&self) -> bool {
339 matches!(
340 self,
341 AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
342 )
343 }
344
345 pub fn all() -> impl Iterator<Item = AppType> {
346 [
347 AppType::Claude,
348 AppType::Codex,
349 AppType::Gemini,
350 AppType::OpenCode,
351 AppType::OpenClaw,
352 AppType::Hermes,
353 ]
354 .into_iter()
355 }
356}
357
358impl std::fmt::Display for AppType {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 write!(f, "{}", self.as_str())
361 }
362}
363
364impl FromStr for AppType {
365 type Err = AppError;
366
367 fn from_str(s: &str) -> Result<Self, Self::Err> {
368 let normalized = s.trim().to_lowercase();
369 match normalized.as_str() {
370 "claude" => Ok(AppType::Claude),
371 "codex" => Ok(AppType::Codex),
372 "gemini" => Ok(AppType::Gemini),
373 "opencode" => Ok(AppType::OpenCode),
374 "openclaw" => Ok(AppType::OpenClaw),
375 "hermes" => Ok(AppType::Hermes),
376 other => Err(AppError::localized(
377 "unsupported_app",
378 format!(
379 "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"
380 ),
381 format!(
382 "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."
383 ),
384 )),
385 }
386 }
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, Default)]
391pub struct CommonConfigSnippets {
392 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub claude: Option<String>,
394
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub codex: Option<String>,
397
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub gemini: Option<String>,
400
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub opencode: Option<String>,
403
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub openclaw: Option<String>,
406
407 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub hermes: Option<String>,
409}
410
411impl CommonConfigSnippets {
412 pub fn get(&self, app: &AppType) -> Option<&String> {
414 match app {
415 AppType::Claude => self.claude.as_ref(),
416 AppType::Codex => self.codex.as_ref(),
417 AppType::Gemini => self.gemini.as_ref(),
418 AppType::OpenCode => self.opencode.as_ref(),
419 AppType::OpenClaw => self.openclaw.as_ref(),
420 AppType::Hermes => self.hermes.as_ref(),
421 }
422 }
423
424 pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
426 match app {
427 AppType::Claude => self.claude = snippet,
428 AppType::Codex => self.codex = snippet,
429 AppType::Gemini => self.gemini = snippet,
430 AppType::OpenCode => self.opencode = snippet,
431 AppType::OpenClaw => self.openclaw = snippet,
432 AppType::Hermes => self.hermes = snippet,
433 }
434 }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct MultiAppConfig {
440 #[serde(default = "default_version")]
441 pub version: u32,
442 #[serde(flatten)]
444 pub apps: HashMap<String, ProviderManager>,
445 #[serde(default)]
447 pub mcp: McpRoot,
448 #[serde(default)]
450 pub prompts: PromptRoot,
451 #[serde(default)]
453 pub skills: SkillStore,
454 #[serde(default)]
456 pub common_config_snippets: CommonConfigSnippets,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub claude_common_config_snippet: Option<String>,
460}
461
462fn default_version() -> u32 {
463 2
464}
465
466impl Default for MultiAppConfig {
467 fn default() -> Self {
468 let mut apps = HashMap::new();
469 apps.insert("claude".to_string(), ProviderManager::default());
470 apps.insert("codex".to_string(), ProviderManager::default());
471 apps.insert("gemini".to_string(), ProviderManager::default());
472 apps.insert("opencode".to_string(), ProviderManager::default());
473 apps.insert("openclaw".to_string(), ProviderManager::default());
474 apps.insert("hermes".to_string(), ProviderManager::default());
475
476 Self {
477 version: 2,
478 apps,
479 mcp: McpRoot::default(),
480 prompts: PromptRoot::default(),
481 skills: SkillStore::default(),
482 common_config_snippets: CommonConfigSnippets::default(),
483 claude_common_config_snippet: None,
484 }
485 }
486}
487
488impl MultiAppConfig {
489 pub fn load() -> Result<Self, AppError> {
491 let config_path = get_app_config_path();
492
493 if !config_path.exists() {
494 log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
495 let config = Self::default_with_auto_import()?;
497 config.save()?;
499 return Ok(config);
500 }
501
502 let content =
504 std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
505
506 let value: serde_json::Value =
509 serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;
510 let is_v1 = value.as_object().is_some_and(|map| {
511 let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false);
512 let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false);
513 let has_apps = map.contains_key("apps");
515 has_providers && has_current && !has_apps
516 });
517 if is_v1 {
518 return Err(AppError::localized(
519 "config.unsupported_v1",
520 "检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch-tui/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
521 "Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch-tui/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
522 ));
523 }
524
525 let has_skills_in_config = value
526 .as_object()
527 .is_some_and(|map| map.contains_key("skills"));
528
529 let mut config: Self =
531 serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
532 let mut updated = false;
533
534 if !has_skills_in_config {
535 let skills_path = get_app_config_dir().join("skills.json");
536 if skills_path.exists() {
537 match std::fs::read_to_string(&skills_path) {
538 Ok(content) => match serde_json::from_str::<SkillStore>(&content) {
539 Ok(store) => {
540 config.skills = store;
541 updated = true;
542 log::info!("已从旧版 skills.json 导入 Claude Skills 配置");
543 }
544 Err(e) => {
545 log::warn!("解析旧版 skills.json 失败: {e}");
546 }
547 },
548 Err(e) => {
549 log::warn!("读取旧版 skills.json 失败: {e}");
550 }
551 }
552 }
553 }
554
555 if !config.apps.contains_key("gemini") {
557 config
558 .apps
559 .insert("gemini".to_string(), ProviderManager::default());
560 updated = true;
561 }
562
563 if !config.apps.contains_key("opencode") {
564 config
565 .apps
566 .insert("opencode".to_string(), ProviderManager::default());
567 updated = true;
568 }
569
570 if !config.apps.contains_key("openclaw") {
571 config
572 .apps
573 .insert("openclaw".to_string(), ProviderManager::default());
574 updated = true;
575 }
576
577 let migrated = config.migrate_mcp_to_unified()?;
579 if migrated {
580 log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
581 updated = true;
582 }
583
584 let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
587 if imported_prompts {
588 updated = true;
589 }
590
591 if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
593 log::info!(
594 "迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
595 );
596 config.common_config_snippets.claude = Some(old_claude_snippet);
597 updated = true;
598 }
599
600 if updated {
601 log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
602 config.save()?;
603 }
604
605 Ok(config)
606 }
607
608 pub fn save(&self) -> Result<(), AppError> {
610 let config_path = get_app_config_path();
611 if config_path.exists() {
613 let backup_path = get_app_config_dir().join("config.json.bak");
614 if let Err(e) = copy_file(&config_path, &backup_path) {
615 log::warn!("备份 config.json 到 .bak 失败: {e}");
616 }
617 }
618
619 write_json_file(&config_path, self)?;
620 Ok(())
621 }
622
623 pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
625 self.apps.get(app.as_str())
626 }
627
628 pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
630 self.apps.get_mut(app.as_str())
631 }
632
633 pub fn ensure_app(&mut self, app: &AppType) {
635 if !self.apps.contains_key(app.as_str()) {
636 self.apps
637 .insert(app.as_str().to_string(), ProviderManager::default());
638 }
639 }
640
641 pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
643 match app {
644 AppType::Claude => &self.mcp.claude,
645 AppType::Codex => &self.mcp.codex,
646 AppType::Gemini => &self.mcp.gemini,
647 AppType::OpenCode => &self.mcp.opencode,
648 AppType::OpenClaw => &self.mcp.openclaw,
649 AppType::Hermes => &self.mcp.hermes,
650 }
651 }
652
653 pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
655 match app {
656 AppType::Claude => &mut self.mcp.claude,
657 AppType::Codex => &mut self.mcp.codex,
658 AppType::Gemini => &mut self.mcp.gemini,
659 AppType::OpenCode => &mut self.mcp.opencode,
660 AppType::OpenClaw => &mut self.mcp.openclaw,
661 AppType::Hermes => &mut self.mcp.hermes,
662 }
663 }
664
665 fn default_with_auto_import() -> Result<Self, AppError> {
667 log::info!("首次启动,创建默认配置并检测提示词文件");
668
669 let mut config = Self::default();
670
671 Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
673 Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
674 Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
675 Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
676 Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;
677
678 Ok(config)
679 }
680
681 fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
692 if !self.prompts.claude.prompts.is_empty()
694 || !self.prompts.codex.prompts.is_empty()
695 || !self.prompts.gemini.prompts.is_empty()
696 || !self.prompts.opencode.prompts.is_empty()
697 || !self.prompts.openclaw.prompts.is_empty()
698 {
699 return Ok(false);
700 }
701
702 log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
703
704 let mut imported = false;
705 for app in [
706 AppType::Claude,
707 AppType::Codex,
708 AppType::Gemini,
709 AppType::OpenCode,
710 AppType::OpenClaw,
711 AppType::Hermes,
712 ] {
713 if Self::auto_import_prompt_if_exists(self, app)? {
715 imported = true;
716 }
717 }
718
719 Ok(imported)
720 }
721
722 fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
728 let file_path = prompt_file_path(&app)?;
729
730 if !file_path.exists() {
732 log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
733 return Ok(false);
734 }
735
736 let content = match std::fs::read_to_string(&file_path) {
738 Ok(c) => c,
739 Err(e) => {
740 log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
741 return Ok(false); }
743 };
744
745 if content.trim().is_empty() {
747 log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
748 return Ok(false);
749 }
750
751 log::info!("发现提示词文件,自动导入: {file_path:?}");
752
753 let timestamp = std::time::SystemTime::now()
755 .duration_since(std::time::UNIX_EPOCH)
756 .unwrap()
757 .as_secs() as i64;
758
759 let id = format!("auto-imported-{timestamp}");
760 let prompt = crate::prompt::Prompt {
761 id: id.clone(),
762 name: format!(
763 "Auto-imported Prompt {}",
764 chrono::Local::now().format("%Y-%m-%d %H:%M")
765 ),
766 content,
767 description: Some("Automatically imported on first launch".to_string()),
768 enabled: true, created_at: Some(timestamp),
770 updated_at: Some(timestamp),
771 };
772
773 let prompts = match app {
775 AppType::Claude => &mut config.prompts.claude.prompts,
776 AppType::Codex => &mut config.prompts.codex.prompts,
777 AppType::Gemini => &mut config.prompts.gemini.prompts,
778 AppType::OpenCode => &mut config.prompts.opencode.prompts,
779 AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
780 AppType::Hermes => &mut config.prompts.hermes.prompts,
781 };
782
783 prompts.insert(id, prompt);
784
785 log::info!("自动导入完成: {}", app.as_str());
786 Ok(true)
787 }
788
789 pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
797 if self.mcp.servers.is_some() {
799 log::debug!("MCP 配置已是统一结构,跳过迁移");
800 return Ok(false);
801 }
802
803 log::info!("检测到旧版 MCP 配置格式,开始迁移到 v3.7.0 统一结构...");
804
805 let mut unified_servers: HashMap<String, McpServer> = HashMap::new();
806 let mut conflicts = Vec::new();
807
808 for app in [
810 AppType::Claude,
811 AppType::Codex,
812 AppType::Gemini,
813 AppType::OpenCode,
814 AppType::Hermes,
815 ] {
816 let old_servers = match app {
817 AppType::Claude => &self.mcp.claude.servers,
818 AppType::Codex => &self.mcp.codex.servers,
819 AppType::Gemini => &self.mcp.gemini.servers,
820 AppType::OpenCode => &self.mcp.opencode.servers,
821 AppType::OpenClaw => continue,
822 AppType::Hermes => &self.mcp.hermes.servers,
823 };
824
825 for (id, entry) in old_servers {
826 let enabled = entry
827 .get("enabled")
828 .and_then(|v| v.as_bool())
829 .unwrap_or(true);
830
831 if let Some(existing) = unified_servers.get_mut(id) {
832 existing.apps.set_enabled_for(&app, enabled);
834
835 if existing.server != *entry.get("server").unwrap_or(&serde_json::json!({})) {
837 conflicts.push(format!(
838 "MCP '{id}' 在 {} 和之前的应用中配置不同,将使用首次遇到的配置",
839 app.as_str()
840 ));
841 }
842 } else {
843 let name = entry
845 .get("name")
846 .and_then(|v| v.as_str())
847 .unwrap_or(id)
848 .to_string();
849
850 let server = entry
851 .get("server")
852 .cloned()
853 .unwrap_or(serde_json::json!({}));
854
855 let description = entry
856 .get("description")
857 .and_then(|v| v.as_str())
858 .map(|s| s.to_string());
859
860 let homepage = entry
861 .get("homepage")
862 .and_then(|v| v.as_str())
863 .map(|s| s.to_string());
864
865 let docs = entry
866 .get("docs")
867 .and_then(|v| v.as_str())
868 .map(|s| s.to_string());
869
870 let tags = entry
871 .get("tags")
872 .and_then(|v| v.as_array())
873 .map(|arr| {
874 arr.iter()
875 .filter_map(|v| v.as_str().map(|s| s.to_string()))
876 .collect()
877 })
878 .unwrap_or_default();
879
880 let mut apps = McpApps::default();
881 apps.set_enabled_for(&app, enabled);
882
883 unified_servers.insert(
884 id.clone(),
885 McpServer {
886 id: id.clone(),
887 name,
888 server,
889 apps,
890 description,
891 homepage,
892 docs,
893 tags,
894 },
895 );
896 }
897 }
898 }
899
900 if !conflicts.is_empty() {
902 log::warn!("MCP 迁移过程中检测到配置冲突:");
903 for conflict in &conflicts {
904 log::warn!(" - {conflict}");
905 }
906 }
907
908 log::info!(
909 "MCP 迁移完成,共迁移 {} 个服务器{}",
910 unified_servers.len(),
911 if !conflicts.is_empty() {
912 format!("(存在 {} 个冲突)", conflicts.len())
913 } else {
914 String::new()
915 }
916 );
917
918 self.mcp.servers = Some(unified_servers);
920
921 self.mcp.claude = McpConfig::default();
923 self.mcp.codex = McpConfig::default();
924 self.mcp.gemini = McpConfig::default();
925 self.mcp.opencode = McpConfig::default();
926
927 Ok(true)
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934 use serde_json::json;
935 use serial_test::serial;
936 use std::env;
937 use std::ffi::OsString;
938 use std::fs;
939 use std::path::Path;
940 use tempfile::TempDir;
941
942 struct TempHome {
943 #[allow(dead_code)] dir: TempDir,
945 _lock: crate::test_support::TestHomeSettingsLock,
946 original_home: Option<OsString>,
947 original_userprofile: Option<OsString>,
948 original_config_dir: Option<OsString>,
949 }
950
951 impl TempHome {
952 fn new() -> Self {
953 let dir = TempDir::new().expect("failed to create temp home");
954 let lock = crate::test_support::lock_test_home_and_settings();
955 let original_home = env::var_os("HOME");
956 let original_userprofile = env::var_os("USERPROFILE");
957 let original_config_dir = env::var_os("CC_SWITCH_CONFIG_DIR");
958
959 unsafe {
960 env::set_var("HOME", dir.path());
961 }
962 unsafe {
963 env::set_var("USERPROFILE", dir.path());
964 }
965 unsafe {
966 env::set_var("CC_SWITCH_CONFIG_DIR", dir.path().join(".cc-switch"));
967 }
968 crate::test_support::set_test_home_override(Some(dir.path()));
969 crate::settings::reload_test_settings();
970
971 Self {
972 dir,
973 _lock: lock,
974 original_home,
975 original_userprofile,
976 original_config_dir,
977 }
978 }
979 }
980
981 impl Drop for TempHome {
982 fn drop(&mut self) {
983 match &self.original_home {
984 Some(value) => unsafe { env::set_var("HOME", value) },
985 None => unsafe { env::remove_var("HOME") },
986 }
987
988 match &self.original_userprofile {
989 Some(value) => unsafe { env::set_var("USERPROFILE", value) },
990 None => unsafe { env::remove_var("USERPROFILE") },
991 }
992
993 match &self.original_config_dir {
994 Some(value) => unsafe { env::set_var("CC_SWITCH_CONFIG_DIR", value) },
995 None => unsafe { env::remove_var("CC_SWITCH_CONFIG_DIR") },
996 }
997
998 crate::test_support::set_test_home_override(
999 self.original_home.as_deref().map(Path::new),
1000 );
1001 crate::settings::reload_test_settings();
1002 }
1003 }
1004
1005 fn write_prompt_file(app: AppType, content: &str) {
1006 let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
1007 if let Some(parent) = path.parent() {
1008 fs::create_dir_all(parent).expect("create parent dir");
1009 }
1010 fs::write(path, content).expect("write prompt");
1011 }
1012
1013 fn seed_stale_test_home_with_gemini_override(home: &std::path::Path) {
1014 let stale_gemini_dir = home.join("custom-gemini");
1015 let _lock = crate::test_support::lock_test_home_and_settings();
1016 let original_config_dir = env::var_os("CC_SWITCH_CONFIG_DIR");
1017
1018 unsafe {
1019 env::set_var("CC_SWITCH_CONFIG_DIR", home.join(".cc-switch"));
1020 }
1021 crate::test_support::set_test_home_override(Some(home));
1022 crate::settings::reload_test_settings();
1023
1024 let mut settings = crate::settings::AppSettings::default();
1025 settings.gemini_config_dir = Some(stale_gemini_dir.to_string_lossy().into_owned());
1026 settings.save().expect("save stale settings");
1027 crate::settings::reload_test_settings();
1028
1029 match original_config_dir {
1030 Some(value) => unsafe { env::set_var("CC_SWITCH_CONFIG_DIR", value) },
1031 None => unsafe { env::remove_var("CC_SWITCH_CONFIG_DIR") },
1032 }
1033 }
1034
1035 #[test]
1036 #[serial(home_settings)]
1037 fn temp_home_refreshes_test_home_override_and_settings_cache() {
1038 let stale_home = TempDir::new().expect("failed to create stale home");
1039 seed_stale_test_home_with_gemini_override(stale_home.path());
1040
1041 let home = TempHome::new();
1042
1043 assert_eq!(
1044 crate::config::home_dir(),
1045 Some(home.dir.path().to_path_buf())
1046 );
1047 assert_eq!(crate::settings::get_gemini_override_dir(), None);
1048 assert_eq!(
1049 crate::prompt_files::prompt_file_path(&AppType::Gemini).expect("gemini prompt path"),
1050 home.dir.path().join(".gemini").join("GEMINI.md")
1051 );
1052 }
1053
1054 #[test]
1055 #[serial]
1056 fn auto_imports_existing_prompt_when_config_missing() {
1057 let _home = TempHome::new();
1058 write_prompt_file(AppType::Claude, "# hello");
1059
1060 let config = MultiAppConfig::load().expect("load config");
1061
1062 assert_eq!(config.prompts.claude.prompts.len(), 1);
1063 let prompt = config
1064 .prompts
1065 .claude
1066 .prompts
1067 .values()
1068 .next()
1069 .expect("prompt exists");
1070 assert!(prompt.enabled);
1071 assert_eq!(prompt.content, "# hello");
1072
1073 let config_path = crate::config::get_app_config_path();
1074 assert!(
1075 config_path.exists(),
1076 "auto import should persist config to disk"
1077 );
1078 }
1079
1080 #[test]
1081 #[serial]
1082 fn skips_empty_prompt_files_during_import() {
1083 let _home = TempHome::new();
1084 write_prompt_file(AppType::Claude, " \n ");
1085
1086 let config = MultiAppConfig::load().expect("load config");
1087 assert!(
1088 config.prompts.claude.prompts.is_empty(),
1089 "empty files must be ignored"
1090 );
1091 }
1092
1093 #[test]
1094 #[serial]
1095 fn auto_import_happens_only_once() {
1096 let _home = TempHome::new();
1097 write_prompt_file(AppType::Claude, "first version");
1098
1099 let first = MultiAppConfig::load().expect("load config");
1100 assert_eq!(first.prompts.claude.prompts.len(), 1);
1101 let claude_prompt = first
1102 .prompts
1103 .claude
1104 .prompts
1105 .values()
1106 .next()
1107 .expect("prompt exists")
1108 .content
1109 .clone();
1110 assert_eq!(claude_prompt, "first version");
1111
1112 write_prompt_file(AppType::Claude, "second version");
1114 let second = MultiAppConfig::load().expect("load config again");
1115
1116 assert_eq!(second.prompts.claude.prompts.len(), 1);
1117 let prompt = second
1118 .prompts
1119 .claude
1120 .prompts
1121 .values()
1122 .next()
1123 .expect("prompt exists");
1124 assert_eq!(
1125 prompt.content, "first version",
1126 "should not re-import when config already exists"
1127 );
1128 }
1129
1130 #[test]
1131 #[serial]
1132 fn auto_imports_gemini_prompt_on_first_launch() {
1133 let _home = TempHome::new();
1134 write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
1135
1136 let config = MultiAppConfig::load().expect("load config");
1137
1138 assert_eq!(config.prompts.gemini.prompts.len(), 1);
1139 let prompt = config
1140 .prompts
1141 .gemini
1142 .prompts
1143 .values()
1144 .next()
1145 .expect("gemini prompt exists");
1146 assert!(prompt.enabled, "gemini prompt should be enabled");
1147 assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
1148 assert_eq!(
1149 prompt.description,
1150 Some("Automatically imported on first launch".to_string())
1151 );
1152 }
1153
1154 #[test]
1155 #[serial]
1156 fn auto_imports_all_three_apps_prompts() {
1157 let _home = TempHome::new();
1158 write_prompt_file(AppType::Claude, "# Claude prompt");
1159 write_prompt_file(AppType::Codex, "# Codex prompt");
1160 write_prompt_file(AppType::Gemini, "# Gemini prompt");
1161
1162 let config = MultiAppConfig::load().expect("load config");
1163
1164 assert_eq!(config.prompts.claude.prompts.len(), 1);
1166 assert_eq!(config.prompts.codex.prompts.len(), 1);
1167 assert_eq!(config.prompts.gemini.prompts.len(), 1);
1168
1169 assert!(
1171 config
1172 .prompts
1173 .claude
1174 .prompts
1175 .values()
1176 .next()
1177 .unwrap()
1178 .enabled
1179 );
1180 assert!(
1181 config
1182 .prompts
1183 .codex
1184 .prompts
1185 .values()
1186 .next()
1187 .unwrap()
1188 .enabled
1189 );
1190 assert!(
1191 config
1192 .prompts
1193 .gemini
1194 .prompts
1195 .values()
1196 .next()
1197 .unwrap()
1198 .enabled
1199 );
1200 }
1201
1202 #[test]
1203 fn migrate_mcp_to_unified_keeps_openclaw_legacy_servers_unmigrated() {
1204 let mut config = MultiAppConfig::default();
1205 config.mcp.servers = None;
1206 config.mcp.claude.servers.insert(
1207 "claude-tool".to_string(),
1208 json!({
1209 "enabled": true,
1210 "name": "Claude Tool",
1211 "server": {"command": "claude-tool"}
1212 }),
1213 );
1214 config.mcp.openclaw.servers.insert(
1215 "openclaw-tool".to_string(),
1216 json!({
1217 "enabled": true,
1218 "name": "OpenClaw Tool",
1219 "server": {"command": "openclaw-tool"}
1220 }),
1221 );
1222
1223 let migrated = config
1224 .migrate_mcp_to_unified()
1225 .expect("mcp migration should succeed");
1226
1227 assert!(migrated);
1228 let unified = config
1229 .mcp
1230 .servers
1231 .as_ref()
1232 .expect("unified servers should exist");
1233 assert!(unified.contains_key("claude-tool"));
1234 assert!(
1235 !unified.contains_key("openclaw-tool"),
1236 "OpenClaw MCP should remain in legacy storage until upstream supports it"
1237 );
1238 assert!(
1239 config.mcp.openclaw.servers.contains_key("openclaw-tool"),
1240 "OpenClaw legacy MCP entries should be preserved"
1241 );
1242 }
1243}