Skip to content
Merged
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
18 changes: 18 additions & 0 deletions apps/web/src/i18n/locales/en-US/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,24 @@
"description": "Backend returned an error."
}
},
"skillsDirectory": {
"installs": "{{count}} installs",
"loadError": {
"title": "Couldn't load the Skills catalog",
"description": "Retry without leaving the Capability Marketplace."
},
"empty": {
"title": "No skills match these filters",
"description": "Try another search term."
},
"install": {
"action": "Install",
"installing": "Installing...",
"installed": "Installed",
"success": "{{name}} was imported as a workspace Skill Capability.",
"failed": "The Skill could not be installed."
}
},
"mcpDirectory": {
"title": "Connectors",
"description": "Browse curated connectors and MCP capabilities published by workspaces in one marketplace.",
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/i18n/locales/zh-CN/admin.json
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,24 @@
"description": "后端返回错误。"
}
},
"skillsDirectory": {
"installs": "{{count}} 次安装",
"loadError": {
"title": "无法加载 Skills 目录",
"description": "可以直接重试,不会离开能力市场。"
},
"empty": {
"title": "没有符合筛选条件的 Skill",
"description": "请更换搜索词。"
},
"install": {
"action": "Install",
"installing": "安装中...",
"installed": "已安装",
"success": "已将 {{name}} 导入为工作区 Skill Capability。",
"failed": "无法安装该 Skill。"
}
},
"mcpDirectory": {
"title": "连接器",
"description": "在同一个能力市场中浏览精选连接器和各工作区发布的 MCP 能力。",
Expand Down
149 changes: 149 additions & 0 deletions apps/web/src/lib/api-skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"

import { apiRequest, noUnreachableRetry } from "./api-client"
import { KEY_CAPABILITIES_WORKSPACE, KEY_CAPABILITY_VERSIONS } from "./api-capabilities"
import type { Capability, CapabilityVersion } from "./api-types"

// Browser-safe equivalent of:
// curl -L https://agent-skill-index.vercel.app/api/skills
// The API endpoint redirects here, but the redirect response itself does not
// include CORS headers, so browser fetch fails before reaching this JSON.
const SKILLS_CATALOG_URL = "https://agent-skill-index.vercel.app/data/latest/skills.json"
const SKILLS_REGISTRY = "skills.sh"
const SAFE_SKILL_REF_PART = /^[A-Za-z0-9._-]+$/

export interface SkillsCatalogItem {
rank?: number
id: string
slug: string
source: string
name: string
installs?: number
sourceType?: string
source_type?: string
installUrl?: string | null
install_url?: string | null
url?: string | null
}

export interface SkillsCatalogResponse {
items: SkillsCatalogItem[]
generatedAt?: string
count?: number
}

export interface InstallSkillResponse {
capability: Capability
capability_version: CapabilityVersion
created_secret_ids: string[]
}

export const KEY_SKILLS_CATALOG = ["admin", "skillsCatalog", "v2"] as const

async function listSkillsCatalog(): Promise<SkillsCatalogResponse> {
const res = await fetch(SKILLS_CATALOG_URL, { headers: { Accept: "application/json" } })
if (!res.ok) throw new Error(`Skills catalog request failed: HTTP ${res.status}`)
const payload = await res.json()
const rawItems: unknown[] = Array.isArray(payload)
? payload
: Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.items)
? payload.items
: []
return {
generatedAt: typeof payload?.generatedAt === "string" ? payload.generatedAt : undefined,
count: typeof payload?.count === "number" ? payload.count : rawItems.length,
items: rawItems.map(normalizeSkillItem).filter(isInstallableCatalogItem),
}
}

function normalizeSkillItem(raw: unknown): SkillsCatalogItem {
const item = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}
const source = stringField(item.source)
const slug = stringField(item.slug)
const id = stringField(item.id) || [source, slug].filter(Boolean).join("/")
const installUrl = nullableStringField(item.installUrl) ?? nullableStringField(item.install_url)
return {
rank: numberField(item.rank),
id,
slug,
source,
name: stringField(item.name) || slug || id,
installs: numberField(item.installs),
sourceType: stringField(item.sourceType) || stringField(item.source_type),
installUrl,
url: nullableStringField(item.url),
}
}

function isInstallableCatalogItem(item: SkillsCatalogItem): boolean {
if (!item.source || !item.slug) return false
const sourceParts = item.source.split("/")
if (sourceParts.length !== 2 || !sourceParts.every(isSafeSkillRefPart)) return false
if (!isSafeSkillRefPart(item.slug)) return false
return true
}

function isSafeSkillRefPart(value: string): boolean {
return value !== "." && value !== ".." && SAFE_SKILL_REF_PART.test(value)
}

function stringField(value: unknown): string {
return typeof value === "string" ? value.trim() : ""
}

function nullableStringField(value: unknown): string | null {
const text = stringField(value)
return text || null
}

function numberField(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined
}

async function installSkill(
workspaceID: string,
skill: SkillsCatalogItem,
): Promise<InstallSkillResponse> {
return apiRequest<InstallSkillResponse>(
`/api/v1/workspaces/${encodeURIComponent(workspaceID)}/skills/install`,
{
method: "POST",
body: {
source: skill.source,
slug: skill.slug,
registry_id: skill.id || `${skill.source}/${skill.slug}`,
registry: SKILLS_REGISTRY,
},
},
)
}

export function useSkillsCatalog() {
return useQuery({
queryKey: KEY_SKILLS_CATALOG,
queryFn: listSkillsCatalog,
retry: noUnreachableRetry,
staleTime: 60_000,
})
}

export function useInstallSkill(workspaceID: string | null) {
const qc = useQueryClient()
return useMutation({
mutationFn: (skill: SkillsCatalogItem) => {
if (!workspaceID) throw new Error("workspace is required")
return installSkill(workspaceID, skill)
},
retry: noUnreachableRetry,
onSuccess: (result) => {
if (!workspaceID) return
void qc.invalidateQueries({ queryKey: KEY_CAPABILITIES_WORKSPACE(workspaceID) })
void qc.invalidateQueries({ queryKey: ["admin", "capability"] })
void qc.invalidateQueries({
queryKey: KEY_CAPABILITY_VERSIONS(workspaceID, result.capability.id),
})
},
})
}
7 changes: 6 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 { SkillsDirectory } from "./SkillsDirectory"

interface MarketplaceTabProps {
itemID: string | null
Expand Down Expand Up @@ -41,7 +42,11 @@ export function MarketplaceTab(props: MarketplaceTabProps) {
/>
}

if (props.itemID || props.typeFilter === "skill") {
if (props.typeFilter === "skill") {
return <SkillsDirectory query={props.query} canImport={props.canImport} onViewCapability={props.onViewCapability} />
}

if (props.itemID) {
return <PublishedMarketplaceTab {...props} />
}

Expand Down
Loading