diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 9651807cc..f92035c0b 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -6,7 +6,9 @@ use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill}; 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 std::sync::Arc; use tauri::State; @@ -85,10 +87,10 @@ pub fn scan_unmanaged_skills( /// 从应用目录导入 Skills #[tauri::command] pub fn import_skills_from_apps( - directories: Vec, + imports: Vec, app_state: State<'_, AppState>, ) -> Result, 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()) } // ========== 发现功能命令 ========== diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index d0b7adada..eec1d8cd2 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -5,6 +5,13 @@ use super::{lock_conn, Database, SCHEMA_VERSION}; use crate::error::AppError; use rusqlite::Connection; +use serde::Serialize; + +#[derive(Serialize)] +struct LegacySkillMigrationRow { + directory: String, + app_type: String, +} impl Database { /// 创建所有数据库表 @@ -846,12 +853,31 @@ impl Database { log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)..."); - // 1. 备份旧数据(用于日志) + // 1. 备份旧数据(用于日志和后续启动迁移) let old_count: i64 = conn .query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0)) .unwrap_or(0); 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 = stmt + .query_map([], |row| { + Ok(LegacySkillMigrationRow { + directory: row.get(0)?, + app_type: row.get(1)?, + }) + }) + .map_err(|e| AppError::Database(format!("读取旧 skills 快照失败: {e}")))? + .collect::, _>>() + .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 数据 // 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/, // 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。 @@ -859,6 +885,10 @@ impl Database { "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. 删除旧表 conn.execute("DROP TABLE IF EXISTS skills", []) diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index eac296d1a..0f80555ae 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -488,6 +488,25 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() { matches!(pending.as_deref(), Some("true") | Some("1")), "skills_ssot_migration_pending should be set after v2->v3 migration" ); + let snapshot: Option = 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 会查不到默认值) let proxy_rows: i64 = conn diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index edbd8727e..426f991f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,7 +28,7 @@ mod store; mod tray; 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 commands::open_provider_terminal; pub use commands::*; @@ -44,6 +44,7 @@ pub use mcp::{ }; pub use provider::{Provider, ProviderMeta}; pub use services::{ + skill::{migrate_skills_to_ssot, ImportSkillSelection}, ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService, SkillService, SpeedtestService, }; diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 1080480b3..02b31aea9 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -165,8 +165,18 @@ impl McpService { pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> { let servers = Self::get_all_servers(state)?; - for server in servers.values() { - Self::sync_server_to_apps(state, server)?; + for app in AppType::all() { + 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(()) diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index ea69a8091..0d3937cde 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -159,6 +159,21 @@ pub struct SkillMetadata { pub description: Option, } +/// 导入已有 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/.skill-lock.json` 文件结构 @@ -717,6 +732,9 @@ impl SkillService { } let skill_md = path.join("SKILL.md"); + if !skill_md.exists() { + continue; + } let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); unmanaged @@ -740,14 +758,18 @@ impl SkillService { /// 将未管理的 Skills 导入到 CC Switch 统一管理 pub fn import_from_apps( db: &Arc, - directories: Vec, + imports: Vec, ) -> Result> { let ssot_dir = Self::get_ssot_dir()?; let agents_lock = parse_agents_lock(); let mut imported = Vec::new(); // 将 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(); @@ -761,10 +783,10 @@ impl SkillService { } 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 = None; - let mut found_in: Vec = Vec::new(); for (base, label) in &search_sources { let skill_path = base.join(&dir_name); @@ -772,7 +794,7 @@ impl SkillService { if source_path.is_none() { 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, None => continue, }; + if !source.join("SKILL.md").exists() { + log::warn!( + "Skip importing '{}' because source '{}' has no SKILL.md", + dir_name, + source.display() + ); + continue; + } // 复制到 SSOT let dest = ssot_dir.join(&dir_name); @@ -791,8 +821,8 @@ impl SkillService { let skill_md = dest.join("SKILL.md"); let (name, description) = Self::read_skill_name_desc(&skill_md, &dir_name); - // 构建启用状态 - let apps = SkillApps::from_labels(&found_in); + // 启用状态仅信任用户本次显式选择,不再根据“在哪些位置找到”自动推断。 + let apps = selection.apps; // 从 lock 文件提取仓库信息 let (id, repo_owner, repo_name, repo_branch, readme_url) = @@ -935,6 +965,33 @@ impl SkillService { 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 和真实目录) pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> { let app_dir = Self::get_app_skills_dir(app)?; @@ -951,6 +1008,36 @@ impl SkillService { /// 同步所有已启用的 Skills 到指定应用 pub fn sync_to_app(db: &Arc, app: &AppType) -> Result<()> { 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 = 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() { if skill.apps.is_enabled_for(app) { @@ -1814,8 +1901,32 @@ fn save_repos_from_lock( pub fn migrate_skills_to_ssot(db: &Arc) -> Result { let ssot_dir = SkillService::get_ssot_dir()?; let agents_lock = parse_agents_lock(); + let snapshot: Vec = + 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 = HashMap::new(); + if has_snapshot { + for row in &snapshot { + if let Ok(app) = row.app_type.parse::() { + discovered + .entry(row.directory.clone()) + .or_default() + .set_enabled_for(&app, true); + } + } + } + // 扫描各应用目录 for app in AppType::all() { let app_dir = match SkillService::get_app_skills_dir(&app) { @@ -1838,6 +1949,12 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { if dir_name.starts_with('.') { continue; } + if !path.join("SKILL.md").exists() { + continue; + } + if has_snapshot && !discovered.contains_key(&dir_name) { + continue; + } // 复制到 SSOT(如果不存在) let ssot_path = ssot_dir.join(&dir_name); @@ -1845,10 +1962,12 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { SkillService::copy_dir_recursive(&path, &ssot_path)?; } - discovered - .entry(dir_name) - .or_default() - .set_enabled_for(&app, true); + if !has_snapshot { + discovered + .entry(dir_name) + .or_default() + .set_enabled_for(&app, true); + } } } @@ -1885,6 +2004,8 @@ pub fn migrate_skills_to_ssot(db: &Arc) -> Result { count += 1; } + let _ = db.set_setting("skills_ssot_migration_snapshot", ""); + log::info!("Skills 迁移完成,共 {count} 个"); Ok(count) diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 560ecdced..1ed24a99d 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -10,7 +10,9 @@ use cc_switch_lib::{ #[path = "support.rs"] 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] 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" ); } + +#[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" + ); +} diff --git a/src-tauri/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs new file mode 100644 index 000000000..913880a0d --- /dev/null +++ b/src-tauri/tests/skill_sync.rs @@ -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" + ); +} diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index 3a45f0e0e..83aa34d13 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -28,7 +28,14 @@ pub fn ensure_test_home() -> &'static Path { /// 清理测试目录中生成的配置文件与缓存。 pub fn reset_test_fs() { 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); if path.exists() { if let Err(err) = std::fs::remove_dir_all(&path) { diff --git a/src/App.tsx b/src/App.tsx index e4ba2eb22..f9f4715b7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -712,17 +712,14 @@ function App() { setCurrentView("skillsDiscovery")} + currentApp={activeApp === "openclaw" ? "claude" : activeApp} /> ); case "skillsDiscovery": return ( ); case "mcp": diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index d485fc99f..59ce35b3d 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -4,6 +4,7 @@ import { Sparkles, Trash2, ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; import { TooltipProvider } from "@/components/ui/tooltip"; import { + type ImportSkillSelection, useInstalledSkills, useToggleSkillApp, useUninstallSkill, @@ -23,6 +24,7 @@ import { ListItemRow } from "@/components/common/ListItemRow"; interface UnifiedSkillsPanelProps { onOpenDiscovery: () => void; + currentApp: AppId; } export interface UnifiedSkillsPanelHandle { @@ -34,7 +36,7 @@ export interface UnifiedSkillsPanelHandle { const UnifiedSkillsPanel = React.forwardRef< UnifiedSkillsPanelHandle, UnifiedSkillsPanelProps ->(({ onOpenDiscovery }, ref) => { +>(({ onOpenDiscovery, currentApp }, ref) => { const { t } = useTranslation(); const [confirmDialog, setConfirmDialog] = useState<{ isOpen: boolean; @@ -103,9 +105,9 @@ const UnifiedSkillsPanel = React.forwardRef< } }; - const handleImport = async (directories: string[]) => { + const handleImport = async (imports: ImportSkillSelection[]) => { try { - const imported = await importMutation.mutateAsync(directories); + const imported = await importMutation.mutateAsync(imports); setImportDialogOpen(false); toast.success(t("skills.importSuccess", { count: imported.length }), { closeButton: true, @@ -120,7 +122,6 @@ const UnifiedSkillsPanel = React.forwardRef< const filePath = await skillsApi.openZipFileDialog(); if (!filePath) return; - const currentApp: AppId = "claude"; const installed = await installFromZipMutation.mutateAsync({ filePath, currentApp, @@ -310,7 +311,7 @@ interface ImportSkillsDialogProps { foundIn: string[]; path: string; }>; - onImport: (directories: string[]) => void; + onImport: (imports: ImportSkillSelection[]) => void; onClose: () => void; } @@ -323,6 +324,22 @@ const ImportSkillsDialog: React.FC = ({ const [selected, setSelected] = useState>( new Set(skills.map((s) => s.directory)), ); + const [selectedApps, setSelectedApps] = useState< + Record + >(() => + 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 newSelected = new Set(selected); @@ -335,7 +352,18 @@ const ImportSkillsDialog: React.FC = ({ }; 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 ( @@ -348,9 +376,9 @@ const ImportSkillsDialog: React.FC = ({
{skills.map((skill) => ( -
)} +
+ { + 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} + /> +
= ({ {skill.path}
- + ))} diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index f3a5830e2..f50f00d4a 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { skillsApi, type DiscoverableSkill, + type ImportSkillSelection, type InstalledSkill, } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; @@ -99,8 +100,8 @@ export function useScanUnmanagedSkills() { export function useImportSkillsFromApps() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (directories: string[]) => - skillsApi.importFromApps(directories), + mutationFn: (imports: ImportSkillSelection[]) => + skillsApi.importFromApps(imports), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }); @@ -169,4 +170,4 @@ export function useInstallSkillsFromZip() { // ========== 辅助类型 ========== -export type { InstalledSkill, DiscoverableSkill, AppId }; +export type { InstalledSkill, DiscoverableSkill, ImportSkillSelection, AppId }; diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 151090773..5b459e356 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -48,6 +48,12 @@ export interface UnmanagedSkill { path: string; } +/** 导入已有 Skill 时提交的应用启用状态 */ +export interface ImportSkillSelection { + directory: string; + apps: SkillApps; +} + /** 技能对象(兼容旧 API) */ export interface Skill { key: string; @@ -103,8 +109,10 @@ export const skillsApi = { }, /** 从应用目录导入 Skills */ - async importFromApps(directories: string[]): Promise { - return await invoke("import_skills_from_apps", { directories }); + async importFromApps( + imports: ImportSkillSelection[], + ): Promise { + return await invoke("import_skills_from_apps", { imports }); }, /** 发现可安装的 Skills(从仓库获取) */