Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/web/src/i18n/locales/en-US/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/i18n/locales/zh-CN/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
99 changes: 99 additions & 0 deletions apps/web/src/lib/api-marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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<MarketplaceCapability[]> {
if (!workspaceID) return []
Expand Down Expand Up @@ -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<SkillDirectoryListResponse> {
if (!workspaceID) return { items: [] }
return apiRequest(`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/skill-directory`)
}

async function getSkillDirectoryItem(workspaceID: string | null, catalogID: string | null): Promise<SkillDirectoryItem> {
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<SkillDirectoryImportResponse> {
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`
}
Expand Down Expand Up @@ -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<SkillDirectoryListResponse>(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<SkillDirectoryItem>(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<typeof useQueryClient>, workspaceID: string | null, capabilityID?: string) {
void qc.invalidateQueries({ queryKey: KEY_MARKETPLACE_LIST(workspaceID ?? "_none") })
void qc.invalidateQueries({ queryKey: KEY_TARGET_MARKETPLACE_INSTALLS(workspaceID ?? "_none") })
Expand Down
32 changes: 31 additions & 1 deletion apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <MCPDirectory
itemID={mcpItemID}
Expand All @@ -41,7 +43,35 @@ export function MarketplaceTab(props: MarketplaceTabProps) {
/>
}

if (props.itemID || props.typeFilter === "skill") {
if (skillItemID !== null) {
return <SkillDirectory
itemID={skillItemID}
query={props.query}
canImport={props.canImport}
onSelectItem={(id) => 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 <SkillDirectory
itemID={null}
query={props.query}
canImport={props.canImport}
onSelectItem={(id) => 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 <PublishedMarketplaceTab {...props} />
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("capabilities.skillDirectory.import.title", { name: item?.name ?? "" })}</DialogTitle>
<DialogDescription>{t("capabilities.skillDirectory.import.description")}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : error ? (
<ErrorState title={t("capabilities.skillDirectory.detail.loadError")} description={error instanceof Error ? error.message : ""} onRetry={onRetry} />
) : item ? (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-2">
<Meta label={t("capabilities.skillDirectory.detail.version")} value={item.version} />
<Meta label={t("capabilities.skillDirectory.detail.license")} value={item.license} />
</div>
<div className="rounded-md border border-line bg-surface-muted/25 p-3">
<p className="text-xs leading-5 text-fg-muted">{t("capabilities.skillDirectory.securityNotice")}</p>
<p className="mt-2 text-xs text-fg-subtle">{item.files?.length ?? 0} {t("capabilities.skillDirectory.detail.supportingFiles")}</p>
</div>
</div>
) : null}
{mutationError ? <p className="text-sm text-destructive">{mutationError instanceof Error ? mutationError.message : t("capabilities.skillDirectory.import.failed")}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>{t("capabilities.skillDirectory.import.cancel")}</Button>
<Button onClick={onConfirm} disabled={!item || loading || !!error || pending || item.installed}>
{pending ? t("capabilities.skillDirectory.import.importing") : t("capabilities.skillDirectory.actions.import")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

function Meta({ label, value }: { label: string; value: string }) {
return <div className="rounded-md border border-line p-2.5"><p className="text-xs text-fg-subtle">{label}</p><p className="mt-1 text-sm text-fg">{value}</p></div>
}
Loading