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.
This commit is contained in:
Jason
2026-04-05 20:21:51 +08:00
parent 6d220b2528
commit 8cfce8abfc
12 changed files with 383 additions and 5 deletions
+11 -2
View File
@@ -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<MigrationResult, String> {
SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string())
}
// ========== 兼容旧 API 的命令 ==========
/// 获取技能列表(兼容旧 API)
+1
View File
@@ -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,
+114 -2
View File
@@ -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<String>,
}
#[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<PathBuf> {
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<Database>,
target: SkillStorageLocation,
) -> Result<MigrationResult> {
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<Vec<SkillBackupEntry>> {
let backup_dir = Self::get_backup_dir()?;
let mut entries = Vec::new();
+25 -1
View File
@@ -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)