From ff03ca1e632fcc4f76d9036d07d789a127ee2210 Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 2 Jan 2026 22:04:02 +0800 Subject: [PATCH] feat(skills): unified management architecture with SSOT and React Query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce SSOT (Single Source of Truth) at ~/.cc-switch/skills/ - Add three-app toggle support (Claude/Codex/Gemini) for each skill - Refactor frontend to use TanStack Query hooks instead of manual state - Add UnifiedSkillsPanel for managing installed skills with app toggles - Add useSkills.ts with declarative data fetching hooks - Extend skills.ts API with unified install/uninstall/toggle methods - Support importing unmanaged skills from app directories - Add v2→v3 database migration for new skills table structure --- src-tauri/src/app_config.rs | 104 ++ src-tauri/src/commands/misc.rs | 9 +- src-tauri/src/commands/skill.rs | 269 +++--- src-tauri/src/database/dao/skills.rs | 158 +++- src-tauri/src/database/migration.rs | 17 +- src-tauri/src/database/mod.rs | 2 +- src-tauri/src/database/schema.rs | 95 +- src-tauri/src/init_status.rs | 41 + src-tauri/src/lib.rs | 62 +- src-tauri/src/services/mod.rs | 3 +- src-tauri/src/services/skill.rs | 938 +++++++++++++------ src/App.tsx | 102 +- src/components/skills/RepoManager.tsx | 4 +- src/components/skills/RepoManagerPanel.tsx | 6 +- src/components/skills/SkillCard.tsx | 6 +- src/components/skills/SkillsPage.tsx | 245 +++-- src/components/skills/UnifiedSkillsPanel.tsx | 436 +++++++++ src/hooks/useSkills.ts | 151 +++ src/i18n/locales/en.json | 25 +- src/i18n/locales/ja.json | 25 +- src/i18n/locales/zh.json | 25 +- src/lib/api/skills.ts | 103 +- tests/msw/handlers.ts | 2 + 23 files changed, 2213 insertions(+), 615 deletions(-) create mode 100644 src/components/skills/UnifiedSkillsPanel.tsx create mode 100644 src/hooks/useSkills.ts diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 2c596771a..513ae5606 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -55,6 +55,110 @@ impl McpApps { } } +/// Skill 应用启用状态(标记 Skill 应用到哪些客户端) +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct SkillApps { + #[serde(default)] + pub claude: bool, + #[serde(default)] + pub codex: bool, + #[serde(default)] + pub gemini: bool, +} + +impl SkillApps { + /// 检查指定应用是否启用 + pub fn is_enabled_for(&self, app: &AppType) -> bool { + match app { + AppType::Claude => self.claude, + AppType::Codex => self.codex, + AppType::Gemini => self.gemini, + } + } + + /// 设置指定应用的启用状态 + pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) { + match app { + AppType::Claude => self.claude = enabled, + AppType::Codex => self.codex = enabled, + AppType::Gemini => self.gemini = enabled, + } + } + + /// 获取所有启用的应用列表 + pub fn enabled_apps(&self) -> Vec { + let mut apps = Vec::new(); + if self.claude { + apps.push(AppType::Claude); + } + if self.codex { + apps.push(AppType::Codex); + } + if self.gemini { + apps.push(AppType::Gemini); + } + apps + } + + /// 检查是否所有应用都未启用 + pub fn is_empty(&self) -> bool { + !self.claude && !self.codex && !self.gemini + } + + /// 仅启用指定应用(其他应用设为禁用) + pub fn only(app: &AppType) -> Self { + let mut apps = Self::default(); + apps.set_enabled_for(app, true); + apps + } +} + +/// 已安装的 Skill(v3.10.0+ 统一结构) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledSkill { + /// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory") + pub id: String, + /// 显示名称 + pub name: String, + /// 描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 安装目录名(在 SSOT 目录中的子目录名) + pub directory: String, + /// 仓库所有者(GitHub 用户/组织) + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_owner: Option, + /// 仓库名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_name: Option, + /// 仓库分支 + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_branch: Option, + /// README URL + #[serde(skip_serializing_if = "Option::is_none")] + pub readme_url: Option, + /// 应用启用状态 + pub apps: SkillApps, + /// 安装时间(Unix 时间戳) + pub installed_at: i64, +} + +/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnmanagedSkill { + /// 目录名 + pub directory: String, + /// 显示名称(从 SKILL.md 解析) + pub name: String, + /// 描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 在哪些应用目录中发现(如 ["claude", "codex"]) + pub found_in: Vec, +} + /// MCP 服务器定义(v3.7.0 统一结构) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpServer { diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 2f17d91ce..c93462797 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1,6 +1,6 @@ #![allow(non_snake_case)] -use crate::init_status::InitErrorPayload; +use crate::init_status::{InitErrorPayload, SkillsMigrationPayload}; use tauri::AppHandle; use tauri_plugin_opener::OpenerExt; @@ -65,6 +65,13 @@ pub async fn get_migration_result() -> Result { Ok(crate::init_status::take_migration_success()) } +/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。 +/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。 +#[tauri::command] +pub async fn get_skills_migration_result() -> Result, String> { + Ok(crate::init_status::take_skills_migration_result()) +} + #[derive(serde::Serialize)] pub struct ToolVersion { name: String, diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index e7f51f80f..9e3eb6076 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -1,12 +1,17 @@ -use crate::app_config::AppType; +//! Skills 命令层 +//! +//! v3.10.0+ 统一管理架构: +//! - 支持三应用开关(Claude/Codex/Gemini) +//! - SSOT 存储在 ~/.cc-switch/skills/ + +use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill}; use crate::error::format_skill_error; -use crate::services::skill::SkillState; -use crate::services::{Skill, SkillRepo, SkillService}; +use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService}; use crate::store::AppState; -use chrono::Utc; use std::sync::Arc; use tauri::State; +/// SkillService 状态包装 pub struct SkillServiceState(pub Arc); /// 解析 app 参数为 AppType @@ -19,65 +24,117 @@ fn parse_app_type(app: &str) -> Result { } } -/// 根据 app_type 生成带前缀的 skill key -fn get_skill_key(app_type: &AppType, directory: &str) -> String { - let prefix = match app_type { - AppType::Claude => "claude", - AppType::Codex => "codex", - AppType::Gemini => "gemini", - }; - format!("{prefix}:{directory}") +// ========== 统一管理命令 ========== + +/// 获取所有已安装的 Skills +#[tauri::command] +pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result, String> { + SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string()) } +/// 安装 Skill(新版统一安装) +/// +/// 参数: +/// - skill: 从发现列表获取的技能信息 +/// - current_app: 当前选中的应用,安装后默认启用该应用 +#[tauri::command] +pub async fn install_skill_unified( + skill: DiscoverableSkill, + current_app: String, + service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result { + let app_type = parse_app_type(¤t_app)?; + + service + .0 + .install(&app_state.db, &skill, &app_type) + .await + .map_err(|e| e.to_string()) +} + +/// 卸载 Skill(新版统一卸载) +#[tauri::command] +pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result { + SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?; + Ok(true) +} + +/// 切换 Skill 的应用启用状态 +#[tauri::command] +pub fn toggle_skill_app( + id: String, + app: String, + enabled: bool, + app_state: State<'_, AppState>, +) -> Result { + let app_type = parse_app_type(&app)?; + SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?; + Ok(true) +} + +/// 扫描未管理的 Skills +#[tauri::command] +pub fn scan_unmanaged_skills( + app_state: State<'_, AppState>, +) -> Result, String> { + SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string()) +} + +/// 从应用目录导入 Skills +#[tauri::command] +pub fn import_skills_from_apps( + directories: Vec, + app_state: State<'_, AppState>, +) -> Result, String> { + SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string()) +} + +// ========== 发现功能命令 ========== + +/// 发现可安装的 Skills(从仓库获取) +#[tauri::command] +pub async fn discover_available_skills( + service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result, String> { + let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; + service + .0 + .discover_available(repos) + .await + .map_err(|e| e.to_string()) +} + +// ========== 兼容旧 API 的命令 ========== + +/// 获取技能列表(兼容旧 API) #[tauri::command] pub async fn get_skills( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result, String> { - get_skills_for_app("claude".to_string(), service, app_state).await + let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; + service + .0 + .list_skills(repos, &app_state.db) + .await + .map_err(|e| e.to_string()) } +/// 获取指定应用的技能列表(兼容旧 API) #[tauri::command] pub async fn get_skills_for_app( app: String, - _service: State<'_, SkillServiceState>, + service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result, String> { - let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; - - let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; - - let skills = service - .list_skills(repos) - .await - .map_err(|e| e.to_string())?; - - // 自动同步本地已安装的 skills 到数据库 - // 这样用户在首次运行时,已有的 skills 会被自动记录 - let existing_states = app_state.db.get_skills().unwrap_or_default(); - - for skill in &skills { - if skill.installed { - let key = get_skill_key(&app_type, &skill.directory); - if !existing_states.contains_key(&key) { - // 本地有该 skill,但数据库中没有记录,自动添加 - if let Err(e) = app_state.db.update_skill_state( - &key, - &SkillState { - installed: true, - installed_at: Utc::now(), - }, - ) { - log::warn!("同步本地 skill {key} 状态到数据库失败: {e}"); - } - } - } - } - - Ok(skills) + // 新版本不再区分应用,统一返回所有技能 + let _ = parse_app_type(&app)?; // 验证 app 参数有效 + get_skills(service, app_state).await } +/// 安装技能(兼容旧 API) #[tauri::command] pub async fn install_skill( directory: String, @@ -87,27 +144,34 @@ pub async fn install_skill( install_skill_for_app("claude".to_string(), directory, service, app_state).await } +/// 安装指定应用的技能(兼容旧 API) #[tauri::command] pub async fn install_skill_for_app( app: String, directory: String, - _service: State<'_, SkillServiceState>, + service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; - // 先在不持有写锁的情况下收集仓库与技能信息 + // 先获取技能信息 let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; - let skills = service - .list_skills(repos) + .0 + .discover_available(repos) .await .map_err(|e| e.to_string())?; let skill = skills - .iter() - .find(|s| s.directory.eq_ignore_ascii_case(&directory)) + .into_iter() + .find(|s| { + let install_name = std::path::Path::new(&s.directory) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| s.directory.clone()); + install_name.eq_ignore_ascii_case(&directory) + || s.directory.eq_ignore_ascii_case(&directory) + }) .ok_or_else(|| { format_skill_error( "SKILL_NOT_FOUND", @@ -116,103 +180,54 @@ pub async fn install_skill_for_app( ) })?; - if !skill.installed { - let repo = SkillRepo { - owner: skill.repo_owner.clone().ok_or_else(|| { - format_skill_error( - "MISSING_REPO_INFO", - &[("directory", &directory), ("field", "owner")], - None, - ) - })?, - name: skill.repo_name.clone().ok_or_else(|| { - format_skill_error( - "MISSING_REPO_INFO", - &[("directory", &directory), ("field", "name")], - None, - ) - })?, - branch: skill - .repo_branch - .clone() - .unwrap_or_else(|| "main".to_string()), - enabled: true, - }; - - service - .install_skill(directory.clone(), repo) - .await - .map_err(|e| e.to_string())?; - } - - let key = get_skill_key(&app_type, &directory); - app_state - .db - .update_skill_state( - &key, - &SkillState { - installed: true, - installed_at: Utc::now(), - }, - ) + service + .0 + .install(&app_state.db, &skill, &app_type) + .await .map_err(|e| e.to_string())?; Ok(true) } +/// 卸载技能(兼容旧 API) #[tauri::command] -pub fn uninstall_skill( - directory: String, - service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result { - uninstall_skill_for_app("claude".to_string(), directory, service, app_state) +pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result { + uninstall_skill_for_app("claude".to_string(), directory, app_state) } +/// 卸载指定应用的技能(兼容旧 API) #[tauri::command] pub fn uninstall_skill_for_app( app: String, directory: String, - _service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { - let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; + let _ = parse_app_type(&app)?; // 验证参数 - service - .uninstall_skill(directory.clone()) - .map_err(|e| e.to_string())?; + // 通过 directory 找到对应的 skill id + let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?; - // Remove from database by setting installed = false - let key = get_skill_key(&app_type, &directory); - app_state - .db - .update_skill_state( - &key, - &SkillState { - installed: false, - installed_at: Utc::now(), - }, - ) - .map_err(|e| e.to_string())?; + let skill = skills + .into_iter() + .find(|s| s.directory.eq_ignore_ascii_case(&directory)) + .ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?; + + SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?; Ok(true) } +// ========== 仓库管理命令 ========== + +/// 获取技能仓库列表 #[tauri::command] -pub fn get_skill_repos( - _service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result, String> { +pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result, String> { app_state.db.get_skill_repos().map_err(|e| e.to_string()) } +/// 添加技能仓库 #[tauri::command] -pub fn add_skill_repo( - repo: SkillRepo, - _service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result { +pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result { app_state .db .save_skill_repo(&repo) @@ -220,11 +235,11 @@ pub fn add_skill_repo( Ok(true) } +/// 删除技能仓库 #[tauri::command] pub fn remove_skill_repo( owner: String, name: String, - _service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { app_state diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 6727059e9..269d11753 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -1,73 +1,156 @@ //! Skills 数据访问对象 //! //! 提供 Skills 和 Skill Repos 的 CRUD 操作。 +//! +//! v3.10.0+ 统一管理架构: +//! - Skills 使用统一的 id 主键,支持三应用启用标志 +//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录 +use crate::app_config::{InstalledSkill, SkillApps}; use crate::database::{lock_conn, Database}; use crate::error::AppError; -use crate::services::skill::{SkillRepo, SkillState}; +use crate::services::skill::SkillRepo; use indexmap::IndexMap; use rusqlite::params; impl Database { - /// 获取所有 Skills 状态 - pub fn get_skills(&self) -> Result, AppError> { + // ========== InstalledSkill CRUD ========== + + /// 获取所有已安装的 Skills + pub fn get_all_installed_skills(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn - .prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC") + .prepare( + "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, + readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at + FROM skills ORDER BY name ASC", + ) .map_err(|e| AppError::Database(e.to_string()))?; let skill_iter = stmt .query_map([], |row| { - let directory: String = row.get(0)?; - let app_type: String = row.get(1)?; - let installed: bool = row.get(2)?; - let installed_at_ts: i64 = row.get(3)?; - - let installed_at = - chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); - - // 构建复合 key:"app_type:directory" - let key = format!("{app_type}:{directory}"); - - Ok(( - key, - SkillState { - installed, - installed_at, + Ok(InstalledSkill { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + directory: row.get(3)?, + repo_owner: row.get(4)?, + repo_name: row.get(5)?, + repo_branch: row.get(6)?, + readme_url: row.get(7)?, + apps: SkillApps { + claude: row.get(8)?, + codex: row.get(9)?, + gemini: row.get(10)?, }, - )) + installed_at: row.get(11)?, + }) }) .map_err(|e| AppError::Database(e.to_string()))?; let mut skills = IndexMap::new(); for skill_res in skill_iter { - let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?; - skills.insert(key, skill); + let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?; + skills.insert(skill.id.clone(), skill); } Ok(skills) } - /// 更新 Skill 状态 - /// key 格式为 "app_type:directory" - pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { - // 解析 key - let (app_type, directory) = if let Some(idx) = key.find(':') { - let (app, dir) = key.split_at(idx); - (app, &dir[1..]) // 跳过冒号 - } else { - // 向后兼容:如果没有前缀,默认为 claude - ("claude", key) - }; + /// 获取单个已安装的 Skill + pub fn get_installed_skill(&self, id: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, + readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at + FROM skills WHERE id = ?1", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + let result = stmt.query_row([id], |row| { + Ok(InstalledSkill { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + directory: row.get(3)?, + repo_owner: row.get(4)?, + repo_name: row.get(5)?, + repo_branch: row.get(6)?, + readme_url: row.get(7)?, + apps: SkillApps { + claude: row.get(8)?, + codex: row.get(9)?, + gemini: row.get(10)?, + }, + installed_at: row.get(11)?, + }) + }); + + match result { + Ok(skill) => Ok(Some(skill)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 保存 Skill(添加或更新) + pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> { let conn = lock_conn!(self.conn); conn.execute( - "INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)", - params![directory, app_type, state.installed, state.installed_at.timestamp()], + "INSERT OR REPLACE INTO skills + (id, name, description, directory, repo_owner, repo_name, repo_branch, + readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + skill.id, + skill.name, + skill.description, + skill.directory, + skill.repo_owner, + skill.repo_name, + skill.repo_branch, + skill.readme_url, + skill.apps.claude, + skill.apps.codex, + skill.apps.gemini, + skill.installed_at, + ], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } + /// 删除 Skill + pub fn delete_skill(&self, id: &str) -> Result { + let conn = lock_conn!(self.conn); + let affected = conn + .execute("DELETE FROM skills WHERE id = ?1", params![id]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(affected > 0) + } + + /// 清空所有 Skills(用于迁移) + pub fn clear_skills(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM skills", []) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 更新 Skill 的应用启用状态 + pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result { + let conn = lock_conn!(self.conn); + let affected = conn + .execute( + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4", + params![apps.claude, apps.codex, apps.gemini, id], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(affected > 0) + } + + // ========== SkillRepo CRUD(保持原有) ========== + /// 获取所有 Skill 仓库 pub fn get_skill_repos(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -101,7 +184,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)", params![repo.owner, repo.name, repo.branch, repo.enabled], - ).map_err(|e| AppError::Database(e.to_string()))?; + ) + .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs index 44c9dc149..3c52e7127 100644 --- a/src-tauri/src/database/migration.rs +++ b/src-tauri/src/database/migration.rs @@ -192,13 +192,16 @@ impl Database { tx: &rusqlite::Transaction<'_>, config: &MultiAppConfig, ) -> Result<(), AppError> { - for (key, state) in &config.skills.skills { - tx.execute( - "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", - params![key, state.installed, state.installed_at.timestamp()], - ) - .map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?; - } + // v3.10.0+:Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/)+ 数据库统一结构。 + // + // 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据, + // 且无法保证 SSOT 目录中一定存在对应的 skill 文件。 + // + // 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。 + // 迁移后可通过: + // - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT) + // - 或后续启动时的自动扫描逻辑 + // 来重建已安装技能记录。 for repo in &config.skills.repos { tx.execute( diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 73a4877a4..bf59dcd61 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 2; +pub(crate) const SCHEMA_VERSION: i32 = 3; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 7071b26c0..563c96716 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -71,11 +71,21 @@ impl Database { PRIMARY KEY (id, app_type) )", []).map_err(|e| AppError::Database(e.to_string()))?; - // 5. Skills 表 + // 5. Skills 表(v3.10.0+ 统一结构) conn.execute( "CREATE TABLE IF NOT EXISTS skills ( - directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type) + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + directory TEXT NOT NULL, + repo_owner TEXT, + repo_name TEXT, + repo_branch TEXT DEFAULT 'main', + readme_url TEXT, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER NOT NULL DEFAULT 0 )", [], ) @@ -331,6 +341,11 @@ impl Database { Self::migrate_v1_to_v2(conn)?; Self::set_user_version(conn, 2)?; } + 2 => { + log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)"); + Self::migrate_v2_to_v3(conn)?; + Self::set_user_version(conn, 3)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -689,6 +704,17 @@ impl Database { /// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键 fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> { + // v3 结构(统一管理架构)已经是更高版本的 skills 表: + // - 主键为 id + // - 包含 enabled_claude / enabled_codex / enabled_gemini 等列 + // 在这种情况下,不应再执行 v1 -> v2 的迁移逻辑,否则会因列不匹配而失败。 + if Self::has_column(conn, "skills", "enabled_claude")? + || Self::has_column(conn, "skills", "id")? + { + log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移"); + return Ok(()); + } + // 检查是否已经是新表结构 if Self::has_column(conn, "skills", "app_type")? { log::info!("skills 表已经包含 app_type 字段,跳过迁移"); @@ -760,6 +786,69 @@ impl Database { Ok(()) } + /// v2 -> v3 迁移:Skills 统一管理架构 + /// + /// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构, + /// 支持三应用启用标志(enabled_claude, enabled_codex, enabled_gemini)。 + /// + /// 迁移策略: + /// 1. 旧数据库只存储安装记录,真正的 skill 文件在文件系统 + /// 2. 直接重建新表结构,后续由 SkillService 在首次启动时扫描文件系统重建数据 + fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> { + // 检查是否已经是新结构(通过检查是否有 enabled_claude 列) + if Self::has_column(conn, "skills", "enabled_claude")? { + log::info!("skills 表已经是 v3 结构,跳过迁移"); + return Ok(()); + } + + log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)..."); + + // 1. 备份旧数据(用于日志) + let old_count: i64 = conn + .query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0)) + .unwrap_or(0); + log::info!("旧 skills 表有 {old_count} 条记录"); + + // 标记:需要在启动后从文件系统扫描并重建 Skills 数据 + // 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/, + // 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。 + let _ = conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')", + [], + ); + + // 2. 删除旧表 + conn.execute("DROP TABLE IF EXISTS skills", []) + .map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?; + + // 3. 创建新表 + conn.execute( + "CREATE TABLE skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + directory TEXT NOT NULL, + repo_owner TEXT, + repo_name TEXT, + repo_branch TEXT DEFAULT 'main', + readme_url TEXT, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER NOT NULL DEFAULT 0 + )", + [], + ) + .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?; + + log::info!( + "skills 表已迁移到 v3 结构。\n\ + 注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。" + ); + + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/init_status.rs b/src-tauri/src/init_status.rs index 86e64a4a9..042dd620a 100644 --- a/src-tauri/src/init_status.rs +++ b/src-tauri/src/init_status.rs @@ -52,6 +52,47 @@ pub fn take_migration_success() -> bool { } } +// ============================================================ +// Skills SSOT 迁移结果状态 +// ============================================================ + +#[derive(Debug, Clone, Serialize)] +pub struct SkillsMigrationPayload { + pub count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +static SKILLS_MIGRATION_RESULT: OnceLock>> = OnceLock::new(); + +fn skills_migration_cell() -> &'static RwLock> { + SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None)) +} + +pub fn set_skills_migration_result(count: usize) { + if let Ok(mut guard) = skills_migration_cell().write() { + *guard = Some(SkillsMigrationPayload { count, error: None }); + } +} + +pub fn set_skills_migration_error(error: String) { + if let Ok(mut guard) = skills_migration_cell().write() { + *guard = Some(SkillsMigrationPayload { + count: 0, + error: Some(error), + }); + } +} + +/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None) +pub fn take_skills_migration_result() -> Option { + if let Ok(mut guard) = skills_migration_cell().write() { + guard.take() + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c1a431fb5..154a4dcef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -324,6 +324,47 @@ pub fn run() { Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"), } + // 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT + // 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。 + match app_state.db.get_setting("skills_ssot_migration_pending") { + Ok(Some(flag)) if flag == "true" || flag == "1" => { + // 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。 + let has_existing = app_state + .db + .get_all_installed_skills() + .map(|skills| !skills.is_empty()) + .unwrap_or(false); + + if has_existing { + log::info!( + "Detected skills_ssot_migration_pending but skills table not empty; skipping auto import." + ); + let _ = app_state + .db + .set_setting("skills_ssot_migration_pending", "false"); + } else { + match crate::services::skill::migrate_skills_to_ssot(&app_state.db) { + Ok(count) => { + log::info!("✓ Auto imported {count} skill(s) into SSOT"); + if count > 0 { + crate::init_status::set_skills_migration_result(count); + } + let _ = app_state + .db + .set_setting("skills_ssot_migration_pending", "false"); + } + Err(e) => { + log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}"); + crate::init_status::set_skills_migration_error(e.to_string()); + // 保留 pending 标志,方便下次启动重试 + } + } + } + } + Ok(_) => {} // 未开启迁移标志,静默跳过 + Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"), + } + // 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过) for app in [ crate::app_config::AppType::Claude, @@ -507,14 +548,8 @@ pub fn run() { app.manage(app_state); // 初始化 SkillService - match SkillService::new() { - Ok(skill_service) => { - app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); - } - Err(e) => { - log::warn!("初始化 SkillService 失败: {e}"); - } - } + let skill_service = SkillService::new(); + app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); // 异常退出恢复 + 代理状态自动恢复 let app_handle = app.handle().clone(); @@ -564,6 +599,7 @@ pub fn run() { commands::open_external, commands::get_init_error, commands::get_migration_result, + commands::get_skills_migration_result, commands::get_app_config_path, commands::open_app_config_folder, commands::get_claude_common_config_snippet, @@ -635,7 +671,15 @@ pub fn run() { commands::check_env_conflicts, commands::delete_env_vars, commands::restore_env_backup, - // Skill management + // Skill management (v3.10.0+ unified) + commands::get_installed_skills, + commands::install_skill_unified, + commands::uninstall_skill_unified, + commands::toggle_skill_app, + commands::scan_unmanaged_skills, + commands::import_skills_from_apps, + commands::discover_available_skills, + // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, commands::install_skill, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index c314c70ef..07abe16e4 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -15,7 +15,8 @@ pub use mcp::McpService; pub use prompt::PromptService; pub use provider::{ProviderService, ProviderSortUpdate}; pub use proxy::ProxyService; -pub use skill::{Skill, SkillRepo, SkillService}; +#[allow(unused_imports)] +pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService}; pub use speedtest::{EndpointLatency, SpeedtestService}; #[allow(unused_imports)] pub use usage_stats::{ diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 977d1a858..62c89f6f9 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -1,16 +1,53 @@ +//! Skills 服务层 +//! +//! v3.10.0+ 统一管理架构: +//! - SSOT(单一事实源):`~/.cc-switch/skills/` +//! - 安装时下载到 SSOT,按需同步到各应用目录 +//! - 数据库存储安装记录和启用状态 + use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use tokio::time::timeout; -use crate::app_config::AppType; +use crate::app_config::{AppType, InstalledSkill, SkillApps, UnmanagedSkill}; +use crate::config::get_app_config_dir; +use crate::database::Database; use crate::error::format_skill_error; -/// 技能对象 +// ========== 数据结构 ========== + +/// 可发现的技能(来自仓库) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoverableSkill { + /// 唯一标识: "owner/name:directory" + pub key: String, + /// 显示名称 (从 SKILL.md 解析) + pub name: String, + /// 技能描述 + pub description: String, + /// 目录名称 (安装路径的最后一段) + pub directory: String, + /// GitHub README URL + #[serde(rename = "readmeUrl")] + pub readme_url: Option, + /// 仓库所有者 + #[serde(rename = "repoOwner")] + pub repo_owner: String, + /// 仓库名称 + #[serde(rename = "repoName")] + pub repo_name: String, + /// 分支名称 + #[serde(rename = "repoBranch")] + pub repo_branch: String, +} + +/// 技能对象(兼容旧 API,内部使用 DiscoverableSkill) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Skill { /// 唯一标识: "owner/name:directory" 或 "local:directory" @@ -50,7 +87,7 @@ pub struct SkillRepo { pub enabled: bool, } -/// 技能安装状态 +/// 技能安装状态(旧版兼容) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillState { /// 是否已安装 @@ -60,10 +97,10 @@ pub struct SkillState { pub installed_at: DateTime, } -/// 持久化存储结构 +/// 持久化存储结构(仓库配置) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillStore { - /// directory -> 安装状态 + /// directory -> 安装状态(旧版兼容,新版不使用) pub skills: HashMap, /// 仓库列表 pub repos: Vec, @@ -74,18 +111,18 @@ impl Default for SkillStore { SkillStore { skills: HashMap::new(), repos: vec![ - SkillRepo { - owner: "ComposioHQ".to_string(), - name: "awesome-claude-skills".to_string(), - branch: "master".to_string(), - enabled: true, - }, SkillRepo { owner: "anthropics".to_string(), name: "skills".to_string(), branch: "main".to_string(), enabled: true, }, + SkillRepo { + owner: "ComposioHQ".to_string(), + name: "awesome-claude-skills".to_string(), + branch: "master".to_string(), + enabled: true, + }, SkillRepo { owner: "cexll".to_string(), name: "myclaude".to_string(), @@ -104,79 +141,475 @@ pub struct SkillMetadata { pub description: Option, } +// ========== SkillService ========== + pub struct SkillService { http_client: Client, - install_dir: PathBuf, - app_type: AppType, +} + +impl Default for SkillService { + fn default() -> Self { + Self::new() + } } impl SkillService { - pub fn new() -> Result { - Self::new_for_app(AppType::Claude) - } - - pub fn new_for_app(app_type: AppType) -> Result { - let install_dir = Self::get_install_dir_for_app(&app_type)?; - - // 确保目录存在 - fs::create_dir_all(&install_dir)?; - - Ok(Self { + pub fn new() -> Self { + Self { http_client: Client::builder() .user_agent("cc-switch") - // 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住 .timeout(std::time::Duration::from_secs(10)) - .build()?, - install_dir, - app_type, - }) + .build() + .expect("Failed to create HTTP client"), + } } - fn get_install_dir_for_app(app_type: &AppType) -> Result { + // ========== 路径管理 ========== + + /// 获取 SSOT 目录(~/.cc-switch/skills/) + pub fn get_ssot_dir() -> Result { + let dir = get_app_config_dir().join("skills"); + fs::create_dir_all(&dir)?; + Ok(dir) + } + + /// 获取应用的 skills 目录 + pub fn get_app_skills_dir(app: &AppType) -> Result { + // 目录覆盖:优先使用用户在 settings.json 中配置的 override 目录 + match app { + AppType::Claude => { + if let Some(custom) = crate::settings::get_claude_override_dir() { + return Ok(custom.join("skills")); + } + } + AppType::Codex => { + if let Some(custom) = crate::settings::get_codex_override_dir() { + return Ok(custom.join("skills")); + } + } + AppType::Gemini => { + if let Some(custom) = crate::settings::get_gemini_override_dir() { + return Ok(custom.join("skills")); + } + } + } + + // 默认路径:回退到用户主目录下的标准位置 let home = dirs::home_dir().context(format_skill_error( "GET_HOME_DIR_FAILED", &[], Some("checkPermission"), ))?; - let dir = match app_type { + Ok(match app { AppType::Claude => home.join(".claude").join("skills"), - AppType::Codex => { - // 检查是否有自定义 Codex 配置目录 - if let Some(custom) = crate::settings::get_codex_override_dir() { - custom.join("skills") - } else { - home.join(".codex").join("skills") - } - } - AppType::Gemini => { - // 为 Gemini 预留,暂时使用默认路径 - home.join(".gemini").join("skills") + AppType::Codex => home.join(".codex").join("skills"), + AppType::Gemini => home.join(".gemini").join("skills"), + }) + } + + // ========== 统一管理方法 ========== + + /// 获取所有已安装的 Skills + pub fn get_all_installed(db: &Arc) -> Result> { + let skills = db.get_all_installed_skills()?; + Ok(skills.into_values().collect()) + } + + /// 安装 Skill + /// + /// 流程: + /// 1. 下载到 SSOT 目录 + /// 2. 保存到数据库 + /// 3. 同步到启用的应用目录 + pub async fn install( + &self, + db: &Arc, + skill: &DiscoverableSkill, + current_app: &AppType, + ) -> Result { + let ssot_dir = Self::get_ssot_dir()?; + + // 使用目录最后一段作为安装名 + let install_name = Path::new(&skill.directory) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| skill.directory.clone()); + + let dest = ssot_dir.join(&install_name); + + // 如果已存在则跳过下载 + if !dest.exists() { + let repo = SkillRepo { + owner: skill.repo_owner.clone(), + name: skill.repo_name.clone(), + branch: skill.repo_branch.clone(), + enabled: true, + }; + + // 下载仓库 + let temp_dir = timeout( + std::time::Duration::from_secs(60), + self.download_repo(&repo), + ) + .await + .map_err(|_| { + anyhow!(format_skill_error( + "DOWNLOAD_TIMEOUT", + &[ + ("owner", &repo.owner), + ("name", &repo.name), + ("timeout", "60") + ], + Some("checkNetwork"), + )) + })??; + + // 复制到 SSOT + let source = temp_dir.join(&skill.directory); + if !source.exists() { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &source.display().to_string())], + Some("checkRepoUrl"), + ))); } + + Self::copy_dir_recursive(&source, &dest)?; + let _ = fs::remove_dir_all(&temp_dir); + } + + // 创建 InstalledSkill 记录 + let installed_skill = InstalledSkill { + id: skill.key.clone(), + name: skill.name.clone(), + description: if skill.description.is_empty() { + None + } else { + Some(skill.description.clone()) + }, + directory: install_name.clone(), + repo_owner: Some(skill.repo_owner.clone()), + repo_name: Some(skill.repo_name.clone()), + repo_branch: Some(skill.repo_branch.clone()), + readme_url: skill.readme_url.clone(), + apps: SkillApps::only(current_app), + installed_at: chrono::Utc::now().timestamp(), }; - Ok(dir) + // 保存到数据库 + db.save_skill(&installed_skill)?; + + // 同步到当前应用目录 + Self::copy_to_app(&install_name, current_app)?; + + log::info!( + "Skill {} 安装成功,已启用 {:?}", + installed_skill.name, + current_app + ); + + Ok(installed_skill) } - pub fn app_type(&self) -> &AppType { - &self.app_type - } -} + /// 卸载 Skill + /// + /// 流程: + /// 1. 从所有应用目录删除 + /// 2. 从 SSOT 删除 + /// 3. 从数据库删除 + pub fn uninstall(db: &Arc, id: &str) -> Result<()> { + // 获取 skill 信息 + let skill = db + .get_installed_skill(id)? + .ok_or_else(|| anyhow!("Skill not found: {}", id))?; -// 核心方法实现 -impl SkillService { - /// 列出所有技能 - pub async fn list_skills(&self, repos: Vec) -> Result> { + // 从所有应用目录删除 + for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + let _ = Self::remove_from_app(&skill.directory, &app); + } + + // 从 SSOT 删除 + let ssot_dir = Self::get_ssot_dir()?; + let skill_path = ssot_dir.join(&skill.directory); + if skill_path.exists() { + fs::remove_dir_all(&skill_path)?; + } + + // 从数据库删除 + db.delete_skill(id)?; + + log::info!("Skill {} 卸载成功", skill.name); + + Ok(()) + } + + /// 切换应用启用状态 + /// + /// 启用:复制到应用目录 + /// 禁用:从应用目录删除 + pub fn toggle_app(db: &Arc, id: &str, app: &AppType, enabled: bool) -> Result<()> { + // 获取当前 skill + let mut skill = db + .get_installed_skill(id)? + .ok_or_else(|| anyhow!("Skill not found: {}", id))?; + + // 更新状态 + skill.apps.set_enabled_for(app, enabled); + + // 同步文件 + if enabled { + Self::copy_to_app(&skill.directory, app)?; + } else { + Self::remove_from_app(&skill.directory, app)?; + } + + // 更新数据库 + db.update_skill_apps(id, &skill.apps)?; + + log::info!("Skill {} 的 {:?} 状态已更新为 {}", skill.name, app, enabled); + + Ok(()) + } + + /// 扫描未管理的 Skills + /// + /// 扫描各应用目录,找出未被 CC Switch 管理的 Skills + pub fn scan_unmanaged(db: &Arc) -> Result> { + let managed_skills = db.get_all_installed_skills()?; + let managed_dirs: HashSet = managed_skills + .values() + .map(|s| s.directory.clone()) + .collect(); + + let mut unmanaged: HashMap = HashMap::new(); + + for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + let app_dir = match Self::get_app_skills_dir(&app) { + Ok(d) => d, + Err(_) => continue, + }; + + if !app_dir.exists() { + continue; + } + + for entry in fs::read_dir(&app_dir)? { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let dir_name = entry.file_name().to_string_lossy().to_string(); + + // 跳过已管理的 + if managed_dirs.contains(&dir_name) { + continue; + } + + // 检查是否有 SKILL.md + let skill_md = path.join("SKILL.md"); + let (name, description) = if skill_md.exists() { + match Self::parse_skill_metadata_static(&skill_md) { + Ok(meta) => ( + meta.name.unwrap_or_else(|| dir_name.clone()), + meta.description, + ), + Err(_) => (dir_name.clone(), None), + } + } else { + (dir_name.clone(), None) + }; + + // 添加或更新 + let app_str = match app { + AppType::Claude => "claude", + AppType::Codex => "codex", + AppType::Gemini => "gemini", + }; + + unmanaged + .entry(dir_name.clone()) + .and_modify(|s| s.found_in.push(app_str.to_string())) + .or_insert(UnmanagedSkill { + directory: dir_name, + name, + description, + found_in: vec![app_str.to_string()], + }); + } + } + + Ok(unmanaged.into_values().collect()) + } + + /// 从应用目录导入 Skills + /// + /// 将未管理的 Skills 导入到 CC Switch 统一管理 + pub fn import_from_apps( + db: &Arc, + directories: Vec, + ) -> Result> { + let ssot_dir = Self::get_ssot_dir()?; + let mut imported = Vec::new(); + + for dir_name in directories { + // 找到源目录(从任一应用目录复制) + let mut source_path: Option = None; + let mut found_in: Vec = Vec::new(); + + for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + if let Ok(app_dir) = Self::get_app_skills_dir(&app) { + let skill_path = app_dir.join(&dir_name); + if skill_path.exists() { + if source_path.is_none() { + source_path = Some(skill_path); + } + let app_str = match app { + AppType::Claude => "claude", + AppType::Codex => "codex", + AppType::Gemini => "gemini", + }; + found_in.push(app_str.to_string()); + } + } + } + + let source = match source_path { + Some(p) => p, + None => continue, + }; + + // 复制到 SSOT + let dest = ssot_dir.join(&dir_name); + if !dest.exists() { + Self::copy_dir_recursive(&source, &dest)?; + } + + // 解析元数据 + let skill_md = dest.join("SKILL.md"); + let (name, description) = if skill_md.exists() { + match Self::parse_skill_metadata_static(&skill_md) { + Ok(meta) => ( + meta.name.unwrap_or_else(|| dir_name.clone()), + meta.description, + ), + Err(_) => (dir_name.clone(), None), + } + } else { + (dir_name.clone(), None) + }; + + // 构建启用状态 + let mut apps = SkillApps::default(); + for app_str in &found_in { + match app_str.as_str() { + "claude" => apps.claude = true, + "codex" => apps.codex = true, + "gemini" => apps.gemini = true, + _ => {} + } + } + + // 创建记录 + let skill = InstalledSkill { + id: format!("local:{}", dir_name), + name, + description, + directory: dir_name, + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps, + installed_at: chrono::Utc::now().timestamp(), + }; + + // 保存到数据库 + db.save_skill(&skill)?; + imported.push(skill); + } + + log::info!("成功导入 {} 个 Skills", imported.len()); + + Ok(imported) + } + + // ========== 文件同步方法 ========== + + /// 复制 Skill 到应用目录 + pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> { + let ssot_dir = Self::get_ssot_dir()?; + let source = ssot_dir.join(directory); + + if !source.exists() { + return Err(anyhow!("Skill 不存在于 SSOT: {}", directory)); + } + + let app_dir = Self::get_app_skills_dir(app)?; + fs::create_dir_all(&app_dir)?; + + let dest = app_dir.join(directory); + + // 如果已存在则先删除 + if dest.exists() { + fs::remove_dir_all(&dest)?; + } + + Self::copy_dir_recursive(&source, &dest)?; + + log::debug!("Skill {} 已复制到 {:?}", directory, app); + + Ok(()) + } + + /// 从应用目录删除 Skill + pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> { + let app_dir = Self::get_app_skills_dir(app)?; + let skill_path = app_dir.join(directory); + + if skill_path.exists() { + fs::remove_dir_all(&skill_path)?; + log::debug!("Skill {} 已从 {:?} 删除", directory, app); + } + + Ok(()) + } + + /// 同步所有已启用的 Skills 到指定应用 + pub fn sync_to_app(db: &Arc, app: &AppType) -> Result<()> { + let skills = db.get_all_installed_skills()?; + + for skill in skills.values() { + if skill.apps.is_enabled_for(app) { + Self::copy_to_app(&skill.directory, app)?; + } + } + + Ok(()) + } + + // ========== 发现功能(保留原有逻辑)========== + + /// 列出所有可发现的技能(从仓库获取) + pub async fn discover_available( + &self, + repos: Vec, + ) -> Result> { let mut skills = Vec::new(); - // 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新 + // 仅使用启用的仓库 let enabled_repos: Vec = repos.into_iter().filter(|repo| repo.enabled).collect(); let fetch_tasks = enabled_repos .iter() .map(|repo| self.fetch_repo_skills(repo)); - let results: Vec>> = futures::future::join_all(fetch_tasks).await; + let results: Vec>> = + futures::future::join_all(fetch_tasks).await; for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) { match result { @@ -185,19 +618,82 @@ impl SkillService { } } - // 合并本地技能 - self.merge_local_skills(&mut skills)?; - // 去重并排序 - Self::deduplicate_skills(&mut skills); + Self::deduplicate_discoverable_skills(&mut skills); + skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + Ok(skills) + } + + /// 列出所有技能(兼容旧 API) + pub async fn list_skills( + &self, + repos: Vec, + db: &Arc, + ) -> Result> { + // 获取可发现的技能 + let discoverable = self.discover_available(repos).await?; + + // 获取已安装的技能 + let installed = db.get_all_installed_skills()?; + let installed_dirs: HashSet = + installed.values().map(|s| s.directory.clone()).collect(); + + // 转换为 Skill 格式 + let mut skills: Vec = discoverable + .into_iter() + .map(|d| { + let install_name = Path::new(&d.directory) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| d.directory.clone()); + + Skill { + key: d.key, + name: d.name, + description: d.description, + directory: d.directory, + readme_url: d.readme_url, + installed: installed_dirs.contains(&install_name), + repo_owner: Some(d.repo_owner), + repo_name: Some(d.repo_name), + repo_branch: Some(d.repo_branch), + } + }) + .collect(); + + // 添加本地已安装但不在仓库中的技能 + for skill in installed.values() { + let already_in_list = skills.iter().any(|s| { + let s_install_name = Path::new(&s.directory) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| s.directory.clone()); + s_install_name == skill.directory + }); + + if !already_in_list { + skills.push(Skill { + key: skill.id.clone(), + name: skill.name.clone(), + description: skill.description.clone().unwrap_or_default(), + directory: skill.directory.clone(), + readme_url: skill.readme_url.clone(), + installed: true, + repo_owner: skill.repo_owner.clone(), + repo_name: skill.repo_name.clone(), + repo_branch: skill.repo_branch.clone(), + }); + } + } + skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); Ok(skills) } /// 从仓库获取技能列表 - async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result> { - // 为单个仓库加载增加整体超时,避免无效链接长时间阻塞 + async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result> { let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo)) .await .map_err(|_| { @@ -211,42 +707,31 @@ impl SkillService { Some("checkNetwork"), )) })??; - let mut skills = Vec::new(); - // 扫描仓库根目录(支持全仓库递归扫描) + let mut skills = Vec::new(); let scan_dir = temp_dir.clone(); - // 递归扫描目录查找所有技能 self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?; - // 清理临时目录 let _ = fs::remove_dir_all(&temp_dir); Ok(skills) } /// 递归扫描目录查找 SKILL.md - /// - /// 规则: - /// 1. 如果当前目录存在 SKILL.md,则识别为技能,停止扫描其子目录(子目录视为功能文件夹) - /// 2. 如果当前目录不存在 SKILL.md,则递归扫描所有子目录 fn scan_dir_recursive( &self, current_dir: &Path, base_dir: &Path, repo: &SkillRepo, - skills: &mut Vec, + skills: &mut Vec, ) -> Result<()> { - // 检查当前目录是否包含 SKILL.md let skill_md = current_dir.join("SKILL.md"); if skill_md.exists() { - // 发现技能!获取相对路径作为目录名 let directory = if current_dir == base_dir { - // 根目录的 SKILL.md,使用仓库名 repo.name.clone() } else { - // 子目录的 SKILL.md,使用相对路径 current_dir .strip_prefix(base_dir) .unwrap_or(current_dir) @@ -258,16 +743,13 @@ impl SkillService { skills.push(skill); } - // 停止扫描此目录的子目录(同级目录都是功能文件夹) return Ok(()); } - // 未发现 SKILL.md,继续递归扫描所有子目录 for entry in fs::read_dir(current_dir)? { let entry = entry?; let path = entry.path(); - // 只处理目录 if path.is_dir() { self.scan_dir_recursive(&path, base_dir, repo, skills)?; } @@ -282,36 +764,34 @@ impl SkillService { skill_md: &Path, directory: &str, repo: &SkillRepo, - ) -> Result { + ) -> Result { let meta = self.parse_skill_metadata(skill_md)?; - // 构建 README URL - let readme_path = directory.to_string(); - - Ok(Skill { + Ok(DiscoverableSkill { key: format!("{}/{}:{}", repo.owner, repo.name, directory), name: meta.name.unwrap_or_else(|| directory.to_string()), description: meta.description.unwrap_or_default(), directory: directory.to_string(), readme_url: Some(format!( "https://github.com/{}/{}/tree/{}/{}", - repo.owner, repo.name, repo.branch, readme_path + repo.owner, repo.name, repo.branch, directory )), - installed: false, - repo_owner: Some(repo.owner.clone()), - repo_name: Some(repo.name.clone()), - repo_branch: Some(repo.branch.clone()), + repo_owner: repo.owner.clone(), + repo_name: repo.name.clone(), + repo_branch: repo.branch.clone(), }) } /// 解析技能元数据 fn parse_skill_metadata(&self, path: &Path) -> Result { - let content = fs::read_to_string(path)?; + Self::parse_skill_metadata_static(path) + } - // 移除 BOM + /// 静态方法:解析技能元数据 + fn parse_skill_metadata_static(path: &Path) -> Result { + let content = fs::read_to_string(path)?; let content = content.trim_start_matches('\u{feff}'); - // 提取 YAML front matter let parts: Vec<&str> = content.splitn(3, "---").collect(); if parts.len() < 3 { return Ok(SkillMetadata { @@ -329,117 +809,10 @@ impl SkillService { Ok(meta) } - /// 合并本地技能 - fn merge_local_skills(&self, skills: &mut Vec) -> Result<()> { - if !self.install_dir.exists() { - return Ok(()); - } - - // 收集所有本地技能 - let mut local_skills = Vec::new(); - self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?; - - // 处理找到的本地技能 - for local_skill in local_skills { - let directory = &local_skill.directory; - - // 更新已安装状态(匹配远程技能) - // 使用目录最后一段进行比较,因为安装时只使用最后一段作为目录名 - let mut found = false; - let local_install_name = Path::new(directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| directory.clone()); - - for skill in skills.iter_mut() { - let remote_install_name = Path::new(&skill.directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| skill.directory.clone()); - - if remote_install_name.eq_ignore_ascii_case(&local_install_name) { - skill.installed = true; - found = true; - break; - } - } - - // 添加本地独有的技能(仅当在仓库中未找到时) - if !found { - skills.push(local_skill); - } - } - - Ok(()) - } - - /// 递归扫描本地目录查找 SKILL.md - fn scan_local_dir_recursive( - &self, - current_dir: &Path, - base_dir: &Path, - skills: &mut Vec, - ) -> Result<()> { - // 检查当前目录是否包含 SKILL.md - let skill_md = current_dir.join("SKILL.md"); - - if skill_md.exists() { - // 发现技能!获取相对路径作为目录名 - let directory = if current_dir == base_dir { - // 如果是 install_dir 本身,使用最后一段路径名 - current_dir - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - } else { - // 使用相对于 install_dir 的路径 - current_dir - .strip_prefix(base_dir) - .unwrap_or(current_dir) - .to_string_lossy() - .to_string() - }; - - // 解析元数据并创建本地技能对象 - if let Ok(meta) = self.parse_skill_metadata(&skill_md) { - skills.push(Skill { - key: format!("local:{directory}"), - name: meta.name.unwrap_or_else(|| directory.clone()), - description: meta.description.unwrap_or_default(), - directory: directory.clone(), - readme_url: None, - installed: true, - repo_owner: None, - repo_name: None, - repo_branch: None, - }); - } - - // 停止扫描此目录的子目录(同级目录都是功能文件夹) - return Ok(()); - } - - // 未发现 SKILL.md,继续递归扫描所有子目录 - for entry in fs::read_dir(current_dir)? { - let entry = entry?; - let path = entry.path(); - - // 只处理目录 - if path.is_dir() { - self.scan_local_dir_recursive(&path, base_dir, skills)?; - } - } - - Ok(()) - } - /// 去重技能列表 - /// 使用完整的 key (owner/name:directory) 来区分不同仓库的同名技能 - fn deduplicate_skills(skills: &mut Vec) { + fn deduplicate_discoverable_skills(skills: &mut Vec) { let mut seen = HashMap::new(); skills.retain(|skill| { - // 使用完整 key 而非仅 directory,允许不同仓库的同名技能共存 let unique_key = skill.key.to_lowercase(); if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) { e.insert(true); @@ -454,9 +827,8 @@ impl SkillService { async fn download_repo(&self, repo: &SkillRepo) -> Result { let temp_dir = tempfile::tempdir()?; let temp_path = temp_dir.path().to_path_buf(); - let _ = temp_dir.keep(); // 保持临时目录,稍后手动清理 + let _ = temp_dir.keep(); - // 尝试多个分支 let branches = if repo.branch.is_empty() { vec!["main", "master"] } else { @@ -486,7 +858,6 @@ impl SkillService { /// 下载并解压 ZIP async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> { - // 下载 ZIP let response = self.http_client.get(url).send().await?; if !response.status().is_success() { let status = response.status().as_u16().to_string(); @@ -503,12 +874,9 @@ impl SkillService { } let bytes = response.bytes().await?; - - // 解压 let cursor = std::io::Cursor::new(bytes); let mut archive = zip::ZipArchive::new(cursor)?; - // 获取根目录名称 (GitHub 的 zip 会有一个根目录) let root_name = if !archive.is_empty() { let first_file = archive.by_index(0)?; let name = first_file.name(); @@ -521,12 +889,10 @@ impl SkillService { ))); }; - // 解压所有文件 for i in 0..archive.len() { let mut file = archive.by_index(i)?; let file_path = file.name(); - // 跳过根目录,直接提取内容 let relative_path = if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) { stripped @@ -554,66 +920,6 @@ impl SkillService { Ok(()) } - /// 安装技能(仅负责下载和文件操作,状态更新由上层负责) - pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> { - // 使用技能目录的最后一段作为安装目录名,避免嵌套路径问题 - // 例如: "skills/codex" -> "codex" - let install_name = Path::new(&directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| directory.clone()); - - let dest = self.install_dir.join(&install_name); - - // 若目标目录已存在,则视为已安装,避免重复下载 - if dest.exists() { - return Ok(()); - } - - // 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程 - let temp_dir = timeout( - std::time::Duration::from_secs(60), - self.download_repo(&repo), - ) - .await - .map_err(|_| { - anyhow!(format_skill_error( - "DOWNLOAD_TIMEOUT", - &[ - ("owner", &repo.owner), - ("name", &repo.name), - ("timeout", "60") - ], - Some("checkNetwork"), - )) - })??; - - // 确定源目录路径(技能相对于仓库根目录的路径) - let source = temp_dir.join(&directory); - - if !source.exists() { - let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow::anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &source.display().to_string())], - Some("checkRepoUrl"), - ))); - } - - // 删除旧版本 - if dest.exists() { - fs::remove_dir_all(&dest)?; - } - - // 递归复制 - Self::copy_dir_recursive(&source, &dest)?; - - // 清理临时目录 - let _ = fs::remove_dir_all(&temp_dir); - - Ok(()) - } - /// 递归复制目录 fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { fs::create_dir_all(dest)?; @@ -633,22 +939,7 @@ impl SkillService { Ok(()) } - /// 卸载技能(仅负责文件操作,状态更新由上层负责) - pub fn uninstall_skill(&self, directory: String) -> Result<()> { - // 使用技能目录的最后一段作为安装目录名,与 install_skill 保持一致 - let install_name = Path::new(&directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| directory.clone()); - - let dest = self.install_dir.join(&install_name); - - if dest.exists() { - fs::remove_dir_all(&dest)?; - } - - Ok(()) - } + // ========== 仓库管理(保留原有逻辑)========== /// 列出仓库 pub fn list_repos(&self, store: &SkillStore) -> Vec { @@ -657,7 +948,6 @@ impl SkillService { /// 添加仓库 pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> { - // 检查重复 if let Some(pos) = store .repos .iter() @@ -680,3 +970,87 @@ impl SkillService { Ok(()) } } + +// ========== 迁移支持 ========== + +/// 首次启动迁移:扫描应用目录,重建数据库 +pub fn migrate_skills_to_ssot(db: &Arc) -> Result { + let ssot_dir = SkillService::get_ssot_dir()?; + let mut discovered: HashMap = HashMap::new(); + + // 扫描各应用目录 + for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + let app_dir = match SkillService::get_app_skills_dir(&app) { + Ok(d) => d, + Err(_) => continue, + }; + + if !app_dir.exists() { + continue; + } + + for entry in fs::read_dir(&app_dir)? { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let dir_name = entry.file_name().to_string_lossy().to_string(); + + // 复制到 SSOT(如果不存在) + let ssot_path = ssot_dir.join(&dir_name); + if !ssot_path.exists() { + SkillService::copy_dir_recursive(&path, &ssot_path)?; + } + + // 记录启用状态 + discovered + .entry(dir_name) + .or_default() + .set_enabled_for(&app, true); + } + } + + // 重建数据库 + db.clear_skills()?; + + let mut count = 0; + for (directory, apps) in discovered { + let ssot_path = ssot_dir.join(&directory); + let skill_md = ssot_path.join("SKILL.md"); + + let (name, description) = if skill_md.exists() { + match SkillService::parse_skill_metadata_static(&skill_md) { + Ok(meta) => ( + meta.name.unwrap_or_else(|| directory.clone()), + meta.description, + ), + Err(_) => (directory.clone(), None), + } + } else { + (directory.clone(), None) + }; + + let skill = InstalledSkill { + id: format!("local:{}", directory), + name, + description, + directory, + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps, + installed_at: chrono::Utc::now().timestamp(), + }; + + db.save_skill(&skill)?; + count += 1; + } + + log::info!("Skills 迁移完成,共 {} 个", count); + + Ok(count) +} diff --git a/src/App.tsx b/src/App.tsx index 5eaeda9bd..040cad238 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { Wrench, Server, RefreshCw, + Search, } from "lucide-react"; import type { Provider } from "@/types"; import type { EnvConflict } from "@/types/env"; @@ -42,6 +43,7 @@ import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; +import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { UniversalProviderPanel } from "@/components/universal"; @@ -52,6 +54,7 @@ type View = | "settings" | "prompts" | "skills" + | "skillsDiscovery" | "mcp" | "agents" | "universal"; @@ -81,6 +84,8 @@ function App() { const promptPanelRef = useRef(null); const mcpPanelRef = useRef(null); const skillsPageRef = useRef(null); + const [openRepoManagerOnDiscovery, setOpenRepoManagerOnDiscovery] = + useState(false); const addActionButtonClass = "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; @@ -106,8 +111,23 @@ function App() { }); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; - // Skills 功能仅支持 Claude 和 Codex - const hasSkillsSupport = activeApp === "claude" || activeApp === "codex"; + const hasSkillsSupport = true; + + const refreshSkillsData = async () => { + try { + await queryClient.invalidateQueries({ queryKey: ["skills"] }); + await queryClient.refetchQueries({ queryKey: ["skills"], type: "active" }); + } catch (error) { + console.error("[App] Failed to refresh skills data", error); + } + }; + + useEffect(() => { + if (currentView === "skillsDiscovery" && openRepoManagerOnDiscovery) { + skillsPageRef.current?.openRepoManager?.(); + setOpenRepoManagerOnDiscovery(false); + } + }, [currentView, openRepoManagerOnDiscovery]); // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 const { @@ -218,6 +238,35 @@ function App() { checkMigration(); }, [t]); + // 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT) + useEffect(() => { + const checkSkillsMigration = async () => { + try { + const result = await invoke<{ count: number; error?: string } | null>( + "get_skills_migration_result", + ); + if (result?.error) { + toast.error(t("migration.skillsFailed"), { + description: t("migration.skillsFailedDescription"), + closeButton: true, + }); + console.error("[App] Skills SSOT migration failed:", result.error); + return; + } + if (result && result.count > 0) { + toast.success(t("migration.skillsSuccess", { count: result.count }), { + closeButton: true, + }); + await queryClient.invalidateQueries({ queryKey: ["skills"] }); + } + } catch (error) { + console.error("[App] Failed to check skills migration result:", error); + } + }; + + checkSkillsMigration(); + }, [t, queryClient]); + // 切换应用时检测当前应用的环境变量冲突 useEffect(() => { const checkEnvOnSwitch = async () => { @@ -390,10 +439,16 @@ function App() { /> ); case "skills": + return ( + setCurrentView("skillsDiscovery")} + /> + ); + case "skillsDiscovery": return ( setCurrentView("providers")} + onClose={() => setCurrentView("skills")} initialApp={activeApp} /> ); @@ -532,7 +587,11 @@ function App() { )} {currentView === "skills" && ( + <> + + + + + )} + {currentView === "skillsDiscovery" && ( <> + + )} + {/* 技能网格(可滚动详情区域) */}
diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx new file mode 100644 index 000000000..fe761f547 --- /dev/null +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -0,0 +1,436 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Sparkles, Trash2, ExternalLink, Download } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { + useInstalledSkills, + useToggleSkillApp, + useUninstallSkill, + useScanUnmanagedSkills, + useImportSkillsFromApps, + type InstalledSkill, + type AppType, +} from "@/hooks/useSkills"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { settingsApi } from "@/lib/api"; +import { toast } from "sonner"; + +interface UnifiedSkillsPanelProps { + onOpenDiscovery: () => void; +} + +/** + * 统一 Skills 管理面板 + * v3.10.0 新架构:所有 Skills 统一管理,每个 Skill 通过开关控制应用到哪些客户端 + */ +export interface UnifiedSkillsPanelHandle { + openDiscovery: () => void; +} + +const UnifiedSkillsPanel = React.forwardRef< + UnifiedSkillsPanelHandle, + UnifiedSkillsPanelProps +>(({ onOpenDiscovery }, ref) => { + const { t } = useTranslation(); + const [confirmDialog, setConfirmDialog] = useState<{ + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + } | null>(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + + // Queries and Mutations + const { data: skills, isLoading } = useInstalledSkills(); + const toggleAppMutation = useToggleSkillApp(); + const uninstallMutation = useUninstallSkill(); + const { data: unmanagedSkills, refetch: scanUnmanaged } = + useScanUnmanagedSkills(); + const importMutation = useImportSkillsFromApps(); + + // Count enabled skills per app + const enabledCounts = useMemo(() => { + const counts = { claude: 0, codex: 0, gemini: 0 }; + if (!skills) return counts; + skills.forEach((skill) => { + if (skill.apps.claude) counts.claude++; + if (skill.apps.codex) counts.codex++; + if (skill.apps.gemini) counts.gemini++; + }); + return counts; + }, [skills]); + + const handleToggleApp = async ( + id: string, + app: AppType, + enabled: boolean, + ) => { + try { + await toggleAppMutation.mutateAsync({ id, app, enabled }); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + + const handleUninstall = (skill: InstalledSkill) => { + setConfirmDialog({ + isOpen: true, + title: t("skills.uninstall"), + message: t("skills.uninstallConfirm", { name: skill.name }), + onConfirm: async () => { + try { + await uninstallMutation.mutateAsync(skill.id); + setConfirmDialog(null); + toast.success(t("skills.uninstallSuccess", { name: skill.name }), { + closeButton: true, + }); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }, + }); + }; + + const handleOpenImport = async () => { + try { + await scanUnmanaged(); + setImportDialogOpen(true); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + + const handleImport = async (directories: string[]) => { + try { + const imported = await importMutation.mutateAsync(directories); + setImportDialogOpen(false); + toast.success( + t("skills.importSuccess", { count: imported.length }), + { closeButton: true }, + ); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + + React.useImperativeHandle(ref, () => ({ + openDiscovery: onOpenDiscovery, + })); + + return ( +
+ {/* Info Section */} +
+
+ {t("skills.installed", { count: skills?.length || 0 })} ·{" "} + {t("skills.apps.claude")}: {enabledCounts.claude} ·{" "} + {t("skills.apps.codex")}: {enabledCounts.codex} ·{" "} + {t("skills.apps.gemini")}: {enabledCounts.gemini} +
+
+ + {/* Content - Scrollable */} +
+ {isLoading ? ( +
+ {t("skills.loading")} +
+ ) : !skills || skills.length === 0 ? ( +
+
+ +
+

+ {t("skills.noInstalled")} +

+

+ {t("skills.noInstalledDescription")} +

+
+ + +
+
+ ) : ( +
+ {skills.map((skill) => ( + handleUninstall(skill)} + /> + ))} +
+ )} +
+ + {/* Confirm Dialog */} + {confirmDialog && ( + setConfirmDialog(null)} + /> + )} + + {/* Import Dialog */} + {importDialogOpen && unmanagedSkills && ( + setImportDialogOpen(false)} + /> + )} +
+ ); +}); + +UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel"; + +/** + * 已安装 Skill 列表项组件 + */ +interface InstalledSkillListItemProps { + skill: InstalledSkill; + onToggleApp: (id: string, app: AppType, enabled: boolean) => void; + onUninstall: () => void; +} + +const InstalledSkillListItem: React.FC = ({ + skill, + onToggleApp, + onUninstall, +}) => { + const { t } = useTranslation(); + + const openDocs = async () => { + if (!skill.readmeUrl) return; + try { + await settingsApi.openExternal(skill.readmeUrl); + } catch { + // ignore + } + }; + + // 生成来源标签 + const sourceLabel = useMemo(() => { + if (skill.repoOwner && skill.repoName) { + return `${skill.repoOwner}/${skill.repoName}`; + } + return t("skills.local"); + }, [skill.repoOwner, skill.repoName, t]); + + return ( +
+ {/* 左侧:Skill 信息 */} +
+
+

{skill.name}

+ {skill.readmeUrl && ( + + )} +
+ {skill.description && ( +

+ {skill.description} +

+ )} +

{sourceLabel}

+
+ + {/* 中间:应用开关 */} +
+
+ + + onToggleApp(skill.id, "claude", checked) + } + /> +
+ +
+ + + onToggleApp(skill.id, "codex", checked) + } + /> +
+ +
+ + + onToggleApp(skill.id, "gemini", checked) + } + /> +
+
+ + {/* 右侧:删除按钮 */} +
+ +
+
+ ); +}; + +/** + * 导入 Skills 对话框 + */ +interface ImportSkillsDialogProps { + skills: Array<{ + directory: string; + name: string; + description?: string; + foundIn: string[]; + }>; + onImport: (directories: string[]) => void; + onClose: () => void; +} + +const ImportSkillsDialog: React.FC = ({ + skills, + onImport, + onClose, +}) => { + const { t } = useTranslation(); + const [selected, setSelected] = useState>( + new Set(skills.map((s) => s.directory)), + ); + + const toggleSelect = (directory: string) => { + const newSelected = new Set(selected); + if (newSelected.has(directory)) { + newSelected.delete(directory); + } else { + newSelected.add(directory); + } + setSelected(newSelected); + }; + + const handleImport = () => { + onImport(Array.from(selected)); + }; + + if (skills.length === 0) { + return ( +
+
+

{t("skills.import")}

+

+ {t("skills.noUnmanagedFound")} +

+
+ +
+
+
+ ); + } + + return ( +
+
+

{t("skills.import")}

+

+ {t("skills.importDescription")} +

+ +
+ {skills.map((skill) => ( + + ))} +
+ +
+ + +
+
+
+ ); +}; + +export default UnifiedSkillsPanel; diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts new file mode 100644 index 000000000..ae8fc742c --- /dev/null +++ b/src/hooks/useSkills.ts @@ -0,0 +1,151 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + skillsApi, + type AppType, + type DiscoverableSkill, + type InstalledSkill, +} from "@/lib/api/skills"; + +/** + * 查询所有已安装的 Skills + */ +export function useInstalledSkills() { + return useQuery({ + queryKey: ["skills", "installed"], + queryFn: () => skillsApi.getInstalled(), + }); +} + +/** + * 发现可安装的 Skills(从仓库获取) + */ +export function useDiscoverableSkills() { + return useQuery({ + queryKey: ["skills", "discoverable"], + queryFn: () => skillsApi.discoverAvailable(), + staleTime: 5 * 60 * 1000, // 5 分钟内不重新获取 + }); +} + +/** + * 安装 Skill + */ +export function useInstallSkill() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + skill, + currentApp, + }: { + skill: DiscoverableSkill; + currentApp: AppType; + }) => skillsApi.installUnified(skill, currentApp), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + +/** + * 卸载 Skill + */ +export function useUninstallSkill() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => skillsApi.uninstallUnified(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + +/** + * 切换 Skill 在特定应用的启用状态 + */ +export function useToggleSkillApp() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + app, + enabled, + }: { + id: string; + app: AppType; + enabled: boolean; + }) => skillsApi.toggleApp(id, app, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + }, + }); +} + +/** + * 扫描未管理的 Skills + */ +export function useScanUnmanagedSkills() { + return useQuery({ + queryKey: ["skills", "unmanaged"], + queryFn: () => skillsApi.scanUnmanaged(), + enabled: false, // 手动触发 + }); +} + +/** + * 从应用目录导入 Skills + */ +export function useImportSkillsFromApps() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (directories: string[]) => skillsApi.importFromApps(directories), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }); + }, + }); +} + +/** + * 获取仓库列表 + */ +export function useSkillRepos() { + return useQuery({ + queryKey: ["skills", "repos"], + queryFn: () => skillsApi.getRepos(), + }); +} + +/** + * 添加仓库 + */ +export function useAddSkillRepo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: skillsApi.addRepo, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "repos"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + +/** + * 删除仓库 + */ +export function useRemoveSkillRepo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ owner, name }: { owner: string; name: string }) => + skillsApi.removeRepo(owner, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "repos"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + +// ========== 辅助类型 ========== + +export type { InstalledSkill, DiscoverableSkill, AppType }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b103da09c..2d0e9ec12 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -870,7 +870,25 @@ "installed": "Installed", "uninstalled": "Not installed" }, - "noResults": "No matching skills found" + "noResults": "No matching skills found", + "noInstalled": "No skills installed", + "noInstalledDescription": "Discover and install skills from repositories, or import existing skills", + "discover": "Discover Skills", + "import": "Import Existing", + "importDescription": "Select skills to import into CC Switch unified management", + "importSuccess": "Successfully imported {{count}} skills", + "importSelected": "Import Selected ({{count}})", + "noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.", + "foundIn": "Found in", + "local": "Local", + "uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps.", + "uninstallInMainPanel": "Please uninstall skills from the main panel", + "notFound": "Skill not found", + "apps": { + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini" + } }, "deeplink": { "confirmImport": "Confirm Import Provider", @@ -958,7 +976,10 @@ "clickToSelect": "Click to select icon" }, "migration": { - "success": "Configuration migrated successfully" + "success": "Configuration migrated successfully", + "skillsSuccess": "Automatically imported {{count}} skill(s) into unified management", + "skillsFailed": "Failed to auto import skills", + "skillsFailedDescription": "Open the Skills page and click \"Import Existing\" to import manually (or restart and try again)." }, "agents": { "title": "Agents" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 51de11899..6273f60e1 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -870,7 +870,25 @@ "installed": "インストール済み", "uninstalled": "未インストール" }, - "noResults": "一致するスキルが見つかりませんでした" + "noResults": "一致するスキルが見つかりませんでした", + "noInstalled": "インストールされたスキルがありません", + "noInstalledDescription": "リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください", + "discover": "スキルを発見", + "import": "既存をインポート", + "importDescription": "CC Switch 統合管理にインポートするスキルを選択してください", + "importSuccess": "{{count}} 件のスキルをインポートしました", + "importSelected": "選択をインポート ({{count}})", + "noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。", + "foundIn": "発見場所", + "local": "ローカル", + "uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除されます。", + "uninstallInMainPanel": "メインパネルからスキルをアンインストールしてください", + "notFound": "スキルが見つかりません", + "apps": { + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini" + } }, "deeplink": { "confirmImport": "プロバイダーのインポートを確認", @@ -958,7 +976,10 @@ "clickToSelect": "クリックでアイコンを選択" }, "migration": { - "success": "設定の移行が完了しました" + "success": "設定の移行が完了しました", + "skillsSuccess": "スキルを {{count}} 件、自動的に統合管理へインポートしました", + "skillsFailed": "スキルの自動インポートに失敗しました", + "skillsFailedDescription": "Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。" }, "agents": { "title": "エージェント" diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 00208c3a3..05311b3e3 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -870,7 +870,25 @@ "installed": "已安装", "uninstalled": "未安装" }, - "noResults": "未找到匹配的技能" + "noResults": "未找到匹配的技能", + "noInstalled": "暂无已安装的技能", + "noInstalledDescription": "从仓库发现并安装技能,或导入已有的技能", + "discover": "发现技能", + "import": "导入已有", + "importDescription": "选择要导入到 CC Switch 统一管理的技能", + "importSuccess": "成功导入 {{count}} 个技能", + "importSelected": "导入已选 ({{count}})", + "noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。", + "foundIn": "发现于", + "local": "本地", + "uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能。", + "uninstallInMainPanel": "请在主面板中卸载技能", + "notFound": "未找到技能", + "apps": { + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini" + } }, "deeplink": { "confirmImport": "确认导入供应商配置", @@ -958,7 +976,10 @@ "clickToSelect": "点击选择图标" }, "migration": { - "success": "配置迁移成功" + "success": "配置迁移成功", + "skillsSuccess": "已自动导入 {{count}} 个技能到统一管理", + "skillsFailed": "自动导入技能失败", + "skillsFailedDescription": "请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。" }, "agents": { "title": "智能体" diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 862813a2a..09a4d6a45 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -1,5 +1,51 @@ import { invoke } from "@tauri-apps/api/core"; +// ========== 类型定义 ========== + +export type AppType = "claude" | "codex" | "gemini"; + +/** Skill 应用启用状态 */ +export interface SkillApps { + claude: boolean; + codex: boolean; + gemini: boolean; +} + +/** 已安装的 Skill(v3.10.0+ 统一结构) */ +export interface InstalledSkill { + id: string; + name: string; + description?: string; + directory: string; + repoOwner?: string; + repoName?: string; + repoBranch?: string; + readmeUrl?: string; + apps: SkillApps; + installedAt: number; +} + +/** 可发现的 Skill(来自仓库) */ +export interface DiscoverableSkill { + key: string; + name: string; + description: string; + directory: string; + readmeUrl?: string; + repoOwner: string; + repoName: string; + repoBranch: string; +} + +/** 未管理的 Skill(用于导入) */ +export interface UnmanagedSkill { + directory: string; + name: string; + description?: string; + foundIn: string[]; +} + +/** 技能对象(兼容旧 API) */ export interface Skill { key: string; name: string; @@ -12,6 +58,7 @@ export interface Skill { repoBranch?: string; } +/** 仓库配置 */ export interface SkillRepo { owner: string; name: string; @@ -19,9 +66,56 @@ export interface SkillRepo { enabled: boolean; } -export type AppType = "claude" | "codex" | "gemini"; +// ========== API ========== export const skillsApi = { + // ========== 统一管理 API (v3.10.0+) ========== + + /** 获取所有已安装的 Skills */ + async getInstalled(): Promise { + return await invoke("get_installed_skills"); + }, + + /** 安装 Skill(统一安装) */ + async installUnified( + skill: DiscoverableSkill, + currentApp: AppType, + ): Promise { + return await invoke("install_skill_unified", { skill, currentApp }); + }, + + /** 卸载 Skill(统一卸载) */ + async uninstallUnified(id: string): Promise { + return await invoke("uninstall_skill_unified", { id }); + }, + + /** 切换 Skill 的应用启用状态 */ + async toggleApp( + id: string, + app: AppType, + enabled: boolean, + ): Promise { + return await invoke("toggle_skill_app", { id, app, enabled }); + }, + + /** 扫描未管理的 Skills */ + async scanUnmanaged(): Promise { + return await invoke("scan_unmanaged_skills"); + }, + + /** 从应用目录导入 Skills */ + async importFromApps(directories: string[]): Promise { + return await invoke("import_skills_from_apps", { directories }); + }, + + /** 发现可安装的 Skills(从仓库获取) */ + async discoverAvailable(): Promise { + return await invoke("discover_available_skills"); + }, + + // ========== 兼容旧 API ========== + + /** 获取技能列表(兼容旧 API) */ async getAll(app: AppType = "claude"): Promise { if (app === "claude") { return await invoke("get_skills"); @@ -29,6 +123,7 @@ export const skillsApi = { return await invoke("get_skills_for_app", { app }); }, + /** 安装技能(兼容旧 API) */ async install(directory: string, app: AppType = "claude"): Promise { if (app === "claude") { return await invoke("install_skill", { directory }); @@ -36,6 +131,7 @@ export const skillsApi = { return await invoke("install_skill_for_app", { app, directory }); }, + /** 卸载技能(兼容旧 API) */ async uninstall( directory: string, app: AppType = "claude", @@ -46,14 +142,19 @@ export const skillsApi = { return await invoke("uninstall_skill_for_app", { app, directory }); }, + // ========== 仓库管理 ========== + + /** 获取仓库列表 */ async getRepos(): Promise { return await invoke("get_skill_repos"); }, + /** 添加仓库 */ async addRepo(repo: SkillRepo): Promise { return await invoke("add_skill_repo", { repo }); }, + /** 删除仓库 */ async removeRepo(owner: string, name: string): Promise { return await invoke("remove_skill_repo", { owner, name }); }, diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index df637d662..ae145c001 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -36,6 +36,8 @@ const withJson = async (request: Request): Promise => { const success = (payload: T) => HttpResponse.json(payload as any); export const handlers = [ + http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)), + http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)), http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => { const { app } = await withJson<{ app: AppId }>(request); return success(getProviders(app));