mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix: replace implicit app inference with explicit selection for Skills import and sync
Skills import previously inferred app enablement from filesystem presence, causing incorrect multi-app activation when the same skill directory existed under multiple app paths. Now the frontend submits explicit app selections via ImportSkillSelection, and schema migration preserves a snapshot of legacy app mappings to avoid lossy reconstruction. Also adds reconciliation to sync_to_app (removes disabled/orphaned symlinks) and MCP sync_all_enabled (removes disabled servers from live config).
This commit is contained in:
@@ -6,7 +6,9 @@
|
|||||||
|
|
||||||
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
||||||
use crate::error::format_skill_error;
|
use crate::error::format_skill_error;
|
||||||
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
use crate::services::skill::{
|
||||||
|
DiscoverableSkill, ImportSkillSelection, Skill, SkillRepo, SkillService,
|
||||||
|
};
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
@@ -85,10 +87,10 @@ pub fn scan_unmanaged_skills(
|
|||||||
/// 从应用目录导入 Skills
|
/// 从应用目录导入 Skills
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_skills_from_apps(
|
pub fn import_skills_from_apps(
|
||||||
directories: Vec<String>,
|
imports: Vec<ImportSkillSelection>,
|
||||||
app_state: State<'_, AppState>,
|
app_state: State<'_, AppState>,
|
||||||
) -> Result<Vec<InstalledSkill>, String> {
|
) -> Result<Vec<InstalledSkill>, String> {
|
||||||
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
|
SkillService::import_from_apps(&app_state.db, imports).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 发现功能命令 ==========
|
// ========== 发现功能命令 ==========
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
use super::{lock_conn, Database, SCHEMA_VERSION};
|
use super::{lock_conn, Database, SCHEMA_VERSION};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LegacySkillMigrationRow {
|
||||||
|
directory: String,
|
||||||
|
app_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 创建所有数据库表
|
/// 创建所有数据库表
|
||||||
@@ -846,12 +853,31 @@ impl Database {
|
|||||||
|
|
||||||
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
|
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
|
||||||
|
|
||||||
// 1. 备份旧数据(用于日志)
|
// 1. 备份旧数据(用于日志和后续启动迁移)
|
||||||
let old_count: i64 = conn
|
let old_count: i64 = conn
|
||||||
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
|
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
log::info!("旧 skills 表有 {old_count} 条记录");
|
log::info!("旧 skills 表有 {old_count} 条记录");
|
||||||
|
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT directory, app_type FROM skills
|
||||||
|
WHERE installed = 1",
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("查询旧 skills 快照失败: {e}")))?;
|
||||||
|
let snapshot_rows: Vec<LegacySkillMigrationRow> = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(LegacySkillMigrationRow {
|
||||||
|
directory: row.get(0)?,
|
||||||
|
app_type: row.get(1)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Database(format!("读取旧 skills 快照失败: {e}")))?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|e| AppError::Database(format!("解析旧 skills 快照失败: {e}")))?;
|
||||||
|
let snapshot_json = serde_json::to_string(&snapshot_rows)
|
||||||
|
.map_err(|e| AppError::Database(format!("序列化旧 skills 快照失败: {e}")))?;
|
||||||
|
|
||||||
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
|
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
|
||||||
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/,
|
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/,
|
||||||
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
|
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
|
||||||
@@ -859,6 +885,10 @@ impl Database {
|
|||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_snapshot', ?1)",
|
||||||
|
[snapshot_json],
|
||||||
|
);
|
||||||
|
|
||||||
// 2. 删除旧表
|
// 2. 删除旧表
|
||||||
conn.execute("DROP TABLE IF EXISTS skills", [])
|
conn.execute("DROP TABLE IF EXISTS skills", [])
|
||||||
|
|||||||
@@ -488,6 +488,25 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
|||||||
matches!(pending.as_deref(), Some("true") | Some("1")),
|
matches!(pending.as_deref(), Some("true") | Some("1")),
|
||||||
"skills_ssot_migration_pending should be set after v2->v3 migration"
|
"skills_ssot_migration_pending should be set after v2->v3 migration"
|
||||||
);
|
);
|
||||||
|
let snapshot: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_snapshot'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
let snapshot = snapshot.expect("skills migration snapshot should be recorded");
|
||||||
|
let snapshot_rows: serde_json::Value =
|
||||||
|
serde_json::from_str(&snapshot).expect("parse skills migration snapshot");
|
||||||
|
assert!(
|
||||||
|
snapshot_rows
|
||||||
|
.as_array()
|
||||||
|
.is_some_and(|rows| rows.iter().any(|row| {
|
||||||
|
row.get("directory").and_then(|v| v.as_str()) == Some("demo-skill")
|
||||||
|
&& row.get("app_type").and_then(|v| v.as_str()) == Some("claude")
|
||||||
|
})),
|
||||||
|
"skills migration snapshot should preserve legacy app mapping"
|
||||||
|
);
|
||||||
|
|
||||||
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
|
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
|
||||||
let proxy_rows: i64 = conn
|
let proxy_rows: i64 = conn
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ mod store;
|
|||||||
mod tray;
|
mod tray;
|
||||||
mod usage_script;
|
mod usage_script;
|
||||||
|
|
||||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
|
||||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||||
pub use commands::open_provider_terminal;
|
pub use commands::open_provider_terminal;
|
||||||
pub use commands::*;
|
pub use commands::*;
|
||||||
@@ -44,6 +44,7 @@ pub use mcp::{
|
|||||||
};
|
};
|
||||||
pub use provider::{Provider, ProviderMeta};
|
pub use provider::{Provider, ProviderMeta};
|
||||||
pub use services::{
|
pub use services::{
|
||||||
|
skill::{migrate_skills_to_ssot, ImportSkillSelection},
|
||||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||||||
SkillService, SpeedtestService,
|
SkillService, SpeedtestService,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -165,8 +165,18 @@ impl McpService {
|
|||||||
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
|
pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> {
|
||||||
let servers = Self::get_all_servers(state)?;
|
let servers = Self::get_all_servers(state)?;
|
||||||
|
|
||||||
for server in servers.values() {
|
for app in AppType::all() {
|
||||||
Self::sync_server_to_apps(state, server)?;
|
if matches!(app, AppType::OpenClaw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for server in servers.values() {
|
||||||
|
if server.apps.is_enabled_for(&app) {
|
||||||
|
Self::sync_server_to_app(state, server, &app)?;
|
||||||
|
} else {
|
||||||
|
Self::remove_server_from_app(state, &server.id, &app)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
+132
-11
@@ -159,6 +159,21 @@ pub struct SkillMetadata {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 导入已有 Skill 时,前端显式提交的启用应用选择
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImportSkillSelection {
|
||||||
|
pub directory: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub apps: SkillApps,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
struct LegacySkillMigrationRow {
|
||||||
|
directory: String,
|
||||||
|
app_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ========== ~/.agents/ lock 文件解析 ==========
|
// ========== ~/.agents/ lock 文件解析 ==========
|
||||||
|
|
||||||
/// `~/.agents/.skill-lock.json` 文件结构
|
/// `~/.agents/.skill-lock.json` 文件结构
|
||||||
@@ -717,6 +732,9 @@ impl SkillService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let skill_md = path.join("SKILL.md");
|
let skill_md = path.join("SKILL.md");
|
||||||
|
if !skill_md.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||||
|
|
||||||
unmanaged
|
unmanaged
|
||||||
@@ -740,14 +758,18 @@ impl SkillService {
|
|||||||
/// 将未管理的 Skills 导入到 CC Switch 统一管理
|
/// 将未管理的 Skills 导入到 CC Switch 统一管理
|
||||||
pub fn import_from_apps(
|
pub fn import_from_apps(
|
||||||
db: &Arc<Database>,
|
db: &Arc<Database>,
|
||||||
directories: Vec<String>,
|
imports: Vec<ImportSkillSelection>,
|
||||||
) -> Result<Vec<InstalledSkill>> {
|
) -> Result<Vec<InstalledSkill>> {
|
||||||
let ssot_dir = Self::get_ssot_dir()?;
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
let agents_lock = parse_agents_lock();
|
let agents_lock = parse_agents_lock();
|
||||||
let mut imported = Vec::new();
|
let mut imported = Vec::new();
|
||||||
|
|
||||||
// 将 lock 文件中发现的仓库保存到 skill_repos
|
// 将 lock 文件中发现的仓库保存到 skill_repos
|
||||||
save_repos_from_lock(db, &agents_lock, directories.iter().map(|s| s.as_str()));
|
save_repos_from_lock(
|
||||||
|
db,
|
||||||
|
&agents_lock,
|
||||||
|
imports.iter().map(|selection| selection.directory.as_str()),
|
||||||
|
);
|
||||||
|
|
||||||
// 收集所有候选搜索目录
|
// 收集所有候选搜索目录
|
||||||
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
|
let mut search_sources: Vec<(PathBuf, String)> = Vec::new();
|
||||||
@@ -761,10 +783,10 @@ impl SkillService {
|
|||||||
}
|
}
|
||||||
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
|
search_sources.push((ssot_dir.clone(), "cc-switch".to_string()));
|
||||||
|
|
||||||
for dir_name in directories {
|
for selection in imports {
|
||||||
|
let dir_name = selection.directory;
|
||||||
// 在所有候选目录中查找
|
// 在所有候选目录中查找
|
||||||
let mut source_path: Option<PathBuf> = None;
|
let mut source_path: Option<PathBuf> = None;
|
||||||
let mut found_in: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
for (base, label) in &search_sources {
|
for (base, label) in &search_sources {
|
||||||
let skill_path = base.join(&dir_name);
|
let skill_path = base.join(&dir_name);
|
||||||
@@ -772,7 +794,7 @@ impl SkillService {
|
|||||||
if source_path.is_none() {
|
if source_path.is_none() {
|
||||||
source_path = Some(skill_path);
|
source_path = Some(skill_path);
|
||||||
}
|
}
|
||||||
found_in.push(label.clone());
|
log::debug!("Skill '{}' found in source '{}'", dir_name, label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,6 +802,14 @@ impl SkillService {
|
|||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
|
if !source.join("SKILL.md").exists() {
|
||||||
|
log::warn!(
|
||||||
|
"Skip importing '{}' because source '{}' has no SKILL.md",
|
||||||
|
dir_name,
|
||||||
|
source.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 复制到 SSOT
|
// 复制到 SSOT
|
||||||
let dest = ssot_dir.join(&dir_name);
|
let dest = ssot_dir.join(&dir_name);
|
||||||
@@ -791,8 +821,8 @@ impl SkillService {
|
|||||||
let skill_md = dest.join("SKILL.md");
|
let skill_md = dest.join("SKILL.md");
|
||||||
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name);
|
||||||
|
|
||||||
// 构建启用状态
|
// 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。
|
||||||
let apps = SkillApps::from_labels(&found_in);
|
let apps = selection.apps;
|
||||||
|
|
||||||
// 从 lock 文件提取仓库信息
|
// 从 lock 文件提取仓库信息
|
||||||
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
let (id, repo_owner, repo_name, repo_branch, readme_url) =
|
||||||
@@ -935,6 +965,33 @@ impl SkillService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判断路径是否为指向 SSOT 目录内的符号链接。
|
||||||
|
fn is_symlink_to_ssot(path: &Path, ssot_dir: &Path) -> bool {
|
||||||
|
if !Self::is_symlink(path) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(target) = fs::read_link(path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if target.is_absolute() && target.starts_with(ssot_dir) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = path
|
||||||
|
.parent()
|
||||||
|
.map(|parent| parent.join(&target))
|
||||||
|
.unwrap_or(target.clone());
|
||||||
|
|
||||||
|
let canonical_ssot = ssot_dir
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| ssot_dir.to_path_buf());
|
||||||
|
let canonical_target = resolved.canonicalize().unwrap_or(resolved);
|
||||||
|
|
||||||
|
canonical_target.starts_with(&canonical_ssot)
|
||||||
|
}
|
||||||
|
|
||||||
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
/// 从应用目录删除 Skill(支持 symlink 和真实目录)
|
||||||
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> {
|
||||||
let app_dir = Self::get_app_skills_dir(app)?;
|
let app_dir = Self::get_app_skills_dir(app)?;
|
||||||
@@ -951,6 +1008,36 @@ impl SkillService {
|
|||||||
/// 同步所有已启用的 Skills 到指定应用
|
/// 同步所有已启用的 Skills 到指定应用
|
||||||
pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {
|
pub fn sync_to_app(db: &Arc<Database>, app: &AppType) -> Result<()> {
|
||||||
let skills = db.get_all_installed_skills()?;
|
let skills = db.get_all_installed_skills()?;
|
||||||
|
let ssot_dir = Self::get_ssot_dir()?;
|
||||||
|
let app_dir = Self::get_app_skills_dir(app)?;
|
||||||
|
|
||||||
|
let indexed_skills: HashMap<String, &InstalledSkill> = skills
|
||||||
|
.values()
|
||||||
|
.map(|skill| (skill.directory.to_lowercase(), skill))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if app_dir.exists() {
|
||||||
|
for entry in fs::read_dir(&app_dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let dir_name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
|
||||||
|
if dir_name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(skill) = indexed_skills.get(&dir_name.to_lowercase()) {
|
||||||
|
if !skill.apps.is_enabled_for(app) {
|
||||||
|
Self::remove_path(&path)?;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if Self::is_symlink_to_ssot(&path, &ssot_dir) {
|
||||||
|
Self::remove_path(&path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for skill in skills.values() {
|
for skill in skills.values() {
|
||||||
if skill.apps.is_enabled_for(app) {
|
if skill.apps.is_enabled_for(app) {
|
||||||
@@ -1814,8 +1901,32 @@ fn save_repos_from_lock(
|
|||||||
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
||||||
let ssot_dir = SkillService::get_ssot_dir()?;
|
let ssot_dir = SkillService::get_ssot_dir()?;
|
||||||
let agents_lock = parse_agents_lock();
|
let agents_lock = parse_agents_lock();
|
||||||
|
let snapshot: Vec<LegacySkillMigrationRow> =
|
||||||
|
match db.get_setting("skills_ssot_migration_snapshot")? {
|
||||||
|
Some(value) if !value.trim().is_empty() => match serde_json::from_str(&value) {
|
||||||
|
Ok(rows) => rows,
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("解析 skills 迁移快照失败,将回退到文件系统扫描: {err}");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_snapshot = !snapshot.is_empty();
|
||||||
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
let mut discovered: HashMap<String, SkillApps> = HashMap::new();
|
||||||
|
|
||||||
|
if has_snapshot {
|
||||||
|
for row in &snapshot {
|
||||||
|
if let Ok(app) = row.app_type.parse::<AppType>() {
|
||||||
|
discovered
|
||||||
|
.entry(row.directory.clone())
|
||||||
|
.or_default()
|
||||||
|
.set_enabled_for(&app, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 扫描各应用目录
|
// 扫描各应用目录
|
||||||
for app in AppType::all() {
|
for app in AppType::all() {
|
||||||
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
let app_dir = match SkillService::get_app_skills_dir(&app) {
|
||||||
@@ -1838,6 +1949,12 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
|||||||
if dir_name.starts_with('.') {
|
if dir_name.starts_with('.') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if !path.join("SKILL.md").exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if has_snapshot && !discovered.contains_key(&dir_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 复制到 SSOT(如果不存在)
|
// 复制到 SSOT(如果不存在)
|
||||||
let ssot_path = ssot_dir.join(&dir_name);
|
let ssot_path = ssot_dir.join(&dir_name);
|
||||||
@@ -1845,10 +1962,12 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
|||||||
SkillService::copy_dir_recursive(&path, &ssot_path)?;
|
SkillService::copy_dir_recursive(&path, &ssot_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
discovered
|
if !has_snapshot {
|
||||||
.entry(dir_name)
|
discovered
|
||||||
.or_default()
|
.entry(dir_name)
|
||||||
.set_enabled_for(&app, true);
|
.or_default()
|
||||||
|
.set_enabled_for(&app, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1885,6 +2004,8 @@ pub fn migrate_skills_to_ssot(db: &Arc<Database>) -> Result<usize> {
|
|||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _ = db.set_setting("skills_ssot_migration_snapshot", "");
|
||||||
|
|
||||||
log::info!("Skills 迁移完成,共 {count} 个");
|
log::info!("Skills 迁移完成,共 {count} 个");
|
||||||
|
|
||||||
Ok(count)
|
Ok(count)
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use cc_switch_lib::{
|
|||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
mod support;
|
mod support;
|
||||||
use support::{create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex};
|
use support::{
|
||||||
|
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn import_default_config_claude_persists_provider() {
|
fn import_default_config_claude_persists_provider() {
|
||||||
@@ -560,3 +562,96 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
|
|||||||
"~/.claude.json should still not exist after skipped sync"
|
"~/.claude.json should still not exist after skipped sync"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let _home = ensure_test_home();
|
||||||
|
|
||||||
|
let mcp_path = get_claude_mcp_path();
|
||||||
|
fs::write(
|
||||||
|
&mcp_path,
|
||||||
|
serde_json::to_string_pretty(&json!({
|
||||||
|
"mcpServers": {
|
||||||
|
"managed-disabled": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
},
|
||||||
|
"external-only": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "external"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("serialize claude mcp"),
|
||||||
|
)
|
||||||
|
.expect("seed claude mcp");
|
||||||
|
|
||||||
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_mcp_server(&McpServer {
|
||||||
|
id: "managed-disabled".to_string(),
|
||||||
|
name: "Managed Disabled".to_string(),
|
||||||
|
server: json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "echo"
|
||||||
|
}),
|
||||||
|
apps: McpApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
})
|
||||||
|
.expect("save disabled server");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_mcp_server(&McpServer {
|
||||||
|
id: "managed-enabled".to_string(),
|
||||||
|
name: "Managed Enabled".to_string(),
|
||||||
|
server: json!({
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "managed"
|
||||||
|
}),
|
||||||
|
apps: McpApps {
|
||||||
|
claude: true,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
|
},
|
||||||
|
description: None,
|
||||||
|
homepage: None,
|
||||||
|
docs: None,
|
||||||
|
tags: Vec::new(),
|
||||||
|
})
|
||||||
|
.expect("save enabled server");
|
||||||
|
|
||||||
|
McpService::sync_all_enabled(&state).expect("reconcile mcp");
|
||||||
|
|
||||||
|
let text = fs::read_to_string(&mcp_path).expect("read claude mcp");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&text).expect("parse claude mcp");
|
||||||
|
let servers = value
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(|entry| entry.as_object())
|
||||||
|
.expect("mcpServers object");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!servers.contains_key("managed-disabled"),
|
||||||
|
"DB-known disabled server should be removed from live config"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
servers.contains_key("managed-enabled"),
|
||||||
|
"DB-known enabled server should be present in live config"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
servers.contains_key("external-only"),
|
||||||
|
"live entries unknown to DB should be preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use cc_switch_lib::{
|
||||||
|
migrate_skills_to_ssot, AppType, ImportSkillSelection, InstalledSkill, SkillApps, SkillService,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[path = "support.rs"]
|
||||||
|
mod support;
|
||||||
|
use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex};
|
||||||
|
|
||||||
|
fn write_skill(dir: &std::path::Path, name: &str) {
|
||||||
|
fs::create_dir_all(dir).expect("create skill dir");
|
||||||
|
fs::write(
|
||||||
|
dir.join("SKILL.md"),
|
||||||
|
format!("---\nname: {name}\ndescription: Test skill\n---\n"),
|
||||||
|
)
|
||||||
|
.expect("write SKILL.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
|
||||||
|
std::os::unix::fs::symlink(src, dest).expect("create symlink");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn symlink_dir(src: &std::path::Path, dest: &std::path::Path) {
|
||||||
|
std::os::windows::fs::symlink_dir(src, dest).expect("create symlink");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_from_apps_respects_explicit_app_selection() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
write_skill(
|
||||||
|
&home.join(".claude").join("skills").join("shared-skill"),
|
||||||
|
"Shared",
|
||||||
|
);
|
||||||
|
write_skill(
|
||||||
|
&home
|
||||||
|
.join(".config")
|
||||||
|
.join("opencode")
|
||||||
|
.join("skills")
|
||||||
|
.join("shared-skill"),
|
||||||
|
"Shared",
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
|
let imported = SkillService::import_from_apps(
|
||||||
|
&state.db,
|
||||||
|
vec![ImportSkillSelection {
|
||||||
|
directory: "shared-skill".to_string(),
|
||||||
|
apps: SkillApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: true,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.expect("import skills");
|
||||||
|
|
||||||
|
assert_eq!(imported.len(), 1, "expected exactly one imported skill");
|
||||||
|
let skill = imported.first().expect("imported skill");
|
||||||
|
assert!(
|
||||||
|
skill.apps.opencode,
|
||||||
|
"explicitly selected OpenCode app should remain enabled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!skill.apps.claude && !skill.apps.codex && !skill.apps.gemini,
|
||||||
|
"import should no longer infer apps from every matching source path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let ssot_dir = home.join(".cc-switch").join("skills");
|
||||||
|
let disabled_skill = ssot_dir.join("disabled-skill");
|
||||||
|
let orphan_skill = ssot_dir.join("orphan-skill");
|
||||||
|
write_skill(&disabled_skill, "Disabled");
|
||||||
|
write_skill(&orphan_skill, "Orphan");
|
||||||
|
|
||||||
|
let opencode_skills_dir = home.join(".config").join("opencode").join("skills");
|
||||||
|
fs::create_dir_all(&opencode_skills_dir).expect("create opencode skills dir");
|
||||||
|
symlink_dir(&disabled_skill, &opencode_skills_dir.join("disabled-skill"));
|
||||||
|
symlink_dir(&orphan_skill, &opencode_skills_dir.join("orphan-skill"));
|
||||||
|
|
||||||
|
let state = create_test_state().expect("create test state");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.save_skill(&InstalledSkill {
|
||||||
|
id: "local:disabled-skill".to_string(),
|
||||||
|
name: "Disabled".to_string(),
|
||||||
|
description: None,
|
||||||
|
directory: "disabled-skill".to_string(),
|
||||||
|
repo_owner: None,
|
||||||
|
repo_name: None,
|
||||||
|
repo_branch: None,
|
||||||
|
readme_url: None,
|
||||||
|
apps: SkillApps {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
|
},
|
||||||
|
installed_at: 0,
|
||||||
|
})
|
||||||
|
.expect("save disabled skill");
|
||||||
|
|
||||||
|
SkillService::sync_to_app(&state.db, &AppType::OpenCode).expect("reconcile skills");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!opencode_skills_dir.join("disabled-skill").exists(),
|
||||||
|
"DB-known disabled skill should be removed from OpenCode live dir"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!opencode_skills_dir.join("orphan-skill").exists(),
|
||||||
|
"orphaned symlink into SSOT should be cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_snapshot_overrides_multi_source_directory_inference() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
write_skill(
|
||||||
|
&home.join(".claude").join("skills").join("demo-skill"),
|
||||||
|
"Demo",
|
||||||
|
);
|
||||||
|
write_skill(
|
||||||
|
&home
|
||||||
|
.join(".config")
|
||||||
|
.join("opencode")
|
||||||
|
.join("skills")
|
||||||
|
.join("demo-skill"),
|
||||||
|
"Demo",
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = create_test_state().expect("create test state");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.set_setting(
|
||||||
|
"skills_ssot_migration_snapshot",
|
||||||
|
r#"[{"directory":"demo-skill","app_type":"claude"}]"#,
|
||||||
|
)
|
||||||
|
.expect("seed migration snapshot");
|
||||||
|
|
||||||
|
let count = migrate_skills_to_ssot(&state.db).expect("migrate skills to ssot");
|
||||||
|
assert_eq!(count, 1, "expected one migrated skill");
|
||||||
|
|
||||||
|
let skills = state.db.get_all_installed_skills().expect("get skills");
|
||||||
|
let migrated = skills
|
||||||
|
.values()
|
||||||
|
.find(|skill| skill.directory == "demo-skill")
|
||||||
|
.expect("migrated demo-skill");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
migrated.apps.claude,
|
||||||
|
"legacy snapshot should preserve Claude enablement"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!migrated.apps.opencode,
|
||||||
|
"migration should no longer infer OpenCode enablement from a duplicate directory alone"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,14 @@ pub fn ensure_test_home() -> &'static Path {
|
|||||||
/// 清理测试目录中生成的配置文件与缓存。
|
/// 清理测试目录中生成的配置文件与缓存。
|
||||||
pub fn reset_test_fs() {
|
pub fn reset_test_fs() {
|
||||||
let home = ensure_test_home();
|
let home = ensure_test_home();
|
||||||
for sub in [".claude", ".codex", ".cc-switch", ".gemini"] {
|
for sub in [
|
||||||
|
".claude",
|
||||||
|
".codex",
|
||||||
|
".cc-switch",
|
||||||
|
".gemini",
|
||||||
|
".config",
|
||||||
|
".openclaw",
|
||||||
|
] {
|
||||||
let path = home.join(sub);
|
let path = home.join(sub);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
if let Err(err) = std::fs::remove_dir_all(&path) {
|
if let Err(err) = std::fs::remove_dir_all(&path) {
|
||||||
|
|||||||
+2
-5
@@ -712,17 +712,14 @@ function App() {
|
|||||||
<UnifiedSkillsPanel
|
<UnifiedSkillsPanel
|
||||||
ref={unifiedSkillsPanelRef}
|
ref={unifiedSkillsPanelRef}
|
||||||
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
|
||||||
|
currentApp={activeApp === "openclaw" ? "claude" : activeApp}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "skillsDiscovery":
|
case "skillsDiscovery":
|
||||||
return (
|
return (
|
||||||
<SkillsPage
|
<SkillsPage
|
||||||
ref={skillsPageRef}
|
ref={skillsPageRef}
|
||||||
initialApp={
|
initialApp={activeApp === "openclaw" ? "claude" : activeApp}
|
||||||
activeApp === "opencode" || activeApp === "openclaw"
|
|
||||||
? "claude"
|
|
||||||
: activeApp
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "mcp":
|
case "mcp":
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Sparkles, Trash2, ExternalLink } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
|
type ImportSkillSelection,
|
||||||
useInstalledSkills,
|
useInstalledSkills,
|
||||||
useToggleSkillApp,
|
useToggleSkillApp,
|
||||||
useUninstallSkill,
|
useUninstallSkill,
|
||||||
@@ -23,6 +24,7 @@ import { ListItemRow } from "@/components/common/ListItemRow";
|
|||||||
|
|
||||||
interface UnifiedSkillsPanelProps {
|
interface UnifiedSkillsPanelProps {
|
||||||
onOpenDiscovery: () => void;
|
onOpenDiscovery: () => void;
|
||||||
|
currentApp: AppId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedSkillsPanelHandle {
|
export interface UnifiedSkillsPanelHandle {
|
||||||
@@ -34,7 +36,7 @@ export interface UnifiedSkillsPanelHandle {
|
|||||||
const UnifiedSkillsPanel = React.forwardRef<
|
const UnifiedSkillsPanel = React.forwardRef<
|
||||||
UnifiedSkillsPanelHandle,
|
UnifiedSkillsPanelHandle,
|
||||||
UnifiedSkillsPanelProps
|
UnifiedSkillsPanelProps
|
||||||
>(({ onOpenDiscovery }, ref) => {
|
>(({ onOpenDiscovery, currentApp }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -103,9 +105,9 @@ const UnifiedSkillsPanel = React.forwardRef<
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async (directories: string[]) => {
|
const handleImport = async (imports: ImportSkillSelection[]) => {
|
||||||
try {
|
try {
|
||||||
const imported = await importMutation.mutateAsync(directories);
|
const imported = await importMutation.mutateAsync(imports);
|
||||||
setImportDialogOpen(false);
|
setImportDialogOpen(false);
|
||||||
toast.success(t("skills.importSuccess", { count: imported.length }), {
|
toast.success(t("skills.importSuccess", { count: imported.length }), {
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
@@ -120,7 +122,6 @@ const UnifiedSkillsPanel = React.forwardRef<
|
|||||||
const filePath = await skillsApi.openZipFileDialog();
|
const filePath = await skillsApi.openZipFileDialog();
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
|
|
||||||
const currentApp: AppId = "claude";
|
|
||||||
const installed = await installFromZipMutation.mutateAsync({
|
const installed = await installFromZipMutation.mutateAsync({
|
||||||
filePath,
|
filePath,
|
||||||
currentApp,
|
currentApp,
|
||||||
@@ -310,7 +311,7 @@ interface ImportSkillsDialogProps {
|
|||||||
foundIn: string[];
|
foundIn: string[];
|
||||||
path: string;
|
path: string;
|
||||||
}>;
|
}>;
|
||||||
onImport: (directories: string[]) => void;
|
onImport: (imports: ImportSkillSelection[]) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +324,22 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
|||||||
const [selected, setSelected] = useState<Set<string>>(
|
const [selected, setSelected] = useState<Set<string>>(
|
||||||
new Set(skills.map((s) => s.directory)),
|
new Set(skills.map((s) => s.directory)),
|
||||||
);
|
);
|
||||||
|
const [selectedApps, setSelectedApps] = useState<
|
||||||
|
Record<string, ImportSkillSelection["apps"]>
|
||||||
|
>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
skills.map((skill) => [
|
||||||
|
skill.directory,
|
||||||
|
{
|
||||||
|
claude: skill.foundIn.includes("claude"),
|
||||||
|
codex: skill.foundIn.includes("codex"),
|
||||||
|
gemini: skill.foundIn.includes("gemini"),
|
||||||
|
opencode: skill.foundIn.includes("opencode"),
|
||||||
|
openclaw: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const toggleSelect = (directory: string) => {
|
const toggleSelect = (directory: string) => {
|
||||||
const newSelected = new Set(selected);
|
const newSelected = new Set(selected);
|
||||||
@@ -335,7 +352,18 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
onImport(Array.from(selected));
|
onImport(
|
||||||
|
Array.from(selected).map((directory) => ({
|
||||||
|
directory,
|
||||||
|
apps: selectedApps[directory] ?? {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
|
openclaw: false,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -348,9 +376,9 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
|
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
|
||||||
{skills.map((skill) => (
|
{skills.map((skill) => (
|
||||||
<label
|
<div
|
||||||
key={skill.directory}
|
key={skill.directory}
|
||||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted cursor-pointer"
|
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -365,6 +393,35 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
|||||||
{skill.description}
|
{skill.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
<AppToggleGroup
|
||||||
|
apps={
|
||||||
|
selectedApps[skill.directory] ?? {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
|
openclaw: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onToggle={(app, enabled) => {
|
||||||
|
setSelectedApps((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[skill.directory]: {
|
||||||
|
...(prev[skill.directory] ?? {
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
opencode: false,
|
||||||
|
openclaw: false,
|
||||||
|
}),
|
||||||
|
[app]: enabled,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
appIds={MCP_SKILLS_APP_IDS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-xs text-muted-foreground/50 mt-1 truncate"
|
className="text-xs text-muted-foreground/50 mt-1 truncate"
|
||||||
title={skill.path}
|
title={skill.path}
|
||||||
@@ -372,7 +429,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
|
|||||||
{skill.path}
|
{skill.path}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
skillsApi,
|
skillsApi,
|
||||||
type DiscoverableSkill,
|
type DiscoverableSkill,
|
||||||
|
type ImportSkillSelection,
|
||||||
type InstalledSkill,
|
type InstalledSkill,
|
||||||
} from "@/lib/api/skills";
|
} from "@/lib/api/skills";
|
||||||
import type { AppId } from "@/lib/api/types";
|
import type { AppId } from "@/lib/api/types";
|
||||||
@@ -99,8 +100,8 @@ export function useScanUnmanagedSkills() {
|
|||||||
export function useImportSkillsFromApps() {
|
export function useImportSkillsFromApps() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (directories: string[]) =>
|
mutationFn: (imports: ImportSkillSelection[]) =>
|
||||||
skillsApi.importFromApps(directories),
|
skillsApi.importFromApps(imports),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
|
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
|
||||||
@@ -169,4 +170,4 @@ export function useInstallSkillsFromZip() {
|
|||||||
|
|
||||||
// ========== 辅助类型 ==========
|
// ========== 辅助类型 ==========
|
||||||
|
|
||||||
export type { InstalledSkill, DiscoverableSkill, AppId };
|
export type { InstalledSkill, DiscoverableSkill, ImportSkillSelection, AppId };
|
||||||
|
|||||||
+10
-2
@@ -48,6 +48,12 @@ export interface UnmanagedSkill {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导入已有 Skill 时提交的应用启用状态 */
|
||||||
|
export interface ImportSkillSelection {
|
||||||
|
directory: string;
|
||||||
|
apps: SkillApps;
|
||||||
|
}
|
||||||
|
|
||||||
/** 技能对象(兼容旧 API) */
|
/** 技能对象(兼容旧 API) */
|
||||||
export interface Skill {
|
export interface Skill {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -103,8 +109,10 @@ export const skillsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** 从应用目录导入 Skills */
|
/** 从应用目录导入 Skills */
|
||||||
async importFromApps(directories: string[]): Promise<InstalledSkill[]> {
|
async importFromApps(
|
||||||
return await invoke("import_skills_from_apps", { directories });
|
imports: ImportSkillSelection[],
|
||||||
|
): Promise<InstalledSkill[]> {
|
||||||
|
return await invoke("import_skills_from_apps", { imports });
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 发现可安装的 Skills(从仓库获取) */
|
/** 发现可安装的 Skills(从仓库获取) */
|
||||||
|
|||||||
Reference in New Issue
Block a user