mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(skills): unified management architecture with SSOT and React Query
- 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
This commit is contained in:
@@ -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<AppType> {
|
||||
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<String>,
|
||||
/// 安装目录名(在 SSOT 目录中的子目录名)
|
||||
pub directory: String,
|
||||
/// 仓库所有者(GitHub 用户/组织)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_owner: Option<String>,
|
||||
/// 仓库名称
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_name: Option<String>,
|
||||
/// 仓库分支
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub repo_branch: Option<String>,
|
||||
/// README URL
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub readme_url: Option<String>,
|
||||
/// 应用启用状态
|
||||
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<String>,
|
||||
/// 在哪些应用目录中发现(如 ["claude", "codex"])
|
||||
pub found_in: Vec<String>,
|
||||
}
|
||||
|
||||
/// MCP 服务器定义(v3.7.0 统一结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServer {
|
||||
|
||||
@@ -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<bool, String> {
|
||||
Ok(crate::init_status::take_migration_success())
|
||||
}
|
||||
|
||||
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
|
||||
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
|
||||
#[tauri::command]
|
||||
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
|
||||
Ok(crate::init_status::take_skills_migration_result())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ToolVersion {
|
||||
name: String,
|
||||
|
||||
+142
-127
@@ -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<SkillService>);
|
||||
|
||||
/// 解析 app 参数为 AppType
|
||||
@@ -19,65 +24,117 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 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<Vec<InstalledSkill>, 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<InstalledSkill, String> {
|
||||
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<bool, String> {
|
||||
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<bool, String> {
|
||||
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<Vec<UnmanagedSkill>, String> {
|
||||
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 从应用目录导入 Skills
|
||||
#[tauri::command]
|
||||
pub fn import_skills_from_apps(
|
||||
directories: Vec<String>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<InstalledSkill>, 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<Vec<DiscoverableSkill>, 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<Vec<Skill>, 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<Vec<Skill>, 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<bool, 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)
|
||||
.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<bool, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
|
||||
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
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<bool, String> {
|
||||
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<Vec<SkillRepo>, String> {
|
||||
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, 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<bool, String> {
|
||||
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
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<bool, String> {
|
||||
app_state
|
||||
|
||||
@@ -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<IndexMap<String, SkillState>, AppError> {
|
||||
// ========== InstalledSkill CRUD ==========
|
||||
|
||||
/// 获取所有已安装的 Skills
|
||||
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, 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<Option<InstalledSkill>, 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<bool, AppError> {
|
||||
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<bool, AppError> {
|
||||
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<Vec<SkillRepo>, 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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
|
||||
@@ -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 返回的模型名称标准化后一致
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
|
||||
|
||||
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
|
||||
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<SkillsMigrationPayload> {
|
||||
if let Ok(mut guard) = skills_migration_cell().write() {
|
||||
guard.take()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+53
-9
@@ -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,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
+656
-282
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user