mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-05 18:39:50 +08:00
* fix(skill): resolve symlinks in ZIP extraction for GitHub repos (#1001) - detect symlink entries via is_symlink() during ZIP extraction and collect target paths - add resolve_symlinks_in_dir() to copy symlink target content into link location - canonicalize base_dir to fix macOS /tmp → /private/tmp path comparison issue - add path traversal safety check to block symlinks pointing outside repo boundary - apply symlink resolution to both download_and_extract and extract_local_zip paths Closes https://github.com/farion1231/cc-switch/issues/1001 * fix(skill): change search to match name and repo instead of description * feat(skill): support importing skills from ~/.agents/skills/ directory - Scan ~/.agents/skills/ in scan_unmanaged() for skill discovery - Parse ~/.agents/.skill-lock.json to extract repo owner/name metadata - Auto-add discovered repos to skill_repos management on import - Add path field to UnmanagedSkill to show discovered location in UI Closes #980 * fix(skill): use metadata name or ZIP filename for root-level SKILL.md imports (#1000) When a ZIP contains SKILL.md at the root without a wrapper directory, the install name was derived from the temp directory name (e.g. .tmpDZKGpF). Now falls back to SKILL.md frontmatter name, then ZIP filename stem. * feat(skill): scan ~/.cc-switch/skills/ for unmanaged skill discovery and import * refactor(skill): unify scan/import logic with lock file skillPath and repo saving - Deduplicate scan_unmanaged and import_from_apps using shared source list - Replace hand-written AppType match with as_str() and AppType::all() - Extract read_skill_name_desc, build_repo_info_from_lock, save_repos_from_lock helpers - Add SkillApps::from_labels for building enable state from source labels - Parse skillPath from .skill-lock.json for correct readme URLs - Save skill repos to skill_repos table in both import and migration paths * fix(skill): resolve symlink and path traversal issues in ZIP skill import * fix(skill): separate source path validation and add canonicalization for symlink safety
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { RefreshCw, Search } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { SkillCard } from "./SkillCard";
|
||
import { RepoManagerPanel } from "./RepoManagerPanel";
|
||
import {
|
||
useDiscoverableSkills,
|
||
useInstalledSkills,
|
||
useInstallSkill,
|
||
useSkillRepos,
|
||
useAddSkillRepo,
|
||
useRemoveSkillRepo,
|
||
} from "@/hooks/useSkills";
|
||
import type { AppId } from "@/lib/api/types";
|
||
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
|
||
import { formatSkillError } from "@/lib/errors/skillErrorParser";
|
||
|
||
interface SkillsPageProps {
|
||
initialApp?: AppId;
|
||
}
|
||
|
||
export interface SkillsPageHandle {
|
||
refresh: () => void;
|
||
openRepoManager: () => void;
|
||
}
|
||
|
||
/**
|
||
* Skills 发现面板
|
||
* 用于浏览和安装来自仓库的 Skills
|
||
*/
|
||
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
|
||
({ initialApp = "claude" }, ref) => {
|
||
const { t } = useTranslation();
|
||
const [repoManagerOpen, setRepoManagerOpen] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [filterRepo, setFilterRepo] = useState<string>("all");
|
||
const [filterStatus, setFilterStatus] = useState<
|
||
"all" | "installed" | "uninstalled"
|
||
>("all");
|
||
|
||
// currentApp 用于安装时的默认应用
|
||
const currentApp = initialApp;
|
||
|
||
// Queries
|
||
const {
|
||
data: discoverableSkills,
|
||
isLoading: loadingDiscoverable,
|
||
isFetching: fetchingDiscoverable,
|
||
refetch: refetchDiscoverable,
|
||
} = useDiscoverableSkills();
|
||
const { data: installedSkills } = useInstalledSkills();
|
||
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
|
||
|
||
// Mutations
|
||
const installMutation = useInstallSkill();
|
||
const addRepoMutation = useAddSkillRepo();
|
||
const removeRepoMutation = useRemoveSkillRepo();
|
||
|
||
// 已安装的 skill key 集合(使用 directory + repoOwner + repoName 组合判断)
|
||
const installedKeys = useMemo(() => {
|
||
if (!installedSkills) return new Set<string>();
|
||
return new Set(
|
||
installedSkills.map((s) => {
|
||
// 构建唯一 key:directory + repoOwner + repoName
|
||
const owner = s.repoOwner?.toLowerCase() || "";
|
||
const name = s.repoName?.toLowerCase() || "";
|
||
return `${s.directory.toLowerCase()}:${owner}:${name}`;
|
||
}),
|
||
);
|
||
}, [installedSkills]);
|
||
|
||
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
|
||
|
||
// 从可发现技能中提取所有仓库选项
|
||
const repoOptions = useMemo(() => {
|
||
if (!discoverableSkills) return [];
|
||
const repoSet = new Set<string>();
|
||
discoverableSkills.forEach((s) => {
|
||
if (s.repoOwner && s.repoName) {
|
||
repoSet.add(`${s.repoOwner}/${s.repoName}`);
|
||
}
|
||
});
|
||
return Array.from(repoSet).sort();
|
||
}, [discoverableSkills]);
|
||
|
||
// 为发现列表补齐 installed 状态,供 SkillCard 使用
|
||
const skills: DiscoverableSkillItem[] = useMemo(() => {
|
||
if (!discoverableSkills) return [];
|
||
return discoverableSkills.map((d) => {
|
||
// 同时处理 / 和 \ 路径分隔符(兼容 Windows 和 Unix)
|
||
const installName =
|
||
d.directory.split(/[/\\]/).pop()?.toLowerCase() ||
|
||
d.directory.toLowerCase();
|
||
// 使用 directory + repoOwner + repoName 组合判断是否已安装
|
||
const key = `${installName}:${d.repoOwner.toLowerCase()}:${d.repoName.toLowerCase()}`;
|
||
return {
|
||
...d,
|
||
installed: installedKeys.has(key),
|
||
};
|
||
});
|
||
}, [discoverableSkills, installedKeys]);
|
||
|
||
const loading = loadingDiscoverable || fetchingDiscoverable;
|
||
|
||
useImperativeHandle(ref, () => ({
|
||
refresh: () => {
|
||
refetchDiscoverable();
|
||
refetchRepos();
|
||
},
|
||
openRepoManager: () => setRepoManagerOpen(true),
|
||
}));
|
||
|
||
const handleInstall = async (directory: string) => {
|
||
// 找到对应的 DiscoverableSkill
|
||
const skill = discoverableSkills?.find(
|
||
(s) =>
|
||
s.directory === directory ||
|
||
s.directory.split("/").pop() === directory,
|
||
);
|
||
if (!skill) {
|
||
toast.error(t("skills.notFound"));
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await installMutation.mutateAsync({
|
||
skill,
|
||
currentApp,
|
||
});
|
||
toast.success(t("skills.installSuccess", { name: skill.name }), {
|
||
closeButton: true,
|
||
});
|
||
} catch (error) {
|
||
const errorMessage =
|
||
error instanceof Error ? error.message : String(error);
|
||
const { title, description } = formatSkillError(
|
||
errorMessage,
|
||
t,
|
||
"skills.installFailed",
|
||
);
|
||
toast.error(title, {
|
||
description,
|
||
duration: 10000,
|
||
});
|
||
console.error("Install skill failed:", error);
|
||
}
|
||
};
|
||
|
||
const handleUninstall = async (_directory: string) => {
|
||
// 在发现面板中,不支持卸载,需要在主面板中操作
|
||
toast.info(t("skills.uninstallInMainPanel"));
|
||
};
|
||
|
||
const handleAddRepo = async (repo: SkillRepo) => {
|
||
try {
|
||
await addRepoMutation.mutateAsync(repo);
|
||
toast.success(
|
||
t("skills.repo.addSuccess", {
|
||
owner: repo.owner,
|
||
name: repo.name,
|
||
}),
|
||
{ closeButton: true },
|
||
);
|
||
} catch (error) {
|
||
toast.error(t("common.error"), {
|
||
description: String(error),
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleRemoveRepo = async (owner: string, name: string) => {
|
||
try {
|
||
await removeRepoMutation.mutateAsync({ owner, name });
|
||
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
|
||
closeButton: true,
|
||
});
|
||
} catch (error) {
|
||
toast.error(t("common.error"), {
|
||
description: String(error),
|
||
});
|
||
}
|
||
};
|
||
|
||
// 过滤技能列表
|
||
const filteredSkills = useMemo(() => {
|
||
// 按仓库筛选
|
||
const byRepo = skills.filter((skill) => {
|
||
if (filterRepo === "all") return true;
|
||
const skillRepo = `${skill.repoOwner}/${skill.repoName}`;
|
||
return skillRepo === filterRepo;
|
||
});
|
||
|
||
// 按安装状态筛选
|
||
const byStatus = byRepo.filter((skill) => {
|
||
if (filterStatus === "installed") return skill.installed;
|
||
if (filterStatus === "uninstalled") return !skill.installed;
|
||
return true;
|
||
});
|
||
|
||
// 按搜索关键词筛选
|
||
if (!searchQuery.trim()) return byStatus;
|
||
|
||
const query = searchQuery.toLowerCase();
|
||
return byStatus.filter((skill) => {
|
||
const name = skill.name?.toLowerCase() || "";
|
||
const repo =
|
||
skill.repoOwner && skill.repoName
|
||
? `${skill.repoOwner}/${skill.repoName}`.toLowerCase()
|
||
: "";
|
||
|
||
return name.includes(query) || repo.includes(query);
|
||
});
|
||
}, [skills, searchQuery, filterRepo, filterStatus]);
|
||
|
||
return (
|
||
<div className="px-6 flex flex-col h-[calc(100vh-8rem)] 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>
|
||
<Button
|
||
variant="link"
|
||
onClick={() => setRepoManagerOpen(true)}
|
||
className="mt-3 text-sm font-normal"
|
||
>
|
||
{t("skills.addRepo")}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* 搜索框和筛选器 */}
|
||
<div className="mb-6 flex flex-col gap-3 md:flex-row md:items-center">
|
||
<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.searchPlaceholder")}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-9 pr-3"
|
||
/>
|
||
</div>
|
||
{/* 仓库筛选 */}
|
||
<div className="w-full md:w-56">
|
||
<Select value={filterRepo} onValueChange={setFilterRepo}>
|
||
<SelectTrigger className="bg-card border shadow-sm text-foreground">
|
||
<SelectValue
|
||
placeholder={t("skills.filter.repo")}
|
||
className="text-left truncate"
|
||
/>
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-card text-foreground shadow-lg max-h-64 min-w-[var(--radix-select-trigger-width)]">
|
||
<SelectItem
|
||
value="all"
|
||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
||
>
|
||
{t("skills.filter.allRepos")}
|
||
</SelectItem>
|
||
{repoOptions.map((repo) => (
|
||
<SelectItem
|
||
key={repo}
|
||
value={repo}
|
||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
||
title={repo}
|
||
>
|
||
<span className="truncate block max-w-[200px]">
|
||
{repo}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* 安装状态筛选 */}
|
||
<div className="w-full md:w-36">
|
||
<Select
|
||
value={filterStatus}
|
||
onValueChange={(val) =>
|
||
setFilterStatus(
|
||
val as "all" | "installed" | "uninstalled",
|
||
)
|
||
}
|
||
>
|
||
<SelectTrigger className="bg-card border shadow-sm text-foreground">
|
||
<SelectValue
|
||
placeholder={t("skills.filter.placeholder")}
|
||
className="text-left"
|
||
/>
|
||
</SelectTrigger>
|
||
<SelectContent className="bg-card text-foreground shadow-lg">
|
||
<SelectItem
|
||
value="all"
|
||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
||
>
|
||
{t("skills.filter.all")}
|
||
</SelectItem>
|
||
<SelectItem
|
||
value="installed"
|
||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
||
>
|
||
{t("skills.filter.installed")}
|
||
</SelectItem>
|
||
<SelectItem
|
||
value="uninstalled"
|
||
className="text-left pr-3 [&[data-state=checked]>span:first-child]:hidden"
|
||
>
|
||
{t("skills.filter.uninstalled")}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{searchQuery && (
|
||
<p className="mt-2 text-sm text-muted-foreground">
|
||
{t("skills.count", { count: filteredSkills.length })}
|
||
</p>
|
||
)}
|
||
</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>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 仓库管理面板 */}
|
||
{repoManagerOpen && (
|
||
<RepoManagerPanel
|
||
repos={repos}
|
||
skills={skills}
|
||
onAdd={handleAddRepo}
|
||
onRemove={handleRemoveRepo}
|
||
onClose={() => setRepoManagerOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
);
|
||
|
||
SkillsPage.displayName = "SkillsPage";
|