From d51e774b20e54e209be8ef766d61d4fc6a367692 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 5 Apr 2026 22:16:10 +0800 Subject: [PATCH] feat: integrate skills.sh search for discovering skills from public registry Add skills.sh API integration allowing users to search and install from a catalog of 91K+ agent skills directly within CC Switch. The search results are converted to DiscoverableSkill objects and reuse the existing install pipeline. Includes fallback directory search for repos where skills are nested in subdirectories, and filters out non-GitHub sources. --- src-tauri/src/commands/skill.rs | 13 ++ src-tauri/src/lib.rs | 1 + src-tauri/src/services/skill.rs | 176 +++++++++++++- src/components/skills/SkillCard.tsx | 17 +- src/components/skills/SkillsPage.tsx | 328 +++++++++++++++++++++++---- src/hooks/useDebouncedValue.ts | 16 ++ src/hooks/useSkills.ts | 22 ++ src/i18n/locales/en.json | 13 ++ src/i18n/locales/ja.json | 13 ++ src/i18n/locales/zh.json | 13 ++ src/lib/api/skills.ts | 28 +++ 11 files changed, 583 insertions(+), 57 deletions(-) create mode 100644 src/hooks/useDebouncedValue.ts diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index 8c63ec589..5bec2b17b 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -9,6 +9,7 @@ use crate::error::format_skill_error; use crate::services::skill::{ DiscoverableSkill, ImportSkillSelection, MigrationResult, Skill, SkillBackupEntry, SkillRepo, SkillService, SkillStorageLocation, SkillUninstallResult, SkillUpdateInfo, + SkillsShSearchResult, }; use crate::store::AppState; use std::sync::Arc; @@ -170,6 +171,18 @@ pub async fn migrate_skill_storage( SkillService::migrate_storage(&app_state.db, target).map_err(|e| e.to_string()) } +/// 搜索 skills.sh 公共目录 +#[tauri::command] +pub async fn search_skills_sh( + query: String, + limit: usize, + offset: usize, +) -> Result { + SkillService::search_skills_sh(&query, limit, offset) + .await + .map_err(|e| e.to_string()) +} + // ========== 兼容旧 API 的命令 ========== /// 获取技能列表(兼容旧 API) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e60c3ae23..d7490e924 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -966,6 +966,7 @@ pub fn run() { commands::check_skill_updates, commands::update_skill, commands::migrate_skill_storage, + commands::search_skills_sh, // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index adadf54ad..2ce39ea02 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -194,6 +194,58 @@ pub struct MigrationResult { pub errors: Vec, } +// ========== skills.sh API 类型 ========== + +/// skills.sh API 原始响应 +/// +/// 注意:API 命名不一致(searchType 是 camelCase,duration_ms 是 snake_case), +/// 因此不能用 rename_all,需要逐字段指定。 +#[derive(Debug, Clone, Deserialize)] +struct SkillsShApiResponse { + pub query: String, + #[serde(rename = "searchType")] + #[allow(dead_code)] + pub search_type: String, + pub skills: Vec, + pub count: usize, + #[allow(dead_code)] + pub duration_ms: u64, +} + +/// skills.sh API 原始技能条目 +#[derive(Debug, Clone, Deserialize)] +struct SkillsShApiSkill { + pub id: String, + #[serde(rename = "skillId")] + pub skill_id: String, + pub name: String, + pub installs: u64, + pub source: String, +} + +/// skills.sh 搜索结果(返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillsShSearchResult { + pub skills: Vec, + pub total_count: usize, + pub query: String, +} + +/// skills.sh 可安装技能(返回给前端) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SkillsShDiscoverableSkill { + pub key: String, + pub name: String, + pub directory: String, + pub repo_owner: String, + pub repo_name: String, + pub repo_branch: String, + pub installs: u64, + pub readme_url: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillBackupEntry { @@ -614,14 +666,28 @@ impl SkillService { repo_branch = used_branch; // 复制到 SSOT - let source = temp_dir.join(&source_rel); + let mut source = temp_dir.join(&source_rel); if !source.exists() { - let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &source.display().to_string())], - Some("checkRepoUrl"), - ))); + // 回退:在 temp_dir 中递归查找名称匹配的目录(含 SKILL.md) + let target_name = source_rel + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if let Some(found) = Self::find_skill_dir_by_name(&temp_dir, &target_name) { + log::info!( + "Skill directory '{}' not found at direct path, using fallback: {}", + target_name, + found.display() + ); + source = found; + } else { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &source.display().to_string())], + Some("checkRepoUrl"), + ))); + } } let canonical_temp = temp_dir.canonicalize().unwrap_or_else(|_| temp_dir.clone()); @@ -1982,6 +2048,38 @@ impl SkillService { } } + /// 在目录树中查找名称匹配且包含 SKILL.md 的子目录 + /// + /// 用于 skills.sh 安装回退:API 只返回 skillId(如 "find-skills"), + /// 但实际文件可能在仓库子目录中(如 "skills/find-skills")。 + fn find_skill_dir_by_name(root: &Path, target_name: &str) -> Option { + fn walk(dir: &Path, target: &str, depth: usize) -> Option { + if depth > 3 { + return None; + } + let entries = fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with('.') { + continue; + } + if name_str.eq_ignore_ascii_case(target) && path.join("SKILL.md").exists() { + return Some(path); + } + if let Some(found) = walk(&path, target, depth + 1) { + return Some(found); + } + } + None + } + walk(root, target_name, 0) + } + /// 去重技能列表(基于完整 key,不同仓库的同名 skill 分开显示) fn deduplicate_discoverable_skills(skills: &mut Vec) { let mut seen = HashMap::new(); @@ -2589,6 +2687,70 @@ impl SkillService { Ok(()) } + + // ========== skills.sh 搜索 ========== + + /// 搜索 skills.sh 公共目录 + pub async fn search_skills_sh( + query: &str, + limit: usize, + offset: usize, + ) -> Result { + let client = crate::proxy::http_client::get(); + + let url = url::Url::parse_with_params( + "https://skills.sh/api/search", + &[ + ("q", query), + ("limit", &limit.to_string()), + ("offset", &offset.to_string()), + ], + )?; + + let resp = client + .get(url) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + let skills = resp + .skills + .into_iter() + .filter_map(|s| { + let parts: Vec<&str> = s.source.splitn(2, '/').collect(); + if parts.len() != 2 { + return None; + } + let (owner, repo) = (parts[0].to_string(), parts[1].to_string()); + // 过滤非 GitHub 来源(如 "skills.volces.com"、"mcp-hub.momenta.works") + if owner.contains('.') || repo.contains('.') { + return None; + } + Some(SkillsShDiscoverableSkill { + key: s.id, + name: s.name, + directory: s.skill_id.clone(), + repo_owner: owner.clone(), + repo_name: repo.clone(), + repo_branch: "main".to_string(), + installs: s.installs, + readme_url: Some(format!( + "https://github.com/{}/{}/tree/main/{}", + owner, repo, s.skill_id + )), + }) + }) + .collect(); + + Ok(SkillsShSearchResult { + skills, + total_count: resp.count, + query: resp.query, + }) + } } // ========== 迁移支持 ========== diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index 5787f72ec..6c0d20b87 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -20,9 +20,15 @@ interface SkillCardProps { skill: SkillCardSkill; onInstall: (directory: string) => Promise; onUninstall: (directory: string) => Promise; + installs?: number; } -export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { +export function SkillCard({ + skill, + onInstall, + onUninstall, + installs, +}: SkillCardProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); @@ -81,6 +87,15 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { {skill.repoOwner}/{skill.repoName} )} + {typeof installs === "number" && ( + + + {installs.toLocaleString()} + + )} {skill.installed && ( diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index ba09c835b..c24d68e94 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -1,4 +1,10 @@ -import { useState, useMemo, forwardRef, useImperativeHandle } from "react"; +import { + useState, + useMemo, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,7 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { RefreshCw, Search } from "lucide-react"; +import { RefreshCw, Search, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { SkillCard } from "./SkillCard"; import { RepoManagerPanel } from "./RepoManagerPanel"; @@ -20,9 +26,14 @@ import { useSkillRepos, useAddSkillRepo, useRemoveSkillRepo, + useSearchSkillsSh, } from "@/hooks/useSkills"; import type { AppId } from "@/lib/api/types"; -import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills"; +import type { + DiscoverableSkill, + SkillRepo, + SkillsShDiscoverableSkill, +} from "@/lib/api/skills"; import { formatSkillError } from "@/lib/errors/skillErrorParser"; interface SkillsPageProps { @@ -34,9 +45,13 @@ export interface SkillsPageHandle { openRepoManager: () => void; } +type SearchSource = "repos" | "skillssh"; + +const SKILLSSH_PAGE_SIZE = 20; + /** * Skills 发现面板 - * 用于浏览和安装来自仓库的 Skills + * 用于浏览和安装来自仓库或 skills.sh 的 Skills */ export const SkillsPage = forwardRef( ({ initialApp = "claude" }, ref) => { @@ -48,6 +63,15 @@ export const SkillsPage = forwardRef( "all" | "installed" | "uninstalled" >("all"); + // skills.sh 搜索状态 + const [searchSource, setSearchSource] = useState("repos"); + const [skillsShInput, setSkillsShInput] = useState(""); + const [skillsShQuery, setSkillsShQuery] = useState(""); + const [skillsShOffset, setSkillsShOffset] = useState(0); + const [accumulatedResults, setAccumulatedResults] = useState< + SkillsShDiscoverableSkill[] + >([]); + // currentApp 用于安装时的默认应用 const currentApp = initialApp; @@ -61,6 +85,33 @@ export const SkillsPage = forwardRef( const { data: installedSkills } = useInstalledSkills(); const { data: repos = [], refetch: refetchRepos } = useSkillRepos(); + // skills.sh 搜索 + const { + data: skillsShResult, + isLoading: loadingSkillsSh, + isFetching: fetchingSkillsSh, + } = useSearchSkillsSh(skillsShQuery, SKILLSSH_PAGE_SIZE, skillsShOffset); + + // 当搜索结果返回时累积 + useEffect(() => { + if (skillsShResult) { + if (skillsShOffset === 0) { + setAccumulatedResults(skillsShResult.skills); + } else { + setAccumulatedResults((prev) => [...prev, ...skillsShResult.skills]); + } + } + }, [skillsShResult, skillsShOffset]); + + // 手动提交搜索 + const handleSkillsShSearch = () => { + const trimmed = skillsShInput.trim(); + if (trimmed.length < 2) return; + setSkillsShOffset(0); + setAccumulatedResults([]); + setSkillsShQuery(trimmed); + }; + // Mutations const installMutation = useInstallSkill(); const addRepoMutation = useAddSkillRepo(); @@ -110,7 +161,16 @@ export const SkillsPage = forwardRef( }); }, [discoverableSkills, installedKeys]); - const loading = loadingDiscoverable || fetchingDiscoverable; + // 检查 skills.sh 结果的安装状态 + const isSkillsShInstalled = (skill: SkillsShDiscoverableSkill): boolean => { + const key = `${skill.directory.toLowerCase()}:${skill.repoOwner.toLowerCase()}:${skill.repoName.toLowerCase()}`; + return installedKeys.has(key); + }; + + const loading = + searchSource === "repos" + ? loadingDiscoverable || fetchingDiscoverable + : false; useImperativeHandle(ref, () => ({ refresh: () => { @@ -120,13 +180,36 @@ export const SkillsPage = forwardRef( openRepoManager: () => setRepoManagerOpen(true), })); + // skills.sh 结果转为 DiscoverableSkill(复用现有安装流程) + const toDiscoverableSkill = ( + s: SkillsShDiscoverableSkill, + ): DiscoverableSkill => ({ + key: s.key, + name: s.name, + description: "", + directory: s.directory, + repoOwner: s.repoOwner, + repoName: s.repoName, + repoBranch: s.repoBranch, + readmeUrl: s.readmeUrl, + }); + const handleInstall = async (directory: string) => { - // 找到对应的 DiscoverableSkill - const skill = discoverableSkills?.find( - (s) => - s.directory === directory || - s.directory.split("/").pop() === directory, - ); + let skill: DiscoverableSkill | undefined; + + if (searchSource === "skillssh") { + const found = accumulatedResults.find((s) => s.directory === directory); + if (found) { + skill = toDiscoverableSkill(found); + } + } else { + skill = discoverableSkills?.find( + (s) => + s.directory === directory || + s.directory.split("/").pop() === directory, + ); + } + if (!skill) { toast.error(t("skills.notFound")); return; @@ -201,7 +284,7 @@ export const SkillsPage = forwardRef( } }; - // 过滤技能列表 + // 过滤技能列表(仓库模式) const filteredSkills = useMemo(() => { // 按仓库筛选 const byRepo = skills.filter((skill) => { @@ -232,35 +315,56 @@ export const SkillsPage = forwardRef( }); }, [skills, searchQuery, filterRepo, filterStatus]); + // 是否有更多 skills.sh 结果 + const hasMoreSkillsSh = + skillsShResult && accumulatedResults.length < skillsShResult.totalCount; + + // 无仓库时默认切换到 skills.sh + const effectiveSource = + searchSource === "repos" && skills.length === 0 && !loading + ? "skillssh" + : searchSource; + return (
{/* 技能网格(可滚动详情区域) */}
- {loading ? ( -
- -
- ) : skills.length === 0 ? ( -
-

- {t("skills.empty")} -

-

- {t("skills.emptyDescription")} -

+ {/* 搜索来源切换 + 搜索框 */} +
+ {/* 来源切换 */} +
+
- ) : ( - <> - {/* 搜索框和筛选器 */} -
+ + {effectiveSource === "repos" ? ( + <> + {/* 仓库模式搜索框 */}
( {t("skills.count", { count: filteredSkills.length })}

)} -
+ + ) : ( + <> + {/* skills.sh 搜索框 */} +
+ + setSkillsShInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSkillsShSearch(); + }} + className="pl-9 pr-3" + /> +
+ + + )} +
- {/* 技能列表或无结果提示 */} - {filteredSkills.length === 0 ? ( + {/* 内容区域 */} + {effectiveSource === "repos" ? ( + /* ===== 仓库模式 ===== */ + loading ? ( +
+ +
+ ) : skills.length === 0 ? ( +
+

+ {t("skills.empty")} +

+

+ {t("skills.emptyDescription")} +

+ +
+ ) : filteredSkills.length === 0 ? ( +
+

+ {t("skills.noResults")} +

+

+ {t("skills.emptyDescription")} +

+
+ ) : ( +
+ {filteredSkills.map((skill) => ( + + ))} +
+ ) + ) : ( + /* ===== skills.sh 模式 ===== */ + <> + {loadingSkillsSh && accumulatedResults.length === 0 ? ( +
+ + + {t("skills.skillssh.loading")} + +
+ ) : skillsShQuery.length < 2 ? ( +
+ +

+ {t("skills.skillssh.searchPlaceholder")} +

+
+ ) : accumulatedResults.length === 0 && !loadingSkillsSh ? (

- {t("skills.noResults")} -

-

- {t("skills.emptyDescription")} + {t("skills.skillssh.noResults", { + query: skillsShQuery, + })}

) : ( -
- {filteredSkills.map((skill) => ( - - ))} -
+ <> +
+ {accumulatedResults.map((skill) => { + const installed = isSkillsShInstalled(skill); + return ( + + ); + })} +
+ + {/* 加载更多 + 底部信息 */} +
+ {hasMoreSkillsSh && ( + + )} +

+ {t("skills.skillssh.poweredBy")} +

+
+ )} )} diff --git a/src/hooks/useDebouncedValue.ts b/src/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..4e9f9a04b --- /dev/null +++ b/src/hooks/useDebouncedValue.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from "react"; + +/** + * 返回一个延迟更新的值,在指定时间内无新变化后才更新。 + * 用于搜索输入等场景,避免每次按键都触发请求。 + */ +export function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index 65d67aa63..ea641f099 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -11,6 +11,7 @@ import { type ImportSkillSelection, type InstalledSkill, type SkillUpdateInfo, + type SkillsShSearchResult, } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; @@ -326,6 +327,26 @@ export function useUpdateSkill() { }); } +// ========== skills.sh 搜索 ========== + +/** + * 搜索 skills.sh 公共目录 + * 使用 300ms staleTime 和 keepPreviousData 实现平滑搜索体验 + */ +export function useSearchSkillsSh( + query: string, + limit: number, + offset: number, +) { + return useQuery({ + queryKey: ["skills", "skillssh", query, limit, offset], + queryFn: () => skillsApi.searchSkillsSh(query, limit, offset), + enabled: query.length >= 2, + staleTime: 5 * 60 * 1000, + placeholderData: keepPreviousData, + }); +} + // ========== 辅助类型 ========== export type { @@ -334,5 +355,6 @@ export type { ImportSkillSelection, SkillBackupEntry, SkillUpdateInfo, + SkillsShSearchResult, AppId, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9b8440e7c..da49b30a6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1650,6 +1650,19 @@ }, "search": "Search Skills", "searchPlaceholder": "Search skill name or repo...", + "searchSource": { + "repos": "Repos", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "Search skills.sh (min 2 chars)...", + "installs": "{{count}} installs", + "loadMore": "Load More", + "loading": "Searching skills.sh...", + "noResults": "No skills found for \"{{query}}\"", + "error": "Failed to search skills.sh", + "poweredBy": "Powered by skills.sh" + }, "filter": { "placeholder": "Filter by status", "all": "All", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f2ccfac41..bc13a3138 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1650,6 +1650,19 @@ }, "search": "スキルを検索", "searchPlaceholder": "スキル名またはリポジトリで検索...", + "searchSource": { + "repos": "リポジトリ", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "skills.sh を検索(2文字以上)...", + "installs": "{{count}} インストール", + "loadMore": "さらに読み込む", + "loading": "skills.sh を検索中...", + "noResults": "\"{{query}}\" に該当するスキルがありません", + "error": "skills.sh の検索に失敗しました", + "poweredBy": "Powered by skills.sh" + }, "filter": { "placeholder": "状態で絞り込み", "all": "すべて", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 6427611ea..431e8354f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1650,6 +1650,19 @@ }, "search": "搜索技能", "searchPlaceholder": "搜索技能名称或仓库名称...", + "searchSource": { + "repos": "仓库", + "skillssh": "skills.sh" + }, + "skillssh": { + "searchPlaceholder": "搜索 skills.sh(至少 2 个字符)...", + "installs": "{{count}} 次安装", + "loadMore": "加载更多", + "loading": "正在搜索 skills.sh...", + "noResults": "未找到 \"{{query}}\" 相关技能", + "error": "搜索 skills.sh 失败", + "poweredBy": "由 skills.sh 提供" + }, "filter": { "placeholder": "状态筛选", "all": "全部", diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 3e92e88b0..456e57b67 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -95,6 +95,25 @@ export interface MigrationResult { errors: string[]; } +/** skills.sh 可发现的技能 */ +export interface SkillsShDiscoverableSkill { + key: string; + name: string; + directory: string; + repoOwner: string; + repoName: string; + repoBranch: string; + installs: number; + readmeUrl?: string; +} + +/** skills.sh 搜索结果 */ +export interface SkillsShSearchResult { + skills: SkillsShDiscoverableSkill[]; + totalCount: number; + query: string; +} + /** 仓库配置 */ export interface SkillRepo { owner: string; @@ -183,6 +202,15 @@ export const skillsApi = { return await invoke("migrate_skill_storage", { target }); }, + /** 搜索 skills.sh 公共目录 */ + async searchSkillsSh( + query: string, + limit: number, + offset: number, + ): Promise { + return await invoke("search_skills_sh", { query, limit, offset }); + }, + // ========== 兼容旧 API ========== /** 获取技能列表(兼容旧 API) */