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