Files
cc-switch/src/components/skills/SkillsPage.tsx
Dex Miller 508aa6070c Fix/skill zip symlink resolution (#1040)
* 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
2026-02-15 20:57:14 +08:00

383 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => {
// 构建唯一 keydirectory + 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";