diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index a76fbe79..af4a01a0 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -945,6 +945,58 @@ "cancel": "Cancel" } }, + "skillDirectory": { + "title": "Skills", + "description": "Browse reviewed Skills and workspace-published Skills in one place.", + "verified": "Verified", + "securityNotice": "Import only stores the reviewed Skill package. It is not executed or bound to an Agent until you enable it.", + "filters": { + "category": "Skill categories", + "allCategories": "All categories", + "verified": "Verified only", + "sort": "Sort Skills" + }, + "sort": { + "featured": "Featured", + "name": "Name", + "newest": "Newest" + }, + "actions": { + "import": "Import", + "installed": "Installed", + "back": "Back to Skills", + "viewCapability": "View Capability" + }, + "loadError": { + "title": "Couldn't load the Skill directory", + "description": "Couldn't load the reviewed Skill directory. Retry without leaving the Capability Marketplace." + }, + "empty": { + "title": "No Skills match these filters", + "description": "Try another search term or clear the category and Verified filters." + }, + "detail": { + "loadError": "Failed to load Skill details", + "notFound": "Skill not found", + "version": "Version", + "license": "License", + "files": "Files", + "supportingFiles": "supporting files", + "publisher": "Publisher", + "homepage": "Homepage", + "repository": "Repository", + "sourceCommit": "Source commit", + "openLink": "Open link" + }, + "import": { + "title": "Import {{name}}?", + "description": "Review the Skill package before importing. Importing does not execute scripts or bind the Skill to an Agent.", + "success": "{{name}} was imported as a workspace Skill Capability.", + "failed": "The Skill could not be imported.", + "importing": "Importing...", + "cancel": "Cancel" + } + }, "marketplaceDetail": { "badge": "From market", "notFound": { diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 258b35b9..1097aa1b 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -945,6 +945,58 @@ "cancel": "取消" } }, + "skillDirectory": { + "title": "Skills", + "description": "在同一个页面浏览经过审核的 Skill 和工作区发布的 Skill。", + "verified": "已验证", + "securityNotice": "导入只会保存经过审核的 Skill 文件包。启用前不会执行,也不会绑定到 Agent。", + "filters": { + "category": "Skill 分类", + "allCategories": "全部分类", + "verified": "仅已验证", + "sort": "Skill 排序" + }, + "sort": { + "featured": "精选优先", + "name": "名称", + "newest": "最新" + }, + "actions": { + "import": "导入", + "installed": "已安装", + "back": "返回 Skills", + "viewCapability": "查看 Capability" + }, + "loadError": { + "title": "无法加载 Skill 目录", + "description": "无法加载经过审核的 Skill 目录。你可以直接重试,不会离开能力市场。" + }, + "empty": { + "title": "没有符合筛选条件的 Skill", + "description": "请更换搜索词,或清除分类和已验证筛选。" + }, + "detail": { + "loadError": "无法加载 Skill 详情", + "notFound": "未找到该 Skill", + "version": "版本", + "license": "许可证", + "files": "文件", + "supportingFiles": "个辅助文件", + "publisher": "发布者", + "homepage": "主页", + "repository": "代码仓库", + "sourceCommit": "来源提交", + "openLink": "打开链接" + }, + "import": { + "title": "导入「{{name}}」?", + "description": "导入前请检查 Skill 文件。导入不会执行脚本,也不会绑定到 Agent。", + "success": "已将「{{name}}」导入为工作区 Skill Capability。", + "failed": "Skill 导入失败。", + "importing": "正在导入...", + "cancel": "取消" + } + }, "marketplaceDetail": { "badge": "来自市场", "notFound": { diff --git a/apps/web/src/lib/api-marketplace.ts b/apps/web/src/lib/api-marketplace.ts index e30a9918..e386904e 100644 --- a/apps/web/src/lib/api-marketplace.ts +++ b/apps/web/src/lib/api-marketplace.ts @@ -120,6 +120,45 @@ export interface MCPDirectoryImportResponse { capability_id: string } +export interface SkillDirectoryFile { + path: string + content: string + kind: "markdown" | "script" | "asset" +} + +export interface SkillDirectoryItem { + id: string + name: string + description: string + publisher: { name: string; url: string } + icon_url?: string + homepage_url?: string + repository_url?: string + verified: boolean + categories: string[] + featured_rank: number + version: string + license: string + source_ref?: string + source_path?: string + slug?: string + title?: string + instruction?: string + trigger?: string + files?: SkillDirectoryFile[] + installed: boolean + installed_capability_id: string | null +} + +export interface SkillDirectoryListResponse { + items: SkillDirectoryItem[] +} + +export interface SkillDirectoryImportResponse { + installed: boolean + capability_id: string +} + interface MarketplaceListResponse { capabilities?: MarketplaceCapability[] marketplace?: MarketplaceCapability[] @@ -155,6 +194,8 @@ export const KEY_INSTALL_COUNT = (workspaceID: string, capabilityID: string) => export const KEY_MARKETPLACE_ENABLED_AGENTS = (workspaceID: string, capabilityID: string) => ["admin", "marketplaceEnabledAgents", workspaceID, capabilityID] as const export const KEY_MCP_DIRECTORY = (workspaceID: string) => ["admin", "mcpDirectory", workspaceID] as const export const KEY_MCP_DIRECTORY_DETAIL = (workspaceID: string, catalogID: string) => ["admin", "mcpDirectoryDetail", workspaceID, catalogID] as const +export const KEY_SKILL_DIRECTORY = (workspaceID: string) => ["admin", "skillDirectory", workspaceID] as const +export const KEY_SKILL_DIRECTORY_DETAIL = (workspaceID: string, catalogID: string) => ["admin", "skillDirectoryDetail", workspaceID, catalogID] as const async function listMarketplace(workspaceID: string | null): Promise { if (!workspaceID) return [] @@ -218,6 +259,20 @@ async function importMCPDirectoryItem(workspaceID: string, catalogID: string): P return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory/${encodeURIComponent(catalogID)}/import`, { method: "POST" }) } +async function listSkillDirectory(workspaceID: string | null): Promise { + if (!workspaceID) return { items: [] } + return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/skill-directory`) +} + +async function getSkillDirectoryItem(workspaceID: string | null, catalogID: string | null): Promise { + if (!workspaceID || !catalogID) throw new Error("workspace and skill catalog item are required") + return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/skill-directory/${encodeURIComponent(catalogID)}`) +} + +async function importSkillDirectoryItem(workspaceID: string, catalogID: string): Promise { + return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/skill-directory/${encodeURIComponent(catalogID)}/import`, { method: "POST" }) +} + export function mcpDirectoryOAuthStartURL(workspaceID: string, catalogID: string): string { return `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/mcp-directory/${encodeURIComponent(catalogID)}/oauth/start` } @@ -355,6 +410,50 @@ export function useImportMCPDirectoryItem(workspaceID: string | null) { }) } +export function useSkillDirectory(workspaceID: string | null) { + return useQuery({ + queryKey: KEY_SKILL_DIRECTORY(workspaceID ?? "_none"), + queryFn: () => listSkillDirectory(workspaceID), + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + +export function useSkillDirectoryDetail(workspaceID: string | null, catalogID: string | null) { + return useQuery({ + queryKey: KEY_SKILL_DIRECTORY_DETAIL(workspaceID ?? "_none", catalogID ?? "_none"), + queryFn: () => getSkillDirectoryItem(workspaceID, catalogID), + enabled: !!workspaceID && !!catalogID, + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + +export function useImportSkillDirectoryItem(workspaceID: string | null) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (catalogID: string) => { + if (!workspaceID) throw new Error("workspace is required") + return importSkillDirectoryItem(workspaceID, catalogID) + }, + retry: noUnreachableRetry, + onSuccess: (result, catalogID) => { + if (!workspaceID) return + qc.setQueryData(KEY_SKILL_DIRECTORY(workspaceID), (current) => current ? { + ...current, + items: current.items.map((item) => item.id === catalogID + ? { ...item, installed: true, installed_capability_id: result.capability_id } + : item), + } : current) + qc.setQueryData(KEY_SKILL_DIRECTORY_DETAIL(workspaceID, catalogID), (current) => current + ? { ...current, installed: true, installed_capability_id: result.capability_id } + : current) + void qc.invalidateQueries({ queryKey: KEY_CAPABILITIES_WORKSPACE(workspaceID) }) + void qc.invalidateQueries({ queryKey: ["admin", "capability"] }) + }, + }) +} + function invalidateMarketplace(qc: ReturnType, workspaceID: string | null, capabilityID?: string) { void qc.invalidateQueries({ queryKey: KEY_MARKETPLACE_LIST(workspaceID ?? "_none") }) void qc.invalidateQueries({ queryKey: KEY_TARGET_MARKETPLACE_INSTALLS(workspaceID ?? "_none") }) diff --git a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx index fa871abc..8fcebb1e 100644 --- a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx +++ b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx @@ -12,6 +12,7 @@ import { useWorkspaceId } from "../../../lib/workspace" import { requiredCredentialsLabel } from "../capability-ui" import type { Capability } from "../../../lib/api-types" import { MCPDirectory } from "./mcp-directory/MCPDirectory" +import { SkillDirectory } from "./skill-directory/SkillDirectory" interface MarketplaceTabProps { itemID: string | null @@ -27,6 +28,7 @@ interface MarketplaceTabProps { export function MarketplaceTab(props: MarketplaceTabProps) { const mcpItemID = props.itemID?.startsWith("mcp:") ? props.itemID.slice(4) : null + const skillItemID = props.itemID?.startsWith("skill:") ? props.itemID.slice(6) : null if (mcpItemID !== null) { return } - if (props.itemID || props.typeFilter === "skill") { + if (skillItemID !== null) { + return props.onSelectItem(id ? `skill:${id}` : null)} + onSelectMarketplaceItem={props.onSelectItem} + onInstallMarketplace={props.onInstall} + canManageMarketplace={props.canManage} + onDeleteMarketplace={props.onDelete} + onViewCapability={props.onViewCapability} + /> + } + + if (props.typeFilter === "skill") { + return props.onSelectItem(id ? `skill:${id}` : null)} + onSelectMarketplaceItem={props.onSelectItem} + onInstallMarketplace={props.onInstall} + canManageMarketplace={props.canManage} + onDeleteMarketplace={props.onDelete} + onViewCapability={props.onViewCapability} + /> + } + + if (props.itemID) { return } diff --git a/apps/web/src/pages/admin/capabilities/skill-directory/ImportSkillDirectoryDialog.tsx b/apps/web/src/pages/admin/capabilities/skill-directory/ImportSkillDirectoryDialog.tsx new file mode 100644 index 00000000..1d0c34bc --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/skill-directory/ImportSkillDirectoryDialog.tsx @@ -0,0 +1,78 @@ +import { useTranslation } from "react-i18next" + +import { Button } from "../../../../components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../../../components/ui/dialog" +import { ErrorState } from "../../../../components/ui/error-state" +import { Skeleton } from "../../../../components/ui/skeleton" +import type { SkillDirectoryItem } from "../../../../lib/api-marketplace" + +export function ImportSkillDirectoryDialog({ + open, + item, + loading, + error, + pending, + mutationError, + onRetry, + onOpenChange, + onConfirm, +}: { + open: boolean + item: SkillDirectoryItem | null + loading: boolean + error: unknown + pending: boolean + mutationError: unknown + onRetry: () => void + onOpenChange: (open: boolean) => void + onConfirm: () => void +}) { + const { t } = useTranslation("admin") + return ( + + + + {t("capabilities.skillDirectory.import.title", { name: item?.name ?? "" })} + {t("capabilities.skillDirectory.import.description")} + + {loading ? ( +
+ + +
+ ) : error ? ( + + ) : item ? ( +
+
+ + +
+
+

{t("capabilities.skillDirectory.securityNotice")}

+

{item.files?.length ?? 0} {t("capabilities.skillDirectory.detail.supportingFiles")}

+
+
+ ) : null} + {mutationError ?

{mutationError instanceof Error ? mutationError.message : t("capabilities.skillDirectory.import.failed")}

: null} + + + + +
+
+ ) +} + +function Meta({ label, value }: { label: string; value: string }) { + return

{label}

{value}

+} diff --git a/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectory.tsx b/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectory.tsx new file mode 100644 index 00000000..fdb5f9d2 --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectory.tsx @@ -0,0 +1,208 @@ +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Check, PackageCheck, Sparkles } from "lucide-react" + +import { Button } from "../../../../components/ui/button" +import { EmptyState } from "../../../../components/ui/empty-state" +import { ErrorState } from "../../../../components/ui/error-state" +import { Skeleton } from "../../../../components/ui/skeleton" +import { + marketplaceSourceName, + type MarketplaceCapability, + useImportSkillDirectoryItem, + useMarketplaceList, + useSkillDirectory, + useSkillDirectoryDetail, + type SkillDirectoryItem, +} from "../../../../lib/api-marketplace" +import { useWorkspaceId } from "../../../../lib/workspace" +import { DirectorySkillCard, PublishedSkillCard } from "./SkillDirectoryCard" +import { ImportSkillDirectoryDialog } from "./ImportSkillDirectoryDialog" +import { SkillDirectoryDetail } from "./SkillDirectoryDetail" + +type DirectorySort = "featured" | "name" | "newest" + +interface SkillDirectoryProps { + itemID: string | null + query: string + canImport: boolean + onSelectItem: (id: string | null) => void + onSelectMarketplaceItem: (id: string | null) => void + onInstallMarketplace: (capability: MarketplaceCapability) => void + canManageMarketplace: boolean + onDeleteMarketplace: (capability: MarketplaceCapability) => void + onViewCapability: (capabilityID: string) => void +} + +export function SkillDirectory({ + itemID, + query, + canImport, + onSelectItem, + onSelectMarketplaceItem, + onInstallMarketplace, + canManageMarketplace, + onDeleteMarketplace, + onViewCapability, +}: SkillDirectoryProps) { + const { t } = useTranslation("admin") + const workspaceID = useWorkspaceId() + const directoryQ = useSkillDirectory(workspaceID) + const marketplaceQ = useMarketplaceList(itemID ? null : workspaceID) + const importMut = useImportSkillDirectoryItem(workspaceID) + const [category, setCategory] = useState("") + const [verifiedOnly, setVerifiedOnly] = useState(false) + const [sort, setSort] = useState("featured") + const [confirmID, setConfirmID] = useState(null) + const [success, setSuccess] = useState<{ name: string; capabilityID: string } | null>(null) + + const detailID = confirmID ?? itemID + const detailQ = useSkillDirectoryDetail(workspaceID, detailID) + const items = useMemo(() => directoryQ.data?.items ?? [], [directoryQ.data?.items]) + const categories = useMemo( + () => Array.from(new Set(items.flatMap((item) => item.categories))).sort((left, right) => left.localeCompare(right)), + [items], + ) + const filtered = useMemo(() => filterItems(items, query, category, verifiedOnly, sort), [items, query, category, verifiedOnly, sort]) + const publishedSkills = useMemo(() => { + if (category || verifiedOnly) return [] + const installedIDs = new Set(items.flatMap((item) => item.installed_capability_id ? [item.installed_capability_id] : [])) + const needle = query.trim().toLocaleLowerCase() + return (marketplaceQ.data ?? []) + .filter((item) => { + if (item.type !== "skill" || installedIDs.has(item.id)) return false + if (!needle) return true + return [item.name, item.description ?? "", marketplaceSourceName(item)].join(" ").toLocaleLowerCase().includes(needle) + }) + .sort((left, right) => left.name.localeCompare(right.name)) + }, [category, items, marketplaceQ.data, query, verifiedOnly]) + const cards = useMemo(() => [ + ...filtered.map((item) => ({ kind: "directory" as const, item })), + ...publishedSkills.map((item) => ({ kind: "marketplace" as const, item })), + ], [filtered, publishedSkills]) + const selectedSummary = items.find((item) => item.id === itemID) ?? null + const selected = detailQ.data?.id === itemID ? detailQ.data : selectedSummary + const confirmItem = detailQ.data?.id === confirmID ? detailQ.data : items.find((item) => item.id === confirmID) ?? null + + const requestImport = (id: string) => { + if (!canImport) return + importMut.reset() + setConfirmID(id) + } + const closeImportDialog = () => { + importMut.reset() + setConfirmID(null) + } + const confirmImport = () => { + if (!confirmID || !confirmItem || confirmItem.installed) return + importMut.mutate(confirmID, { + onSuccess: (result) => { + setSuccess({ name: confirmItem.name, capabilityID: result.capability_id }) + closeImportDialog() + }, + }) + } + + const importDialog = ( + void detailQ.refetch()} + onOpenChange={(open) => !open && closeImportDialog()} + onConfirm={confirmImport} + /> + ) + + if (itemID) { + return ( + <> + {success ? : null} + onSelectItem(null)} + onRetry={() => void detailQ.refetch()} + onImport={() => requestImport(itemID)} + onViewCapability={onViewCapability} + /> + {importDialog} + + ) + } + + return ( +
+
+
+
+

{t("capabilities.skillDirectory.title")}

+

{t("capabilities.skillDirectory.description")}

+
+
+
+
+ setCategory("")}>{t("capabilities.skillDirectory.filters.allCategories")} + {categories.map((value) => setCategory(value)}>{value})} +
+ + +
+
+ + {success ? : null} + {directoryQ.error ? void directoryQ.refetch()} /> : null} + {marketplaceQ.error ? void marketplaceQ.refetch()} /> : null} + {cards.length === 0 && (directoryQ.isLoading || marketplaceQ.isLoading) ? ( +
{Array.from({ length: 6 }).map((_, index) => )}
+ ) : cards.length === 0 && !directoryQ.error && !marketplaceQ.error ? ( + + ) : cards.length > 0 ? ( +
+ {cards.map((card) => card.kind === "directory" ? ( + onSelectItem(card.item.id)} onImport={() => requestImport(card.item.id)} onViewCapability={onViewCapability} /> + ) : ( + onSelectMarketplaceItem(card.item.id)} onInstall={() => onInstallMarketplace(card.item)} onDelete={() => onDeleteMarketplace(card.item)} onViewCapability={() => onViewCapability(card.item.id)} /> + ))} +
+ ) : null} + {importDialog} +
+ ) +} + +function filterItems(items: SkillDirectoryItem[], query: string, category: string, verifiedOnly: boolean, sort: DirectorySort) { + const needle = query.trim().toLocaleLowerCase() + const filtered = items.filter((item) => { + if (category && !item.categories.includes(category)) return false + if (verifiedOnly && !item.verified) return false + if (!needle) return true + return [item.name, item.description, item.publisher.name, ...item.categories].join(" ").toLocaleLowerCase().includes(needle) + }) + return filtered.sort((left, right) => { + if (sort === "name") return left.name.localeCompare(right.name) + if (sort === "newest") return right.version.localeCompare(left.version) || left.featured_rank - right.featured_rank + return left.featured_rank - right.featured_rank || left.name.localeCompare(right.name) + }) +} + +function SuccessBanner({ success, onViewCapability }: { success: { name: string; capabilityID: string }; onViewCapability: (capabilityID: string) => void }) { + const { t } = useTranslation("admin") + return

{t("capabilities.skillDirectory.import.success", { name: success.name })}

+} + +function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: string }) { + return +} diff --git a/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectoryCard.tsx new file mode 100644 index 00000000..7770fc2b --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectoryCard.tsx @@ -0,0 +1,108 @@ +import { ArrowRight, Check, Sparkles, X } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import { marketplaceSourceName, type MarketplaceCapability, type SkillDirectoryItem } from "../../../../lib/api-marketplace" + +export function SkillDirectoryIcon({ item, large = false }: { item: Pick; large?: boolean }) { + const size = large ? "h-14 w-14 rounded-xl" : "h-11 w-11 rounded-lg" + return ( + + {item.icon_url ? : } + + ) +} + +export function DirectorySkillCard({ item, canImport, onOpen, onImport, onViewCapability }: { + item: SkillDirectoryItem + canImport: boolean + onOpen: () => void + onImport: () => void + onViewCapability: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + return ( +
+ +
+ {item.installed && item.installed_capability_id ? ( + + ) : ( + + )} +
+
+ ) +} + +export function PublishedSkillCard({ capability, canManage, onOpen, onInstall, onDelete, onViewCapability }: { + capability: MarketplaceCapability + canManage: boolean + onOpen: () => void + onInstall: () => void + onDelete: () => void + onViewCapability: () => void +}) { + const { t } = useTranslation("admin") + const source = marketplaceSourceName(capability) + const count = capability.installed_agent_count ?? capability.enabled_agent_count ?? capability.install_count ?? 0 + return ( +
+ +
+ {capability.self_published ? ( +
+ + {canManage ? : null} +
+ ) : ( + + )} +
+
+ ) +} diff --git a/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectoryDetail.tsx b/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectoryDetail.tsx new file mode 100644 index 00000000..8110ce2d --- /dev/null +++ b/apps/web/src/pages/admin/capabilities/skill-directory/SkillDirectoryDetail.tsx @@ -0,0 +1,112 @@ +import { ArrowLeft, ExternalLink, ShieldCheck, Sparkles } from "lucide-react" +import { useTranslation } from "react-i18next" + +import { Badge } from "../../../../components/ui/badge" +import { Button } from "../../../../components/ui/button" +import { EmptyState } from "../../../../components/ui/empty-state" +import { ErrorState } from "../../../../components/ui/error-state" +import { Skeleton } from "../../../../components/ui/skeleton" +import type { SkillDirectoryItem } from "../../../../lib/api-marketplace" +import { SkillFileTree } from "../SkillFileTree" +import type { CanonicalSkillSpec } from "../types" +import { SkillDirectoryIcon } from "./SkillDirectoryCard" + +export function SkillDirectoryDetail({ + item, + loading, + error, + canImport, + onBack, + onRetry, + onImport, + onViewCapability, +}: { + item: SkillDirectoryItem | null + loading: boolean + error: unknown + canImport: boolean + onBack: () => void + onRetry: () => void + onImport: () => void + onViewCapability: (capabilityID: string) => void +}) { + const { t } = useTranslation("admin") + if (loading && !item) { + return
+ } + if (error) { + return + } + if (!item) { + return {t("capabilities.skillDirectory.actions.back")}} /> + } + + const skill: CanonicalSkillSpec = { + slug: item.slug ?? item.id, + title: item.title ?? item.name, + description: item.description, + instruction: item.instruction ?? "", + trigger: item.trigger, + files: item.files, + } + + return ( +
+ +
+
+ +
+
+

{item.name}

+ {item.verified ? {t("capabilities.skillDirectory.verified")} : null} + {item.installed ? {t("capabilities.skillDirectory.actions.installed")} : null} +
+

{item.publisher.name}

+

{item.description}

+
+
+
+ + + +
+
+
+ +
+
+ {item.installed && item.installed_capability_id ? ( + + ) : ( + + )} +
+
+
+ ) +} + +function Meta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return

{label}

{value}

+} + +function ExternalLinkRow({ label, value, href }: { label: string; value: string; href?: string }) { + let safeHref: string | undefined + try { + if (href) { + const parsed = new URL(href) + if (parsed.protocol === "http:" || parsed.protocol === "https:") safeHref = parsed.toString() + } + } catch { + safeHref = undefined + } + return

{label}

{safeHref ? {value} :

}
+} diff --git a/catalog/skills/README.md b/catalog/skills/README.md new file mode 100644 index 00000000..d3773449 --- /dev/null +++ b/catalog/skills/README.md @@ -0,0 +1,29 @@ +# Built-in Skill Directory + +`catalog.json` is the reviewed, embedded Skill directory shipped with Parsar. +Each item points at a pinned upstream commit and a vendored package under +`items//`. Importing an item uses the embedded files, not a user-supplied +URL or a live Git checkout. + +## Included Sources + +| Skill | Source | License | Pinned commit | +| --- | --- | --- | --- | +| Frontend Design | `https://github.com/anthropics/skills/tree/main/skills/frontend-design` | Apache-2.0 | `b29e7cf65e5cb78a5ac33d582270551bc74a14eb` | +| Webapp Testing | `https://github.com/anthropics/skills/tree/main/skills/webapp-testing` | Apache-2.0 | `b29e7cf65e5cb78a5ac33d582270551bc74a14eb` | +| React Composition Patterns | `https://github.com/vercel-labs/agent-skills/tree/main/skills/composition-patterns` | MIT | `7c180d9044c9ae2b442b567aad4e42a28dd5ed62` | + +The original license files are kept inside the vendored packages where the +upstream source provides them. Vercel's skill declares MIT in its `SKILL.md`. + +## Updating an Item + +1. Review the upstream package and its license. +2. Copy the complete Skill directory, including `SKILL.md`, references, rules, + scripts, examples, and license files. +3. Pin `source_ref` to the exact 40-character commit SHA. +4. Update `version` and `updated_at` in `catalog.json`. +5. Run the Skill catalog tests and `make check`. + +Catalog packages must not contain API keys, tokens, passwords, or executable +install hooks. Import stores the package and does not run its scripts. diff --git a/catalog/skills/catalog.json b/catalog/skills/catalog.json new file mode 100644 index 00000000..f5f73da6 --- /dev/null +++ b/catalog/skills/catalog.json @@ -0,0 +1,66 @@ +{ + "schema_version": 1, + "updated_at": "2026-08-03T00:00:00Z", + "items": [ + { + "id": "frontend-design", + "name": "Frontend Design", + "description": "Create distinctive, production-quality frontend interfaces with deliberate visual direction and strong interaction design.", + "publisher": { + "name": "Anthropic", + "url": "https://github.com/anthropics/skills" + }, + "icon_url": "https://github.com/anthropics.png?size=128", + "homepage_url": "https://github.com/anthropics/skills/tree/main/skills/frontend-design", + "repository_url": "https://github.com/anthropics/skills", + "verified": true, + "categories": ["Design", "Frontend"], + "featured_rank": 1, + "version": "b29e7cf", + "license": "Apache-2.0", + "source_ref": "b29e7cf65e5cb78a5ac33d582270551bc74a14eb", + "source_path": "skills/frontend-design", + "content_path": "items/frontend-design" + }, + { + "id": "webapp-testing", + "name": "Webapp Testing", + "description": "Test local web applications with browser automation, inspect behavior, and diagnose frontend issues with repeatable workflows.", + "publisher": { + "name": "Anthropic", + "url": "https://github.com/anthropics/skills" + }, + "icon_url": "https://github.com/anthropics.png?size=128", + "homepage_url": "https://github.com/anthropics/skills/tree/main/skills/webapp-testing", + "repository_url": "https://github.com/anthropics/skills", + "verified": true, + "categories": ["Developer Tools", "Testing", "Web"], + "featured_rank": 2, + "version": "b29e7cf", + "license": "Apache-2.0", + "source_ref": "b29e7cf65e5cb78a5ac33d582270551bc74a14eb", + "source_path": "skills/webapp-testing", + "content_path": "items/webapp-testing" + }, + { + "id": "composition-patterns", + "name": "React Composition Patterns", + "description": "Design flexible React components with composition, compound components, and scalable state boundaries instead of prop-heavy APIs.", + "publisher": { + "name": "Vercel Engineering", + "url": "https://github.com/vercel-labs/agent-skills" + }, + "icon_url": "https://github.com/vercel.png?size=128", + "homepage_url": "https://github.com/vercel-labs/agent-skills/tree/main/skills/composition-patterns", + "repository_url": "https://github.com/vercel-labs/agent-skills", + "verified": true, + "categories": ["Developer Tools", "React", "Architecture"], + "featured_rank": 3, + "version": "7c180d9", + "license": "MIT", + "source_ref": "7c180d9044c9ae2b442b567aad4e42a28dd5ed62", + "source_path": "skills/composition-patterns", + "content_path": "items/composition-patterns" + } + ] +} diff --git a/catalog/skills/catalog.schema.json b/catalog/skills/catalog.schema.json new file mode 100644 index 00000000..9e1ef7ba --- /dev/null +++ b/catalog/skills/catalog.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/MiniMax-AI-Dev/parsar/catalog/skills/catalog.schema.json", + "title": "Parsar Skill Directory Catalog", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "updated_at", "items"], + "properties": { + "schema_version": { "const": 1 }, + "updated_at": { "type": "string", "format": "date-time" }, + "items": { + "type": "array", + "items": { "$ref": "#/$defs/item" } + } + }, + "$defs": { + "httpsUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "publisher": { + "type": "object", + "additionalProperties": false, + "required": ["name", "url"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "url": { "$ref": "#/$defs/httpsUrl" } + } + }, + "item": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "publisher", + "verified", + "categories", + "featured_rank", + "version", + "license", + "source_ref", + "source_path", + "content_path" + ], + "properties": { + "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]*$" }, + "name": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "publisher": { "$ref": "#/$defs/publisher" }, + "icon_url": { "$ref": "#/$defs/httpsUrl" }, + "homepage_url": { "$ref": "#/$defs/httpsUrl" }, + "repository_url": { "$ref": "#/$defs/httpsUrl" }, + "verified": { "type": "boolean" }, + "categories": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "featured_rank": { "type": "integer", "minimum": 1 }, + "version": { "type": "string", "minLength": 1 }, + "license": { "type": "string", "minLength": 1 }, + "source_ref": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "source_path": { "type": "string", "pattern": "^(?!/)(?!.*\\.\\.)[a-zA-Z0-9._/-]+$" }, + "content_path": { "type": "string", "pattern": "^items/[a-z0-9][a-z0-9._-]*$" } + } + } + } +} diff --git a/catalog/skills/embed.go b/catalog/skills/embed.go new file mode 100644 index 00000000..73b073cf --- /dev/null +++ b/catalog/skills/embed.go @@ -0,0 +1,12 @@ +package skillcatalogdata + +import "embed" + +// FS contains the catalog metadata and the pinned, reviewed Skill packages. +// The server never fetches arbitrary source URLs during an import. +// +//go:embed catalog.json items +var FS embed.FS + +//go:embed catalog.json +var CatalogJSON []byte diff --git a/catalog/skills/items/composition-patterns/AGENTS.md b/catalog/skills/items/composition-patterns/AGENTS.md new file mode 100644 index 00000000..099c96d5 --- /dev/null +++ b/catalog/skills/items/composition-patterns/AGENTS.md @@ -0,0 +1,946 @@ +# React Composition Patterns + +**Version 1.0.0** +Engineering +January 2026 + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring React codebases using composition. Humans +> may also find it useful, but guidance here is optimized for automation +> and consistency by AI-assisted workflows. + +--- + +## Abstract + +Composition patterns for building flexible, maintainable React components. Avoid boolean prop proliferation by using compound components, lifting state, and composing internals. These patterns make codebases easier for both humans and AI agents to work with as they scale. + +--- + +## Table of Contents + +1. [Component Architecture](#1-component-architecture) — **HIGH** + - 1.1 [Avoid Boolean Prop Proliferation](#11-avoid-boolean-prop-proliferation) + - 1.2 [Use Compound Components](#12-use-compound-components) +2. [State Management](#2-state-management) — **MEDIUM** + - 2.1 [Decouple State Management from UI](#21-decouple-state-management-from-ui) + - 2.2 [Define Generic Context Interfaces for Dependency Injection](#22-define-generic-context-interfaces-for-dependency-injection) + - 2.3 [Lift State into Provider Components](#23-lift-state-into-provider-components) +3. [Implementation Patterns](#3-implementation-patterns) — **MEDIUM** + - 3.1 [Create Explicit Component Variants](#31-create-explicit-component-variants) + - 3.2 [Prefer Composing Children Over Render Props](#32-prefer-composing-children-over-render-props) +4. [React 19 APIs](#4-react-19-apis) — **MEDIUM** + - 4.1 [React 19 API Changes](#41-react-19-api-changes) + +--- + +## 1. Component Architecture + +**Impact: HIGH** + +Fundamental patterns for structuring components to avoid prop +proliferation and enable flexible composition. + +### 1.1 Avoid Boolean Prop Proliferation + +**Impact: CRITICAL (prevents unmaintainable component variants)** + +Don't add boolean props like `isThread`, `isEditing`, `isDMThread` to customize + +component behavior. Each boolean doubles possible states and creates + +unmaintainable conditional logic. Use composition instead. + +**Incorrect: boolean props create exponential complexity** + +```tsx +function Composer({ + onSubmit, + isThread, + channelId, + isDMThread, + dmId, + isEditing, + isForwarding, +}: Props) { + return ( +
+
+ + {isDMThread ? ( + + ) : isThread ? ( + + ) : null} + {isEditing ? ( + + ) : isForwarding ? ( + + ) : ( + + )} +