diff --git a/src-tauri/src/cli/commands/skills.rs b/src-tauri/src/cli/commands/skills.rs index be379205..96a0e6f1 100644 --- a/src-tauri/src/cli/commands/skills.rs +++ b/src-tauri/src/cli/commands/skills.rs @@ -7,7 +7,7 @@ use crate::cli::commands::app_targets::{ }; use crate::cli::ui::{create_table, highlight, info, success}; use crate::error::AppError; -use crate::services::skill::{ImportSkillSelection, SkillRepo, SyncMethod}; +use crate::services::skill::{ImportSkillSelection, SkillRepo, SkillStorageLocation, SyncMethod}; use crate::services::SkillService; #[derive(Subcommand)] @@ -110,6 +110,12 @@ pub enum SkillsCommand { #[arg(value_enum)] method: Option, }, + /// Get or set the skills SSOT storage location (cc-switch|unified) + StorageLocation { + /// Optional location to set (omit to show current). Setting triggers migration. + #[arg(value_enum)] + location: Option, + }, /// Manage skill repositories #[command(subcommand)] Repos(SkillReposCommand), @@ -164,6 +170,7 @@ pub fn execute(cmd: SkillsCommand, app: Option) -> Result<(), AppError> SkillsCommand::ImportFromApps { apps, directories } => import_from_apps(apps, directories), SkillsCommand::Info { spec } => show_skill_info(&spec), SkillsCommand::SyncMethod { method } => sync_method(method), + SkillsCommand::StorageLocation { location } => storage_location(location), SkillsCommand::Repos(repos_cmd) => execute_repos(repos_cmd), } } @@ -619,6 +626,53 @@ fn sync_method(method: Option) -> Result<(), AppError> { Ok(()) } +fn storage_location(location: Option) -> Result<(), AppError> { + match location { + Some(target) => { + let current = crate::settings::get_skill_storage_location(); + if current == target { + println!("Skill 存储位置已是当前值,无需迁移。"); + return Ok(()); + } + let result = SkillService::migrate_storage(target)?; + let label = match target { + SkillStorageLocation::CcSwitch => "cc-switch (~/.cc-switch/skills)", + SkillStorageLocation::Unified => "unified (~/.agents/skills)", + }; + if result.errors.is_empty() { + println!( + "{}", + success(&format!( + "Skill 存储位置已切换为 {label}: {} 迁移, {} 跳过", + result.migrated_count, result.skipped_count + )) + ); + } else { + eprintln!( + "Skill 存储位置切换为 {label} 但部分失败: {} 失败, {} 跳过", + result.errors.len(), + result.skipped_count + ); + for e in &result.errors { + eprintln!(" - {e}"); + } + eprintln!("备份位于 {{config_dir}}/skill-backups/,可手工恢复。"); + } + } + None => { + let current = crate::settings::get_skill_storage_location(); + println!( + "{}", + match current { + SkillStorageLocation::CcSwitch => "cc-switch", + SkillStorageLocation::Unified => "unified", + } + ); + } + } + Ok(()) +} + fn parse_repo_spec(raw: &str) -> Result { let raw = raw.trim().trim_end_matches('/'); if raw.is_empty() { diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 2c2d1d15..473c2237 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -5642,6 +5642,43 @@ pub mod texts { } } + pub fn tui_settings_skills_storage_location_label() -> &'static str { + if is_chinese() { + "存储位置" + } else { + "Storage location" + } + } + + pub fn tui_skills_storage_location_title() -> &'static str { + if is_chinese() { + "选择存储位置" + } else { + "Select Storage Location" + } + } + + pub fn tui_skills_storage_location_name( + location: crate::services::skill::SkillStorageLocation, + ) -> &'static str { + match location { + crate::services::skill::SkillStorageLocation::CcSwitch => { + if is_chinese() { + "cc-switch (~/.cc-switch/skills)" + } else { + "cc-switch (~/.cc-switch/skills)" + } + } + crate::services::skill::SkillStorageLocation::Unified => { + if is_chinese() { + "unified (~/.agents/skills)" + } else { + "unified (~/.agents/skills)" + } + } + } + } + pub fn tui_skills_installed_summary(installed: usize, enabled: usize, app: &str) -> String { if is_chinese() { format!("已安装: {installed} 当前应用({app})已启用: {enabled}") @@ -8564,6 +8601,14 @@ pub mod texts { } } + pub fn tui_toast_skills_storage_location_set(location: &str) -> String { + if is_chinese() { + format!("存储位置已切换为: {location}") + } else { + format!("Storage location set to: {location}") + } + } + pub fn tui_toast_repo_spec_empty() -> &'static str { if is_chinese() { "仓库不能为空。" diff --git a/src-tauri/src/cli/tui/app.rs b/src-tauri/src/cli/tui/app.rs index f284403f..52962614 100644 --- a/src-tauri/src/cli/tui/app.rs +++ b/src-tauri/src/cli/tui/app.rs @@ -7,7 +7,7 @@ use crate::app_config::AppType; use crate::cli::i18n::current_language; use crate::cli::i18n::texts; use crate::cli::i18n::Language; -use crate::services::skill::SyncMethod; +use crate::services::skill::{SkillStorageLocation, SyncMethod}; use super::data::UiData; use super::form::{ diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 647c97cf..7c1d3abf 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -66,6 +66,9 @@ pub enum Action { SkillsSetSyncMethod { method: SyncMethod, }, + SkillsSetStorageLocation { + location: SkillStorageLocation, + }, SkillsDiscover { query: String, source: SkillsDiscoverSource, @@ -497,6 +500,7 @@ pub enum SettingsItem { PreferredEditor, VisibleAppsMode, VisibleApps, + SkillsStorageLocation, OpenClawConfigDir, ManagedAccounts, SkipClaudeOnboarding, @@ -508,7 +512,7 @@ pub enum SettingsItem { } impl SettingsItem { - pub const ALL: [SettingsItem; 14] = [ + pub const ALL: [SettingsItem; 15] = [ SettingsItem::ManagedAccounts, SettingsItem::Language, SettingsItem::Theme, @@ -516,6 +520,7 @@ impl SettingsItem { SettingsItem::PreferredEditor, SettingsItem::VisibleAppsMode, SettingsItem::VisibleApps, + SettingsItem::SkillsStorageLocation, SettingsItem::OpenClawConfigDir, SettingsItem::SkipClaudeOnboarding, SettingsItem::ClaudePluginIntegration, diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index d6e278b7..d8b3a7f8 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -929,6 +929,14 @@ impl App { }; Action::None } + Some(SettingsItem::SkillsStorageLocation) => { + self.overlay = Overlay::SkillsStorageLocationPicker { + selected: storage_location_picker_index( + crate::settings::get_skill_storage_location(), + ), + }; + Action::None + } Some(SettingsItem::OpenClawConfigDir) => { let buffer = crate::settings::get_settings() .openclaw_config_dir diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index 68a13e56..838609f2 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -1802,6 +1802,20 @@ pub(crate) fn sync_method_for_picker_index(index: usize) -> SyncMethod { } } +pub(crate) fn storage_location_picker_index(location: SkillStorageLocation) -> usize { + match location { + SkillStorageLocation::CcSwitch => 0, + SkillStorageLocation::Unified => 1, + } +} + +pub(crate) fn storage_location_for_picker_index(index: usize) -> SkillStorageLocation { + match index { + 1 => SkillStorageLocation::Unified, + _ => SkillStorageLocation::CcSwitch, + } +} + pub(crate) fn openclaw_tools_profile_picker_index(profile: Option<&str>) -> Option { OPENCLAW_TOOLS_PROFILE_PICKER_VALUES .iter() diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index 6e9f448c..09e874ee 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -85,6 +85,9 @@ impl App { if let Some(action) = self.handle_sync_method_picker_key(key, data) { return Some(action); } + if let Some(action) = self.handle_storage_location_picker_key(key, data) { + return Some(action); + } if let Some(action) = self.handle_claude_api_format_picker_key(key, data) { return Some(action); } @@ -282,6 +285,42 @@ impl App { }) } + fn handle_storage_location_picker_key( + &mut self, + key: KeyEvent, + data: &UiData, + ) -> Option { + let Overlay::SkillsStorageLocationPicker { selected } = &mut self.overlay else { + return None; + }; + + Some(match key.code { + KeyCode::Esc => { + self.close_overlay(); + Action::None + } + KeyCode::Up => { + *selected = selected.saturating_sub(1); + Action::None + } + KeyCode::Down => { + *selected = (*selected + 1).min(1); + Action::None + } + KeyCode::Enter => { + let location = storage_location_for_picker_index(*selected); + let unchanged = location == data.skills.storage_location; + self.overlay = Overlay::None; + if unchanged { + Action::None + } else { + Action::SkillsSetStorageLocation { location } + } + } + _ => Action::None, + }) + } + fn handle_claude_api_format_picker_key( &mut self, key: KeyEvent, diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 939826f5..69173121 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -10735,6 +10735,30 @@ mod tests { )); } + #[test] + fn settings_skills_storage_location_item_opens_picker_overlay() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Settings; + app.focus = Focus::Content; + app.settings_idx = SettingsItem::ALL + .iter() + .position(|item| matches!(item, SettingsItem::SkillsStorageLocation)) + .expect("SkillsStorageLocation missing from SettingsItem::ALL"); + + let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + assert!(matches!(action, Action::None)); + assert!(matches!( + &app.overlay, + Overlay::SkillsStorageLocationPicker { selected } + if *selected == storage_location_picker_index( + crate::settings::get_skill_storage_location() + ) + )); + } + #[test] #[serial(home_settings)] fn visible_apps_picker_rejects_zero_selection_without_closing() { diff --git a/src-tauri/src/cli/tui/app/types.rs b/src-tauri/src/cli/tui/app/types.rs index ea9949f3..65d95ebf 100644 --- a/src-tauri/src/cli/tui/app/types.rs +++ b/src-tauri/src/cli/tui/app/types.rs @@ -4693,6 +4693,9 @@ pub enum Overlay { SkillsSyncMethodPicker { selected: usize, }, + SkillsStorageLocationPicker { + selected: usize, + }, McpKeyValuePicker { kind: crate::cli::tui::form::McpKeyValueKind, selected: usize, @@ -4858,6 +4861,7 @@ impl Overlay { | Overlay::SkillsAppsPicker { .. } | Overlay::SkillsImportPicker { .. } | Overlay::SkillsSyncMethodPicker { .. } + | Overlay::SkillsStorageLocationPicker { .. } | Overlay::McpKeyValuePicker { .. } | Overlay::McpTypePicker { .. } | Overlay::SpeedtestResult { .. } @@ -4900,6 +4904,7 @@ impl Overlay { | Overlay::SkillsAppsPicker { .. } | Overlay::SkillsImportPicker { .. } | Overlay::SkillsSyncMethodPicker { .. } + | Overlay::SkillsStorageLocationPicker { .. } | Overlay::McpKeyValuePicker { .. } | Overlay::McpTypePicker { .. } | Overlay::Loading { .. } diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index f8eaa731..21889b70 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -318,6 +318,7 @@ pub struct SkillsSnapshot { pub installed: Vec, pub repos: Vec, pub sync_method: crate::services::skill::SyncMethod, + pub storage_location: crate::services::skill::SkillStorageLocation, } #[derive(Debug, Clone, Default)] @@ -3636,6 +3637,7 @@ fn load_skills_snapshot() -> Result { installed: SkillService::list_installed()?, repos: SkillService::list_repos()?, sync_method: SkillService::get_sync_method()?, + storage_location: crate::settings::get_skill_storage_location(), }) } @@ -3651,6 +3653,7 @@ fn load_skills_snapshot_from_state(state: &AppState) -> Result CacheInvalidation { | Action::SkillsUninstall { .. } | Action::SkillsSync { .. } | Action::SkillsSetSyncMethod { .. } + | Action::SkillsSetStorageLocation { .. } | Action::SkillsRepoAdd { .. } | Action::SkillsRepoRemove { .. } | Action::SkillsRepoToggleEnabled { .. } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index 9fd469ca..d05e6ff2 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -848,6 +848,9 @@ pub(crate) fn handle_action( Action::SkillsUninstall { directory } => skills::uninstall(&mut ctx, directory), Action::SkillsSync { app: scope } => skills::sync(&mut ctx, scope), Action::SkillsSetSyncMethod { method } => skills::set_sync_method(&mut ctx, method), + Action::SkillsSetStorageLocation { location } => { + skills::set_storage_location(&mut ctx, location) + } Action::SkillsDiscover { query, source, diff --git a/src-tauri/src/cli/tui/runtime_actions/skills.rs b/src-tauri/src/cli/tui/runtime_actions/skills.rs index 19385f80..f1c61b05 100644 --- a/src-tauri/src/cli/tui/runtime_actions/skills.rs +++ b/src-tauri/src/cli/tui/runtime_actions/skills.rs @@ -2,7 +2,7 @@ use crate::app_config::{AppType, SkillApps}; use crate::cli::i18n::texts; use crate::error::AppError; use crate::services::{ - skill::{ImportSkillSelection, SyncMethod}, + skill::{ImportSkillSelection, SkillStorageLocation, SyncMethod}, SkillService, }; @@ -164,6 +164,40 @@ pub(super) fn set_sync_method( Ok(()) } +pub(super) fn set_storage_location( + ctx: &mut RuntimeActionContext<'_>, + location: SkillStorageLocation, +) -> Result<(), AppError> { + let result = SkillService::migrate_storage(location)?; + *ctx.data = super::super::data::UiData::load(&ctx.app.app_type)?; + let name = texts::tui_skills_storage_location_name(location); + if result.errors.is_empty() { + let msg = if result.skipped_count > 0 { + format!( + "{}({} 个跳过)", + texts::tui_toast_skills_storage_location_set(&name), + result.skipped_count + ) + } else { + texts::tui_toast_skills_storage_location_set(&name) + }; + ctx.app.push_toast(msg, ToastKind::Success); + } else { + let first = result.errors.first().cloned().unwrap_or_default(); + ctx.app.push_toast( + format!( + "{}:{} 个失败({} 个跳过),已尝试清理半成品。首条错误: {}", + texts::tui_toast_skills_storage_location_set(&name), + result.errors.len(), + result.skipped_count, + first + ), + ToastKind::Warning, + ); + } + Ok(()) +} + pub(super) fn discover( ctx: &mut RuntimeActionContext<'_>, query: String, diff --git a/src-tauri/src/cli/tui/ui/config.rs b/src-tauri/src/cli/tui/ui/config.rs index 8b48ccad..5b03b799 100644 --- a/src-tauri/src/cli/tui/ui/config.rs +++ b/src-tauri/src/cli/tui/ui/config.rs @@ -20,6 +20,7 @@ fn settings_section(item: SettingsItem) -> SettingsSection { | SettingsItem::PreferredEditor => SettingsSection::General, SettingsItem::VisibleAppsMode | SettingsItem::VisibleApps + | SettingsItem::SkillsStorageLocation | SettingsItem::OpenClawConfigDir => SettingsSection::Applications, SettingsItem::SkipClaudeOnboarding | SettingsItem::ClaudePluginIntegration @@ -3477,6 +3478,10 @@ pub(super) fn render_settings( texts::tui_settings_visible_apps_label().to_string(), visible_apps_summary(&visible_apps), ), + super::app::SettingsItem::SkillsStorageLocation => ( + texts::tui_settings_skills_storage_location_label().to_string(), + texts::tui_skills_storage_location_name(data.skills.storage_location).to_string(), + ), super::app::SettingsItem::OpenClawConfigDir => ( texts::tui_settings_openclaw_config_dir_label().to_string(), openclaw_config_dir.clone().unwrap_or_else(|| { diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index ff6bf573..64bbb5a5 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -2418,6 +2418,58 @@ pub(super) fn render_skills_sync_method_picker_overlay( frame.render_stateful_widget(list, body_area, &mut state); } +pub(super) fn render_skills_storage_location_picker_overlay( + frame: &mut Frame<'_>, + data: &UiData, + content_area: Rect, + theme: &theme::Theme, + selected: usize, +) { + let locations = [ + crate::services::skill::SkillStorageLocation::CcSwitch, + crate::services::skill::SkillStorageLocation::Unified, + ]; + + let body_area = overlay_frame( + frame, + content_area, + theme, + texts::tui_skills_storage_location_title(), + &[ + ("←→", texts::tui_key_select()), + ("Enter", texts::tui_key_apply()), + ("Esc", texts::tui_key_cancel()), + ], + OverlaySize::FitRows { + width: OVERLAY_FIXED_LG.0, + body_rows: locations.len() as u16, + }, + overlay_border_style(theme, false), + ); + + let current = data.skills.storage_location; + + let items = locations.into_iter().map(|location| { + let marker = if location == current { + texts::tui_marker_active() + } else { + texts::tui_marker_inactive() + }; + ListItem::new(Line::from(Span::raw(format!( + "{marker} {}", + texts::tui_skills_storage_location_name(location) + )))) + }); + + let list = List::new(items) + .highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + + let mut state = ListState::default(); + state.select(Some(selected)); + frame.render_stateful_widget(list, body_area, &mut state); +} + #[expect( clippy::too_many_arguments, reason = "app picker renderer receives list state and display labels" diff --git a/src-tauri/src/cli/tui/ui/overlay/render.rs b/src-tauri/src/cli/tui/ui/overlay/render.rs index 71a8926a..2a1f529a 100644 --- a/src-tauri/src/cli/tui/ui/overlay/render.rs +++ b/src-tauri/src/cli/tui/ui/overlay/render.rs @@ -263,6 +263,15 @@ pub(crate) fn render_overlay( *selected, ) } + Overlay::SkillsStorageLocationPicker { selected } => { + super::pickers::render_skills_storage_location_picker_overlay( + frame, + data, + content_area, + theme, + *selected, + ) + } Overlay::McpKeyValuePicker { kind, selected } => { super::mcp_key_value::render_mcp_key_value_picker_overlay( frame, diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index fd34d54b..8c2222f4 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -4376,6 +4376,32 @@ fn settings_page_shows_visible_apps_row_value() { assert!(all.contains("claude, gemini, openclaw"), "{all}"); } +#[test] +#[serial(home_settings)] +fn settings_page_shows_skills_storage_location_row_value() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + let temp_home = TempDir::new().expect("create temp home"); + let _home = SettingsEnvGuard::set_home(temp_home.path()); + + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Settings; + app.focus = Focus::Content; + + let all = all_text(&render(&app, &minimal_data(&app.app_type))); + + assert!( + all.contains(texts::tui_settings_skills_storage_location_label()), + "{all}" + ); + assert!( + all.contains(texts::tui_skills_storage_location_name( + crate::settings::get_skill_storage_location() + )), + "{all}" + ); +} + #[test] #[serial(home_settings)] fn settings_page_shows_visible_apps_mode_row_value() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ed8d564d..d60c253e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -69,6 +69,7 @@ pub use mcp::{ }; pub use provider::{Provider, ProviderMeta, UsageScript}; pub use proxy::{ProxyConfig, ProxyServerInfo, ProxyStatus}; +pub use services::skill::SkillStorageLocation; pub use services::{ reapply_current_codex_official_live, AuthService, ConfigService, CredentialStatus, EndpointLatency, ExtraUsage, HealthStatus, ImportSkillSelection, ManagedAuthAccount, @@ -78,10 +79,10 @@ pub use services::{ SyncDecision, WebDavSyncService, WebDavSyncSummary, }; pub use settings::{ - get_enable_claude_plugin_integration, get_s3_sync_settings, get_skip_claude_onboarding, - get_webdav_sync_settings, set_enable_claude_plugin_integration, set_s3_sync_settings, - set_skip_claude_onboarding, set_webdav_sync_settings, update_s3_sync_status, update_settings, - update_webdav_sync_status, webdav_jianguoyun_preset, AppSettings, S3SyncSettings, - WebDavSyncSettings, WebDavSyncStatus, + get_enable_claude_plugin_integration, get_s3_sync_settings, get_skill_storage_location, + get_skip_claude_onboarding, get_webdav_sync_settings, set_enable_claude_plugin_integration, + set_s3_sync_settings, set_skill_storage_location, set_skip_claude_onboarding, + set_webdav_sync_settings, update_s3_sync_status, update_settings, update_webdav_sync_status, + webdav_jianguoyun_preset, AppSettings, S3SyncSettings, WebDavSyncSettings, WebDavSyncStatus, }; pub use store::AppState; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 5d5fd301..12577175 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -120,6 +120,26 @@ pub enum SyncMethod { Copy, } +/// Skill SSOT 存储位置(上游 GUI 对齐)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)] +#[serde(rename_all = "snake_case")] +pub enum SkillStorageLocation { + /// CC Switch 管理目录 (~/.cc-switch/skills/) + #[default] + #[value(alias = "cc_switch")] + CcSwitch, + /// Agent Skills 统一标准目录 (~/.agents/skills/) + Unified, +} + +/// 结果 of a skills SSOT storage migration (`migrate_storage`). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct MigrationResult { + pub migrated_count: usize, + pub skipped_count: usize, + pub errors: Vec, +} + /// Explicit app matrix submitted when importing unmanaged skills. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -599,12 +619,153 @@ impl SkillService { // Paths // --------------------------------------------------------------------- + /// 解析指定存储位置对应的 SSOT 目录路径(不创建目录)。 + /// + /// `get_ssot_dir()` 与 `migrate_storage()` 共用此函数,保证两处路径解析一致; + /// unified 模式的目标在 `~/.agents/skills`(不在受管 config root 下), + /// 因此 `create_managed_config_dir_all` 会自然退化为普通 `create_dir_all`。 + fn ssot_dir_for(location: SkillStorageLocation) -> PathBuf { + match location { + SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::Unified => crate::config::home_dir() + .unwrap_or_else(|| get_app_config_dir()) + .join(".agents") + .join("skills"), + } + } + pub fn get_ssot_dir() -> Result { - let dir = get_app_config_dir().join("skills"); + let dir = Self::ssot_dir_for(crate::settings::get_skill_storage_location()); create_managed_config_dir_all(&dir)?; Ok(dir) } + /// Migrate the skills SSOT directory between storage locations. + /// + /// Walks the current SSOT directory itself (each subdirectory is one skill), + /// moves every skill into the target location, backs the source up first, + /// persists the new `skill_storage_location`, then refreshes app dirs. + pub fn migrate_storage(target: SkillStorageLocation) -> Result { + let current = crate::settings::get_skill_storage_location(); + if current == target { + return Ok(MigrationResult::default()); + } + + let old_dir = Self::get_ssot_dir()?; + let new_dir = Self::ssot_dir_for(target); + // 与 get_ssot_dir 一致:目标目录用受管安全创建(Unix 下拒绝符号链接组件)。 + create_managed_config_dir_all(&new_dir)?; + Self::validate_skill_storage_destination(&new_dir)?; + + // 步骤 0【迁移前备份】: 目录复制(简单可靠),后续可精化为 zip 归档。 + let backup_dir = get_app_config_dir().join("skill-backups"); + let has_skills = old_dir.is_dir() + && fs::read_dir(&old_dir) + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false); + if has_skills { + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + let stamp = Utc::now().format("%Y%m%d%H%M%S"); + let snapshot = backup_dir.join(format!("skills-{stamp}")); + Self::copy_dir_recursive(&old_dir, &snapshot)?; + } + + let mut result = MigrationResult::default(); + let mut migrated_dirs: Vec = Vec::new(); + if old_dir.is_dir() { + for entry in fs::read_dir(&old_dir).map_err(|e| AppError::io(&old_dir, e))? { + let entry = entry.map_err(|e| AppError::io(&old_dir, e))?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let directory = entry.file_name().to_string_lossy().to_string(); + if directory.starts_with('.') { + continue; + } + + let src = old_dir.join(&directory); + let dst = new_dir.join(&directory); + if !src.exists() { + result.skipped_count += 1; + continue; + } + if dst.exists() { + result.skipped_count += 1; + continue; + } + + match fs::rename(&src, &dst) { + Ok(()) => { + migrated_dirs.push(directory); + result.migrated_count += 1; + } + Err(_) => match Self::copy_dir_recursive(&src, &dst) { + Ok(()) => { + if let Err(e) = fs::remove_dir_all(&src) { + // 源未删干净:新旧各有一份,提示用户手动清理。 + result.errors.push(format!( + "{directory}: 源目录清理失败(已复制,旧目录保留): {e}" + )); + } + migrated_dirs.push(directory); + result.migrated_count += 1; + } + Err(e) => { + // 复制失败可能留下半成品目标目录:清理它,避免后续 + // sync_to_app_dir 用残缺副本覆盖 app 完整副本。 + let clean_msg = match fs::remove_dir_all(&dst) { + Ok(()) => String::new(), + Err(ce) => format!(";清理半成品目标目录失败: {ce}"), + }; + result.errors.push(format!("{directory}: {e}{clean_msg}")); + } + }, + } + } + } + + crate::settings::set_skill_storage_location(target)?; + + // 刷新 app 目录(best effort:失败仅告警,不影响迁移结果)。 + let method = crate::settings::get_skill_sync_method(); + for directory in &migrated_dirs { + for app in Self::supported_skill_apps().filter(Self::app_supports_skills) { + if let Err(e) = Self::sync_to_app_dir(directory, &app, method) { + log::warn!("迁移后同步 Skill {directory} 到 {app:?} 失败: {e}"); + } + } + } + + Ok(result) + } + + /// Reject migration targets that alias an app's skills directory + /// (the CLI has no `paths_alias`, so canonicalized paths are compared). + fn validate_skill_storage_destination(dest: &Path) -> Result<(), AppError> { + for app in Self::skill_source_apps() { + if !Self::app_supports_skills(&app) { + continue; + } + let app_dir = match Self::get_app_skills_dir(&app) { + Ok(dir) => dir, + Err(_) => continue, + }; + if let (Ok(dest_canon), Ok(app_canon)) = (dest.canonicalize(), app_dir.canonicalize()) { + if dest_canon == app_canon { + return Err(AppError::Message(format!( + "迁移目标 {} 与 {} 的 skills 目录别名({}),拒绝迁移", + dest.display(), + app.as_str(), + app_canon.display() + ))); + } + } + } + Ok(()) + } + pub fn get_app_skills_dir(app: &AppType) -> Result { // Override directories follow the same pattern as upstream: /skills match app { @@ -2458,6 +2619,28 @@ mod tests { ); } + #[test] + fn get_ssot_dir_switches_on_location() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + + crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch) + .expect("set cc_switch storage"); + let cc_dir = SkillService::get_ssot_dir().expect("resolve cc_switch SSOT"); + assert!( + cc_dir.ends_with(".cc-switch/skills"), + "cc_switch mode: {cc_dir:?}" + ); + + crate::settings::set_skill_storage_location(SkillStorageLocation::Unified) + .expect("set unified storage"); + let agents_dir = SkillService::get_ssot_dir().expect("resolve unified SSOT"); + assert!( + agents_dir.ends_with(".agents/skills"), + "unified mode: {agents_dir:?}" + ); + } + #[test] fn update_deployment_keeps_existing_app_copy_until_replacement_is_ready() { let home = tempfile::tempdir().expect("create isolated home"); @@ -2645,4 +2828,144 @@ mod tests { "a/repo@dev|b/repo@main" ); } + + #[test] + fn migrate_storage_noop_when_same_target() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch) + .expect("set cc_switch storage"); + + let result = SkillService::migrate_storage(SkillStorageLocation::CcSwitch) + .expect("noop migration must succeed"); + assert_eq!(result.migrated_count, 0, "noop must not migrate anything"); + assert_eq!(result.skipped_count, 0, "noop must not skip anything"); + assert!( + result.errors.is_empty(), + "noop must not record errors: {:?}", + result.errors + ); + assert_eq!( + crate::settings::get_skill_storage_location(), + SkillStorageLocation::CcSwitch + ); + } + + #[test] + fn migrate_storage_moves_skills_and_updates_setting() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch) + .expect("set cc_switch storage"); + + let old_dir = SkillService::get_ssot_dir().expect("resolve cc_switch SSOT"); + let skill_dir = old_dir.join("test-skill"); + fs::create_dir_all(&skill_dir).expect("create test skill directory"); + fs::write(skill_dir.join("SKILL.md"), "# Test Skill").expect("write test skill manifest"); + + let result = SkillService::migrate_storage(SkillStorageLocation::Unified) + .expect("migrate to unified storage must succeed"); + assert_eq!(result.migrated_count, 1, "one skill must migrate"); + assert!( + result.errors.is_empty(), + "migration must not record errors: {:?}", + result.errors + ); + + let new_dir = SkillService::get_ssot_dir().expect("resolve unified SSOT"); + assert!( + new_dir.join("test-skill").join("SKILL.md").exists(), + "skill must exist in the new SSOT: {:?}", + new_dir + ); + assert!( + !skill_dir.exists(), + "old skill directory must be gone: {:?}", + skill_dir + ); + assert_eq!( + crate::settings::get_skill_storage_location(), + SkillStorageLocation::Unified + ); + } + + #[test] + fn migrate_storage_creates_backup_first() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch) + .expect("set cc_switch storage"); + + let old_dir = SkillService::get_ssot_dir().expect("resolve cc_switch SSOT"); + let skill_dir = old_dir.join("test-skill"); + fs::create_dir_all(&skill_dir).expect("create test skill directory"); + fs::write(skill_dir.join("SKILL.md"), "# Test Skill").expect("write test skill manifest"); + + SkillService::migrate_storage(SkillStorageLocation::Unified) + .expect("migrate to unified storage must succeed"); + + let backup_root = get_app_config_dir().join("skill-backups"); + assert!( + backup_root.is_dir(), + "backup root must exist: {:?}", + backup_root + ); + let snapshots: Vec<_> = fs::read_dir(&backup_root) + .expect("read backup root") + .filter_map(Result::ok) + .collect(); + assert!( + !snapshots.is_empty(), + "backup must contain at least one snapshot" + ); + let snapshot = snapshots[0].path(); + assert!( + snapshot.join("test-skill").join("SKILL.md").exists(), + "backup snapshot must contain the migrated skill: {:?}", + snapshot + ); + } + + /// P1-1 回归:迁移目标路径被占用导致 rename 与 copy 都失败时, + /// 失败项必须记入 errors,源目录保持不动,且存储位置不得切换 + /// (避免后续 sync_to_app_dir 用半成品覆盖 app 完整副本)。 + #[test] + fn migrate_storage_keeps_source_and_setting_on_failure() { + let home = tempfile::tempdir().expect("create isolated home"); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + crate::settings::set_skill_storage_location(SkillStorageLocation::CcSwitch) + .expect("set cc_switch storage"); + + let old_dir = SkillService::get_ssot_dir().expect("resolve cc_switch SSOT"); + let skill_dir = old_dir.join("test-skill"); + fs::create_dir_all(&skill_dir).expect("create test skill directory"); + fs::write(skill_dir.join("SKILL.md"), "# Test Skill").expect("write test skill manifest"); + + // 让迁移目标路径变成一个普通文件(而非目录): + // rename 失败(dest 已是文件),copy 的 create_dir_all(dest) 也失败 → Err 分支。 + let new_dir = crate::config::home_dir() + .expect("home") + .join(".agents") + .join("skills"); + fs::create_dir_all(new_dir.parent().expect("parent")).expect("create parent"); + fs::write(&new_dir, "blocker").expect("write blocker file at dest path"); + + // 目标路径被文件占用:受管创建在迁移任何内容前就应失败(P1-3 行为), + // 源目录与设置都必须保持原样。 + let err = SkillService::migrate_storage(SkillStorageLocation::Unified) + .expect_err("migration must fail when dest path is blocked by a file"); + assert!( + err.to_string().contains("File exists") || err.to_string().contains("AlreadyExists"), + "error should mention the blocked destination: {err}" + ); + assert!( + skill_dir.join("SKILL.md").exists(), + "source skill must remain untouched after failure" + ); + assert_eq!( + crate::settings::get_skill_storage_location(), + SkillStorageLocation::CcSwitch, + "location must not switch when migration fails early" + ); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index fc916920..d4e37cee 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -578,6 +578,9 @@ pub struct AppSettings { /// Skills 同步方式(auto|symlink|copy) #[serde(default)] pub skill_sync_method: crate::services::skill::SyncMethod, + /// Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/) + #[serde(default)] + pub skill_storage_location: crate::services::skill::SkillStorageLocation, #[serde(default, skip_serializing_if = "Option::is_none")] pub security: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -646,6 +649,7 @@ impl Default for AppSettings { unify_codex_migrate_existing: None, usage_auto_sync: default_usage_auto_sync(), skill_sync_method: crate::services::skill::SyncMethod::default(), + skill_storage_location: crate::services::skill::SkillStorageLocation::default(), security: None, webdav_sync: None, s3_sync: None, @@ -1252,6 +1256,21 @@ pub fn set_skill_sync_method(method: crate::services::skill::SyncMethod) -> Resu update_settings(settings) } +pub fn get_skill_storage_location() -> crate::services::skill::SkillStorageLocation { + settings_store() + .read() + .map(|s| s.skill_storage_location) + .unwrap_or_default() +} + +pub fn set_skill_storage_location( + location: crate::services::skill::SkillStorageLocation, +) -> Result<(), AppError> { + let mut settings = get_settings(); + settings.skill_storage_location = location; + update_settings(settings) +} + pub fn get_webdav_sync_settings() -> Option { settings_store() .read() diff --git a/src-tauri/tests/skills_service.rs b/src-tauri/tests/skills_service.rs index f5ee33ed..59285595 100644 --- a/src-tauri/tests/skills_service.rs +++ b/src-tauri/tests/skills_service.rs @@ -1,4 +1,7 @@ -use cc_switch_lib::{AppType, Database, ImportSkillSelection, SkillApps, SkillService}; +use cc_switch_lib::{ + get_skill_storage_location, set_skill_storage_location, AppType, Database, + ImportSkillSelection, SkillApps, SkillService, SkillStorageLocation, +}; #[path = "support.rs"] mod support; @@ -350,3 +353,50 @@ fn pending_migration_with_existing_managed_list_does_not_claim_unmanaged_skills( "unmanaged skill should remain unmanaged (not added to db)" ); } + +#[test] +fn storage_location_defaults_to_cc_switch() { + let _guard = lock_test_mutex(); + reset_test_fs(); + + assert_eq!( + get_skill_storage_location(), + SkillStorageLocation::CcSwitch, + "without configuration the storage location should default to CC Switch" + ); +} + +#[test] +fn storage_location_persists_and_roundtrips() { + let _guard = lock_test_mutex(); + reset_test_fs(); + + set_skill_storage_location(SkillStorageLocation::Unified).expect("set skill storage location"); + + assert_eq!( + get_skill_storage_location(), + SkillStorageLocation::Unified, + "set value should be readable back after persisting" + ); +} + +#[test] +fn storage_location_cli_roundtrips_via_service() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + set_skill_storage_location(SkillStorageLocation::Unified).expect("set unified"); + assert_eq!( + get_skill_storage_location(), + SkillStorageLocation::Unified, + "value set through settings API should be readable" + ); + + set_skill_storage_location(SkillStorageLocation::CcSwitch).expect("set cc_switch"); + assert_eq!( + get_skill_storage_location(), + SkillStorageLocation::CcSwitch, + "switching back should roundtrip" + ); +}