feat: render skill marketplace detail view

This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 19:23:43 +08:00
parent 2dc7404025
commit 4f6508f495
10 changed files with 1187 additions and 10 deletions

View File

@ -2004,6 +2004,55 @@ export const en = {
'skillCenter.tab.marketplace': 'Marketplace',
'skillCenter.tab.mine': 'Mine',
'skillCenter.marketplace.loading': 'Loading skill marketplace...',
'skillCenter.marketplace.refresh': 'Refresh',
'skillCenter.marketplace.searchLabel': 'Search',
'skillCenter.marketplace.searchPlaceholder': 'Search name, author, or use case...',
'skillCenter.marketplace.clearSearch': 'Clear search',
'skillCenter.marketplace.runSearch': 'Run search',
'skillCenter.marketplace.sourceLabel': 'Source',
'skillCenter.marketplace.sortLabel': 'Sort',
'skillCenter.marketplace.total': 'Total',
'skillCenter.marketplace.installed': 'Installed',
'skillCenter.marketplace.empty': 'No marketplace skills found',
'skillCenter.marketplace.detailLoading': 'Loading skill details...',
'skillCenter.marketplace.noSelection': 'Select a skill to inspect safety and install status.',
'skillCenter.marketplace.closeDetail': 'Close skill details',
'skillCenter.marketplace.source.auto': 'Auto',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sort.downloads': 'Downloads',
'skillCenter.marketplace.sort.installs': 'Installs',
'skillCenter.marketplace.sort.stars': 'Stars',
'skillCenter.marketplace.sort.updated': 'Updated',
'skillCenter.marketplace.sort.trending': 'Trending',
'skillCenter.marketplace.installedBadge': 'Installed',
'skillCenter.marketplace.trust.clean': 'Clean',
'skillCenter.marketplace.trust.benign': 'Benign',
'skillCenter.marketplace.trust.signed': 'Signed',
'skillCenter.marketplace.trust.official': 'Official',
'skillCenter.marketplace.trust.warning': 'Warning',
'skillCenter.marketplace.trust.unknown': 'Unknown',
'skillCenter.marketplace.trust.blocked': 'Blocked',
'skillCenter.marketplace.requiresApiKey': 'API key',
'skillCenter.marketplace.license': 'License',
'skillCenter.marketplace.files': 'Files',
'skillCenter.marketplace.downloads': 'Downloads',
'skillCenter.marketplace.stars': 'Stars',
'skillCenter.marketplace.riskLabels': 'Risk signals',
'skillCenter.marketplace.risk.allowed-tools': 'Allowed tools',
'skillCenter.marketplace.risk.hooks': 'Hooks',
'skillCenter.marketplace.risk.scripts': 'Scripts',
'skillCenter.marketplace.risk.executables': 'Executables',
'skillCenter.marketplace.risk.external-network': 'Network',
'skillCenter.marketplace.risk.requires-api-key': 'API key',
'skillCenter.marketplace.entryPreview': 'Entry preview',
'skillCenter.marketplace.install': 'Install',
'skillCenter.marketplace.installing': 'Installing...',
'skillCenter.marketplace.installedAction': 'Installed',
'skillCenter.marketplace.conflictAction': 'Conflict',
'skillCenter.marketplace.blockedAction': 'Blocked',
'skillCenter.marketplace.openSource': 'Open source',
'skillCenter.marketplace.openUpstream': 'Open upstream',
// ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close',

View File

@ -2006,6 +2006,55 @@ export const jp: Record<TranslationKey, string> = {
'skillCenter.tab.marketplace': 'マーケット',
'skillCenter.tab.mine': '自分のスキル',
'skillCenter.marketplace.loading': 'スキルマーケットを読み込み中...',
'skillCenter.marketplace.refresh': '更新',
'skillCenter.marketplace.searchLabel': '検索',
'skillCenter.marketplace.searchPlaceholder': '名前、作者、用途で検索...',
'skillCenter.marketplace.clearSearch': '検索をクリア',
'skillCenter.marketplace.runSearch': '検索を実行',
'skillCenter.marketplace.sourceLabel': 'ソース',
'skillCenter.marketplace.sortLabel': '並び替え',
'skillCenter.marketplace.total': '合計',
'skillCenter.marketplace.installed': 'インストール済み',
'skillCenter.marketplace.empty': 'マーケットスキルが見つかりません',
'skillCenter.marketplace.detailLoading': 'スキル詳細を読み込み中...',
'skillCenter.marketplace.noSelection': 'スキルを選択して安全性とインストール状態を確認します。',
'skillCenter.marketplace.closeDetail': 'スキル詳細を閉じる',
'skillCenter.marketplace.source.auto': '自動',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sort.downloads': 'ダウンロード',
'skillCenter.marketplace.sort.installs': 'インストール',
'skillCenter.marketplace.sort.stars': 'スター',
'skillCenter.marketplace.sort.updated': '更新日時',
'skillCenter.marketplace.sort.trending': '人気',
'skillCenter.marketplace.installedBadge': 'インストール済み',
'skillCenter.marketplace.trust.clean': '安全',
'skillCenter.marketplace.trust.benign': '低リスク',
'skillCenter.marketplace.trust.signed': '署名済み',
'skillCenter.marketplace.trust.official': '公式',
'skillCenter.marketplace.trust.warning': '注意',
'skillCenter.marketplace.trust.unknown': '不明',
'skillCenter.marketplace.trust.blocked': 'ブロック',
'skillCenter.marketplace.requiresApiKey': 'API キー',
'skillCenter.marketplace.license': 'ライセンス',
'skillCenter.marketplace.files': 'ファイル',
'skillCenter.marketplace.downloads': 'ダウンロード',
'skillCenter.marketplace.stars': 'スター',
'skillCenter.marketplace.riskLabels': 'リスクシグナル',
'skillCenter.marketplace.risk.allowed-tools': '許可ツール',
'skillCenter.marketplace.risk.hooks': 'Hooks',
'skillCenter.marketplace.risk.scripts': 'スクリプト',
'skillCenter.marketplace.risk.executables': '実行ファイル',
'skillCenter.marketplace.risk.external-network': '外部ネットワーク',
'skillCenter.marketplace.risk.requires-api-key': 'API キー',
'skillCenter.marketplace.entryPreview': 'エントリプレビュー',
'skillCenter.marketplace.install': 'インストール',
'skillCenter.marketplace.installing': 'インストール中...',
'skillCenter.marketplace.installedAction': 'インストール済み',
'skillCenter.marketplace.conflictAction': '競合',
'skillCenter.marketplace.blockedAction': 'インストール不可',
'skillCenter.marketplace.openSource': 'ソースを開く',
'skillCenter.marketplace.openUpstream': '上流を開く',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '閉じる',

View File

@ -2006,6 +2006,55 @@ export const kr: Record<TranslationKey, string> = {
'skillCenter.tab.marketplace': '마켓',
'skillCenter.tab.mine': '내 스킬',
'skillCenter.marketplace.loading': '스킬 마켓을 불러오는 중...',
'skillCenter.marketplace.refresh': '새로고침',
'skillCenter.marketplace.searchLabel': '검색',
'skillCenter.marketplace.searchPlaceholder': '이름, 작성자, 용도로 검색...',
'skillCenter.marketplace.clearSearch': '검색 지우기',
'skillCenter.marketplace.runSearch': '검색 실행',
'skillCenter.marketplace.sourceLabel': '소스',
'skillCenter.marketplace.sortLabel': '정렬',
'skillCenter.marketplace.total': '전체',
'skillCenter.marketplace.installed': '설치됨',
'skillCenter.marketplace.empty': '마켓 스킬을 찾을 수 없습니다',
'skillCenter.marketplace.detailLoading': '스킬 세부 정보를 불러오는 중...',
'skillCenter.marketplace.noSelection': '스킬을 선택해 안전성과 설치 상태를 확인합니다.',
'skillCenter.marketplace.closeDetail': '스킬 세부 정보 닫기',
'skillCenter.marketplace.source.auto': '자동',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sort.downloads': '다운로드',
'skillCenter.marketplace.sort.installs': '설치',
'skillCenter.marketplace.sort.stars': '스타',
'skillCenter.marketplace.sort.updated': '업데이트',
'skillCenter.marketplace.sort.trending': '인기',
'skillCenter.marketplace.installedBadge': '설치됨',
'skillCenter.marketplace.trust.clean': '안전',
'skillCenter.marketplace.trust.benign': '낮은 위험',
'skillCenter.marketplace.trust.signed': '서명됨',
'skillCenter.marketplace.trust.official': '공식',
'skillCenter.marketplace.trust.warning': '주의',
'skillCenter.marketplace.trust.unknown': '알 수 없음',
'skillCenter.marketplace.trust.blocked': '차단됨',
'skillCenter.marketplace.requiresApiKey': 'API 키',
'skillCenter.marketplace.license': '라이선스',
'skillCenter.marketplace.files': '파일',
'skillCenter.marketplace.downloads': '다운로드',
'skillCenter.marketplace.stars': '스타',
'skillCenter.marketplace.riskLabels': '위험 신호',
'skillCenter.marketplace.risk.allowed-tools': '허용 도구',
'skillCenter.marketplace.risk.hooks': 'Hooks',
'skillCenter.marketplace.risk.scripts': '스크립트',
'skillCenter.marketplace.risk.executables': '실행 파일',
'skillCenter.marketplace.risk.external-network': '외부 네트워크',
'skillCenter.marketplace.risk.requires-api-key': 'API 키',
'skillCenter.marketplace.entryPreview': '엔트리 미리보기',
'skillCenter.marketplace.install': '설치',
'skillCenter.marketplace.installing': '설치 중...',
'skillCenter.marketplace.installedAction': '설치됨',
'skillCenter.marketplace.conflictAction': '충돌',
'skillCenter.marketplace.blockedAction': '설치 불가',
'skillCenter.marketplace.openSource': '소스 열기',
'skillCenter.marketplace.openUpstream': '업스트림 열기',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '닫기',

View File

@ -2006,6 +2006,55 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.tab.marketplace': '市場',
'skillCenter.tab.mine': '我的',
'skillCenter.marketplace.loading': '正在載入技能市場...',
'skillCenter.marketplace.refresh': '重新整理',
'skillCenter.marketplace.searchLabel': '搜尋',
'skillCenter.marketplace.searchPlaceholder': '搜尋名稱、作者或用途...',
'skillCenter.marketplace.clearSearch': '清空搜尋',
'skillCenter.marketplace.runSearch': '執行搜尋',
'skillCenter.marketplace.sourceLabel': '來源',
'skillCenter.marketplace.sortLabel': '排序',
'skillCenter.marketplace.total': '總數',
'skillCenter.marketplace.installed': '已安裝',
'skillCenter.marketplace.empty': '沒有找到市場技能',
'skillCenter.marketplace.detailLoading': '正在載入技能詳情...',
'skillCenter.marketplace.noSelection': '選擇一個技能檢視安全與安裝狀態。',
'skillCenter.marketplace.closeDetail': '關閉技能詳情',
'skillCenter.marketplace.source.auto': '自動',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sort.downloads': '下載量',
'skillCenter.marketplace.sort.installs': '安裝量',
'skillCenter.marketplace.sort.stars': '星標',
'skillCenter.marketplace.sort.updated': '最近更新',
'skillCenter.marketplace.sort.trending': '熱門',
'skillCenter.marketplace.installedBadge': '已安裝',
'skillCenter.marketplace.trust.clean': '安全',
'skillCenter.marketplace.trust.benign': '低風險',
'skillCenter.marketplace.trust.signed': '已簽名',
'skillCenter.marketplace.trust.official': '官方',
'skillCenter.marketplace.trust.warning': '需留意',
'skillCenter.marketplace.trust.unknown': '未知',
'skillCenter.marketplace.trust.blocked': '已攔截',
'skillCenter.marketplace.requiresApiKey': '需要 API Key',
'skillCenter.marketplace.license': '授權',
'skillCenter.marketplace.files': '檔案',
'skillCenter.marketplace.downloads': '下載',
'skillCenter.marketplace.stars': '星標',
'skillCenter.marketplace.riskLabels': '風險訊號',
'skillCenter.marketplace.risk.allowed-tools': '允許工具',
'skillCenter.marketplace.risk.hooks': 'Hooks',
'skillCenter.marketplace.risk.scripts': '腳本',
'skillCenter.marketplace.risk.executables': '可執行檔',
'skillCenter.marketplace.risk.external-network': '外部網路',
'skillCenter.marketplace.risk.requires-api-key': 'API Key',
'skillCenter.marketplace.entryPreview': '入口預覽',
'skillCenter.marketplace.install': '安裝',
'skillCenter.marketplace.installing': '正在安裝...',
'skillCenter.marketplace.installedAction': '已安裝',
'skillCenter.marketplace.conflictAction': '存在衝突',
'skillCenter.marketplace.blockedAction': '不可安裝',
'skillCenter.marketplace.openSource': '開啟來源',
'skillCenter.marketplace.openUpstream': '開啟上游',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '關閉',

View File

@ -2006,6 +2006,55 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.tab.marketplace': '市场',
'skillCenter.tab.mine': '我的',
'skillCenter.marketplace.loading': '正在加载技能市场...',
'skillCenter.marketplace.refresh': '刷新',
'skillCenter.marketplace.searchLabel': '搜索',
'skillCenter.marketplace.searchPlaceholder': '搜索名称、作者或用途...',
'skillCenter.marketplace.clearSearch': '清空搜索',
'skillCenter.marketplace.runSearch': '执行搜索',
'skillCenter.marketplace.sourceLabel': '来源',
'skillCenter.marketplace.sortLabel': '排序',
'skillCenter.marketplace.total': '总数',
'skillCenter.marketplace.installed': '已安装',
'skillCenter.marketplace.empty': '没有找到市场技能',
'skillCenter.marketplace.detailLoading': '正在加载技能详情...',
'skillCenter.marketplace.noSelection': '选择一个技能查看安全与安装状态。',
'skillCenter.marketplace.closeDetail': '关闭技能详情',
'skillCenter.marketplace.source.auto': '自动',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sort.downloads': '下载量',
'skillCenter.marketplace.sort.installs': '安装量',
'skillCenter.marketplace.sort.stars': '星标',
'skillCenter.marketplace.sort.updated': '最近更新',
'skillCenter.marketplace.sort.trending': '热门',
'skillCenter.marketplace.installedBadge': '已安装',
'skillCenter.marketplace.trust.clean': '安全',
'skillCenter.marketplace.trust.benign': '低风险',
'skillCenter.marketplace.trust.signed': '已签名',
'skillCenter.marketplace.trust.official': '官方',
'skillCenter.marketplace.trust.warning': '需留意',
'skillCenter.marketplace.trust.unknown': '未知',
'skillCenter.marketplace.trust.blocked': '已拦截',
'skillCenter.marketplace.requiresApiKey': '需要 API Key',
'skillCenter.marketplace.license': '许可',
'skillCenter.marketplace.files': '文件',
'skillCenter.marketplace.downloads': '下载',
'skillCenter.marketplace.stars': '星标',
'skillCenter.marketplace.riskLabels': '风险信号',
'skillCenter.marketplace.risk.allowed-tools': '允许工具',
'skillCenter.marketplace.risk.hooks': 'Hooks',
'skillCenter.marketplace.risk.scripts': '脚本',
'skillCenter.marketplace.risk.executables': '可执行文件',
'skillCenter.marketplace.risk.external-network': '外部网络',
'skillCenter.marketplace.risk.requires-api-key': 'API Key',
'skillCenter.marketplace.entryPreview': '入口预览',
'skillCenter.marketplace.install': '安装',
'skillCenter.marketplace.installing': '正在安装...',
'skillCenter.marketplace.installedAction': '已安装',
'skillCenter.marketplace.conflictAction': '存在冲突',
'skillCenter.marketplace.blockedAction': '不可安装',
'skillCenter.marketplace.openSource': '打开来源',
'skillCenter.marketplace.openUpstream': '打开上游',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',

View File

@ -0,0 +1,169 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { skillMarketApi } from '../api/skillMarket'
import { useSettingsStore } from '../stores/settingsStore'
import { useSkillMarketStore } from '../stores/skillMarketStore'
import type { SkillMarketDetail, SkillMarketItem } from '../types/skillMarket'
import { SkillCenter } from './SkillCenter'
vi.mock('../api/skillMarket', () => ({
skillMarketApi: {
list: vi.fn(),
detail: vi.fn(),
install: vi.fn(),
},
}))
vi.mock('../components/skills/SkillList', () => ({
SkillList: () => <div data-testid="installed-skill-list">Installed skills</div>,
}))
const mockedSkillMarketApi = vi.mocked(skillMarketApi)
function makeItem(overrides: Partial<SkillMarketItem> = {}): SkillMarketItem {
return {
source: 'clawhub',
sourceMode: 'primary',
slug: 'ppt-generator',
displayName: 'PPT Generator',
summary: 'Create presentation decks from structured prompts.',
owner: 'OpenClaw',
canonicalUrl: 'https://clawhub.ai/skills/ppt-generator',
upstreamUrl: 'https://github.com/example/ppt-generator',
license: 'MIT',
version: '1.0.0',
downloads: 42000,
stars: 128,
trustState: 'clean',
installed: false,
...overrides,
}
}
function makeDetail(overrides: Partial<SkillMarketDetail> = {}): SkillMarketDetail {
return {
...makeItem(),
files: [{ path: 'SKILL.md', size: 512 }],
entryPreview: '# PPT Generator',
riskLabels: [],
installEligibility: { status: 'installable' },
...overrides,
}
}
describe('SkillCenter', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useSkillMarketStore.setState({
items: [],
selectedDetail: null,
source: 'auto',
sort: 'downloads',
query: '',
isLoading: false,
isDetailLoading: false,
isInstalling: false,
error: null,
})
mockedSkillMarketApi.list.mockResolvedValue({
items: [makeItem()],
nextCursor: null,
source: 'clawhub',
sourceStatus: 'ok',
})
mockedSkillMarketApi.detail.mockResolvedValue({ detail: makeDetail() })
mockedSkillMarketApi.install.mockResolvedValue({
installed: true,
skillName: 'ppt-generator',
targetPath: '/Users/nanmi/.claude/skills/ppt-generator',
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useSkillMarketStore.setState({
items: [],
selectedDetail: null,
source: 'auto',
sort: 'downloads',
query: '',
isLoading: false,
isDetailLoading: false,
isInstalling: false,
error: null,
})
})
it('loads marketplace cards and opens a detail confirmation panel', async () => {
render(<SkillCenter />)
expect(await screen.findByRole('button', { name: 'PPT Generator' })).toBeInTheDocument()
expect(mockedSkillMarketApi.list).toHaveBeenCalledWith({
source: 'auto',
sort: 'downloads',
q: undefined,
})
fireEvent.click(screen.getByRole('button', { name: 'PPT Generator' }))
await waitFor(() => {
expect(mockedSkillMarketApi.detail).toHaveBeenCalledWith('clawhub', 'ppt-generator')
})
const detail = await screen.findByText('# PPT Generator')
expect(detail).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Install' })).toBeEnabled()
expect(screen.getByText('Open upstream')).toHaveAttribute('href', 'https://github.com/example/ppt-generator')
})
it('submits market searches without auto-searching every keystroke', async () => {
render(<SkillCenter />)
await screen.findByRole('button', { name: 'PPT Generator' })
mockedSkillMarketApi.list.mockClear()
fireEvent.change(screen.getByLabelText('Search'), {
target: { value: ' ppt ' },
})
expect(mockedSkillMarketApi.list).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Run search' }))
await waitFor(() => {
expect(mockedSkillMarketApi.list).toHaveBeenCalledWith({
source: 'auto',
sort: 'downloads',
q: 'ppt',
})
})
})
it('marks blocked marketplace details as not installable', async () => {
mockedSkillMarketApi.detail.mockResolvedValue({
detail: makeDetail({
trustState: 'blocked',
riskLabels: ['scripts'],
installEligibility: { status: 'blocked', reason: 'Risky script detected.' },
}),
})
render(<SkillCenter />)
fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' }))
const detailPanel = await screen.findByText('Risk signals')
expect(detailPanel).toBeInTheDocument()
expect(screen.getByText('Scripts')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Blocked' })).toBeDisabled()
})
it('switches to the installed skills tab', async () => {
render(<SkillCenter />)
await screen.findByRole('button', { name: 'PPT Generator' })
fireEvent.click(screen.getByRole('tab', { name: 'Mine' }))
expect(screen.getByTestId('installed-skill-list')).toBeInTheDocument()
expect(within(screen.getByTestId('skill-mine-tab')).getByText('Installed skills')).toBeInTheDocument()
})
})

View File

@ -1,19 +1,94 @@
import { useState } from 'react'
import { useEffect, useMemo, useState, type FormEvent } from 'react'
import {
AlertTriangle,
Check,
Download,
ExternalLink,
FileText,
Lock,
RefreshCw,
Search,
ShieldCheck,
X,
} from 'lucide-react'
import { SkillList } from '../components/skills/SkillList'
import { useTranslation } from '../i18n'
import { useSkillMarketStore } from '../stores/skillMarketStore'
import type {
SkillMarketDetail,
SkillMarketInstallEligibility,
SkillMarketItem,
SkillMarketListSource,
SkillMarketSort,
SkillMarketTrustState,
} from '../types/skillMarket'
type SkillCenterTab = 'marketplace' | 'mine'
const SOURCE_OPTIONS: SkillMarketListSource[] = ['auto', 'clawhub', 'skillhub']
const SORT_OPTIONS: SkillMarketSort[] = ['downloads', 'installs', 'stars', 'updated', 'trending']
const TRUST_SAFE: SkillMarketTrustState[] = ['clean', 'benign', 'signed', 'official']
export function SkillCenter() {
const t = useTranslation()
const [activeTab, setActiveTab] = useState<SkillCenterTab>('marketplace')
const {
items,
selectedDetail,
source,
sort,
query,
isLoading,
isDetailLoading,
isInstalling,
error,
setSource,
setSort,
setQuery,
fetchItems,
fetchDetail,
installSelected,
clearDetail,
} = useSkillMarketStore()
useEffect(() => {
void fetchItems()
}, [fetchItems, source, sort])
const installedCount = useMemo(
() => items.filter((item) => item.installed).length,
[items],
)
const handleSearch = (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault()
void fetchItems()
}
const handleClearSearch = () => {
setQuery('')
void fetchItems()
}
return (
<div className="flex h-full min-h-0 flex-col bg-[var(--color-surface)]">
<header className="flex flex-none flex-col gap-4 border-b border-[var(--color-border)] px-6 py-4">
<h2 className="text-lg font-semibold tracking-normal text-[var(--color-text-primary)]">
{t('skillCenter.title')}
</h2>
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-semibold tracking-normal text-[var(--color-text-primary)]">
{t('skillCenter.title')}
</h2>
{activeTab === 'marketplace' ? (
<button
type="button"
onClick={() => void fetchItems()}
disabled={isLoading}
className="inline-flex h-9 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw size={15} className={isLoading ? 'animate-spin' : ''} aria-hidden="true" />
{t('skillCenter.marketplace.refresh')}
</button>
) : null}
</div>
<div
role="tablist"
aria-label={t('skillCenter.title')}
@ -42,13 +117,109 @@ export function SkillCenter() {
<main className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{activeTab === 'marketplace' ? (
<div
<section
role="tabpanel"
data-testid="skill-marketplace-tab"
className="text-sm text-[var(--color-text-secondary)]"
className="flex min-h-full min-w-0 flex-col gap-4"
>
{t('skillCenter.marketplace.loading')}
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(280px,1fr)_160px_160px_auto] lg:items-end">
<form onSubmit={handleSearch} className="min-w-0">
<label className="mb-1.5 block text-xs font-medium text-[var(--color-text-secondary)]" htmlFor="skill-market-search">
{t('skillCenter.marketplace.searchLabel')}
</label>
<div className="relative">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
<input
id="skill-market-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('skillCenter.marketplace.searchPlaceholder')}
className="h-10 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-20 text-sm text-[var(--color-text-primary)] outline-none transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
{query ? (
<button
type="button"
aria-label={t('skillCenter.marketplace.clearSearch')}
onClick={handleClearSearch}
className="absolute right-11 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<X size={14} aria-hidden="true" />
</button>
) : null}
<button
type="submit"
aria-label={t('skillCenter.marketplace.runSearch')}
className="absolute right-1.5 top-1/2 flex h-7 w-8 -translate-y-1/2 items-center justify-center rounded-md bg-[var(--color-brand)] text-white transition-colors hover:bg-[var(--color-brand-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<Search size={14} aria-hidden="true" />
</button>
</div>
</form>
<SelectField
label={t('skillCenter.marketplace.sourceLabel')}
value={source}
onChange={(value) => setSource(value as SkillMarketListSource)}
options={SOURCE_OPTIONS.map((value) => ({
value,
label: t(`skillCenter.marketplace.source.${value}`),
}))}
/>
<SelectField
label={t('skillCenter.marketplace.sortLabel')}
value={sort}
onChange={(value) => setSort(value as SkillMarketSort)}
options={SORT_OPTIONS.map((value) => ({
value,
label: t(`skillCenter.marketplace.sort.${value}`),
}))}
/>
<div className="grid grid-cols-2 gap-2 lg:w-[168px]">
<Metric label={t('skillCenter.marketplace.total')} value={String(items.length)} />
<Metric label={t('skillCenter.marketplace.installed')} value={String(installedCount)} />
</div>
</div>
{error ? (
<div className="rounded-md border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-3 py-2 text-sm text-[var(--color-error)]">
{error}
</div>
) : null}
<div className="grid min-h-0 gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
<div className="min-w-0">
{isLoading ? (
<LoadingState label={t('skillCenter.marketplace.loading')} />
) : items.length === 0 ? (
<EmptyState label={t('skillCenter.marketplace.empty')} />
) : (
<div className="grid gap-3 sm:grid-cols-2 2xl:grid-cols-3">
{items.map((item) => (
<SkillMarketCard
key={`${item.source}-${item.slug}`}
item={item}
active={selectedDetail?.source === item.source && selectedDetail.slug === item.slug}
onSelect={() => void fetchDetail(item.source, item.slug)}
/>
))}
</div>
)}
</div>
<SkillMarketDetailPanel
detail={selectedDetail}
loading={isDetailLoading}
installing={isInstalling}
onInstall={() => void installSelected()}
onClose={clearDetail}
/>
</div>
</section>
) : (
<div role="tabpanel" data-testid="skill-mine-tab">
<SkillList />
@ -59,6 +230,319 @@ export function SkillCenter() {
)
}
function SkillMarketCard({
item,
active,
onSelect,
}: {
item: SkillMarketItem
active: boolean
onSelect: () => void
}) {
const t = useTranslation()
const stats = formatStats(item, t)
return (
<button
type="button"
aria-label={item.displayName}
onClick={onSelect}
className={[
'group flex min-h-[168px] flex-col justify-between rounded-lg border bg-[var(--color-surface)] p-4 text-left transition-colors',
'hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
active ? 'border-[var(--color-border-focus)] bg-[var(--color-surface-selected)]' : 'border-[var(--color-border)]',
].join(' ')}
>
<div className="min-w-0">
<div className="mb-2 flex items-start justify-between gap-2">
<h3 className="min-w-0 truncate text-sm font-semibold text-[var(--color-text-primary)]">
{item.displayName}
</h3>
<TrustBadge state={item.trustState} />
</div>
<p className="line-clamp-3 text-sm leading-5 text-[var(--color-text-secondary)]">
{item.summaryZh || item.summary}
</p>
</div>
<div className="mt-4 flex min-w-0 items-end justify-between gap-3">
<div className="min-w-0 text-xs text-[var(--color-text-tertiary)]">
<div className="truncate">
{item.owner ? `@${item.owner}` : t(`skillCenter.marketplace.source.${item.source}`)}
</div>
{item.license ? <div className="truncate">{item.license}</div> : null}
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
{item.installed ? (
<span className="inline-flex items-center gap-1 rounded-md bg-[var(--color-success-container)] px-1.5 py-1 text-[var(--color-success)]">
<Check size={12} aria-hidden="true" />
{t('skillCenter.marketplace.installedBadge')}
</span>
) : null}
{stats ? <span className="truncate">{stats}</span> : null}
</div>
</div>
</button>
)
}
function SkillMarketDetailPanel({
detail,
loading,
installing,
onInstall,
onClose,
}: {
detail: SkillMarketDetail | null
loading: boolean
installing: boolean
onInstall: () => void
onClose: () => void
}) {
const t = useTranslation()
if (loading && !detail) {
return (
<aside className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<LoadingState label={t('skillCenter.marketplace.detailLoading')} />
</aside>
)
}
if (!detail) {
return (
<aside className="flex min-h-[320px] items-center justify-center rounded-lg border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 text-center text-sm text-[var(--color-text-tertiary)]">
{t('skillCenter.marketplace.noSelection')}
</aside>
)
}
const eligibility = detail.installEligibility
const installable = eligibility.status === 'installable'
return (
<aside className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-start justify-between gap-3 border-b border-[var(--color-border)] px-4 py-3">
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-[var(--color-text-primary)]">
{detail.displayName}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span>{t(`skillCenter.marketplace.source.${detail.source}`)}</span>
{detail.owner ? <span>@{detail.owner}</span> : null}
{detail.version ? <span>{detail.version}</span> : null}
</div>
</div>
<button
type="button"
aria-label={t('skillCenter.marketplace.closeDetail')}
onClick={onClose}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<X size={16} aria-hidden="true" />
</button>
</div>
<div className="flex flex-col gap-4 p-4">
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{detail.summaryZh || detail.summary}
</p>
<div className="flex flex-wrap gap-2">
<TrustBadge state={detail.trustState} />
<EligibilityBadge eligibility={eligibility} />
{detail.requiresApiKey ? (
<span className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border)] px-2 py-1 text-xs text-[var(--color-text-secondary)]">
<Lock size={12} aria-hidden="true" />
{t('skillCenter.marketplace.requiresApiKey')}
</span>
) : null}
</div>
{detail.trustSummary ? (
<div className="rounded-md bg-[var(--color-surface-container-low)] px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
{detail.trustSummary}
</div>
) : null}
<dl className="grid grid-cols-2 gap-2 text-xs">
<MetaItem label={t('skillCenter.marketplace.license')} value={detail.license || '-'} />
<MetaItem label={t('skillCenter.marketplace.files')} value={String(detail.files.length)} />
<MetaItem label={t('skillCenter.marketplace.downloads')} value={formatNumber(detail.downloads)} />
<MetaItem label={t('skillCenter.marketplace.stars')} value={formatNumber(detail.stars)} />
</dl>
{detail.riskLabels.length > 0 ? (
<div>
<div className="mb-2 text-xs font-medium text-[var(--color-text-secondary)]">
{t('skillCenter.marketplace.riskLabels')}
</div>
<div className="flex flex-wrap gap-1.5">
{detail.riskLabels.map((label) => (
<span
key={label}
className="rounded-md bg-[var(--color-warning-container)] px-2 py-1 text-xs text-[var(--color-warning)]"
>
{t(`skillCenter.marketplace.risk.${label}`)}
</span>
))}
</div>
</div>
) : null}
{detail.entryPreview ? (
<div>
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-secondary)]">
<FileText size={13} aria-hidden="true" />
{t('skillCenter.marketplace.entryPreview')}
</div>
<pre className="max-h-40 overflow-auto rounded-md bg-[var(--color-surface-container-low)] p-3 text-xs leading-5 text-[var(--color-text-secondary)]">
{detail.entryPreview}
</pre>
</div>
) : null}
<div className="flex flex-col gap-2 border-t border-[var(--color-border)] pt-4">
<button
type="button"
onClick={onInstall}
disabled={!installable || installing}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[var(--color-brand)] px-4 text-sm font-semibold text-white transition-colors hover:bg-[var(--color-brand-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)]"
>
<Download size={15} aria-hidden="true" />
{installing ? t('skillCenter.marketplace.installing') : installLabel(t, eligibility)}
</button>
<div className="flex flex-wrap gap-3 text-xs">
<a
href={detail.canonicalUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline"
>
{t('skillCenter.marketplace.openSource')}
<ExternalLink size={12} aria-hidden="true" />
</a>
{detail.upstreamUrl ? (
<a
href={detail.upstreamUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[var(--color-brand)] hover:underline"
>
{t('skillCenter.marketplace.openUpstream')}
<ExternalLink size={12} aria-hidden="true" />
</a>
) : null}
</div>
</div>
</div>
</aside>
)
}
function SelectField({
label,
value,
options,
onChange,
}: {
label: string
value: string
options: Array<{ value: string; label: string }>
onChange: (value: string) => void
}) {
return (
<label className="min-w-0">
<span className="mb-1.5 block text-xs font-medium text-[var(--color-text-secondary)]">
{label}
</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="h-10 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<div className="text-[11px] text-[var(--color-text-tertiary)]">{label}</div>
<div className="mt-0.5 text-sm font-semibold text-[var(--color-text-primary)]">{value}</div>
</div>
)
}
function TrustBadge({ state }: { state: SkillMarketTrustState }) {
const t = useTranslation()
const safe = TRUST_SAFE.includes(state)
const blocked = state === 'blocked' || state === 'unknown'
const className = safe
? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
: blocked
? 'bg-[var(--color-error-container)] text-[var(--color-error)]'
: 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
const Icon = safe ? ShieldCheck : blocked ? AlertTriangle : AlertTriangle
return (
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${className}`}>
<Icon size={12} aria-hidden="true" />
{t(`skillCenter.marketplace.trust.${state}`)}
</span>
)
}
function EligibilityBadge({ eligibility }: { eligibility: SkillMarketInstallEligibility }) {
const t = useTranslation()
const status = eligibility.status
const className = status === 'installable'
? 'bg-[var(--color-success-container)] text-[var(--color-success)]'
: status === 'installed'
? 'bg-[var(--color-info-container)] text-[var(--color-info)]'
: 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
return (
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs ${className}`}>
{status === 'installable' ? <Download size={12} aria-hidden="true" /> : <Lock size={12} aria-hidden="true" />}
{installLabel(t, eligibility)}
</span>
)
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-[var(--color-surface-container-low)] px-3 py-2">
<dt className="text-[11px] text-[var(--color-text-tertiary)]">{label}</dt>
<dd className="mt-0.5 truncate text-[var(--color-text-primary)]">{value}</dd>
</div>
)
}
function LoadingState({ label }: { label: string }) {
return (
<div className="flex min-h-[220px] items-center justify-center gap-2 text-sm text-[var(--color-text-secondary)]">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
{label}
</div>
)
}
function EmptyState({ label }: { label: string }) {
return (
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 text-sm text-[var(--color-text-tertiary)]">
{label}
</div>
)
}
function tabClass(active: boolean) {
return [
'min-w-[6rem] rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
@ -68,3 +552,31 @@ function tabClass(active: boolean) {
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]',
].join(' ')
}
function formatStats(item: SkillMarketItem, t: ReturnType<typeof useTranslation>): string {
if (typeof item.downloads === 'number') {
return `${formatNumber(item.downloads)} ${t('skillCenter.marketplace.downloads')}`
}
if (typeof item.installs === 'number') {
return `${formatNumber(item.installs)} ${t('skillCenter.marketplace.sort.installs')}`
}
if (typeof item.stars === 'number') {
return `${formatNumber(item.stars)} ${t('skillCenter.marketplace.stars')}`
}
return ''
}
function formatNumber(value: number | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '-'
return new Intl.NumberFormat().format(value)
}
function installLabel(
t: ReturnType<typeof useTranslation>,
eligibility: SkillMarketInstallEligibility,
): string {
if (eligibility.status === 'installable') return t('skillCenter.marketplace.install')
if (eligibility.status === 'installed') return t('skillCenter.marketplace.installedAction')
if (eligibility.status === 'conflict') return t('skillCenter.marketplace.conflictAction')
return t('skillCenter.marketplace.blockedAction')
}

View File

@ -670,6 +670,57 @@ describe('skill market service source selection', () => {
expect(fetchCalls).toHaveLength(0)
})
it('builds detail from the explicit ClawHub list and marks installed eligibility', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE)
},
installedSkillNames: new Set(['skill-vetter']),
})
const detail = await service.getDetail({ source: 'clawhub', slug: 'skill-vetter' })
expect(fetchCalls).toHaveLength(1)
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
expect(fetchCalls[0]).toContain('query=skill-vetter')
expect(fetchCalls[0]).toContain('limit=100')
expect(detail).toMatchObject({
source: 'clawhub',
slug: 'skill-vetter',
installed: true,
files: [],
riskLabels: [],
installEligibility: {
status: 'installed',
installedSkillName: 'skill-vetter',
},
})
})
it('blocks detail installs when list trust metadata is not installable', async () => {
const service = createSkillMarketService({
fetchImpl: async () => Response.json(SKILLHUB_TOP_SKILLS_RESPONSE),
installedSkillNames: new Set(),
})
const detail = await service.getDetail({ source: 'skillhub', slug: 'skill-vetter' })
expect(detail).toMatchObject({
source: 'skillhub',
slug: 'skill-vetter',
trustState: 'unknown',
installed: false,
files: [],
riskLabels: [],
installEligibility: {
status: 'blocked',
reason: expect.stringContaining('missing or inconclusive'),
},
})
})
})
describe('skill market risk analysis', () => {
@ -800,6 +851,92 @@ describe('skill market API', () => {
await expect(res.json()).resolves.toMatchObject({ error: 'invalid_json' })
})
it('returns detail route responses from the service', async () => {
let capturedParams: unknown
setSkillMarketServiceFactoryForTests(() => ({
list: async () => {
throw new Error('list should not be called by detail route')
},
listSkills: async () => {
throw new Error('listSkills should not be called by detail route')
},
getDetail: async (params) => {
capturedParams = params
return {
source: 'clawhub',
sourceMode: 'primary',
slug: 'skill-vetter',
displayName: 'Skill Vetter',
summary: 'Reviews skill packages before install.',
canonicalUrl: 'https://clawhub.ai/skill-vetter',
trustState: 'clean',
installed: false,
files: [],
riskLabels: [],
installEligibility: { status: 'installable' },
}
},
}))
const url = new URL('/api/skill-market/clawhub/%73kill-vetter', 'http://localhost:3456')
const req = new Request(url, { method: 'GET' })
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'clawhub', '%73kill-vetter'])
expect(res.status).toBe(200)
expect(capturedParams).toEqual({
source: 'clawhub',
slug: 'skill-vetter',
})
await expect(res.json()).resolves.toMatchObject({
detail: {
source: 'clawhub',
slug: 'skill-vetter',
files: [],
riskLabels: [],
installEligibility: { status: 'installable' },
},
})
})
it('rejects unsupported detail sources', async () => {
const url = new URL('/api/skill-market/auto/skill-vetter', 'http://localhost:3456')
const req = new Request(url, { method: 'GET' })
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'auto', 'skill-vetter'])
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: 'unsupported_source' })
})
it('returns 404 for detail requests with missing slugs', async () => {
const url = new URL('/api/skill-market/clawhub', 'http://localhost:3456')
const req = new Request(url, { method: 'GET' })
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'clawhub'])
expect(res.status).toBe(404)
await expect(res.json()).resolves.toMatchObject({ error: 'not_found' })
})
it('returns 404 when detail lookup misses the requested slug', async () => {
setSkillMarketServiceFactoryForTests(() => ({
list: async () => {
throw new Error('list should not be called by detail route')
},
listSkills: async () => {
throw new Error('listSkills should not be called by detail route')
},
getDetail: async () => null,
}))
const url = new URL('/api/skill-market/skillhub/not-found', 'http://localhost:3456')
const req = new Request(url, { method: 'GET' })
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'skillhub', 'not-found'])
expect(res.status).toBe(404)
await expect(res.json()).resolves.toMatchObject({ error: 'not_found' })
})
it('routes skill market requests through the API router without network access', async () => {
mock.module('@whiskeysockets/baileys', () => ({
DisconnectReason: { loggedOut: 401 },
@ -872,6 +1009,7 @@ describe('skill market API', () => {
sourceStatus: 'ok',
}
},
getDetail: async () => null,
}
})
const url = new URL('/api/skill-market?source=skillhub&sort=updated&q=vetter&cursor=abc&limit=12', 'http://localhost:3456')

View File

@ -4,11 +4,13 @@ import {
type SkillMarketService,
type SkillMarketServiceOptions,
} from '../services/skillMarket/service.js'
import type { SkillMarketSource } from '../services/skillMarket/types.js'
import { collectUserSkillNames } from './skills.js'
type SkillMarketServiceFactory = (options: SkillMarketServiceOptions) => SkillMarketService
const SUPPORTED_SOURCES = new Set(['auto', 'clawhub', 'skillhub'])
const SUPPORTED_DETAIL_SOURCES = new Set(['clawhub', 'skillhub'])
const SUPPORTED_SORTS = new Set(['downloads', 'installs', 'stars', 'updated', 'trending'])
const MAX_LIMIT = 100
@ -50,12 +52,41 @@ export async function handleSkillMarketApi(
}
if (action !== undefined) {
return jsonError('not_found', `Unknown skill market endpoint: ${action}`, 404)
return handleDetail(req, segments, action)
}
return jsonError('method_not_allowed', 'Method not allowed for skill market.', 405)
}
async function handleDetail(req: Request, segments: string[], sourceSegment: string): Promise<Response> {
if (!SUPPORTED_DETAIL_SOURCES.has(sourceSegment)) {
return jsonError('unsupported_source', `Unsupported skill market source: ${sourceSegment}`, 400)
}
if (req.method !== 'GET') {
return jsonError('method_not_allowed', 'Method not allowed for skill market detail.', 405)
}
const slug = decodeSkillSlug(segments[3])
if (!slug || segments.length !== 4) {
return jsonError('not_found', 'Skill market skill not found.', 404)
}
const service = skillMarketServiceFactory({
installedSkillNames: collectUserSkillNames,
})
const detail = await service.getDetail({
source: sourceSegment as SkillMarketSource,
slug,
})
if (!detail) {
return jsonError('not_found', 'Skill market skill not found.', 404)
}
return Response.json({ detail })
}
function parseListParams(url: URL): SkillMarketListParams | Response {
const params: SkillMarketListParams = {}
const source = url.searchParams.get('source')
@ -97,6 +128,19 @@ function parseListParams(url: URL): SkillMarketListParams | Response {
return params
}
function decodeSkillSlug(segment: string | undefined): string | null {
if (segment === undefined) {
return null
}
try {
const decoded = decodeURIComponent(segment).trim()
return decoded || null
} catch {
return null
}
}
async function handleInstallSkeleton(req: Request): Promise<Response> {
const body = await parseJsonObject(req)
if (!body) {

View File

@ -1,6 +1,12 @@
import { normalizeClawHubList } from './clawhubAdapter.js'
import { normalizeSkillHubList } from './skillhubAdapter.js'
import type { SkillMarketItem, SkillMarketListResult } from './types.js'
import type {
SkillMarketDetail,
SkillMarketItem,
SkillMarketListResult,
SkillMarketSource,
SkillMarketTrustState,
} from './types.js'
export type SkillMarketListSource = 'auto' | 'clawhub' | 'skillhub'
@ -12,6 +18,11 @@ export type SkillMarketListParams = {
sort?: 'downloads' | 'installs' | 'stars' | 'updated' | 'trending'
}
export type SkillMarketDetailParams = {
source: SkillMarketSource
slug: string
}
type FetchImpl = typeof fetch
type InstalledSkillNamesProvider = Set<string> | (() => Set<string> | Promise<Set<string>>)
@ -24,6 +35,7 @@ export type SkillMarketServiceOptions = {
export type SkillMarketService = {
listSkills: (params?: SkillMarketListParams) => Promise<SkillMarketListResult>
list: (params?: SkillMarketListParams) => Promise<SkillMarketListResult>
getDetail: (params: SkillMarketDetailParams) => Promise<SkillMarketDetail | null>
}
const CLAWHUB_SKILLS_URL = 'https://clawhub.ai/api/v1/skills'
@ -32,6 +44,12 @@ const DEFAULT_LIMIT = 24
const MAX_LIMIT = 100
const CATALOG_CACHE_TTL_MS = 5 * 60 * 1_000
const FAILURE_CACHE_TTL_MS = 60 * 1_000
const DETAIL_INSTALLABLE_TRUST_STATES = new Set<SkillMarketTrustState>([
'clean',
'benign',
'signed',
'official',
])
type CatalogCacheEntry = {
expiresAt: number
@ -179,12 +197,63 @@ export function createSkillMarketService(options: SkillMarketServiceOptions = {}
}
}
async function getDetail(params: SkillMarketDetailParams): Promise<SkillMarketDetail | null> {
if (params.source !== 'clawhub' && params.source !== 'skillhub') {
throw new Error(`Unsupported skill market source: ${params.source}`)
}
const slug = params.slug.trim()
if (!slug) {
return null
}
const list = await listSkills({
source: params.source,
query: slug,
limit: MAX_LIMIT,
})
const item = list.items.find((candidate) => candidate.source === params.source && candidate.slug === slug)
if (!item) {
return null
}
return detailFromListItem(item)
}
return {
listSkills,
list: listSkills,
getDetail,
}
}
function detailFromListItem(item: SkillMarketItem): SkillMarketDetail {
return {
...item,
files: [],
riskLabels: [],
installEligibility: installEligibilityFromListItem(item),
}
}
function installEligibilityFromListItem(item: SkillMarketItem): SkillMarketDetail['installEligibility'] {
if (item.installed) {
return {
status: 'installed',
installedSkillName: item.slug,
}
}
if (DETAIL_INSTALLABLE_TRUST_STATES.has(item.trustState)) {
return { status: 'installable' }
}
if (item.trustState === 'warning') {
return { status: 'blocked', reason: 'Skill market trust metadata contains warnings.' }
}
if (item.trustState === 'unknown') {
return { status: 'blocked', reason: 'Skill market trust metadata is missing or inconclusive.' }
}
return { status: 'blocked', reason: 'Skill market trust metadata blocked this skill.' }
}
function clawHubUrlFor(params: SkillMarketListParams): URL {
const url = new URL(CLAWHUB_SKILLS_URL)
url.searchParams.set('sort', clawHubSort(params.sort))