Skip to content
56 changes: 55 additions & 1 deletion src-tauri/src/cli/commands/skills.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -110,6 +110,12 @@ pub enum SkillsCommand {
#[arg(value_enum)]
method: Option<SyncMethod>,
},
/// 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<SkillStorageLocation>,
},
/// Manage skill repositories
#[command(subcommand)]
Repos(SkillReposCommand),
Expand Down Expand Up @@ -164,6 +170,7 @@ pub fn execute(cmd: SkillsCommand, app: Option<AppType>) -> 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),
}
}
Expand Down Expand Up @@ -619,6 +626,53 @@ fn sync_method(method: Option<SyncMethod>) -> Result<(), AppError> {
Ok(())
}

fn storage_location(location: Option<SkillStorageLocation>) -> 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<SkillRepo, AppError> {
let raw = raw.trim().trim_end_matches('/');
if raw.is_empty() {
Expand Down
45 changes: 45 additions & 0 deletions src-tauri/src/cli/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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() {
"仓库不能为空。"
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/cli/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/src/cli/tui/app/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ pub enum Action {
SkillsSetSyncMethod {
method: SyncMethod,
},
SkillsSetStorageLocation {
location: SkillStorageLocation,
},
SkillsDiscover {
query: String,
source: SkillsDiscoverSource,
Expand Down Expand Up @@ -497,6 +500,7 @@ pub enum SettingsItem {
PreferredEditor,
VisibleAppsMode,
VisibleApps,
SkillsStorageLocation,
OpenClawConfigDir,
ManagedAccounts,
SkipClaudeOnboarding,
Expand All @@ -508,14 +512,15 @@ pub enum SettingsItem {
}

impl SettingsItem {
pub const ALL: [SettingsItem; 14] = [
pub const ALL: [SettingsItem; 15] = [
SettingsItem::ManagedAccounts,
SettingsItem::Language,
SettingsItem::Theme,
SettingsItem::Icons,
SettingsItem::PreferredEditor,
SettingsItem::VisibleAppsMode,
SettingsItem::VisibleApps,
SettingsItem::SkillsStorageLocation,
SettingsItem::OpenClawConfigDir,
SettingsItem::SkipClaudeOnboarding,
SettingsItem::ClaudePluginIntegration,
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/cli/tui/app/content_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src-tauri/src/cli/tui/app/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
OPENCLAW_TOOLS_PROFILE_PICKER_VALUES
.iter()
Expand Down
39 changes: 39 additions & 0 deletions src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -282,6 +285,42 @@ impl App {
})
}

fn handle_storage_location_picker_key(
&mut self,
key: KeyEvent,
data: &UiData,
) -> Option<Action> {
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,
Expand Down
24 changes: 24 additions & 0 deletions src-tauri/src/cli/tui/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/cli/tui/app/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4693,6 +4693,9 @@ pub enum Overlay {
SkillsSyncMethodPicker {
selected: usize,
},
SkillsStorageLocationPicker {
selected: usize,
},
McpKeyValuePicker {
kind: crate::cli::tui::form::McpKeyValueKind,
selected: usize,
Expand Down Expand Up @@ -4858,6 +4861,7 @@ impl Overlay {
| Overlay::SkillsAppsPicker { .. }
| Overlay::SkillsImportPicker { .. }
| Overlay::SkillsSyncMethodPicker { .. }
| Overlay::SkillsStorageLocationPicker { .. }
| Overlay::McpKeyValuePicker { .. }
| Overlay::McpTypePicker { .. }
| Overlay::SpeedtestResult { .. }
Expand Down Expand Up @@ -4900,6 +4904,7 @@ impl Overlay {
| Overlay::SkillsAppsPicker { .. }
| Overlay::SkillsImportPicker { .. }
| Overlay::SkillsSyncMethodPicker { .. }
| Overlay::SkillsStorageLocationPicker { .. }
| Overlay::McpKeyValuePicker { .. }
| Overlay::McpTypePicker { .. }
| Overlay::Loading { .. }
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/cli/tui/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ pub struct SkillsSnapshot {
pub installed: Vec<crate::services::skill::InstalledSkill>,
pub repos: Vec<crate::services::skill::SkillRepo>,
pub sync_method: crate::services::skill::SyncMethod,
pub storage_location: crate::services::skill::SkillStorageLocation,
}

#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -3636,6 +3637,7 @@ fn load_skills_snapshot() -> Result<SkillsSnapshot, AppError> {
installed: SkillService::list_installed()?,
repos: SkillService::list_repos()?,
sync_method: SkillService::get_sync_method()?,
storage_location: crate::settings::get_skill_storage_location(),
})
}

Expand All @@ -3651,6 +3653,7 @@ fn load_skills_snapshot_from_state(state: &AppState) -> Result<SkillsSnapshot, A
installed,
repos: state.db.get_skill_repos()?,
sync_method: SkillService::get_sync_method()?,
storage_location: crate::settings::get_skill_storage_location(),
})
}

Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/cli/tui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2292,6 +2292,7 @@ fn cache_invalidation_for_action(action: &Action) -> CacheInvalidation {
| Action::SkillsUninstall { .. }
| Action::SkillsSync { .. }
| Action::SkillsSetSyncMethod { .. }
| Action::SkillsSetStorageLocation { .. }
| Action::SkillsRepoAdd { .. }
| Action::SkillsRepoRemove { .. }
| Action::SkillsRepoToggleEnabled { .. }
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/cli/tui/runtime_actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading