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.
This commit is contained in:
Jason
2026-04-05 22:16:10 +08:00
parent 33f5d56afd
commit d51e774b20
11 changed files with 583 additions and 57 deletions
+16 -1
View File
@@ -20,9 +20,15 @@ interface SkillCardProps {
skill: SkillCardSkill;
onInstall: (directory: string) => Promise<void>;
onUninstall: (directory: string) => Promise<void>;
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}
</Badge>
)}
{typeof installs === "number" && (
<Badge
variant="secondary"
className="shrink-0 text-[10px] px-1.5 py-0 h-4"
>
<Download className="h-2.5 w-2.5 mr-0.5" />
{installs.toLocaleString()}
</Badge>
)}
</div>
</div>
{skill.installed && (
+279 -49
View File
@@ -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<SkillsPageHandle, SkillsPageProps>(
({ initialApp = "claude" }, ref) => {
@@ -48,6 +63,15 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
"all" | "installed" | "uninstalled"
>("all");
// skills.sh 搜索状态
const [searchSource, setSearchSource] = useState<SearchSource>("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<SkillsPageHandle, SkillsPageProps>(
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<SkillsPageHandle, SkillsPageProps>(
});
}, [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<SkillsPageHandle, SkillsPageProps>(
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<SkillsPageHandle, SkillsPageProps>(
}
};
// 过滤技能列表
// 过滤技能列表(仓库模式)
const filteredSkills = useMemo(() => {
// 按仓库筛选
const byRepo = skills.filter((skill) => {
@@ -232,35 +315,56 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
});
}, [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 (
<div className="px-6 flex flex-col flex-1 min-h-0 overflow-hidden bg-background/50">
{/* 技能网格(可滚动详情区域) */}
<div className="flex-1 overflow-y-auto overflow-x-hidden animate-fade-in">
<div className="py-4">
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
{/* 搜索来源切换 + 搜索框 */}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
{/* 来源切换 */}
<div className="inline-flex gap-1 rounded-md border border-border-default bg-background p-1 shrink-0">
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
type="button"
size="sm"
variant={effectiveSource === "repos" ? "default" : "ghost"}
className={
effectiveSource === "repos"
? "shadow-sm min-w-[64px]"
: "text-muted-foreground hover:text-foreground hover:bg-muted min-w-[64px]"
}
onClick={() => setSearchSource("repos")}
>
{t("skills.addRepo")}
{t("skills.searchSource.repos")}
</Button>
<Button
type="button"
size="sm"
variant={effectiveSource === "skillssh" ? "default" : "ghost"}
className={
effectiveSource === "skillssh"
? "shadow-sm min-w-[80px]"
: "text-muted-foreground hover:text-foreground hover:bg-muted min-w-[80px]"
}
onClick={() => setSearchSource("skillssh")}
>
skills.sh
</Button>
</div>
) : (
<>
{/* 搜索框和筛选器 */}
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
{effectiveSource === "repos" ? (
<>
{/* 仓库模式搜索框 */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
@@ -345,29 +449,155 @@ export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
{t("skills.count", { count: filteredSkills.length })}
</p>
)}
</div>
</>
) : (
<>
{/* skills.sh 搜索框 */}
<div className="relative flex-1 min-w-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("skills.skillssh.searchPlaceholder")}
value={skillsShInput}
onChange={(e) => setSkillsShInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSkillsShSearch();
}}
className="pl-9 pr-3"
/>
</div>
<Button
size="sm"
onClick={handleSkillsShSearch}
disabled={
skillsShInput.trim().length < 2 || fetchingSkillsSh
}
className="shrink-0"
>
{fetchingSkillsSh ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Search className="h-3.5 w-3.5 mr-1.5" />
)}
{t("skills.search")}
</Button>
</>
)}
</div>
{/* 技能列表或无结果提示 */}
{filteredSkills.length === 0 ? (
{/* 内容区域 */}
{effectiveSource === "repos" ? (
/* ===== 仓库模式 ===== */
loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.empty")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
<Button
variant="link"
onClick={() => setRepoManagerOpen(true)}
className="mt-3 text-sm font-normal"
>
{t("skills.addRepo")}
</Button>
</div>
) : filteredSkills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.noResults")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
)
) : (
/* ===== skills.sh 模式 ===== */
<>
{loadingSkillsSh && accumulatedResults.length === 0 ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-sm text-muted-foreground">
{t("skills.skillssh.loading")}
</span>
</div>
) : skillsShQuery.length < 2 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Search className="h-12 w-12 text-muted-foreground/30 mb-4" />
<p className="text-sm text-muted-foreground">
{t("skills.skillssh.searchPlaceholder")}
</p>
</div>
) : accumulatedResults.length === 0 && !loadingSkillsSh ? (
<div className="flex flex-col items-center justify-center h-48 text-center">
<p className="text-lg font-medium text-foreground">
{t("skills.noResults")}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{t("skills.emptyDescription")}
{t("skills.skillssh.noResults", {
query: skillsShQuery,
})}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSkills.map((skill) => (
<SkillCard
key={skill.key}
skill={skill}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
))}
</div>
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{accumulatedResults.map((skill) => {
const installed = isSkillsShInstalled(skill);
return (
<SkillCard
key={skill.key}
skill={{
...toDiscoverableSkill(skill),
installed,
}}
installs={skill.installs}
onInstall={handleInstall}
onUninstall={handleUninstall}
/>
);
})}
</div>
{/* 加载更多 + 底部信息 */}
<div className="mt-6 flex flex-col items-center gap-2">
{hasMoreSkillsSh && (
<Button
variant="outline"
size="sm"
disabled={fetchingSkillsSh}
onClick={() =>
setSkillsShOffset(
(prev) => prev + SKILLSSH_PAGE_SIZE,
)
}
>
{fetchingSkillsSh ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : null}
{t("skills.skillssh.loadMore")}
</Button>
)}
<p className="text-xs text-muted-foreground">
{t("skills.skillssh.poweredBy")}
</p>
</div>
</>
)}
</>
)}
+16
View File
@@ -0,0 +1,16 @@
import { useState, useEffect } from "react";
/**
* 返回一个延迟更新的值,在指定时间内无新变化后才更新。
* 用于搜索输入等场景,避免每次按键都触发请求。
*/
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
+22
View File
@@ -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,
};
+13
View File
@@ -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",
+13
View File
@@ -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": "すべて",
+13
View File
@@ -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": "全部",
+28
View File
@@ -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<SkillsShSearchResult> {
return await invoke("search_skills_sh", { query, limit, offset });
},
// ========== 兼容旧 API ==========
/** 获取技能列表(兼容旧 API) */