From 4f6508f4953d11988e1002bff84e647d7040f21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 3 Jul 2026 19:23:43 +0800 Subject: [PATCH] feat: render skill marketplace detail view --- desktop/src/i18n/locales/en.ts | 49 ++ desktop/src/i18n/locales/jp.ts | 49 ++ desktop/src/i18n/locales/kr.ts | 49 ++ desktop/src/i18n/locales/zh-TW.ts | 49 ++ desktop/src/i18n/locales/zh.ts | 49 ++ desktop/src/pages/SkillCenter.test.tsx | 169 +++++++ desktop/src/pages/SkillCenter.tsx | 528 ++++++++++++++++++++- src/server/__tests__/skill-market.test.ts | 138 ++++++ src/server/api/skill-market.ts | 46 +- src/server/services/skillMarket/service.ts | 71 ++- 10 files changed, 1187 insertions(+), 10 deletions(-) create mode 100644 desktop/src/pages/SkillCenter.test.tsx diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index f2033442..a5e02eb5 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index bee63fbf..39a5defa 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -2006,6 +2006,55 @@ export const jp: Record = { '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': '閉じる', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 64106138..b08bd34f 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -2006,6 +2006,55 @@ export const kr: Record = { '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': '닫기', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 5eba3ba9..3964fd1e 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -2006,6 +2006,55 @@ export const zh: Record = { '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': '關閉', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 55508478..dc401db9 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -2006,6 +2006,55 @@ export const zh: Record = { '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': '关闭', diff --git a/desktop/src/pages/SkillCenter.test.tsx b/desktop/src/pages/SkillCenter.test.tsx new file mode 100644 index 00000000..1588b426 --- /dev/null +++ b/desktop/src/pages/SkillCenter.test.tsx @@ -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: () =>
Installed skills
, +})) + +const mockedSkillMarketApi = vi.mocked(skillMarketApi) + +function makeItem(overrides: Partial = {}): 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 { + 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() + + 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() + 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() + + 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() + 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() + }) +}) diff --git a/desktop/src/pages/SkillCenter.tsx b/desktop/src/pages/SkillCenter.tsx index 39c4bb6d..46e54372 100644 --- a/desktop/src/pages/SkillCenter.tsx +++ b/desktop/src/pages/SkillCenter.tsx @@ -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('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) => { + event?.preventDefault() + void fetchItems() + } + + const handleClearSearch = () => { + setQuery('') + void fetchItems() + } return (
-

- {t('skillCenter.title')} -

+
+

+ {t('skillCenter.title')} +

+ {activeTab === 'marketplace' ? ( + + ) : null} +
{activeTab === 'marketplace' ? ( -
- {t('skillCenter.marketplace.loading')} -
+
+
+ +
+
+
+ + setSource(value as SkillMarketListSource)} + options={SOURCE_OPTIONS.map((value) => ({ + value, + label: t(`skillCenter.marketplace.source.${value}`), + }))} + /> + setSort(value as SkillMarketSort)} + options={SORT_OPTIONS.map((value) => ({ + value, + label: t(`skillCenter.marketplace.sort.${value}`), + }))} + /> + +
+ + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ {isLoading ? ( + + ) : items.length === 0 ? ( + + ) : ( +
+ {items.map((item) => ( + void fetchDetail(item.source, item.slug)} + /> + ))} +
+ )} +
+ + void installSelected()} + onClose={clearDetail} + /> +
+ ) : (
@@ -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 ( + + ) +} + +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 ( + + ) + } + + if (!detail) { + return ( + + ) + } + + const eligibility = detail.installEligibility + const installable = eligibility.status === 'installable' + + return ( + + ) +} + +function SelectField({ + label, + value, + options, + onChange, +}: { + label: string + value: string + options: Array<{ value: string; label: string }> + onChange: (value: string) => void +}) { + return ( + + ) +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +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 ( + + + ) +} + +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 ( + + {status === 'installable' ? + ) +} + +function MetaItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function LoadingState({ label }: { label: string }) { + return ( +
+
+ {label} +
+ ) +} + +function EmptyState({ label }: { label: string }) { + return ( +
+ {label} +
+ ) +} + 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): 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, + 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') +} diff --git a/src/server/__tests__/skill-market.test.ts b/src/server/__tests__/skill-market.test.ts index 8969f201..f962af64 100644 --- a/src/server/__tests__/skill-market.test.ts +++ b/src/server/__tests__/skill-market.test.ts @@ -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') diff --git a/src/server/api/skill-market.ts b/src/server/api/skill-market.ts index dee43e4d..b67d3eea 100644 --- a/src/server/api/skill-market.ts +++ b/src/server/api/skill-market.ts @@ -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 { + 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 { const body = await parseJsonObject(req) if (!body) { diff --git a/src/server/services/skillMarket/service.ts b/src/server/services/skillMarket/service.ts index 8451063b..be880755 100644 --- a/src/server/services/skillMarket/service.ts +++ b/src/server/services/skillMarket/service.ts @@ -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 | (() => Set | Promise>) @@ -24,6 +35,7 @@ export type SkillMarketServiceOptions = { export type SkillMarketService = { listSkills: (params?: SkillMarketListParams) => Promise list: (params?: SkillMarketListParams) => Promise + getDetail: (params: SkillMarketDetailParams) => Promise } 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([ + 'clean', + 'benign', + 'signed', + 'official', +]) type CatalogCacheEntry = { expiresAt: number @@ -179,12 +197,63 @@ export function createSkillMarketService(options: SkillMarketServiceOptions = {} } } + async function getDetail(params: SkillMarketDetailParams): Promise { + 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))