mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: render skill marketplace detail view
This commit is contained in:
parent
2dc7404025
commit
4f6508f495
@ -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',
|
||||
|
||||
@ -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': '閉じる',
|
||||
|
||||
@ -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': '닫기',
|
||||
|
||||
@ -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': '關閉',
|
||||
|
||||
@ -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': '关闭',
|
||||
|
||||
169
desktop/src/pages/SkillCenter.test.tsx
Normal file
169
desktop/src/pages/SkillCenter.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user