From 8cfce8abfc623f2c7417d8a812e156bd4c00f9ac Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 20:21:51 +0800 Subject: [PATCH] feat: add skill storage location toggle between CC Switch and ~/.agents/skills Allow users to choose between storing skills in CC Switch's managed directory (~/.cc-switch/skills/) or the Agent Skills open standard directory (~/.agents/skills/). Includes migration logic that safely moves files before updating settings, with confirmation dialog for non-empty installations. --- src-tauri/src/commands/skill.rs | 13 +- src-tauri/src/lib.rs | 1 + src-tauri/src/services/skill.rs | 116 +++++++++++- src-tauri/src/settings.rs | 26 ++- src/components/settings/SettingsPage.tsx | 11 ++ .../settings/SkillStorageLocationSettings.tsx | 165 ++++++++++++++++++ src/i18n/locales/en.json | 12 ++ src/i18n/locales/ja.json | 12 ++ src/i18n/locales/zh.json | 12 ++ src/lib/api/skills.ts | 14 ++ src/lib/schemas/settings.ts | 1 + src/types.ts | 5 + 12 files changed, 383 insertions(+), 5 deletions(-) create mode 100644 src/components/settings/SkillStorageLocationSettings.tsx diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index a55b5e6f7..8c63ec589 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -7,8 +7,8 @@ use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill}; use crate::error::format_skill_error; use crate::services::skill::{ - DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService, - SkillUninstallResult, SkillUpdateInfo, + DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo, + SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo, }; use crate::store::AppState; use std::sync::Arc; @@ -161,6 +161,15 @@ pub async fn update_skill( .map_err(|e| e.to_string()) } +/// 迁移 Skill 存储位置 +#[tauri::command] +pub async fn migrate_skill_storage( + target: SkillStorageLocation, + app_state: State<'_, AppState>, +) -> Result { + SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string()) +} + // ========== 兼容旧 API 的命令 ========== /// 获取技能列表(兼容旧 API) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dca642610..e60c3ae23 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -965,6 +965,7 @@ pub fn run() { commands::discover_available_skills, commands::check_skill_updates, commands::update_skill, + commands::migrate_skill_storage, // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f26803d34..adadf54ad 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -34,6 +34,17 @@ pub enum SyncMethod { Copy, } +/// Skill 存储位置(SSOT 目录选择) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SkillStorageLocation { + /// CC Switch 管理目录 (~/.cc-switch/skills/) + #[default] + CcSwitch, + /// Agent Skills 统一标准目录 (~/.agents/skills/) + Unified, +} + /// 可发现的技能(来自仓库) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoverableSkill { @@ -174,6 +185,15 @@ pub struct SkillUpdateInfo { pub remote_hash: String, } +/// Skill 存储位置迁移结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationResult { + pub migrated_count: usize, + pub skipped_count: usize, + pub errors: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillBackupEntry { @@ -403,9 +423,20 @@ impl SkillService { // ========== 路径管理 ========== - /// 获取 SSOT 目录(~/.cc-switch/skills/) + /// 获取 SSOT 目录(根据设置返回 ~/.cc-switch/skills/ 或 ~/.agents/skills/) pub fn get_ssot_dir() -> Result { - let dir = get_app_config_dir().join("skills"); + let location = crate::settings::get_skill_storage_location(); + let dir = match location { + SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::Unified => { + let home = dirs::home_dir().context(format_skill_error( + "GET_HOME_DIR_FAILED", + &[], + Some("checkPermission"), + ))?; + home.join(".agents").join("skills") + } + }; fs::create_dir_all(&dir)?; Ok(dir) } @@ -1052,6 +1083,87 @@ impl SkillService { Ok(count) } + /// 迁移 Skill 存储位置(在两个 SSOT 目录间移动文件) + /// + /// 安全策略:先移文件,后改设置。中途崩溃时设置仍指向旧目录。 + pub fn migrate_storage( + db: &Arc, + target: SkillStorageLocation, + ) -> Result { + let current = crate::settings::get_skill_storage_location(); + if current == target { + return Ok(MigrationResult { + migrated_count: 0, + skipped_count: 0, + errors: vec![], + }); + } + + // 1. 解析旧目录和新目录(不改设置) + let old_dir = Self::get_ssot_dir()?; + let new_dir = match target { + SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::Unified => { + let home = dirs::home_dir().context("Cannot determine home directory")?; + home.join(".agents").join("skills") + } + }; + fs::create_dir_all(&new_dir)?; + + // 2. 逐个移动 skill 目录 + let skills = db.get_all_installed_skills()?; + let mut result = MigrationResult { + migrated_count: 0, + skipped_count: 0, + errors: vec![], + }; + + for skill in skills.values() { + let src = old_dir.join(&skill.directory); + let dst = new_dir.join(&skill.directory); + + if !src.exists() { + result.skipped_count += 1; + continue; + } + if dst.exists() { + result.skipped_count += 1; + continue; + } + + // 优先 rename(同文件系统原子操作),失败则 copy+delete + match fs::rename(&src, &dst) { + Ok(()) => result.migrated_count += 1, + Err(_) => match Self::copy_dir_recursive(&src, &dst) { + Ok(()) => { + let _ = fs::remove_dir_all(&src); + result.migrated_count += 1; + } + Err(e) => { + result.errors.push(format!("{}: {e}", skill.directory)); + } + }, + } + } + + // 3. 文件移动完成后才持久化设置 + crate::settings::set_skill_storage_location(target)?; + + // 4. 刷新所有应用目录的 symlink(指向新 SSOT) + for app in AppType::all() { + let _ = Self::sync_to_app(db, &app); + } + + log::info!( + "Skill 存储迁移完成: {} 迁移, {} 跳过, {} 错误", + result.migrated_count, + result.skipped_count, + result.errors.len() + ); + + Ok(result) + } + pub fn list_backups() -> Result> { let backup_dir = Self::get_backup_dir()?; let mut entries = Vec::new(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b44929dd1..1d1dd836e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -6,7 +6,7 @@ use std::sync::{OnceLock, RwLock}; use crate::app_config::AppType; use crate::error::AppError; -use crate::services::skill::SyncMethod; +use crate::services::skill::{SkillStorageLocation, SyncMethod}; /// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -245,6 +245,9 @@ pub struct AppSettings { /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy #[serde(default)] pub skill_sync_method: SyncMethod, + /// Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/) + #[serde(default)] + pub skill_storage_location: SkillStorageLocation, // ===== WebDAV 同步设置 ===== #[serde(default, skip_serializing_if = "Option::is_none")] @@ -307,6 +310,7 @@ impl Default for AppSettings { current_provider_opencode: None, current_provider_openclaw: None, skill_sync_method: SyncMethod::default(), + skill_storage_location: SkillStorageLocation::default(), webdav_sync: None, webdav_backup: None, backup_interval_hours: None, @@ -642,6 +646,26 @@ pub fn get_skill_sync_method() -> SyncMethod { .skill_sync_method } +// ===== Skill 存储位置管理函数 ===== + +/// 获取 Skill 存储位置配置 +pub fn get_skill_storage_location() -> SkillStorageLocation { + settings_store() + .read() + .unwrap_or_else(|e| { + log::warn!("设置锁已毒化,使用恢复值: {e}"); + e.into_inner() + }) + .skill_storage_location +} + +/// 设置 Skill 存储位置 +pub fn set_skill_storage_location(location: SkillStorageLocation) -> Result<(), AppError> { + mutate_settings(|s| { + s.skill_storage_location = location; + }) +} + // ===== 备份策略管理函数 ===== /// Get the effective auto-backup interval in hours (default 24) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index cb5361598..b01cba7dd 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -32,6 +32,7 @@ import { LanguageSettings } from "@/components/settings/LanguageSettings"; import { ThemeSettings } from "@/components/settings/ThemeSettings"; import { WindowSettings } from "@/components/settings/WindowSettings"; import { AppVisibilitySettings } from "@/components/settings/AppVisibilitySettings"; +import { SkillStorageLocationSettings } from "@/components/settings/SkillStorageLocationSettings"; import { SkillSyncMethodSettings } from "@/components/settings/SkillSyncMethodSettings"; import { TerminalSettings } from "@/components/settings/TerminalSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; @@ -44,6 +45,7 @@ import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; import { UsageDashboard } from "@/components/usage/UsageDashboard"; import { LogConfigPanel } from "@/components/settings/LogConfigPanel"; import { AuthCenterPanel } from "@/components/settings/AuthCenterPanel"; +import { useInstalledSkills } from "@/hooks/useSkills"; import { useSettings } from "@/hooks/useSettings"; import { useImportExport } from "@/hooks/useImportExport"; import { useTranslation } from "react-i18next"; @@ -96,6 +98,8 @@ export function SettingsPage({ resetStatus, } = useImportExport({ onImportSuccess }); + const { data: installedSkills } = useInstalledSkills(); + const [activeTab, setActiveTab] = useState("general"); const [showRestartPrompt, setShowRestartPrompt] = useState(false); @@ -229,6 +233,13 @@ export function SettingsPage({ settings={settings} onChange={handleAutoSave} /> + + updateSettings({ skillStorageLocation: location }) + } + /> diff --git a/src/components/settings/SkillStorageLocationSettings.tsx b/src/components/settings/SkillStorageLocationSettings.tsx new file mode 100644 index 000000000..1aae08f87 --- /dev/null +++ b/src/components/settings/SkillStorageLocationSettings.tsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { skillsApi, type MigrationResult } from "@/lib/api/skills"; +import type { SkillStorageLocation } from "@/types"; + +export interface SkillStorageLocationSettingsProps { + value: SkillStorageLocation; + installedCount: number; + onMigrated: (target: SkillStorageLocation) => void; +} + +export function SkillStorageLocationSettings({ + value, + installedCount, + onMigrated, +}: SkillStorageLocationSettingsProps) { + const { t } = useTranslation(); + const [pendingTarget, setPendingTarget] = + useState(null); + const [isMigrating, setIsMigrating] = useState(false); + + const handleSelect = (target: SkillStorageLocation) => { + if (target === value) return; + if (installedCount > 0) { + setPendingTarget(target); + } else { + doMigrate(target); + } + }; + + const doMigrate = async (target: SkillStorageLocation) => { + setIsMigrating(true); + setPendingTarget(null); + try { + const result: MigrationResult = await skillsApi.migrateStorage(target); + if (result.errors.length > 0) { + toast.warning( + t("settings.skillStorage.migrationPartial", { + migrated: result.migratedCount, + errors: result.errors.length, + }), + ); + } else { + toast.success( + t("settings.skillStorage.migrationSuccess", { + count: result.migratedCount, + }), + ); + } + onMigrated(target); + } catch (error) { + toast.error(String(error)); + } finally { + setIsMigrating(false); + } + }; + + return ( +
+
+

+ {t("settings.skillStorage.title")} +

+

+ {t("settings.skillStorage.description")} +

+
+
+ handleSelect("cc_switch")} + > + {t("settings.skillStorage.ccSwitch")} + + handleSelect("unified")} + > + {isMigrating && value !== "unified" ? ( + + ) : null} + {t("settings.skillStorage.unified")} + +
+

+ {value === "unified" + ? t("settings.skillStorage.unifiedHint") + : t("settings.skillStorage.ccSwitchHint")} +

+ + {/* 迁移确认对话框 */} + { + if (!open) setPendingTarget(null); + }} + > + + + {t("settings.skillStorage.confirmTitle")} + + {t("settings.skillStorage.confirmMessage", { + count: installedCount, + })} + + + + + + + + +
+ ); +} + +interface StorageButtonProps { + active: boolean; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function StorageButton({ + active, + disabled, + onClick, + children, +}: StorageButtonProps) { + return ( + + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 663634e50..9b8440e7c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "Skill Storage Location", + "description": "Choose where CC Switch stores the master copies of your skills", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "Skills are stored in ~/.cc-switch/skills/ and synced to each app via symlink or copy.", + "unifiedHint": "Skills are stored in ~/.agents/skills/, the Agent Skills open standard. Compatible tools (Claude Code, Codex, Gemini CLI, etc.) discover skills here natively.", + "confirmTitle": "Migrate Skill Storage", + "confirmMessage": "{{count}} skill(s) will be moved to the new location. Continue?", + "migrationSuccess": "Successfully migrated {{count}} skill(s)", + "migrationPartial": "Migrated {{migrated}} skill(s), {{errors}} error(s). Check logs for details." + }, "skillSync": { "title": "Skill Sync Method", "description": "Choose how to sync Skills files", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0a432b889..f2ccfac41 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "スキル保存場所", + "description": "CC Switch がスキルのマスターコピーを保存するディレクトリを選択します", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "スキルは ~/.cc-switch/skills/ に保存され、シンボリックリンクまたはコピーで各アプリに同期されます。", + "unifiedHint": "スキルは ~/.agents/skills/ に保存されます(Agent Skills オープン標準)。対応ツール(Claude Code、Codex、Gemini CLI など)はこのディレクトリのスキルを直接検出します。", + "confirmTitle": "スキル保存場所の移行", + "confirmMessage": "{{count}} 個のスキルを新しい場所に移動します。続行しますか?", + "migrationSuccess": "{{count}} 個のスキルを移行しました", + "migrationPartial": "{{migrated}} 個のスキルを移行、{{errors}} 個のエラー。詳細はログを確認してください。" + }, "skillSync": { "title": "スキル同期方式", "description": "スキルファイルの同期方法を選択", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index dd2715b3b..6427611ea 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -478,6 +478,18 @@ "geminiDesc": "Google Gemini CLI", "opencodeDesc": "OpenCode CLI" }, + "skillStorage": { + "title": "技能存储位置", + "description": "选择 CC Switch 存放技能主副本的目录", + "ccSwitch": "CC Switch", + "unified": "~/.agents/skills", + "ccSwitchHint": "技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。", + "unifiedHint": "技能存储在 ~/.agents/skills/,遵循 Agent Skills 开放标准。兼容的工具(Claude Code、Codex、Gemini CLI 等)可直接发现此目录中的技能。", + "confirmTitle": "迁移技能存储", + "confirmMessage": "将移动 {{count}} 个技能到新位置,是否继续?", + "migrationSuccess": "已成功迁移 {{count}} 个技能", + "migrationPartial": "迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志" + }, "skillSync": { "title": "Skill 同步方式", "description": "选择 Skills 的文件同步策略", diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 52463975d..3e92e88b0 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -88,6 +88,13 @@ export interface SkillUpdateInfo { remoteHash: string; } +/** 存储位置迁移结果 */ +export interface MigrationResult { + migratedCount: number; + skippedCount: number; + errors: string[]; +} + /** 仓库配置 */ export interface SkillRepo { owner: string; @@ -169,6 +176,13 @@ export const skillsApi = { return await invoke("update_skill", { id }); }, + /** 迁移 Skill 存储位置 */ + async migrateStorage( + target: "cc_switch" | "unified", + ): Promise { + return await invoke("migrate_skill_storage", { target }); + }, + // ========== 兼容旧 API ========== /** 获取技能列表(兼容旧 API) */ diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index c73781ce8..752251369 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -29,6 +29,7 @@ export const settingsSchema = z.object({ // Skill 同步设置 skillSyncMethod: z.enum(["auto", "symlink", "copy"]).optional(), + skillStorageLocation: z.enum(["cc_switch", "unified"]).optional(), // WebDAV v2 同步设置(通过专用命令保存,schema 仅用于读取) webdavSync: z diff --git a/src/types.ts b/src/types.ts index 868a05cc6..f3abcb0f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -177,6 +177,9 @@ export interface ProviderMeta { // Skill 同步方式 export type SkillSyncMethod = "auto" | "symlink" | "copy"; +// Skill 存储位置 +export type SkillStorageLocation = "cc_switch" | "unified"; + // Claude API 格式类型 // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 @@ -292,6 +295,8 @@ export interface Settings { // ===== Skill 同步设置 ===== // Skill 同步方式:auto(默认,优先 symlink)、symlink、copy skillSyncMethod?: SkillSyncMethod; + // Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/) + skillStorageLocation?: SkillStorageLocation; // ===== WebDAV v2 同步设置 ===== webdavSync?: WebDavSyncSettings;