Skip to main content

cc_switch_lib/
app_config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::str::FromStr;
4
5use crate::services::skill::SkillStore;
6
7/// MCP 服务器应用状态(标记应用到哪些客户端)
8#[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    /// 检查指定应用是否启用
24    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    /// 设置指定应用的启用状态
36    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    /// 获取所有启用的应用列表
48    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    /// 检查是否所有应用都未启用
69    pub fn is_empty(&self) -> bool {
70        !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
71    }
72}
73
74/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
75#[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/// 已安装的 Skill(上游兼容结构)
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct InstalledSkill {
145    /// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory")
146    pub id: String,
147    /// 显示名称
148    pub name: String,
149    /// 描述
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub description: Option<String>,
152    /// 安装目录名(在 SSOT 目录中的子目录名)
153    pub directory: String,
154    /// 仓库所有者(GitHub 用户/组织)
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub repo_owner: Option<String>,
157    /// 仓库名称
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub repo_name: Option<String>,
160    /// 仓库分支
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub repo_branch: Option<String>,
163    /// README URL
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub readme_url: Option<String>,
166    /// 应用启用状态
167    pub apps: SkillApps,
168    /// 安装时间(Unix 时间戳)
169    pub installed_at: i64,
170}
171
172/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理)
173#[derive(Debug, Clone, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct UnmanagedSkill {
176    /// 目录名
177    pub directory: String,
178    /// 显示名称(从 SKILL.md 解析)
179    pub name: String,
180    /// 描述
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub description: Option<String>,
183    /// 在哪些应用目录中发现(如 ["claude", "codex"])
184    pub found_in: Vec<String>,
185}
186
187/// MCP 服务器定义(v3.7.0 统一结构)
188#[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/// MCP 配置:单客户端维度(v3.6.x 及以前,保留用于向后兼容)
205#[derive(Debug, Clone, Serialize, Deserialize, Default)]
206pub struct McpConfig {
207    /// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
208    #[serde(default)]
209    pub servers: HashMap<String, serde_json::Value>,
210}
211
212impl McpConfig {
213    /// 检查配置是否为空
214    pub fn is_empty(&self) -> bool {
215        self.servers.is_empty()
216    }
217}
218
219/// MCP 根配置(v3.7.0 新旧结构并存)
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct McpRoot {
222    /// 统一的 MCP 服务器存储(v3.7.0+)
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub servers: Option<HashMap<String, McpServer>>,
225
226    /// 旧的分应用存储(v3.6.x 及以前,保留用于迁移)
227    #[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            // v3.7.0+ 默认使用新的统一结构(空 HashMap)
245            servers: Some(HashMap::new()),
246            // 旧结构保持空,仅用于反序列化旧配置时的迁移
247            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/// Prompt 配置:单客户端维度
258#[derive(Debug, Clone, Serialize, Deserialize, Default)]
259pub struct PromptConfig {
260    #[serde(default)]
261    pub prompts: HashMap<String, crate::prompt::Prompt>,
262}
263
264/// Prompt 根:按客户端分开维护
265#[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/// 应用类型
287#[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
298/// Apps shown in the MCP server picker (no OpenClaw — it has no MCP support).
299pub const MCP_PICKER_APPS: &[AppType] = &[
300    AppType::Claude,
301    AppType::Codex,
302    AppType::Gemini,
303    AppType::OpenCode,
304    AppType::Hermes,
305];
306
307/// Apps shown in the "Visible Apps" settings picker (all apps).
308pub 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
317/// Apps shown in the skills picker (no OpenClaw — it has no skills support).
318pub 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/// 通用配置片段(按应用分治)
390#[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    /// 获取指定应用的通用配置片段
413    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    /// 设置指定应用的通用配置片段
425    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/// 多应用配置结构(向后兼容)
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct MultiAppConfig {
440    #[serde(default = "default_version")]
441    pub version: u32,
442    /// 应用管理器(claude/codex)
443    #[serde(flatten)]
444    pub apps: HashMap<String, ProviderManager>,
445    /// MCP 配置(按客户端分治)
446    #[serde(default)]
447    pub mcp: McpRoot,
448    /// Prompt 配置(按客户端分治)
449    #[serde(default)]
450    pub prompts: PromptRoot,
451    /// Claude Skills 配置
452    #[serde(default)]
453    pub skills: SkillStore,
454    /// 通用配置片段(按应用分治)
455    #[serde(default)]
456    pub common_config_snippets: CommonConfigSnippets,
457    /// Claude 通用配置片段(旧字段,用于向后兼容迁移)
458    #[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    /// 从文件加载配置(仅支持 v2 结构)
490    pub fn load() -> Result<Self, AppError> {
491        let config_path = get_app_config_path();
492
493        if !config_path.exists() {
494            log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
495            // 使用新的方法,支持自动导入提示词
496            let config = Self::default_with_auto_import()?;
497            // 立即保存到磁盘
498            config.save()?;
499            return Ok(config);
500        }
501
502        // 尝试读取文件
503        let content =
504            std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
505
506        // 先解析为 Value,以便严格判定是否为 v1 结构;
507        // 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
508        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            // v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据)
514            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        // 解析 v2 结构
530        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        // 确保 gemini 应用存在(兼容旧配置文件)
556        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        // 执行 MCP 迁移(v3.6.x → v3.7.0)
578        let migrated = config.migrate_mcp_to_unified()?;
579        if migrated {
580            log::info!("MCP 配置已迁移到 v3.7.0 统一结构,保存配置...");
581            updated = true;
582        }
583
584        // 对于已经存在的配置文件,如果此前版本还没有 Prompt 功能,
585        // 且 prompts 仍然是空的,则尝试自动导入现有提示词文件。
586        let imported_prompts = config.maybe_auto_import_prompts_for_existing_config()?;
587        if imported_prompts {
588            updated = true;
589        }
590
591        // 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
592        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    /// 保存配置到文件
609    pub fn save(&self) -> Result<(), AppError> {
610        let config_path = get_app_config_path();
611        // 先备份旧版(若存在)到 ~/.cc-switch-tui/config.json.bak,再写入新内容
612        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    /// 获取指定应用的管理器
624    pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
625        self.apps.get(app.as_str())
626    }
627
628    /// 获取指定应用的管理器(可变引用)
629    pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
630        self.apps.get_mut(app.as_str())
631    }
632
633    /// 确保应用存在
634    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    /// 获取指定客户端的 MCP 配置(不可变引用)
642    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    /// 获取指定客户端的 MCP 配置(可变引用)
654    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    /// 创建默认配置并自动导入已存在的提示词文件
666    fn default_with_auto_import() -> Result<Self, AppError> {
667        log::info!("首次启动,创建默认配置并检测提示词文件");
668
669        let mut config = Self::default();
670
671        // 为每个应用尝试自动导入提示词
672        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    /// 已存在配置文件时的 Prompt 自动导入逻辑
682    ///
683    /// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。
684    /// 判定规则:
685    /// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
686    /// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md)
687    ///
688    /// 返回值:
689    /// - Ok(true)  表示至少有一个应用成功导入了提示词
690    /// - Ok(false) 表示无需导入或未导入任何内容
691    fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
692        // 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
693        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            // 复用已有的单应用导入逻辑
714            if Self::auto_import_prompt_if_exists(self, app)? {
715                imported = true;
716            }
717        }
718
719        Ok(imported)
720    }
721
722    /// 检查并自动导入单个应用的提示词文件
723    ///
724    /// 返回值:
725    /// - Ok(true)  表示成功导入了非空文件
726    /// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
727    fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
728        let file_path = prompt_file_path(&app)?;
729
730        // 检查文件是否存在
731        if !file_path.exists() {
732            log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
733            return Ok(false);
734        }
735
736        // 读取文件内容
737        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); // 失败时不中断,继续处理其他应用
742            }
743        };
744
745        // 检查内容是否为空
746        if content.trim().is_empty() {
747            log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
748            return Ok(false);
749        }
750
751        log::info!("发现提示词文件,自动导入: {file_path:?}");
752
753        // 创建提示词对象
754        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, // 自动启用
769            created_at: Some(timestamp),
770            updated_at: Some(timestamp),
771        };
772
773        // 插入到对应的应用配置中
774        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    /// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
790    ///
791    /// 迁移策略:
792    /// 1. 检查是否已经迁移(mcp.servers 是否存在)
793    /// 2. 收集所有应用的 MCP,按 ID 去重合并
794    /// 3. 生成统一的 McpServer 结构,标记应用到哪些客户端
795    /// 4. 清空旧的分应用配置
796    pub fn migrate_mcp_to_unified(&mut self) -> Result<bool, AppError> {
797        // 检查是否已经是新结构
798        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        // 收集所有应用的 MCP
809        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                    // 该 ID 已存在,合并 apps 字段
833                    existing.apps.set_enabled_for(&app, enabled);
834
835                    // 检测配置冲突(同 ID 但配置不同)
836                    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                    // 首次遇到该 MCP,创建新条目
844                    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        // 记录冲突警告
901        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        // 替换为新结构
919        self.mcp.servers = Some(unified_servers);
920
921        // 清空旧的分应用配置
922        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)] // 字段通过 Drop trait 管理临时目录生命周期
944        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        // 覆盖文件内容,但保留 config.json
1113        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        // 验证所有三个应用的提示词都被导入
1165        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        // 验证所有提示词都被启用
1170        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}