From 536b4584d3d75616b0b3b28ea0605e11c830eb6c 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: Wed, 8 Jul 2026 20:43:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E6=96=B0=E5=A2=9E=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E5=B8=82=E5=9C=BA=E5=8A=9F=E8=83=BD=EF=BC=8C=E8=81=9A?= =?UTF-8?q?=E5=90=88=20ClawHub=20=E4=B8=8E=20SkillHub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server 层新增 market 服务:聚合两个上游、跨来源去重、TTL 缓存与降级、原子安装/卸载 - 桌面端新增市场页面(搜索/筛选/分页/详情/文件预览/安装确认),挂载为侧边栏顶层 Tab - 本地已装技能详情页改造为与市场详情共用同一套展示组件,并支持卸载 - 5 语言 i18n 补齐,新增 server/desktop 测试覆盖 --- desktop/src/__tests__/agentsSettings.test.tsx | 8 +- desktop/src/__tests__/skillsSettings.test.tsx | 6 +- desktop/src/api/market.ts | 64 +++ .../src/components/layout/ContentRouter.tsx | 3 + desktop/src/components/layout/Sidebar.tsx | 26 +- desktop/src/components/market/FilePreview.tsx | 191 +++++++ desktop/src/components/market/FilterBar.tsx | 99 ++++ .../market/InstallConfirmDialog.test.tsx | 90 +++ .../market/InstallConfirmDialog.tsx | 114 ++++ .../components/market/InstallStateBadge.tsx | 39 ++ desktop/src/components/market/MarketHome.tsx | 160 ++++++ .../components/market/MarketSkillDetail.tsx | 201 +++++++ .../src/components/market/SecurityBadge.tsx | 37 ++ .../src/components/market/SkillCard.test.tsx | 84 +++ desktop/src/components/market/SkillCard.tsx | 139 +++++ .../market/SkillDetailView.test.tsx | 118 ++++ .../src/components/market/SkillDetailView.tsx | 202 +++++++ .../src/components/market/SourceStatusBar.tsx | 53 ++ desktop/src/components/skills/SkillDetail.tsx | 537 ++++++------------ desktop/src/i18n/locales/en.ts | 92 +++ desktop/src/i18n/locales/jp.ts | 92 +++ desktop/src/i18n/locales/kr.ts | 92 +++ desktop/src/i18n/locales/zh-TW.ts | 92 +++ desktop/src/i18n/locales/zh.ts | 92 +++ desktop/src/pages/Market.tsx | 116 ++++ desktop/src/stores/marketStore.test.ts | 244 ++++++++ desktop/src/stores/marketStore.ts | 313 ++++++++++ desktop/src/stores/tabStore.ts | 7 +- desktop/src/types/market.ts | 90 +++ desktop/src/types/skill.ts | 11 + .../fixtures/market/clawhub-detail.json | 1 + .../fixtures/market/clawhub-list.json | 1 + .../fixtures/market/clawhub-search.json | 1 + .../market/clawhub-version-detail.json | 1 + .../fixtures/market/skillhub-detail.json | 1 + .../fixtures/market/skillhub-files.json | 1 + .../fixtures/market/skillhub-list.json | 1 + .../fixtures/market/skillhub-search.json | 1 + src/server/__tests__/market-api.test.ts | 183 ++++++ src/server/__tests__/market-install.test.ts | 211 +++++++ src/server/__tests__/market-providers.test.ts | 287 ++++++++++ src/server/__tests__/market-service.test.ts | 325 +++++++++++ src/server/api/market.ts | 144 +++++ src/server/api/skills.ts | 14 +- src/server/router.ts | 4 + src/server/services/market/cache.ts | 103 ++++ src/server/services/market/clawhubProvider.ts | 308 ++++++++++ src/server/services/market/installService.ts | 247 ++++++++ src/server/services/market/marketService.ts | 362 ++++++++++++ src/server/services/market/providerFetch.ts | 121 ++++ .../services/market/skillhubProvider.ts | 267 +++++++++ src/server/services/market/types.ts | 200 +++++++ 52 files changed, 5816 insertions(+), 380 deletions(-) create mode 100644 desktop/src/api/market.ts create mode 100644 desktop/src/components/market/FilePreview.tsx create mode 100644 desktop/src/components/market/FilterBar.tsx create mode 100644 desktop/src/components/market/InstallConfirmDialog.test.tsx create mode 100644 desktop/src/components/market/InstallConfirmDialog.tsx create mode 100644 desktop/src/components/market/InstallStateBadge.tsx create mode 100644 desktop/src/components/market/MarketHome.tsx create mode 100644 desktop/src/components/market/MarketSkillDetail.tsx create mode 100644 desktop/src/components/market/SecurityBadge.tsx create mode 100644 desktop/src/components/market/SkillCard.test.tsx create mode 100644 desktop/src/components/market/SkillCard.tsx create mode 100644 desktop/src/components/market/SkillDetailView.test.tsx create mode 100644 desktop/src/components/market/SkillDetailView.tsx create mode 100644 desktop/src/components/market/SourceStatusBar.tsx create mode 100644 desktop/src/pages/Market.tsx create mode 100644 desktop/src/stores/marketStore.test.ts create mode 100644 desktop/src/stores/marketStore.ts create mode 100644 desktop/src/types/market.ts create mode 100644 src/server/__tests__/fixtures/market/clawhub-detail.json create mode 100644 src/server/__tests__/fixtures/market/clawhub-list.json create mode 100644 src/server/__tests__/fixtures/market/clawhub-search.json create mode 100644 src/server/__tests__/fixtures/market/clawhub-version-detail.json create mode 100644 src/server/__tests__/fixtures/market/skillhub-detail.json create mode 100644 src/server/__tests__/fixtures/market/skillhub-files.json create mode 100644 src/server/__tests__/fixtures/market/skillhub-list.json create mode 100644 src/server/__tests__/fixtures/market/skillhub-search.json create mode 100644 src/server/__tests__/market-api.test.ts create mode 100644 src/server/__tests__/market-install.test.ts create mode 100644 src/server/__tests__/market-providers.test.ts create mode 100644 src/server/__tests__/market-service.test.ts create mode 100644 src/server/api/market.ts create mode 100644 src/server/services/market/cache.ts create mode 100644 src/server/services/market/clawhubProvider.ts create mode 100644 src/server/services/market/installService.ts create mode 100644 src/server/services/market/marketService.ts create mode 100644 src/server/services/market/providerFetch.ts create mode 100644 src/server/services/market/skillhubProvider.ts create mode 100644 src/server/services/market/types.ts diff --git a/desktop/src/__tests__/agentsSettings.test.tsx b/desktop/src/__tests__/agentsSettings.test.tsx index 4831d974..b61d63b1 100644 --- a/desktop/src/__tests__/agentsSettings.test.tsx +++ b/desktop/src/__tests__/agentsSettings.test.tsx @@ -365,7 +365,6 @@ describe('Settings > Skills tab', () => { render() switchToSkillsTab() - expect(screen.getByText('Skill metadata')).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument() const rendererRoot = screen.getByRole('heading', { name: 'Heading' }).closest('div[class*="prose"]') @@ -375,7 +374,7 @@ describe('Settings > Skills tab', () => { expect(screen.getByText('Helpful quote')).toBeInTheDocument() }) - it('keeps code files rendered in CodeViewer instead of markdown prose', () => { + it('keeps code files rendered in CodeViewer instead of markdown prose', async () => { useSkillStore.setState({ selectedSkill: MOCK_SKILL_DETAIL, clearSelection: () => useSkillStore.setState({ selectedSkill: null }), @@ -384,9 +383,10 @@ describe('Settings > Skills tab', () => { render() switchToSkillsTab() - fireEvent.click(screen.getAllByText('helper.ts')[0]!) + fireEvent.click(screen.getByTestId('skill-detail-tab-files')) + fireEvent.click(await screen.findByTestId('market-file-item-helper.ts')) - expect(screen.getByTestId('code-viewer')).toHaveTextContent('export const helper = true') + expect(await screen.findByTestId('code-viewer')).toHaveTextContent('export const helper = true') expect(screen.queryByRole('heading', { name: 'Heading' })).not.toBeInTheDocument() }) }) diff --git a/desktop/src/__tests__/skillsSettings.test.tsx b/desktop/src/__tests__/skillsSettings.test.tsx index 0398840b..d1aa1eba 100644 --- a/desktop/src/__tests__/skillsSettings.test.tsx +++ b/desktop/src/__tests__/skillsSettings.test.tsx @@ -253,10 +253,10 @@ describe('Settings > Skills tab', () => { render() switchToSkillsTab() - expect(screen.getByText('Skill metadata')).toBeInTheDocument() - expect(screen.getByText('/slash')).toBeInTheDocument() - expect(screen.getByText('Frontmatter description')).toBeInTheDocument() + expect(screen.getByText('Alpha Skill')).toBeInTheDocument() + expect(screen.getByText('First skill description')).toBeInTheDocument() expect(screen.getByText('Read, Edit')).toBeInTheDocument() + expect(screen.getByText('sonnet')).toBeInTheDocument() expect(screen.getByText('Hello')).toBeInTheDocument() expect(screen.queryByText(/^---$/)).not.toBeInTheDocument() }) diff --git a/desktop/src/api/market.ts b/desktop/src/api/market.ts new file mode 100644 index 00000000..0ee47703 --- /dev/null +++ b/desktop/src/api/market.ts @@ -0,0 +1,64 @@ +import { api } from './client' +import type { + MarketFileContent, + MarketInstalledFilter, + MarketListResponse, + MarketSecurityFilter, + MarketSource, + MarketSourceFilter, + NormalizedSkill, + NormalizedSkillDetail, + SourceStatusInfo, +} from '../types/market' + +export type MarketListParams = { + q?: string + source?: MarketSourceFilter + security?: MarketSecurityFilter + installed?: MarketInstalledFilter + cursor?: string + limit?: number +} + +export const marketApi = { + list: (params: MarketListParams = {}) => { + const search = new URLSearchParams() + if (params.q) search.set('q', params.q) + if (params.source && params.source !== 'all') search.set('source', params.source) + if (params.security && params.security !== 'all') search.set('security', params.security) + if (params.installed && params.installed !== 'all') search.set('installed', params.installed) + if (params.cursor) search.set('cursor', params.cursor) + if (params.limit) search.set('limit', String(params.limit)) + const query = search.toString() + return api.get(`/api/market/skills${query ? `?${query}` : ''}`, { timeout: 30_000 }) + }, + + detail: (source: MarketSource, slug: string) => + api.get<{ skill: NormalizedSkillDetail; sourceStatus: SourceStatusInfo }>( + `/api/market/skills/${source}/${encodeURIComponent(slug)}`, + { timeout: 30_000 }, + ), + + fileContent: (source: MarketSource, slug: string, path: string) => + api.get<{ file: MarketFileContent }>( + `/api/market/skills/${source}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`, + { timeout: 30_000 }, + ), + + install: (id: string) => + api.post<{ ok: boolean; installedPath: string; skill: NormalizedSkill }>( + '/api/market/install', + { id }, + { timeout: 120_000 }, + ), + + uninstall: (id: string) => + api.post<{ ok: boolean; removedPath: string; skill: NormalizedSkill | null }>( + '/api/market/uninstall', + { id }, + { timeout: 30_000 }, + ), + + status: () => + api.get<{ sources: Record }>('/api/market/status'), +} diff --git a/desktop/src/components/layout/ContentRouter.tsx b/desktop/src/components/layout/ContentRouter.tsx index 2e4845f0..1f42fc1b 100644 --- a/desktop/src/components/layout/ContentRouter.tsx +++ b/desktop/src/components/layout/ContentRouter.tsx @@ -3,6 +3,7 @@ import { useTabStore } from '../../stores/tabStore' import { EmptySession } from '../../pages/EmptySession' import { ActiveSession } from '../../pages/ActiveSession' import { ScheduledTasks } from '../../pages/ScheduledTasks' +import { Market } from '../../pages/Market' import { Settings } from '../../pages/Settings' import { TerminalSettings } from '../../pages/TerminalSettings' import { TraceList } from '../../pages/TraceList' @@ -28,6 +29,8 @@ export function ContentRouter() { page = } else if (activeTabType === 'scheduled') { page = + } else if (activeTabType === 'market') { + page = } else if (activeTabType === 'trace') { const traceSessionId = tabs.find((t) => t.sessionId === activeTabId)?.traceSessionId page = traceSessionId ? : diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 9a692dd5..c70420ed 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -6,7 +6,7 @@ import { useTranslation, type TranslationKey } from '../../i18n' import { ConfirmDialog } from '../shared/ConfirmDialog' import { GlobalSearchModal } from '../search/GlobalSearchModal' import type { SessionListItem } from '../../types/session' -import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID } from '../../stores/tabStore' +import { useTabStore, SETTINGS_TAB_ID, SCHEDULED_TAB_ID, MARKET_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useOpenTargetStore } from '../../stores/openTargetStore' import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences' @@ -687,6 +687,19 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) { {t('sidebar.scheduled')} )} + { + useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.market'), 'market') + closeMobileDrawer() + }} + icon={} + > + {t('sidebar.market')} + {expanded ? ( @@ -1945,6 +1958,17 @@ function ClockIcon() { ) } +function StorefrontIcon() { + return ( + + + + + + + ) +} + function SearchIcon() { return ( diff --git a/desktop/src/components/market/FilePreview.tsx b/desktop/src/components/market/FilePreview.tsx new file mode 100644 index 00000000..468daa69 --- /dev/null +++ b/desktop/src/components/market/FilePreview.tsx @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from '../../i18n' +import { CodeViewer } from '../chat/CodeViewer' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' + +export type PreviewFile = { + path: string + size: number + language: string + tooBig?: boolean +} + +export type PreviewFileContent = { + path: string + content: string + language: string + size: number + truncated: boolean +} + +const LANG_ICONS: Record = { + markdown: 'description', + python: 'code', + javascript: 'javascript', + typescript: 'code', + bash: 'terminal', + json: 'data_object', + yaml: 'data_object', + text: 'notes', +} + +function formatSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${bytes} B` +} + +type LoadState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'error'; message: string } + | { kind: 'loaded'; file: PreviewFileContent } + +/** + * Two-pane file preview: file list on the left, rendered content on the + * right. Content is fetched lazily via `loadFile` and cached per path for + * the lifetime of the component. Serves both market (async fetch) and + * locally installed skills (loadFile resolves from memory). + */ +export function FilePreview({ + files, + loadFile, + initialPath, +}: { + files: PreviewFile[] + loadFile: (path: string) => Promise + initialPath?: string +}) { + const t = useTranslation() + const defaultPath = initialPath ?? files.find((f) => f.path === 'SKILL.md')?.path ?? files[0]?.path ?? null + const [activePath, setActivePath] = useState(defaultPath) + const [state, setState] = useState({ kind: 'idle' }) + const cacheRef = useRef(new Map()) + const requestSeq = useRef(0) + + const open = useCallback( + async (path: string) => { + setActivePath(path) + const cached = cacheRef.current.get(path) + if (cached) { + setState({ kind: 'loaded', file: cached }) + return + } + const seq = ++requestSeq.current + setState({ kind: 'loading' }) + try { + const file = await loadFile(path) + cacheRef.current.set(path, file) + if (requestSeq.current !== seq) return + setState({ kind: 'loaded', file }) + } catch (err) { + if (requestSeq.current !== seq) return + setState({ kind: 'error', message: err instanceof Error ? err.message : String(err) }) + } + }, + [loadFile], + ) + + useEffect(() => { + if (defaultPath) void open(defaultPath) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + if (files.length === 0) { + return ( +
+ folder_off +

{t('market.file.noFiles')}

+
+ ) + } + + const activeFile = files.find((f) => f.path === activePath) + + return ( +
+
+ {files.map((file) => { + const active = file.path === activePath + return ( + + ) + })} +
+ +
+ {activeFile && ( +
+ {activeFile.path} + {activeFile.language} + {formatSize(activeFile.size)} + {state.kind === 'loaded' && state.file.truncated && ( + + content_cut + {t('market.file.truncated')} + + )} +
+ )} + +
+ {state.kind === 'loading' && ( +
+
+
+ )} + {state.kind === 'error' && ( +
+ error +

{t('market.file.loadError')}

+

{state.message}

+ +
+ )} + {state.kind === 'idle' && ( +

{t('market.file.empty')}

+ )} + {state.kind === 'loaded' && + (state.file.language === 'markdown' ? ( + + ) : ( + + ))} +
+
+
+ ) +} diff --git a/desktop/src/components/market/FilterBar.tsx b/desktop/src/components/market/FilterBar.tsx new file mode 100644 index 00000000..d58c1158 --- /dev/null +++ b/desktop/src/components/market/FilterBar.tsx @@ -0,0 +1,99 @@ +import { useTranslation } from '../../i18n' +import { Dropdown } from '../shared/Dropdown' +import { useMarketStore, type MarketFilters } from '../../stores/marketStore' +import type { + MarketInstalledFilter, + MarketSecurityFilter, + MarketSourceFilter, +} from '../../types/market' + +function FilterTrigger({ label, value, active }: { label: string; value: string; active: boolean }) { + return ( + + {label} + {value} + + expand_more + + + ) +} + +export function FilterBar({ className = '' }: { className?: string }) { + const t = useTranslation() + const filters = useMarketStore((s) => s.filters) + const setFilter = useMarketStore((s) => s.setFilter) + + const sourceItems: Array<{ value: MarketSourceFilter; label: string }> = [ + { value: 'all', label: t('market.source.all') }, + { value: 'clawhub', label: t('market.source.clawhub') }, + { value: 'skillhub', label: t('market.source.skillhub') }, + ] + const securityItems: Array<{ value: MarketSecurityFilter; label: string }> = [ + { value: 'all', label: t('market.security.all') }, + { value: 'verified', label: t('market.security.verified') }, + { value: 'benign', label: t('market.security.benign') }, + { value: 'unknown', label: t('market.security.unknown') }, + { value: 'flagged', label: t('market.security.flagged') }, + ] + const installedItems: Array<{ value: MarketInstalledFilter; label: string }> = [ + { value: 'all', label: t('market.installedFilter.all') }, + { value: 'installed', label: t('market.installedFilter.installed') }, + { value: 'installable', label: t('market.installedFilter.installable') }, + ] + + const labelFor = ( + items: Array<{ value: MarketFilters[K]; label: string }>, + value: MarketFilters[K], + ) => items.find((i) => i.value === value)?.label ?? String(value) + + return ( +
+ setFilter('source', value)} + width={220} + trigger={ + + } + /> + setFilter('security', value)} + width={220} + trigger={ + + } + /> + setFilter('installed', value)} + width={220} + trigger={ + + } + /> +
+ ) +} diff --git a/desktop/src/components/market/InstallConfirmDialog.test.tsx b/desktop/src/components/market/InstallConfirmDialog.test.tsx new file mode 100644 index 00000000..4a718412 --- /dev/null +++ b/desktop/src/components/market/InstallConfirmDialog.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { InstallConfirmDialog } from './InstallConfirmDialog' +import { useSettingsStore } from '../../stores/settingsStore' +import type { NormalizedSkill } from '../../types/market' + +function makeSkill(overrides: Partial = {}): NormalizedSkill { + return { + id: 'skillhub:demo', + source: 'skillhub', + slug: 'demo', + name: '示例技能', + summary: 'demo', + author: { handle: 'alice' }, + stats: { downloads: 1 }, + tags: [], + version: '2.0.0', + securityStatus: 'benign', + installState: 'installable', + ...overrides, + } +} + +beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) +}) + +describe('InstallConfirmDialog', () => { + it('shows name, source, version, security and install location', () => { + render( + , + ) + + expect(screen.getByTestId('market-install-confirm')).toHaveTextContent('示例技能') + expect(screen.getAllByText('SkillHub').length).toBeGreaterThan(0) + expect(screen.getByText('v2.0.0')).toBeInTheDocument() + expect(screen.getByTestId('security-badge-benign')).toBeInTheDocument() + expect(screen.getByText('~/.claude/skills/demo/')).toBeInTheDocument() + expect(screen.getByText(/new sessions/)).toBeInTheDocument() + }) + + it('warns strongly for flagged skills', () => { + render( + , + ) + + expect(screen.getByText(/flagged this skill as potentially risky/)).toBeInTheDocument() + }) + + it('warns for unaudited skills', () => { + render( + , + ) + + expect(screen.getByText(/has not been security-audited/)).toBeInTheDocument() + }) + + it('confirms and cancels', () => { + const onConfirm = vi.fn() + const onClose = vi.fn() + render() + + fireEvent.click(screen.getByTestId('market-install-confirm-button')) + expect(onConfirm).toHaveBeenCalled() + + fireEvent.click(screen.getByText('Cancel')) + expect(onClose).toHaveBeenCalled() + }) + + it('disables both buttons while installing', () => { + render() + + expect(screen.getByTestId('market-install-confirm-button')).toBeDisabled() + expect(screen.getByText('Cancel').closest('button')).toBeDisabled() + }) +}) diff --git a/desktop/src/components/market/InstallConfirmDialog.tsx b/desktop/src/components/market/InstallConfirmDialog.tsx new file mode 100644 index 00000000..c04d7efd --- /dev/null +++ b/desktop/src/components/market/InstallConfirmDialog.tsx @@ -0,0 +1,114 @@ +import { useTranslation } from '../../i18n' +import type { NormalizedSkill } from '../../types/market' +import { Modal } from '../shared/Modal' +import { SecurityBadge } from './SecurityBadge' + +const RISK_KEYS = { + verified: 'market.installConfirm.riskVerified', + benign: 'market.installConfirm.riskBenign', + unknown: 'market.installConfirm.riskUnknown', + flagged: 'market.installConfirm.riskFlagged', +} as const + +export function InstallConfirmDialog({ + skill, + open, + installing, + onConfirm, + onClose, +}: { + skill: NormalizedSkill | null + open: boolean + installing: boolean + onConfirm: () => void + onClose: () => void +}) { + const t = useTranslation() + if (!skill) return null + + const risky = skill.securityStatus === 'flagged' || skill.securityStatus === 'unknown' + + return ( + {} : onClose} title={t('market.installConfirm.title')} width={480}> +
+

+ {t('market.installConfirm.message', { name: skill.name, source: t(`market.source.${skill.source}`) })} +

+ +
+
+ {t('market.filter.source')} + {t(`market.source.${skill.source}`)} +
+ {skill.version && ( +
+ {t('market.detail.version')} + v{skill.version} +
+ )} +
+ {t('market.filter.security')} + +
+
+ {t('market.installConfirm.location')} + + ~/.claude/skills/{skill.slug}/ + +
+
+ +
+ + {skill.securityStatus === 'flagged' ? 'gpp_maybe' : risky ? 'shield_question' : 'gpp_good'} + + {t(RISK_KEYS[skill.securityStatus])} +
+ +

{t('market.installConfirm.effectNote')}

+ +
+ + +
+
+
+ ) +} diff --git a/desktop/src/components/market/InstallStateBadge.tsx b/desktop/src/components/market/InstallStateBadge.tsx new file mode 100644 index 00000000..625be572 --- /dev/null +++ b/desktop/src/components/market/InstallStateBadge.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from '../../i18n' +import type { InstallState } from '../../types/market' + +const STYLES: Record = { + installed: { + icon: 'check_circle', + className: 'bg-[var(--color-success-container)] text-[var(--color-success)]', + }, + installable: { + icon: 'download', + className: 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]', + }, + 'not-installable': { + icon: 'block', + className: 'bg-[var(--color-error-container)] text-[var(--color-error)]', + }, +} + +const LABEL_KEYS: Record = { + installed: 'market.install.state.installed', + installable: 'market.install.state.installable', + 'not-installable': 'market.install.state.notInstallable', +} + +export function InstallStateBadge({ state, className = '' }: { state: InstallState; className?: string }) { + const t = useTranslation() + const style = STYLES[state] + return ( + + + {style.icon} + + {t(LABEL_KEYS[state])} + + ) +} diff --git a/desktop/src/components/market/MarketHome.tsx b/desktop/src/components/market/MarketHome.tsx new file mode 100644 index 00000000..783ed3f1 --- /dev/null +++ b/desktop/src/components/market/MarketHome.tsx @@ -0,0 +1,160 @@ +import { useEffect } from 'react' +import { useTranslation } from '../../i18n' +import { useMarketStore } from '../../stores/marketStore' +import { FilterBar } from './FilterBar' +import { SkillCard } from './SkillCard' +import { SourceStatusBar } from './SourceStatusBar' + +export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string) => void }) { + const t = useTranslation() + const { + items, + nextCursor, + sources, + query, + filters, + isLoading, + isLoadingMore, + error, + fetchList, + loadMore, + setQuery, + installingIds, + } = useMarketStore() + + useEffect(() => { + if (items.length === 0 && !isLoading && !error) { + void fetchList({ reset: true }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const hasActiveFilters = + filters.source !== 'all' || filters.security !== 'all' || filters.installed !== 'all' + const hasQuery = query.trim().length > 0 + + return ( +
+
+
+
+
+
+ storefront +

{t('market.title')}

+
+

{t('market.subtitle')}

+
+ +
+ +
+
+ search + setQuery(event.target.value)} + placeholder={t('market.searchPlaceholder')} + className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" + /> + {query && ( + + )} +
+ +
+ + {!isLoading && items.length > 0 && ( +

+ {t('market.resultCount', { count: String(items.length) })} +

+ )} +
+ + {isLoading && ( +
+
+

{t('market.loading')}

+
+ )} + + {!isLoading && error && ( +
+ cloud_off +

{t('market.error.list')}

+

{error}

+ +
+ )} + + {!isLoading && !error && items.length === 0 && ( +
+ + {hasQuery || hasActiveFilters ? 'search_off' : 'storefront'} + +

+ {hasQuery || hasActiveFilters ? t('market.emptySearch') : t('market.empty')} +

+

+ {hasQuery || hasActiveFilters ? t('market.emptySearchHint') : t('market.emptyHint')} +

+
+ )} + + {!isLoading && items.length > 0 && ( + <> +
+ {items.map((skill) => ( + void useMarketStore.getState().openDetail(id)} + onInstall={onRequestInstall} + installing={installingIds.has(skill.id)} + /> + ))} +
+ + {nextCursor && ( +
+ +
+ )} + + )} +
+
+ ) +} diff --git a/desktop/src/components/market/MarketSkillDetail.tsx b/desktop/src/components/market/MarketSkillDetail.tsx new file mode 100644 index 00000000..5278043f --- /dev/null +++ b/desktop/src/components/market/MarketSkillDetail.tsx @@ -0,0 +1,201 @@ +import { useCallback, useMemo } from 'react' +import { useTranslation } from '../../i18n' +import { useMarketStore } from '../../stores/marketStore' +import { SkillDetailView, type SkillDetailMetaItem } from './SkillDetailView' + +function formatCount(value?: number): string { + if (value === undefined) return '—' + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k` + return String(value) +} + +function formatDate(ts?: number): string { + if (!ts) return '—' + try { + return new Date(ts).toLocaleDateString() + } catch { + return '—' + } +} + +export function MarketSkillDetail({ + onRequestInstall, + onRequestUninstall, +}: { + onRequestInstall: (id: string) => void + onRequestUninstall: (id: string) => void +}) { + const t = useTranslation() + const selectedId = useMarketStore((s) => s.selectedId) + const detail = useMarketStore((s) => s.detail) + const isDetailLoading = useMarketStore((s) => s.isDetailLoading) + const detailError = useMarketStore((s) => s.detailError) + const installingIds = useMarketStore((s) => s.installingIds) + const installError = useMarketStore((s) => s.installError) + const backToList = useMarketStore((s) => s.backToList) + const refreshDetail = useMarketStore((s) => s.refreshDetail) + const fetchFileContent = useMarketStore((s) => s.fetchFileContent) + + const loadFile = useCallback( + (path: string) => { + if (!selectedId) return Promise.reject(new Error('No skill selected')) + return fetchFileContent(selectedId, path) + }, + [selectedId, fetchFileContent], + ) + + const meta = useMemo(() => { + if (!detail) return [] + const items: SkillDetailMetaItem[] = [ + { + label: t('market.detail.author'), + value: detail.author.displayName || detail.author.handle || '—', + }, + { label: t('market.detail.downloads'), value: formatCount(detail.stats.downloads) }, + ] + if (detail.stats.installs !== undefined) { + items.push({ label: t('market.detail.installs'), value: formatCount(detail.stats.installs) }) + } + if (detail.stats.stars !== undefined) { + items.push({ label: t('market.detail.stars'), value: formatCount(detail.stats.stars) }) + } + items.push({ label: t('market.detail.updated'), value: formatDate(detail.updatedAt) }) + if (detail.category) items.push({ label: t('market.detail.category'), value: detail.category }) + if (detail.license) items.push({ label: t('market.detail.license'), value: detail.license }) + if (detail.requiresApiKey) { + items.push({ + label: t('market.detail.requiresApiKey'), + value: key, + }) + } + return items + }, [detail, t]) + + if (!selectedId) return null + + if (isDetailLoading) { + return ( +
+
+

{t('market.loading')}

+
+ ) + } + + if (detailError || !detail) { + return ( +
+ error +

{t('market.detail.loadError')}

+ {detailError &&

{detailError}

} +
+ + +
+
+ ) + } + + const installing = installingIds.has(detail.id) + const mirrorSource = detail.mirrors?.length + ? detail.mirrors[0]!.split(':')[0] + : detail.upstream + ? detail.upstream.source + : null + + const actions = ( + <> + {detail.installState === 'installable' && ( + + )} + {detail.installState === 'installed' && ( + + )} + + ) + + const banner = ( + <> + {mirrorSource && ( +

+ {t('market.detail.mirror', { source: t(`market.source.${mirrorSource as 'clawhub' | 'skillhub'}`) })} +

+ )} + {installError && installError.id === detail.id && ( +
+ error + + {installError.kind === 'generic' + ? t('market.installError.generic', { message: installError.message }) + : t(`market.installError.${installError.kind}`)} + +
+ )} + + ) + + return ( + ({ path: f.path, size: f.size, language: f.language, tooBig: f.tooBig }))} + loadFile={loadFile} + onBack={backToList} + backLabel={t('market.detail.back')} + /> + ) +} diff --git a/desktop/src/components/market/SecurityBadge.tsx b/desktop/src/components/market/SecurityBadge.tsx new file mode 100644 index 00000000..398f549b --- /dev/null +++ b/desktop/src/components/market/SecurityBadge.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from '../../i18n' +import type { SecurityStatus } from '../../types/market' + +const STYLES: Record = { + verified: { + icon: 'verified', + className: 'bg-[var(--color-success-container)] text-[var(--color-success)]', + }, + benign: { + icon: 'gpp_good', + className: 'bg-[var(--color-success-container)] text-[var(--color-success)]', + }, + unknown: { + icon: 'shield_question', + className: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]', + }, + flagged: { + icon: 'gpp_maybe', + className: 'bg-[var(--color-error-container)] text-[var(--color-error)]', + }, +} + +export function SecurityBadge({ status, className = '' }: { status: SecurityStatus; className?: string }) { + const t = useTranslation() + const style = STYLES[status] + return ( + + + {style.icon} + + {t(`market.security.${status}`)} + + ) +} diff --git a/desktop/src/components/market/SkillCard.test.tsx b/desktop/src/components/market/SkillCard.test.tsx new file mode 100644 index 00000000..3aa50d90 --- /dev/null +++ b/desktop/src/components/market/SkillCard.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { SkillCard } from './SkillCard' +import { useSettingsStore } from '../../stores/settingsStore' +import type { NormalizedSkill } from '../../types/market' + +function makeSkill(overrides: Partial = {}): NormalizedSkill { + return { + id: 'clawhub:demo', + source: 'clawhub', + slug: 'demo', + name: 'Demo Skill', + summary: 'Does demo things', + author: { handle: 'alice', displayName: 'Alice' }, + stats: { downloads: 12_345, stars: 42 }, + tags: ['git', 'workflow', 'automation', 'extra-tag'], + version: '1.2.0', + securityStatus: 'benign', + installState: 'installable', + ...overrides, + } +} + +beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) +}) + +describe('SkillCard', () => { + it('renders name, summary, source, author, stats and badges', () => { + render() + + expect(screen.getByText('Demo Skill')).toBeInTheDocument() + expect(screen.getByText('Does demo things')).toBeInTheDocument() + expect(screen.getByText('ClawHub')).toBeInTheDocument() + expect(screen.getByText('by Alice')).toBeInTheDocument() + expect(screen.getByText('12.3k')).toBeInTheDocument() + expect(screen.getByTestId('security-badge-benign')).toBeInTheDocument() + expect(screen.getByTestId('install-badge-installable')).toBeInTheDocument() + expect(screen.getByText('v1.2.0')).toBeInTheDocument() + // 4 tags → 3 shown + "+1" + expect(screen.getByText('+1')).toBeInTheDocument() + }) + + it('shows the installed badge and hides the quick-install button when installed', () => { + render() + + expect(screen.getByTestId('install-badge-installed')).toBeInTheDocument() + expect(screen.queryByText('Install')).not.toBeInTheDocument() + }) + + it('shows the not-installable badge', () => { + render() + + expect(screen.getByTestId('install-badge-not-installable')).toBeInTheDocument() + }) + + it('opens the detail on click and installs via the quick action without opening', () => { + const onOpen = vi.fn() + const onInstall = vi.fn() + render() + + fireEvent.click(screen.getByText('Install')) + expect(onInstall).toHaveBeenCalledWith('clawhub:demo') + expect(onOpen).not.toHaveBeenCalled() + + fireEvent.click(screen.getByText('Demo Skill')) + expect(onOpen).toHaveBeenCalledWith('clawhub:demo') + }) + + it('disables the quick-install button while installing', () => { + render() + + expect(screen.getByText('Installing…').closest('button')).toBeDisabled() + }) + + it('flags risky skills visibly', () => { + render() + + expect(screen.getByTestId('security-badge-flagged')).toBeInTheDocument() + expect(screen.getByText('Flagged')).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/market/SkillCard.tsx b/desktop/src/components/market/SkillCard.tsx new file mode 100644 index 00000000..d43ffffc --- /dev/null +++ b/desktop/src/components/market/SkillCard.tsx @@ -0,0 +1,139 @@ +import { useTranslation } from '../../i18n' +import type { NormalizedSkill } from '../../types/market' +import { InstallStateBadge } from './InstallStateBadge' +import { SecurityBadge } from './SecurityBadge' + +function formatCount(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k` + return String(value) +} + +const MAX_VISIBLE_TAGS = 3 + +export function SkillCard({ + skill, + onOpen, + onInstall, + installing, +}: { + skill: NormalizedSkill + onOpen: (id: string) => void + onInstall?: (id: string) => void + installing?: boolean +}) { + const t = useTranslation() + const extraTags = Math.max(0, skill.tags.length - MAX_VISIBLE_TAGS) + + return ( +
onOpen(skill.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onOpen(skill.id) + } + }} + > +
+ {skill.iconUrl ? ( + + ) : ( + + auto_awesome + + )} +
+
+ {skill.name} + {skill.version && ( + + v{skill.version} + + )} +
+
+ + + {skill.source === 'clawhub' ? 'public' : 'language'} + + {t(`market.source.${skill.source}`)} + + {skill.author.handle && ( + {t('market.card.by', { author: skill.author.displayName || skill.author.handle })} + )} +
+
+
+ +

+ {skill.summary || t('market.detail.noDescription')} +

+ + {skill.tags.length > 0 && ( +
+ {skill.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => ( + + {tag} + + ))} + {extraTags > 0 && ( + + {t('market.card.moreTags', { count: String(extraTags) })} + + )} +
+ )} + +
+
+ + +
+
+ + download + {formatCount(skill.stats.downloads)} + + {typeof skill.stats.stars === 'number' && skill.stats.stars > 0 && ( + + star + {formatCount(skill.stats.stars)} + + )} + {onInstall && skill.installState === 'installable' && ( + + )} +
+
+
+ ) +} diff --git a/desktop/src/components/market/SkillDetailView.test.tsx b/desktop/src/components/market/SkillDetailView.test.tsx new file mode 100644 index 00000000..c4ba2a8b --- /dev/null +++ b/desktop/src/components/market/SkillDetailView.test.tsx @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' + +vi.mock('../markdown/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content, variant }: { content: string; variant?: string }) => ( +
+ ), +})) + +vi.mock('../chat/CodeViewer', () => ({ + CodeViewer: ({ code, showLineNumbers }: { code: string; showLineNumbers?: boolean }) => ( +
{code}
+ ), +})) + +import { SkillDetailView } from './SkillDetailView' +import { useSettingsStore } from '../../stores/settingsStore' +import type { PreviewFileContent } from './FilePreview' + +const FILES = [ + { path: 'SKILL.md', size: 100, language: 'markdown' }, + { path: 'scripts/run.py', size: 200, language: 'python' }, +] + +function loadFileFromMemory(path: string): Promise { + if (path === 'SKILL.md') { + return Promise.resolve({ path, content: '# Overview doc', language: 'markdown', size: 100, truncated: false }) + } + return Promise.resolve({ path, content: 'print("hi")', language: 'python', size: 200, truncated: true }) +} + +function renderView(overrides: Partial[0]> = {}) { + return render( + , + ) +} + +beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) +}) + +describe('SkillDetailView', () => { + it('renders the decision header with badges and the overview markdown', () => { + renderView() + + expect(screen.getByText('Demo Skill')).toBeInTheDocument() + expect(screen.getByText('v1.0.0')).toBeInTheDocument() + expect(screen.getByTestId('security-badge-benign')).toBeInTheDocument() + expect(screen.getByTestId('install-badge-installable')).toBeInTheDocument() + const markdown = screen.getByTestId('markdown-renderer') + expect(markdown).toHaveAttribute('data-content', '# Body') + expect(markdown).toHaveAttribute('data-variant', 'document') + }) + + it('shows the not-installable reason prominently', () => { + renderView({ installState: 'not-installable', notInstallableReason: 'name-conflict' }) + + expect(screen.getByTestId('market-not-installable-reason')).toHaveTextContent( + 'A local skill with the same name already exists', + ) + }) + + it('lists security reports with links', () => { + renderView({ + securityReports: [ + { vendor: 'keen', status: 'benign', statusText: 'Safe', reportUrl: 'https://example.com/report' }, + ], + }) + + expect(screen.getByTestId('market-security-reports')).toHaveTextContent('keen') + expect(screen.getByRole('link', { name: 'View report' })).toHaveAttribute('href', 'https://example.com/report') + }) + + it('switches to the files tab and previews code with line numbers and truncation notice', async () => { + renderView() + + fireEvent.click(screen.getByTestId('skill-detail-tab-files')) + expect(await screen.findByTestId('market-file-preview')).toBeInTheDocument() + // SKILL.md is auto-selected → markdown preview + expect(await screen.findAllByTestId('markdown-renderer')).toBeTruthy() + + fireEvent.click(screen.getByTestId('market-file-item-scripts/run.py')) + const code = await screen.findByTestId('code-viewer') + expect(code).toHaveTextContent('print("hi")') + expect(code).toHaveAttribute('data-line-numbers', 'true') + expect(screen.getByText(/Preview truncated/)).toBeInTheDocument() + }) + + it('shows a file load error with retry', async () => { + const failingLoad = vi.fn().mockRejectedValue(new Error('fetch failed')) + renderView({ loadFile: failingLoad }) + + fireEvent.click(screen.getByTestId('skill-detail-tab-files')) + expect(await screen.findByTestId('market-file-error')).toHaveTextContent('Failed to load this file') + expect(screen.getByText('fetch failed')).toBeInTheDocument() + }) + + it('renders custom actions in the decision area', () => { + renderView({ actions: }) + + expect(screen.getByText('Install now')).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/market/SkillDetailView.tsx b/desktop/src/components/market/SkillDetailView.tsx new file mode 100644 index 00000000..82b649c8 --- /dev/null +++ b/desktop/src/components/market/SkillDetailView.tsx @@ -0,0 +1,202 @@ +import { useState, type ReactNode } from 'react' +import { useTranslation } from '../../i18n' +import type { + InstallState, + NotInstallableReason, + SecurityReport, + SecurityStatus, +} from '../../types/market' +import { InstallStateBadge } from './InstallStateBadge' +import { SecurityBadge } from './SecurityBadge' +import { FilePreview, type PreviewFile, type PreviewFileContent } from './FilePreview' +import { MarkdownRenderer } from '../markdown/MarkdownRenderer' + +export type SkillDetailMetaItem = { + label: string + value: ReactNode +} + +export type SkillDetailViewProps = { + name: string + version?: string + iconUrl?: string + sourceLabel: string + summary?: string + securityStatus?: SecurityStatus + securityReports?: SecurityReport[] + installState?: InstallState + notInstallableReason?: NotInstallableReason + /** Action buttons rendered in the decision area (install / uninstall / open). */ + actions?: ReactNode + /** Optional banner below the header (e.g. install errors). */ + banner?: ReactNode + meta: SkillDetailMetaItem[] + description: string + files: PreviewFile[] + loadFile: (path: string) => Promise + onBack: () => void + backLabel: string +} + +/** + * Shared, data-source-agnostic skill detail layout. Both the online market + * detail and the locally-installed skill detail render through this view so + * the reading experience stays identical. + */ +export function SkillDetailView(props: SkillDetailViewProps) { + const t = useTranslation() + const [tab, setTab] = useState<'overview' | 'files'>('overview') + + return ( +
+
+ + + {/* Install decision area */} +
+
+
+ {props.iconUrl ? ( + + ) : ( + + auto_awesome + + )} +
+
+

{props.name}

+ {props.version && ( + + v{props.version} + + )} +
+
+ + {props.sourceLabel} + + {props.securityStatus && } + {props.installState && } +
+ {props.summary && ( +

+ {props.summary} +

+ )} +
+
+ {props.actions &&
{props.actions}
} +
+ + {props.installState === 'not-installable' && props.notInstallableReason && ( +
+ + block + + {t(`market.reason.${props.notInstallableReason}`)} +
+ )} + + {props.securityReports && props.securityReports.length > 0 && ( +
+ + {t('market.detail.securityReport')} + + {props.securityReports.map((report) => ( + + {report.vendor} + {report.statusText} + {report.reportUrl && ( + e.stopPropagation()} + > + {t('market.detail.viewReport')} + + )} + + ))} +
+ )} + + {props.banner} +
+ + {/* Meta grid */} + {props.meta.length > 0 && ( +
+ {props.meta.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ )} + + {/* Tabs */} +
+ {(['overview', 'files'] as const).map((key) => ( + + ))} +
+ + {tab === 'overview' && ( +
+ {props.description.trim() ? ( + + ) : ( +

{t('market.detail.noDescription')}

+ )} +
+ )} + + {tab === 'files' && } +
+
+ ) +} diff --git a/desktop/src/components/market/SourceStatusBar.tsx b/desktop/src/components/market/SourceStatusBar.tsx new file mode 100644 index 00000000..73e12d16 --- /dev/null +++ b/desktop/src/components/market/SourceStatusBar.tsx @@ -0,0 +1,53 @@ +import { useTranslation } from '../../i18n' +import type { MarketSource, SourceStatusInfo } from '../../types/market' +import { MARKET_SOURCES } from '../../types/market' + +const DOT_CLASSES: Record = { + ok: 'bg-[var(--color-success)]', + degraded: 'bg-[var(--color-warning)]', + failed: 'bg-[var(--color-error)]', + cached: 'bg-[var(--color-text-tertiary)]', +} + +function formatTime(ts?: number): string { + if (!ts) return '' + try { + return new Date(ts).toLocaleTimeString() + } catch { + return '' + } +} + +export function SourceStatusBar({ + sources, + className = '', +}: { + sources: Partial> + className?: string +}) { + const t = useTranslation() + return ( +
+ {MARKET_SOURCES.map((source) => { + const info = sources[source] + if (!info) return null + const statusLabel = + info.status === 'cached' && info.fetchedAt + ? t('market.sourceStatus.cachedAt', { time: formatTime(info.fetchedAt) }) + : t(`market.sourceStatus.${info.status}`) + return ( + + + {t(`market.source.${source}`)} + {statusLabel} + + ) + })} +
+ ) +} diff --git a/desktop/src/components/skills/SkillDetail.tsx b/desktop/src/components/skills/SkillDetail.tsx index 7788fe46..bb5fe2b4 100644 --- a/desktop/src/components/skills/SkillDetail.tsx +++ b/desktop/src/components/skills/SkillDetail.tsx @@ -1,13 +1,14 @@ -import { useMemo, useState, type ReactNode } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useSkillStore } from '../../stores/skillStore' import { useTranslation } from '../../i18n' -import { MarkdownRenderer } from '../markdown/MarkdownRenderer' -import { CodeViewer } from '../chat/CodeViewer' -import type { FileTreeNode, SkillFrontmatter } from '../../types/skill' import { useUIStore } from '../../stores/uiStore' +import { marketApi } from '../../api/market' +import { useMarketStore } from '../../stores/marketStore' +import { SkillDetailView, type SkillDetailMetaItem } from '../market/SkillDetailView' +import type { PreviewFileContent } from '../market/FilePreview' +import { ConfirmDialog } from '../shared/ConfirmDialog' const META_PRIORITY = [ - 'description', 'when_to_use', 'argument-hint', 'model', @@ -16,29 +17,95 @@ const META_PRIORITY = [ 'paths', 'agent', 'context', - 'version', 'user-invocable', ] as const +function formatMetaKey(key: string) { + return key.replace(/[-_]/g, ' ') +} + +function formatMetaValue(value: unknown): string { + if (Array.isArray(value)) return value.map((item) => String(item)).join(', ') + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (typeof value === 'object' && value !== null) return JSON.stringify(value) + return String(value) +} + export function SkillDetail() { - const { selectedSkill, selectedSkillReturnTab, isDetailLoading, clearSelection } = useSkillStore() + const { selectedSkill, selectedSkillReturnTab, isDetailLoading, clearSelection, fetchSkills } = useSkillStore() const t = useTranslation() - const [selectedFile, setSelectedFile] = useState('SKILL.md') + const [confirmUninstall, setConfirmUninstall] = useState(false) + const [uninstalling, setUninstalling] = useState(false) - const normalizedSelection = useMemo(() => { - if (!selectedSkill) return 'SKILL.md' - return selectedSkill.files.some((file) => file.path === selectedFile) - ? selectedFile - : selectedSkill.files[0]?.path || 'SKILL.md' - }, [selectedFile, selectedSkill]) - - const handleBack = () => { + const handleBack = useCallback(() => { const returnTab = selectedSkillReturnTab clearSelection() if (returnTab === 'plugins') { useUIStore.getState().setPendingSettingsTab('plugins') } - } + }, [selectedSkillReturnTab, clearSelection]) + + const files = selectedSkill?.files ?? [] + + const loadFile = useCallback( + (path: string): Promise => { + const file = files.find((f) => f.path === path) + if (!file) return Promise.reject(new Error(`File not found: ${path}`)) + const content = file.language === 'markdown' ? (file.body ?? file.content) : file.content + return Promise.resolve({ + path: file.path, + content, + language: file.language, + size: file.content.length, + truncated: false, + }) + }, + [files], + ) + + const meta = useMemo(() => { + if (!selectedSkill) return [] + const skillMeta = selectedSkill.meta + const items: SkillDetailMetaItem[] = [ + { label: t('settings.skills.summary.source'), value: t(`settings.skills.source.${skillMeta.source}`) }, + { label: t('settings.skills.summary.totalFiles'), value: String(selectedSkill.files.length) }, + { + label: t('settings.skills.summary.tokens'), + value: t('settings.skills.tokenEstimateShort', { + count: String(Math.ceil(skillMeta.contentLength / 4)), + }), + }, + ] + if (selectedSkill.marketMeta?.installedAt) { + items.push({ + label: t('market.install.state.installed'), + value: new Date(selectedSkill.marketMeta.installedAt).toLocaleDateString(), + }) + } + const entry = selectedSkill.files.find((f) => f.isEntry) + const frontmatter = entry?.frontmatter + if (frontmatter) { + const entries = Object.entries(frontmatter) + .filter(([key, value]) => { + if (key === 'name' || key === 'description' || key === 'version') return false + if (value == null) return false + if (typeof value === 'string') return value.trim().length > 0 + if (Array.isArray(value)) return value.length > 0 + return true + }) + .sort((a, b) => { + const aIndex = META_PRIORITY.indexOf(a[0] as (typeof META_PRIORITY)[number]) + const bIndex = META_PRIORITY.indexOf(b[0] as (typeof META_PRIORITY)[number]) + const normalizedA = aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex + const normalizedB = bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex + return normalizedA - normalizedB || a[0].localeCompare(b[0]) + }) + for (const [key, value] of entries) { + items.push({ label: formatMetaKey(key), value: formatMetaValue(value) }) + } + } + return items + }, [selectedSkill, t]) if (isDetailLoading) { return ( @@ -50,363 +117,97 @@ export function SkillDetail() { if (!selectedSkill) return null - const { meta, tree, files } = selectedSkill - const currentFile = files.find((f) => f.path === normalizedSelection) || files[0] - const frontmatter = currentFile?.frontmatter - const metaEntries = getMetaEntries(frontmatter) + const skillMeta = selectedSkill.meta + const marketMeta = selectedSkill.marketMeta + const entryFile = selectedSkill.files.find((f) => f.isEntry) + const description = entryFile ? (entryFile.body ?? entryFile.content) : '' - return ( -
-
- -
+ const runUninstall = async () => { + if (!marketMeta) return + setUninstalling(true) + try { + await marketApi.uninstall(marketMeta.id) + useUIStore.getState().addToast({ + type: 'success', + message: t('market.uninstall.success', { name: skillMeta.displayName || skillMeta.name }), + }) + setConfirmUninstall(false) + clearSelection() + void fetchSkills() + // Keep the market list in sync when it has this skill loaded. + const market = useMarketStore.getState() + const detailCache = new Map(market.detailCache) + detailCache.delete(marketMeta.id) + useMarketStore.setState({ + detailCache, + items: market.items.map((item) => + item.id === marketMeta.id + ? { ...item, installState: 'installable', installedInfo: undefined, notInstallableReason: undefined } + : item, + ), + }) + } catch (err) { + useUIStore.getState().addToast({ + type: 'error', + message: err instanceof Error ? err.message : String(err), + }) + } finally { + setUninstalling(false) + } + } -
-
-
-
- {t('settings.skills.entryEyebrow')} -
-
-

- {meta.displayName || meta.name} -

- {t(`settings.skills.source.${meta.source}`)} - {meta.version && v{meta.version}} - {meta.userInvocable && {t('settings.skills.slashCommand')}} -
-

- {meta.description} -

-
- {t('settings.skills.tokenEstimate', { count: String(Math.ceil(meta.contentLength / 4)) })} - - {files.length} {t('settings.skills.files')} - - {currentFile?.isEntry ? t('settings.skills.entryFile') : currentFile?.path} -
-
- -
- - - - file.isEntry) ? 'SKILL.md' : '—'} - icon="article" - /> -
-
-
- - {metaEntries.length > 0 && ( -
-
- - tune - -

- {t('settings.skills.metaTitle')} -

-
-
- {metaEntries.map(([key, value]) => ( -
-
- {formatMetaKey(key)} -
-
- {formatMetaValue(value)} -
-
- ))} -
-
+ const actions = marketMeta ? ( + + ) : undefined -
- - -
-
-
-
- - {currentFile?.path} - - {currentFile?.isEntry && {t('settings.skills.entryFile')}} -
-
- {t('settings.skills.readingMode', { - mode: - currentFile?.language === 'markdown' - ? t('settings.skills.docMode') - : t('settings.skills.codeMode'), - })} -
-
-
- - {currentFile?.language} - -
-
- -
-
- {files.map((file) => { - const active = file.path === normalizedSelection - return ( - - ) - })} -
-
- -
- {currentFile && ( -
- {currentFile.language === 'markdown' ? ( - - ) : ( - - )} -
- )} -
-
-
-
- ) -} - -function TreeView({ - nodes, - selectedPath, - onSelect, - depth, -}: { - nodes: FileTreeNode[] - selectedPath: string - onSelect: (path: string) => void - depth: number -}) { return ( <> - {nodes.map((node) => ( - - ))} + ({ + path: f.path, + size: f.content.length, + language: f.language, + }))} + loadFile={loadFile} + onBack={handleBack} + backLabel={t('settings.skills.back')} + /> + + setConfirmUninstall(false)} + onConfirm={() => void runUninstall()} + title={t('market.uninstall.confirmTitle')} + body={t('market.uninstall.confirmMessage', { + name: skillMeta.displayName || skillMeta.name, + path: selectedSkill.skillRoot, + })} + confirmLabel={t('market.uninstall.action')} + cancelLabel={t('market.installConfirm.cancel')} + confirmVariant="danger" + loading={uninstalling} + /> ) } - -function TreeItem({ - node, - selectedPath, - onSelect, - depth, -}: { - node: FileTreeNode - selectedPath: string - onSelect: (path: string) => void - depth: number -}) { - const [expanded, setExpanded] = useState(true) - const isSelected = node.path === selectedPath - const isDir = node.type === 'directory' - - const icon = isDir ? (expanded ? 'folder_open' : 'folder') : fileIcon(node.name) - - return ( -
- - - {isDir && expanded && node.children && ( - - )} -
- ) -} - -function DetailStat({ - label, - value, - icon, -}: { - label: string - value: string - icon: string -}) { - return ( -
-
- {icon} - {label} -
-
- {value} -
-
- ) -} - -function MetaPill({ children }: { children: ReactNode }) { - return ( - - {children} - - ) -} - -function getMetaEntries(frontmatter?: SkillFrontmatter): Array<[string, unknown]> { - if (!frontmatter) return [] - - const entries = Object.entries(frontmatter).filter(([, value]) => { - if (value == null) return false - if (typeof value === 'string') return value.trim().length > 0 - if (Array.isArray(value)) return value.length > 0 - return true - }) - - entries.sort((a, b) => { - const aIndex = META_PRIORITY.indexOf(a[0] as (typeof META_PRIORITY)[number]) - const bIndex = META_PRIORITY.indexOf(b[0] as (typeof META_PRIORITY)[number]) - const normalizedA = aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex - const normalizedB = bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex - return normalizedA - normalizedB || a[0].localeCompare(b[0]) - }) - - return entries -} - -function formatMetaKey(key: string) { - return key.replace(/[-_]/g, ' ') -} - -function formatMetaValue(value: unknown) { - if (Array.isArray(value)) { - return value.map((item) => String(item)).join(', ') - } - if (typeof value === 'boolean') { - return value ? 'true' : 'false' - } - if (typeof value === 'object' && value !== null) { - return JSON.stringify(value) - } - return String(value) -} - -function fileIcon(filename: string): string { - const ext = filename.split('.').pop()?.toLowerCase() - switch (ext) { - case 'md': - return 'description' - case 'ts': - case 'tsx': - case 'js': - case 'jsx': - case 'py': - case 'rs': - case 'go': - return 'code' - case 'json': - case 'yaml': - case 'yml': - case 'toml': - return 'data_object' - case 'sh': - case 'bash': - return 'terminal' - default: - return 'draft' - } -} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 747bb0bd..4409e234 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1970,6 +1970,98 @@ export const en = { 'tabs.hideWorkspace': 'Hide Workspace', 'tabs.showBrowser': 'Show Browser', 'tabs.hideBrowser': 'Hide Browser', + + // Skills Market + 'sidebar.market': 'Skills Market', + 'market.title': 'Skills Market', + 'market.subtitle': 'Browse, preview and install skills from ClawHub and SkillHub.', + 'market.searchPlaceholder': 'Search skills by name, keyword…', + 'market.clearSearch': 'Clear search', + 'market.resultCount': '{count} skills', + 'market.loadMore': 'Load more', + 'market.loadingMore': 'Loading more…', + 'market.loading': 'Loading skills…', + 'market.empty': 'No skills available', + 'market.emptyHint': 'Both sources returned no results. Check the source status above.', + 'market.emptySearch': 'No skills match your search', + 'market.emptySearchHint': 'Try different keywords or clear the filters.', + 'market.error.list': 'Failed to load the skills market', + 'market.retry': 'Retry', + 'market.filter.source': 'Source', + 'market.filter.security': 'Security', + 'market.filter.installed': 'Install status', + 'market.source.all': 'All sources', + 'market.source.clawhub': 'ClawHub', + 'market.source.skillhub': 'SkillHub', + 'market.security.all': 'All security states', + 'market.security.verified': 'Verified', + 'market.security.benign': 'Scanned safe', + 'market.security.unknown': 'Not audited', + 'market.security.flagged': 'Flagged', + 'market.installedFilter.all': 'All skills', + 'market.installedFilter.installed': 'Installed', + 'market.installedFilter.installable': 'Not installed', + 'market.sourceStatus.ok': 'Online', + 'market.sourceStatus.degraded': 'Degraded', + 'market.sourceStatus.failed': 'Unavailable', + 'market.sourceStatus.cached': 'Cached', + 'market.sourceStatus.cachedAt': 'Cached {time}', + 'market.card.by': 'by {author}', + 'market.card.moreTags': '+{count}', + 'market.install.state.installed': 'Installed', + 'market.install.state.installable': 'Installable', + 'market.install.state.notInstallable': 'Not installable', + 'market.install.action': 'Install', + 'market.install.installing': 'Installing…', + 'market.uninstall.action': 'Uninstall', + 'market.uninstall.uninstalling': 'Uninstalling…', + 'market.uninstall.confirmTitle': 'Uninstall skill', + 'market.uninstall.confirmMessage': 'Remove "{name}" from your local skills? The files under {path} will be deleted.', + 'market.uninstall.success': '"{name}" was uninstalled.', + 'market.reason.empty-file-list': 'This skill has no installable files (missing SKILL.md).', + 'market.reason.file-too-large': 'This skill contains files that exceed the size limit.', + 'market.reason.too-many-files': 'This skill contains too many files to install safely.', + 'market.reason.invalid-name': 'The skill name cannot be used as a local directory name.', + 'market.reason.name-conflict': 'A local skill with the same name already exists. Rename or remove it first.', + 'market.reason.source-unavailable': 'The source of this skill is currently unavailable.', + 'market.detail.back': 'Back to market', + 'market.detail.overview': 'Overview', + 'market.detail.files': 'Files', + 'market.detail.author': 'Author', + 'market.detail.version': 'Version', + 'market.detail.updated': 'Updated', + 'market.detail.category': 'Category', + 'market.detail.license': 'License', + 'market.detail.downloads': 'Downloads', + 'market.detail.installs': 'Installs', + 'market.detail.stars': 'Stars', + 'market.detail.securityReport': 'Security reports', + 'market.detail.viewReport': 'View report', + 'market.detail.mirror': 'Also listed on {source}', + 'market.detail.requiresApiKey': 'Requires API key', + 'market.detail.loadError': 'Failed to load skill details', + 'market.detail.noDescription': 'This skill provides no description.', + 'market.file.empty': 'Select a file on the left to preview it.', + 'market.file.truncated': 'Preview truncated — the file is larger than the preview limit.', + 'market.file.loadError': 'Failed to load this file', + 'market.file.noFiles': 'No files available for this skill.', + 'market.installConfirm.title': 'Install skill', + 'market.installConfirm.message': 'Install "{name}" from {source}?', + 'market.installConfirm.location': 'Install location', + 'market.installConfirm.effectNote': 'The skill becomes available in new sessions after installation.', + 'market.installConfirm.riskBenign': 'Security scans found no risks in this skill.', + 'market.installConfirm.riskVerified': 'This skill is from a verified publisher and passed security scans.', + 'market.installConfirm.riskUnknown': 'This skill has not been security-audited. Review its files before installing.', + 'market.installConfirm.riskFlagged': 'Security scans flagged this skill as potentially risky. Install only if you trust it.', + 'market.installConfirm.confirm': 'Install', + 'market.installConfirm.cancel': 'Cancel', + 'market.installSuccess': '"{name}" installed. It becomes available in new sessions.', + 'market.installError.network': 'Install failed: could not reach the skill source. Check your network and retry.', + 'market.installError.checksum': 'Install aborted: a downloaded file failed integrity verification. Do not retry until the source is trustworthy.', + 'market.installError.exists': 'Install failed: this skill is already installed or being installed.', + 'market.installError.disk': 'Install failed: could not write to disk. Check permissions and free space.', + 'market.installError.notInstallable': 'Install failed: this skill cannot be installed.', + 'market.installError.generic': 'Install failed: {message}', } as const export type TranslationKey = keyof typeof en diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 40524ada..4043c2ab 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1972,4 +1972,96 @@ export const jp: Record = { 'tabs.hideWorkspace': 'ワークスペースを非表示', 'tabs.showBrowser': 'ブラウザを表示', 'tabs.hideBrowser': 'ブラウザを非表示', + + // スキルマーケット + 'sidebar.market': 'スキルマーケット', + 'market.title': 'スキルマーケット', + 'market.subtitle': 'ClawHub と SkillHub のスキルを閲覧・プレビュー・インストールできます。', + 'market.searchPlaceholder': 'スキル名・キーワードで検索…', + 'market.clearSearch': '検索をクリア', + 'market.resultCount': '{count} 件のスキル', + 'market.loadMore': 'さらに読み込む', + 'market.loadingMore': '読み込み中…', + 'market.loading': 'スキルを読み込み中…', + 'market.empty': '利用可能なスキルがありません', + 'market.emptyHint': 'どちらのソースからも結果が返りませんでした。上部のソース状態を確認してください。', + 'market.emptySearch': '検索に一致するスキルがありません', + 'market.emptySearchHint': '別のキーワードを試すか、フィルタをクリアしてください。', + 'market.error.list': 'スキルマーケットの読み込みに失敗しました', + 'market.retry': '再試行', + 'market.filter.source': 'ソース', + 'market.filter.security': 'セキュリティ', + 'market.filter.installed': 'インストール状態', + 'market.source.all': 'すべてのソース', + 'market.source.clawhub': 'ClawHub', + 'market.source.skillhub': 'SkillHub', + 'market.security.all': 'すべてのセキュリティ状態', + 'market.security.verified': '認証済み', + 'market.security.benign': 'スキャン済み・安全', + 'market.security.unknown': '未監査', + 'market.security.flagged': 'リスクあり', + 'market.installedFilter.all': 'すべてのスキル', + 'market.installedFilter.installed': 'インストール済み', + 'market.installedFilter.installable': '未インストール', + 'market.sourceStatus.ok': '正常', + 'market.sourceStatus.degraded': '低下', + 'market.sourceStatus.failed': '利用不可', + 'market.sourceStatus.cached': 'キャッシュ', + 'market.sourceStatus.cachedAt': '{time} 時点のキャッシュ', + 'market.card.by': '作者 {author}', + 'market.card.moreTags': '+{count}', + 'market.install.state.installed': 'インストール済み', + 'market.install.state.installable': 'インストール可能', + 'market.install.state.notInstallable': 'インストール不可', + 'market.install.action': 'インストール', + 'market.install.installing': 'インストール中…', + 'market.uninstall.action': 'アンインストール', + 'market.uninstall.uninstalling': 'アンインストール中…', + 'market.uninstall.confirmTitle': 'スキルをアンインストール', + 'market.uninstall.confirmMessage': '「{name}」をローカルから削除しますか?{path} 配下のファイルが削除されます。', + 'market.uninstall.success': '「{name}」をアンインストールしました。', + 'market.reason.empty-file-list': 'このスキルにはインストール可能なファイルがありません(SKILL.md がありません)。', + 'market.reason.file-too-large': 'このスキルにはサイズ制限を超えるファイルが含まれています。', + 'market.reason.too-many-files': 'このスキルはファイル数が多すぎるため、安全にインストールできません。', + 'market.reason.invalid-name': 'スキル名をローカルのディレクトリ名として使用できません。', + 'market.reason.name-conflict': '同名のローカルスキルが既に存在します。先に名前を変更するか削除してください。', + 'market.reason.source-unavailable': 'このスキルの提供元は現在利用できません。', + 'market.detail.back': 'マーケットに戻る', + 'market.detail.overview': '概要', + 'market.detail.files': 'ファイル', + 'market.detail.author': '作者', + 'market.detail.version': 'バージョン', + 'market.detail.updated': '更新日時', + 'market.detail.category': 'カテゴリ', + 'market.detail.license': 'ライセンス', + 'market.detail.downloads': 'ダウンロード数', + 'market.detail.installs': 'インストール数', + 'market.detail.stars': 'スター', + 'market.detail.securityReport': 'セキュリティレポート', + 'market.detail.viewReport': 'レポートを見る', + 'market.detail.mirror': '{source} にも掲載', + 'market.detail.requiresApiKey': 'API キーが必要', + 'market.detail.loadError': 'スキル詳細の読み込みに失敗しました', + 'market.detail.noDescription': 'このスキルには説明がありません。', + 'market.file.empty': '左側のファイルを選択するとプレビューできます。', + 'market.file.truncated': 'プレビューは切り詰められています。ファイルがプレビュー上限を超えています。', + 'market.file.loadError': 'ファイルの読み込みに失敗しました', + 'market.file.noFiles': 'このスキルにはプレビュー可能なファイルがありません。', + 'market.installConfirm.title': 'スキルをインストール', + 'market.installConfirm.message': '{source} から「{name}」をインストールしますか?', + 'market.installConfirm.location': 'インストール先', + 'market.installConfirm.effectNote': 'インストール後、スキルは新しいセッションで有効になります。', + 'market.installConfirm.riskBenign': 'セキュリティスキャンではこのスキルにリスクは見つかりませんでした。', + 'market.installConfirm.riskVerified': 'このスキルは認証済みの発行者によるもので、セキュリティスキャンに合格しています。', + 'market.installConfirm.riskUnknown': 'このスキルはセキュリティ監査を受けていません。インストール前にファイルを確認してください。', + 'market.installConfirm.riskFlagged': 'セキュリティスキャンでこのスキルに潜在的なリスクが検出されました。信頼できる場合のみインストールしてください。', + 'market.installConfirm.confirm': 'インストール', + 'market.installConfirm.cancel': 'キャンセル', + 'market.installSuccess': '「{name}」をインストールしました。新しいセッションで有効になります。', + 'market.installError.network': 'インストール失敗:スキルの提供元に接続できません。ネットワークを確認して再試行してください。', + 'market.installError.checksum': 'インストール中止:ダウンロードしたファイルが整合性検証に失敗しました。提供元が信頼できると確認できるまで再試行しないでください。', + 'market.installError.exists': 'インストール失敗:このスキルは既にインストール済みか、インストール中です。', + 'market.installError.disk': 'インストール失敗:ディスクへの書き込みに失敗しました。権限と空き容量を確認してください。', + 'market.installError.notInstallable': 'インストール失敗:このスキルはインストールできません。', + 'market.installError.generic': 'インストール失敗:{message}', } diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 2ea02d90..253e0cd9 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1972,4 +1972,96 @@ export const kr: Record = { 'tabs.hideWorkspace': '작업 공간 숨기기', 'tabs.showBrowser': '브라우저 표시', 'tabs.hideBrowser': '브라우저 숨기기', + + // 스킬 마켓 + 'sidebar.market': '스킬 마켓', + 'market.title': '스킬 마켓', + 'market.subtitle': 'ClawHub와 SkillHub의 스킬을 탐색·미리보기·설치할 수 있습니다.', + 'market.searchPlaceholder': '스킬 이름, 키워드로 검색…', + 'market.clearSearch': '검색 지우기', + 'market.resultCount': '{count}개의 스킬', + 'market.loadMore': '더 불러오기', + 'market.loadingMore': '불러오는 중…', + 'market.loading': '스킬을 불러오는 중…', + 'market.empty': '사용 가능한 스킬이 없습니다', + 'market.emptyHint': '두 소스 모두 결과를 반환하지 않았습니다. 위의 소스 상태를 확인하세요.', + 'market.emptySearch': '검색과 일치하는 스킬이 없습니다', + 'market.emptySearchHint': '다른 키워드를 시도하거나 필터를 지워보세요.', + 'market.error.list': '스킬 마켓을 불러오지 못했습니다', + 'market.retry': '다시 시도', + 'market.filter.source': '소스', + 'market.filter.security': '보안', + 'market.filter.installed': '설치 상태', + 'market.source.all': '모든 소스', + 'market.source.clawhub': 'ClawHub', + 'market.source.skillhub': 'SkillHub', + 'market.security.all': '모든 보안 상태', + 'market.security.verified': '인증됨', + 'market.security.benign': '스캔 완료·안전', + 'market.security.unknown': '미감사', + 'market.security.flagged': '위험 표시됨', + 'market.installedFilter.all': '모든 스킬', + 'market.installedFilter.installed': '설치됨', + 'market.installedFilter.installable': '미설치', + 'market.sourceStatus.ok': '정상', + 'market.sourceStatus.degraded': '저하됨', + 'market.sourceStatus.failed': '사용 불가', + 'market.sourceStatus.cached': '캐시', + 'market.sourceStatus.cachedAt': '{time} 기준 캐시', + 'market.card.by': '작성자 {author}', + 'market.card.moreTags': '+{count}', + 'market.install.state.installed': '설치됨', + 'market.install.state.installable': '설치 가능', + 'market.install.state.notInstallable': '설치 불가', + 'market.install.action': '설치', + 'market.install.installing': '설치 중…', + 'market.uninstall.action': '제거', + 'market.uninstall.uninstalling': '제거 중…', + 'market.uninstall.confirmTitle': '스킬 제거', + 'market.uninstall.confirmMessage': '"{name}"을(를) 로컬에서 제거하시겠습니까? {path} 아래의 파일이 삭제됩니다.', + 'market.uninstall.success': '"{name}"이(가) 제거되었습니다.', + 'market.reason.empty-file-list': '이 스킬에는 설치 가능한 파일이 없습니다(SKILL.md 없음).', + 'market.reason.file-too-large': '이 스킬에는 크기 제한을 초과하는 파일이 있습니다.', + 'market.reason.too-many-files': '이 스킬은 파일 수가 너무 많아 안전하게 설치할 수 없습니다.', + 'market.reason.invalid-name': '스킬 이름을 로컬 디렉터리 이름으로 사용할 수 없습니다.', + 'market.reason.name-conflict': '같은 이름의 로컬 스킬이 이미 있습니다. 먼저 이름을 바꾸거나 삭제하세요.', + 'market.reason.source-unavailable': '이 스킬의 소스를 현재 사용할 수 없습니다.', + 'market.detail.back': '마켓으로 돌아가기', + 'market.detail.overview': '개요', + 'market.detail.files': '파일', + 'market.detail.author': '작성자', + 'market.detail.version': '버전', + 'market.detail.updated': '업데이트', + 'market.detail.category': '카테고리', + 'market.detail.license': '라이선스', + 'market.detail.downloads': '다운로드', + 'market.detail.installs': '설치 수', + 'market.detail.stars': '스타', + 'market.detail.securityReport': '보안 보고서', + 'market.detail.viewReport': '보고서 보기', + 'market.detail.mirror': '{source}에도 등록됨', + 'market.detail.requiresApiKey': 'API 키 필요', + 'market.detail.loadError': '스킬 상세 정보를 불러오지 못했습니다', + 'market.detail.noDescription': '이 스킬에는 설명이 없습니다.', + 'market.file.empty': '왼쪽에서 파일을 선택하면 미리 볼 수 있습니다.', + 'market.file.truncated': '미리보기가 잘렸습니다. 파일이 미리보기 제한을 초과합니다.', + 'market.file.loadError': '파일을 불러오지 못했습니다', + 'market.file.noFiles': '이 스킬에는 미리 볼 파일이 없습니다.', + 'market.installConfirm.title': '스킬 설치', + 'market.installConfirm.message': '{source}에서 "{name}"을(를) 설치하시겠습니까?', + 'market.installConfirm.location': '설치 위치', + 'market.installConfirm.effectNote': '설치 후 새 세션에서 스킬을 사용할 수 있습니다.', + 'market.installConfirm.riskBenign': '보안 스캔에서 이 스킬의 위험이 발견되지 않았습니다.', + 'market.installConfirm.riskVerified': '이 스킬은 인증된 게시자가 제공했으며 보안 스캔을 통과했습니다.', + 'market.installConfirm.riskUnknown': '이 스킬은 보안 감사를 받지 않았습니다. 설치 전에 파일을 확인하세요.', + 'market.installConfirm.riskFlagged': '보안 스캔에서 이 스킬에 잠재적 위험이 표시되었습니다. 신뢰할 수 있는 경우에만 설치하세요.', + 'market.installConfirm.confirm': '설치', + 'market.installConfirm.cancel': '취소', + 'market.installSuccess': '"{name}" 설치 완료. 새 세션에서 사용할 수 있습니다.', + 'market.installError.network': '설치 실패: 스킬 소스에 연결할 수 없습니다. 네트워크를 확인하고 다시 시도하세요.', + 'market.installError.checksum': '설치 중단: 다운로드한 파일이 무결성 검증에 실패했습니다. 소스를 신뢰할 수 있을 때까지 다시 시도하지 마세요.', + 'market.installError.exists': '설치 실패: 이 스킬은 이미 설치되었거나 설치 중입니다.', + 'market.installError.disk': '설치 실패: 디스크에 쓸 수 없습니다. 권한과 여유 공간을 확인하세요.', + 'market.installError.notInstallable': '설치 실패: 이 스킬은 설치할 수 없습니다.', + 'market.installError.generic': '설치 실패: {message}', } diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 2503af48..c2c0a025 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1972,4 +1972,96 @@ export const zh: Record = { 'tabs.hideWorkspace': '隱藏工作區', 'tabs.showBrowser': '顯示瀏覽器', 'tabs.hideBrowser': '隱藏瀏覽器', + + // 技能市集 + 'sidebar.market': '技能市集', + 'market.title': '技能市集', + 'market.subtitle': '瀏覽、預覽並安裝來自 ClawHub 與 SkillHub 的技能。', + 'market.searchPlaceholder': '依名稱、關鍵字搜尋技能…', + 'market.clearSearch': '清除搜尋', + 'market.resultCount': '{count} 個技能', + 'market.loadMore': '載入更多', + 'market.loadingMore': '正在載入更多…', + 'market.loading': '正在載入技能…', + 'market.empty': '暫無可用技能', + 'market.emptyHint': '兩個來源都沒有回傳結果,請檢查上方的來源狀態。', + 'market.emptySearch': '沒有符合的技能', + 'market.emptySearchHint': '換個關鍵字試試,或清除篩選條件。', + 'market.error.list': '技能市集載入失敗', + 'market.retry': '重試', + 'market.filter.source': '來源', + 'market.filter.security': '安全狀態', + 'market.filter.installed': '安裝狀態', + 'market.source.all': '全部來源', + 'market.source.clawhub': 'ClawHub', + 'market.source.skillhub': 'SkillHub', + 'market.security.all': '全部安全狀態', + 'market.security.verified': '已認證', + 'market.security.benign': '掃描無風險', + 'market.security.unknown': '未稽核', + 'market.security.flagged': '存在風險', + 'market.installedFilter.all': '全部技能', + 'market.installedFilter.installed': '已安裝', + 'market.installedFilter.installable': '未安裝', + 'market.sourceStatus.ok': '正常', + 'market.sourceStatus.degraded': '降級', + 'market.sourceStatus.failed': '不可用', + 'market.sourceStatus.cached': '快取', + 'market.sourceStatus.cachedAt': '快取於 {time}', + 'market.card.by': '作者 {author}', + 'market.card.moreTags': '+{count}', + 'market.install.state.installed': '已安裝', + 'market.install.state.installable': '可安裝', + 'market.install.state.notInstallable': '不可安裝', + 'market.install.action': '安裝', + 'market.install.installing': '安裝中…', + 'market.uninstall.action': '解除安裝', + 'market.uninstall.uninstalling': '解除安裝中…', + 'market.uninstall.confirmTitle': '解除安裝技能', + 'market.uninstall.confirmMessage': '確定從本機移除「{name}」嗎?{path} 下的檔案將被刪除。', + 'market.uninstall.success': '「{name}」已解除安裝。', + 'market.reason.empty-file-list': '該技能沒有可安裝的檔案(缺少 SKILL.md)。', + 'market.reason.file-too-large': '該技能包含超過大小限制的檔案。', + 'market.reason.too-many-files': '該技能檔案數量過多,無法安全安裝。', + 'market.reason.invalid-name': '技能名稱無法作為本機目錄名稱使用。', + 'market.reason.name-conflict': '本機已存在同名技能,請先重新命名或刪除後再安裝。', + 'market.reason.source-unavailable': '該技能所屬的來源目前不可用。', + 'market.detail.back': '返回市集', + 'market.detail.overview': '概覽', + 'market.detail.files': '檔案', + 'market.detail.author': '作者', + 'market.detail.version': '版本', + 'market.detail.updated': '更新時間', + 'market.detail.category': '分類', + 'market.detail.license': '授權條款', + 'market.detail.downloads': '下載量', + 'market.detail.installs': '安裝量', + 'market.detail.stars': '收藏', + 'market.detail.securityReport': '安全報告', + 'market.detail.viewReport': '檢視報告', + 'market.detail.mirror': '也收錄於 {source}', + 'market.detail.requiresApiKey': '需要 API Key', + 'market.detail.loadError': '技能詳情載入失敗', + 'market.detail.noDescription': '該技能未提供說明。', + 'market.file.empty': '在左側選擇一個檔案進行預覽。', + 'market.file.truncated': '預覽已截斷——檔案超出預覽大小限制。', + 'market.file.loadError': '檔案載入失敗', + 'market.file.noFiles': '該技能暫無可預覽檔案。', + 'market.installConfirm.title': '安裝技能', + 'market.installConfirm.message': '確定從 {source} 安裝「{name}」嗎?', + 'market.installConfirm.location': '安裝位置', + 'market.installConfirm.effectNote': '安裝完成後,技能將在新會話中生效。', + 'market.installConfirm.riskBenign': '安全掃描未發現該技能存在風險。', + 'market.installConfirm.riskVerified': '該技能來自已認證發布者,並通過了安全掃描。', + 'market.installConfirm.riskUnknown': '該技能未經過安全稽核,安裝前請先檢查其檔案內容。', + 'market.installConfirm.riskFlagged': '安全掃描標記該技能存在潛在風險,僅在您信任它時安裝。', + 'market.installConfirm.confirm': '安裝', + 'market.installConfirm.cancel': '取消', + 'market.installSuccess': '「{name}」安裝成功,將在新會話中生效。', + 'market.installError.network': '安裝失敗:無法連線技能來源,請檢查網路後重試。', + 'market.installError.checksum': '安裝已中止:下載檔案未通過完整性驗證。在確認來源可信前請勿重試。', + 'market.installError.exists': '安裝失敗:該技能已安裝或正在安裝中。', + 'market.installError.disk': '安裝失敗:寫入磁碟失敗,請檢查權限與剩餘空間。', + 'market.installError.notInstallable': '安裝失敗:該技能不可安裝。', + 'market.installError.generic': '安裝失敗:{message}', } diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 1b5b096a..82d34d4b 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1972,4 +1972,96 @@ export const zh: Record = { 'tabs.hideWorkspace': '隐藏工作区', 'tabs.showBrowser': '显示浏览器', 'tabs.hideBrowser': '隐藏浏览器', + + // 技能市场 + 'sidebar.market': '技能市场', + 'market.title': '技能市场', + 'market.subtitle': '浏览、预览并安装来自 ClawHub 与 SkillHub 的技能。', + 'market.searchPlaceholder': '按名称、关键词搜索技能…', + 'market.clearSearch': '清除搜索', + 'market.resultCount': '{count} 个技能', + 'market.loadMore': '加载更多', + 'market.loadingMore': '正在加载更多…', + 'market.loading': '正在加载技能…', + 'market.empty': '暂无可用技能', + 'market.emptyHint': '两个来源都没有返回结果,请检查上方的来源状态。', + 'market.emptySearch': '没有匹配的技能', + 'market.emptySearchHint': '换个关键词试试,或清除筛选条件。', + 'market.error.list': '技能市场加载失败', + 'market.retry': '重试', + 'market.filter.source': '来源', + 'market.filter.security': '安全状态', + 'market.filter.installed': '安装状态', + 'market.source.all': '全部来源', + 'market.source.clawhub': 'ClawHub', + 'market.source.skillhub': 'SkillHub', + 'market.security.all': '全部安全状态', + 'market.security.verified': '已认证', + 'market.security.benign': '扫描无风险', + 'market.security.unknown': '未审计', + 'market.security.flagged': '存在风险', + 'market.installedFilter.all': '全部技能', + 'market.installedFilter.installed': '已安装', + 'market.installedFilter.installable': '未安装', + 'market.sourceStatus.ok': '正常', + 'market.sourceStatus.degraded': '降级', + 'market.sourceStatus.failed': '不可用', + 'market.sourceStatus.cached': '缓存', + 'market.sourceStatus.cachedAt': '缓存于 {time}', + 'market.card.by': '作者 {author}', + 'market.card.moreTags': '+{count}', + 'market.install.state.installed': '已安装', + 'market.install.state.installable': '可安装', + 'market.install.state.notInstallable': '不可安装', + 'market.install.action': '安装', + 'market.install.installing': '安装中…', + 'market.uninstall.action': '卸载', + 'market.uninstall.uninstalling': '卸载中…', + 'market.uninstall.confirmTitle': '卸载技能', + 'market.uninstall.confirmMessage': '确定从本地移除「{name}」吗?{path} 下的文件将被删除。', + 'market.uninstall.success': '「{name}」已卸载。', + 'market.reason.empty-file-list': '该技能没有可安装的文件(缺少 SKILL.md)。', + 'market.reason.file-too-large': '该技能包含超过大小限制的文件。', + 'market.reason.too-many-files': '该技能文件数量过多,无法安全安装。', + 'market.reason.invalid-name': '技能名称无法作为本地目录名使用。', + 'market.reason.name-conflict': '本地已存在同名技能,请先重命名或删除后再安装。', + 'market.reason.source-unavailable': '该技能所属的来源当前不可用。', + 'market.detail.back': '返回市场', + 'market.detail.overview': '概览', + 'market.detail.files': '文件', + 'market.detail.author': '作者', + 'market.detail.version': '版本', + 'market.detail.updated': '更新时间', + 'market.detail.category': '分类', + 'market.detail.license': '许可证', + 'market.detail.downloads': '下载量', + 'market.detail.installs': '安装量', + 'market.detail.stars': '收藏', + 'market.detail.securityReport': '安全报告', + 'market.detail.viewReport': '查看报告', + 'market.detail.mirror': '也收录于 {source}', + 'market.detail.requiresApiKey': '需要 API Key', + 'market.detail.loadError': '技能详情加载失败', + 'market.detail.noDescription': '该技能未提供说明。', + 'market.file.empty': '在左侧选择一个文件进行预览。', + 'market.file.truncated': '预览已截断——文件超出预览大小限制。', + 'market.file.loadError': '文件加载失败', + 'market.file.noFiles': '该技能暂无可预览文件。', + 'market.installConfirm.title': '安装技能', + 'market.installConfirm.message': '确定从 {source} 安装「{name}」吗?', + 'market.installConfirm.location': '安装位置', + 'market.installConfirm.effectNote': '安装完成后,技能将在新会话中生效。', + 'market.installConfirm.riskBenign': '安全扫描未发现该技能存在风险。', + 'market.installConfirm.riskVerified': '该技能来自已认证发布者,并通过了安全扫描。', + 'market.installConfirm.riskUnknown': '该技能未经过安全审计,安装前请先检查其文件内容。', + 'market.installConfirm.riskFlagged': '安全扫描标记该技能存在潜在风险,仅在您信任它时安装。', + 'market.installConfirm.confirm': '安装', + 'market.installConfirm.cancel': '取消', + 'market.installSuccess': '「{name}」安装成功,将在新会话中生效。', + 'market.installError.network': '安装失败:无法连接技能来源,请检查网络后重试。', + 'market.installError.checksum': '安装已中止:下载文件未通过完整性校验。在确认来源可信前请勿重试。', + 'market.installError.exists': '安装失败:该技能已安装或正在安装中。', + 'market.installError.disk': '安装失败:写入磁盘失败,请检查权限与剩余空间。', + 'market.installError.notInstallable': '安装失败:该技能不可安装。', + 'market.installError.generic': '安装失败:{message}', } diff --git a/desktop/src/pages/Market.tsx b/desktop/src/pages/Market.tsx new file mode 100644 index 00000000..8ab9642c --- /dev/null +++ b/desktop/src/pages/Market.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { useTranslation } from '../i18n' +import { useMarketStore } from '../stores/marketStore' +import { useSkillStore } from '../stores/skillStore' +import { useUIStore } from '../stores/uiStore' +import { InstallConfirmDialog } from '../components/market/InstallConfirmDialog' +import { MarketHome } from '../components/market/MarketHome' +import { MarketSkillDetail } from '../components/market/MarketSkillDetail' +import { ConfirmDialog } from '../components/shared/ConfirmDialog' +import type { NormalizedSkill } from '../types/market' + +export function Market() { + const t = useTranslation() + const selectedId = useMarketStore((s) => s.selectedId) + const installingIds = useMarketStore((s) => s.installingIds) + const [confirmInstall, setConfirmInstall] = useState(null) + const [confirmUninstall, setConfirmUninstall] = useState(null) + + const findSkill = (id: string): NormalizedSkill | null => { + const state = useMarketStore.getState() + if (state.detail?.id === id) return state.detail + return state.items.find((item) => item.id === id) ?? state.detailCache.get(id) ?? null + } + + const requestInstall = (id: string) => { + const skill = findSkill(id) + if (skill) setConfirmInstall(skill) + } + + const requestUninstall = (id: string) => { + const skill = findSkill(id) + if (skill) setConfirmUninstall(skill) + } + + const runInstall = async () => { + const skill = confirmInstall + if (!skill) return + const ok = await useMarketStore.getState().install(skill.id) + setConfirmInstall(null) + if (ok) { + useUIStore.getState().addToast({ + type: 'success', + message: t('market.installSuccess', { name: skill.name }), + }) + // Keep the Settings → Skills browser in sync. + void useSkillStore.getState().fetchSkills() + } else { + const error = useMarketStore.getState().installError + if (error) { + useUIStore.getState().addToast({ + type: 'error', + message: + error.kind === 'generic' + ? t('market.installError.generic', { message: error.message }) + : t(`market.installError.${error.kind}`), + }) + } + } + } + + const runUninstall = async () => { + const skill = confirmUninstall + if (!skill) return + const ok = await useMarketStore.getState().uninstall(skill.id) + setConfirmUninstall(null) + if (ok) { + useUIStore.getState().addToast({ + type: 'success', + message: t('market.uninstall.success', { name: skill.name }), + }) + void useSkillStore.getState().fetchSkills() + } else { + const error = useMarketStore.getState().installError + if (error) { + useUIStore.getState().addToast({ type: 'error', message: error.message }) + } + } + } + + return ( +
+ {selectedId ? ( + + ) : ( + + )} + + void runInstall()} + onClose={() => setConfirmInstall(null)} + /> + + setConfirmUninstall(null)} + onConfirm={() => void runUninstall()} + title={t('market.uninstall.confirmTitle')} + body={ + confirmUninstall + ? t('market.uninstall.confirmMessage', { + name: confirmUninstall.name, + path: `~/.claude/skills/${confirmUninstall.slug}/`, + }) + : '' + } + confirmLabel={t('market.uninstall.action')} + cancelLabel={t('market.installConfirm.cancel')} + confirmVariant="danger" + loading={confirmUninstall !== null && installingIds.has(confirmUninstall.id)} + /> +
+ ) +} diff --git a/desktop/src/stores/marketStore.test.ts b/desktop/src/stores/marketStore.test.ts new file mode 100644 index 00000000..6b1bb9ac --- /dev/null +++ b/desktop/src/stores/marketStore.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../api/market', () => ({ + marketApi: { + list: vi.fn(), + detail: vi.fn(), + fileContent: vi.fn(), + install: vi.fn(), + uninstall: vi.fn(), + status: vi.fn(), + }, +})) + +import { marketApi } from '../api/market' +import { useMarketStore, classifyInstallError } from './marketStore' +import { ApiError } from '../api/client' +import type { MarketListResponse, NormalizedSkill, NormalizedSkillDetail } from '../types/market' + +const mockedApi = vi.mocked(marketApi) + +function makeSkill(overrides: Partial = {}): NormalizedSkill { + return { + id: 'clawhub:demo', + source: 'clawhub', + slug: 'demo', + name: 'Demo', + summary: 'A demo skill', + author: { handle: 'alice' }, + stats: { downloads: 10 }, + tags: [], + securityStatus: 'unknown', + installState: 'installable', + ...overrides, + } +} + +function makeDetail(overrides: Partial = {}): NormalizedSkillDetail { + return { + ...makeSkill(), + description: '# Demo', + files: [{ path: 'SKILL.md', size: 10, language: 'markdown', tooBig: false }], + totalSize: 10, + ...overrides, + } +} + +function listResponse(items: NormalizedSkill[], nextCursor: string | null = null): MarketListResponse { + return { + items, + nextCursor, + sources: { + clawhub: { status: 'ok' }, + skillhub: { status: 'ok' }, + }, + } +} + +beforeEach(() => { + vi.clearAllMocks() + useMarketStore.setState({ + items: [], + nextCursor: null, + sources: {}, + query: '', + filters: { source: 'all', security: 'all', installed: 'all' }, + isLoading: false, + isLoadingMore: false, + error: null, + selectedId: null, + detail: null, + isDetailLoading: false, + detailError: null, + detailCache: new Map(), + activeFilePath: null, + fileCache: new Map(), + installingIds: new Set(), + installError: null, + }) +}) + +describe('marketStore list', () => { + it('fetches and stores items with source statuses', async () => { + mockedApi.list.mockResolvedValue(listResponse([makeSkill()], 'cursor-1')) + + await useMarketStore.getState().fetchList({ reset: true }) + + const state = useMarketStore.getState() + expect(state.items).toHaveLength(1) + expect(state.nextCursor).toBe('cursor-1') + expect(state.sources.clawhub?.status).toBe('ok') + expect(state.isLoading).toBe(false) + }) + + it('stores the error message when the request fails', async () => { + mockedApi.list.mockRejectedValue(new Error('boom')) + + await useMarketStore.getState().fetchList({ reset: true }) + + expect(useMarketStore.getState().error).toBe('boom') + expect(useMarketStore.getState().isLoading).toBe(false) + }) + + it('appends deduplicated items on loadMore', async () => { + const first = makeSkill({ id: 'clawhub:a', slug: 'a' }) + const dupe = makeSkill({ id: 'clawhub:a', slug: 'a' }) + const fresh = makeSkill({ id: 'skillhub:b', slug: 'b', source: 'skillhub' }) + useMarketStore.setState({ items: [first], nextCursor: 'next' }) + mockedApi.list.mockResolvedValue(listResponse([dupe, fresh], null)) + + await useMarketStore.getState().loadMore() + + const state = useMarketStore.getState() + expect(state.items.map((i) => i.id)).toEqual(['clawhub:a', 'skillhub:b']) + expect(state.nextCursor).toBeNull() + }) + + it('does not loadMore without a cursor', async () => { + await useMarketStore.getState().loadMore() + expect(mockedApi.list).not.toHaveBeenCalled() + }) + + it('passes filters to the api', async () => { + mockedApi.list.mockResolvedValue(listResponse([])) + useMarketStore.setState({ filters: { source: 'skillhub', security: 'benign', installed: 'installed' } }) + + await useMarketStore.getState().fetchList({ reset: true }) + + expect(mockedApi.list).toHaveBeenCalledWith( + expect.objectContaining({ source: 'skillhub', security: 'benign', installed: 'installed' }), + ) + }) +}) + +describe('marketStore detail cache', () => { + it('fetches detail once and serves the second open from cache', async () => { + mockedApi.detail.mockResolvedValue({ skill: makeDetail(), sourceStatus: { status: 'ok' } }) + + await useMarketStore.getState().openDetail('clawhub:demo') + expect(useMarketStore.getState().detail?.id).toBe('clawhub:demo') + + useMarketStore.getState().backToList() + await useMarketStore.getState().openDetail('clawhub:demo') + + expect(mockedApi.detail).toHaveBeenCalledTimes(1) + expect(useMarketStore.getState().detail?.id).toBe('clawhub:demo') + }) + + it('records detailError on failure', async () => { + mockedApi.detail.mockRejectedValue(new Error('down')) + + await useMarketStore.getState().openDetail('clawhub:demo') + + expect(useMarketStore.getState().detailError).toBe('down') + expect(useMarketStore.getState().isDetailLoading).toBe(false) + }) +}) + +describe('marketStore file cache', () => { + it('caches file content per skill+path', async () => { + mockedApi.fileContent.mockResolvedValue({ + file: { path: 'SKILL.md', content: '# x', language: 'markdown', size: 3, truncated: false }, + }) + + const first = await useMarketStore.getState().fetchFileContent('clawhub:demo', 'SKILL.md') + const second = await useMarketStore.getState().fetchFileContent('clawhub:demo', 'SKILL.md') + + expect(first.content).toBe('# x') + expect(second).toBe(first) + expect(mockedApi.fileContent).toHaveBeenCalledTimes(1) + }) +}) + +describe('marketStore install/uninstall', () => { + it('marks the item installed in list and detail after install', async () => { + const detail = makeDetail() + useMarketStore.setState({ + items: [makeSkill()], + detail, + selectedId: detail.id, + detailCache: new Map([[detail.id, detail]]), + }) + mockedApi.install.mockResolvedValue({ + ok: true, + installedPath: '/tmp/skills/demo', + skill: makeSkill({ installState: 'installed', installedInfo: { dirName: 'demo' } }), + }) + + const ok = await useMarketStore.getState().install('clawhub:demo') + + expect(ok).toBe(true) + const state = useMarketStore.getState() + expect(state.items[0]!.installState).toBe('installed') + expect(state.detail?.installState).toBe('installed') + expect(state.detailCache.get('clawhub:demo')?.installState).toBe('installed') + expect(state.installingIds.has('clawhub:demo')).toBe(false) + }) + + it('prevents concurrent installs of the same skill', async () => { + useMarketStore.setState({ installingIds: new Set(['clawhub:demo']) }) + + const ok = await useMarketStore.getState().install('clawhub:demo') + + expect(ok).toBe(false) + expect(mockedApi.install).not.toHaveBeenCalled() + }) + + it('classifies install errors and clears the installing flag', async () => { + useMarketStore.setState({ items: [makeSkill()] }) + mockedApi.install.mockRejectedValue(new ApiError(502, { error: 'MARKET_CHECKSUM_MISMATCH', message: 'bad hash' })) + + const ok = await useMarketStore.getState().install('clawhub:demo') + + expect(ok).toBe(false) + const state = useMarketStore.getState() + expect(state.installError?.kind).toBe('checksum') + expect(state.installingIds.has('clawhub:demo')).toBe(false) + }) + + it('flips state back to installable after uninstall', async () => { + useMarketStore.setState({ items: [makeSkill({ installState: 'installed' })] }) + mockedApi.uninstall.mockResolvedValue({ + ok: true, + removedPath: '/tmp/skills/demo', + skill: makeSkill({ installState: 'installable' }), + }) + + const ok = await useMarketStore.getState().uninstall('clawhub:demo') + + expect(ok).toBe(true) + expect(useMarketStore.getState().items[0]!.installState).toBe('installable') + }) +}) + +describe('classifyInstallError', () => { + it('maps API error codes to error kinds', () => { + expect(classifyInstallError(new ApiError(409, { error: 'MARKET_ALREADY_INSTALLED', message: 'x' })).kind).toBe('exists') + expect(classifyInstallError(new ApiError(409, { error: 'MARKET_INSTALL_IN_PROGRESS', message: 'x' })).kind).toBe('exists') + expect(classifyInstallError(new ApiError(422, { error: 'MARKET_NOT_INSTALLABLE', message: 'x' })).kind).toBe('notInstallable') + expect(classifyInstallError(new ApiError(500, { error: 'MARKET_DISK_ERROR', message: 'x' })).kind).toBe('disk') + expect(classifyInstallError(new ApiError(502, { error: 'MARKET_UPSTREAM_TIMEOUT', message: 'x' })).kind).toBe('network') + expect(classifyInstallError(new Error('Request timed out after 120s')).kind).toBe('network') + expect(classifyInstallError(new Error('weird')).kind).toBe('generic') + }) +}) diff --git a/desktop/src/stores/marketStore.ts b/desktop/src/stores/marketStore.ts new file mode 100644 index 00000000..03226e74 --- /dev/null +++ b/desktop/src/stores/marketStore.ts @@ -0,0 +1,313 @@ +import { create } from 'zustand' +import { marketApi } from '../api/market' +import { ApiError } from '../api/client' +import type { + MarketFileContent, + MarketInstalledFilter, + MarketSecurityFilter, + MarketSource, + MarketSourceFilter, + NormalizedSkill, + NormalizedSkillDetail, + SourceStatusInfo, +} from '../types/market' + +export type MarketInstallErrorKind = 'network' | 'checksum' | 'exists' | 'disk' | 'notInstallable' | 'generic' + +export type MarketFilters = { + source: MarketSourceFilter + security: MarketSecurityFilter + installed: MarketInstalledFilter +} + +const PAGE_SIZE = 24 +const SEARCH_DEBOUNCE_MS = 300 + +export function classifyInstallError(error: unknown): { kind: MarketInstallErrorKind; message: string } { + const message = error instanceof Error ? error.message : String(error) + if (error instanceof ApiError) { + const code = + error.body && typeof error.body === 'object' && 'error' in error.body + ? String((error.body as { error?: unknown }).error) + : '' + if (code === 'MARKET_CHECKSUM_MISMATCH') return { kind: 'checksum', message } + if (code === 'MARKET_ALREADY_INSTALLED' || code === 'MARKET_INSTALL_IN_PROGRESS') { + return { kind: 'exists', message } + } + if (code === 'MARKET_NOT_INSTALLABLE') return { kind: 'notInstallable', message } + if (code === 'MARKET_DISK_ERROR') return { kind: 'disk', message } + if (code.startsWith('MARKET_UPSTREAM')) return { kind: 'network', message } + return { kind: 'generic', message } + } + if (message.toLowerCase().includes('timed out') || message.toLowerCase().includes('fetch')) { + return { kind: 'network', message } + } + return { kind: 'generic', message } +} + +type MarketStore = { + items: NormalizedSkill[] + nextCursor: string | null + sources: Partial> + query: string + filters: MarketFilters + isLoading: boolean + isLoadingMore: boolean + error: string | null + + selectedId: string | null + detail: NormalizedSkillDetail | null + isDetailLoading: boolean + detailError: string | null + detailCache: Map + + activeFilePath: string | null + fileCache: Map + + installingIds: Set + installError: { id: string; kind: MarketInstallErrorKind; message: string } | null + + fetchList: (options?: { reset?: boolean }) => Promise + loadMore: () => Promise + setQuery: (q: string) => void + setFilter: (key: K, value: MarketFilters[K]) => void + openDetail: (id: string) => Promise + refreshDetail: (id: string) => Promise + /** Fetch a file's content with session-level caching. Throws on failure. */ + fetchFileContent: (id: string, path: string) => Promise + install: (id: string) => Promise + uninstall: (id: string) => Promise + backToList: () => void +} + +let searchDebounceTimer: ReturnType | null = null +let listRequestSeq = 0 + +function fileCacheKey(id: string, path: string): string { + return `${id}:${path}` +} + +/** Replace an item in place (list + detail) after install/uninstall state changes. */ +function mergeSkillUpdate(state: MarketStore, updated: NormalizedSkill): Partial { + const items = state.items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)) + const patch: Partial = { items } + if (state.detail && state.detail.id === updated.id) { + const detail = { + ...state.detail, + installState: updated.installState, + notInstallableReason: updated.notInstallableReason, + installedInfo: updated.installedInfo, + } + patch.detail = detail + const cache = new Map(state.detailCache) + cache.set(updated.id, detail) + patch.detailCache = cache + } else { + const cached = state.detailCache.get(updated.id) + if (cached) { + const cache = new Map(state.detailCache) + cache.set(updated.id, { + ...cached, + installState: updated.installState, + notInstallableReason: updated.notInstallableReason, + installedInfo: updated.installedInfo, + }) + patch.detailCache = cache + } + } + return patch +} + +export const useMarketStore = create((set, get) => ({ + items: [], + nextCursor: null, + sources: {}, + query: '', + filters: { source: 'all', security: 'all', installed: 'all' }, + isLoading: false, + isLoadingMore: false, + error: null, + + selectedId: null, + detail: null, + isDetailLoading: false, + detailError: null, + detailCache: new Map(), + + activeFilePath: null, + fileCache: new Map(), + + installingIds: new Set(), + installError: null, + + fetchList: async ({ reset = true } = {}) => { + const seq = ++listRequestSeq + const { query, filters } = get() + set({ isLoading: true, error: null, ...(reset ? { items: [], nextCursor: null } : {}) }) + try { + const result = await marketApi.list({ + q: query.trim() || undefined, + source: filters.source, + security: filters.security, + installed: filters.installed, + limit: PAGE_SIZE, + }) + if (seq !== listRequestSeq) return + set({ + items: result.items, + nextCursor: result.nextCursor, + sources: result.sources, + isLoading: false, + }) + } catch (err) { + if (seq !== listRequestSeq) return + set({ error: err instanceof Error ? err.message : String(err), isLoading: false }) + } + }, + + loadMore: async () => { + const { nextCursor, isLoadingMore, isLoading, query, filters, items } = get() + if (!nextCursor || isLoadingMore || isLoading) return + const seq = listRequestSeq + set({ isLoadingMore: true }) + try { + const result = await marketApi.list({ + q: query.trim() || undefined, + source: filters.source, + security: filters.security, + installed: filters.installed, + cursor: nextCursor, + limit: PAGE_SIZE, + }) + if (seq !== listRequestSeq) return + const seen = new Set(items.map((i) => i.id)) + const appended = result.items.filter((i) => !seen.has(i.id)) + set({ + items: [...items, ...appended], + nextCursor: result.nextCursor, + sources: result.sources, + isLoadingMore: false, + }) + } catch (err) { + if (seq !== listRequestSeq) return + set({ error: err instanceof Error ? err.message : String(err), isLoadingMore: false }) + } + }, + + setQuery: (q) => { + set({ query: q }) + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) + searchDebounceTimer = setTimeout(() => { + void get().fetchList({ reset: true }) + }, SEARCH_DEBOUNCE_MS) + }, + + setFilter: (key, value) => { + set({ filters: { ...get().filters, [key]: value } }) + void get().fetchList({ reset: true }) + }, + + openDetail: async (id) => { + const { detailCache } = get() + const cached = detailCache.get(id) + set({ selectedId: id, detailError: null, activeFilePath: null, installError: null }) + if (cached) { + set({ detail: cached, isDetailLoading: false }) + return + } + set({ detail: null, isDetailLoading: true }) + await get().refreshDetail(id) + }, + + refreshDetail: async (id) => { + const [source, ...slugParts] = id.split(':') + const slug = slugParts.join(':') + set({ isDetailLoading: get().detail?.id !== id, detailError: null }) + try { + const { skill } = await marketApi.detail(source as MarketSource, slug) + if (get().selectedId !== id) return + const cache = new Map(get().detailCache) + cache.set(id, skill) + set({ detail: skill, detailCache: cache, isDetailLoading: false, detailError: null }) + } catch (err) { + if (get().selectedId !== id) return + set({ + detailError: err instanceof Error ? err.message : String(err), + isDetailLoading: false, + }) + } + }, + + fetchFileContent: async (id, path) => { + const key = fileCacheKey(id, path) + const cached = get().fileCache.get(key) + if (cached) return cached + const [source, ...slugParts] = id.split(':') + const slug = slugParts.join(':') + const { file } = await marketApi.fileContent(source as MarketSource, slug, path) + const cache = new Map(get().fileCache) + cache.set(key, file) + set({ fileCache: cache }) + return file + }, + + install: async (id) => { + const { installingIds } = get() + if (installingIds.has(id)) return false + set({ installingIds: new Set(installingIds).add(id), installError: null }) + try { + const result = await marketApi.install(id) + const state = get() + const next = new Set(state.installingIds) + next.delete(id) + set({ installingIds: next, ...mergeSkillUpdate(state, result.skill) }) + return true + } catch (err) { + const state = get() + const next = new Set(state.installingIds) + next.delete(id) + const classified = classifyInstallError(err) + set({ installingIds: next, installError: { id, ...classified } }) + return false + } + }, + + uninstall: async (id) => { + const { installingIds } = get() + if (installingIds.has(id)) return false + set({ installingIds: new Set(installingIds).add(id), installError: null }) + try { + const result = await marketApi.uninstall(id) + const state = get() + const next = new Set(state.installingIds) + next.delete(id) + if (result.skill) { + set({ installingIds: next, ...mergeSkillUpdate(state, result.skill) }) + } else { + // Upstream lookup failed after removal — flip local state manually. + const fallback: Partial = { + installState: 'installable', + installedInfo: undefined, + notInstallableReason: undefined, + } + const current = state.items.find((i) => i.id === id) + set({ + installingIds: next, + ...mergeSkillUpdate(state, { ...(current ?? ({ id } as NormalizedSkill)), ...fallback } as NormalizedSkill), + }) + } + return true + } catch (err) { + const state = get() + const next = new Set(state.installingIds) + next.delete(id) + const classified = classifyInstallError(err) + set({ installingIds: next, installError: { id, ...classified } }) + return false + } + }, + + backToList: () => { + set({ selectedId: null, detail: null, detailError: null, activeFilePath: null, installError: null }) + }, +})) diff --git a/desktop/src/stores/tabStore.ts b/desktop/src/stores/tabStore.ts index 30e15586..e2da9f24 100644 --- a/desktop/src/stores/tabStore.ts +++ b/desktop/src/stores/tabStore.ts @@ -7,12 +7,13 @@ const TAB_STORAGE_KEY = 'cc-haha-open-tabs' export const SETTINGS_TAB_ID = '__settings__' export const SCHEDULED_TAB_ID = '__scheduled__' +export const MARKET_TAB_ID = '__market__' export const TRACE_LIST_TAB_ID = '__traces__' export const TERMINAL_TAB_PREFIX = '__terminal__' export const TRACE_TAB_PREFIX = '__trace__' export const WORKBENCH_TAB_PREFIX = '__workbench__' -export type TabType = 'session' | 'settings' | 'scheduled' | 'terminal' | 'trace' | 'traces' | 'workbench' +export type TabType = 'session' | 'settings' | 'scheduled' | 'market' | 'terminal' | 'trace' | 'traces' | 'workbench' export type Tab = { sessionId: string @@ -283,14 +284,14 @@ export const useTabStore = create((set, get) => ({ const validTabs: Tab[] = data.openTabs .filter((t) => { // Special tabs are always valid - if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'traces') return true + if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'market' || t.type === 'traces') return true if (t.type === 'trace') return !!t.traceSessionId && existingIds.has(t.traceSessionId) if (t.type === 'terminal') return false // Session tabs must exist on server return existingIds.has(t.sessionId) }) .map((t) => { - if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'traces') { + if (t.type === 'settings' || t.type === 'scheduled' || t.type === 'market' || t.type === 'traces') { return { sessionId: t.sessionId, title: t.title, type: t.type, status: 'idle' as const } } if (t.type === 'trace' && t.traceSessionId) { diff --git a/desktop/src/types/market.ts b/desktop/src/types/market.ts new file mode 100644 index 00000000..5e259e63 --- /dev/null +++ b/desktop/src/types/market.ts @@ -0,0 +1,90 @@ +export type MarketSource = 'clawhub' | 'skillhub' + +export const MARKET_SOURCES: MarketSource[] = ['clawhub', 'skillhub'] + +export type SecurityStatus = 'verified' | 'benign' | 'unknown' | 'flagged' + +export type InstallState = 'installed' | 'installable' | 'not-installable' + +export type SourceHealthStatus = 'ok' | 'degraded' | 'failed' | 'cached' + +export type NotInstallableReason = + | 'empty-file-list' + | 'file-too-large' + | 'too-many-files' + | 'invalid-name' + | 'name-conflict' + | 'source-unavailable' + +export type SecurityReport = { + vendor: string + status: string + statusText: string + reportUrl?: string +} + +export type NormalizedSkill = { + id: string + source: MarketSource + slug: string + name: string + summary: string + author: { handle: string; displayName?: string; avatarUrl?: string } + stats: { downloads: number; installs?: number; stars?: number } + tags: string[] + category?: string + version?: string + updatedAt?: number + iconUrl?: string + securityStatus: SecurityStatus + securityReports?: SecurityReport[] + requiresApiKey?: boolean + verified?: boolean + upstream?: { source: MarketSource; slug: string } + mirrors?: string[] + installState: InstallState + notInstallableReason?: NotInstallableReason + installedInfo?: { version?: string; installedAt?: string; dirName: string } +} + +export type MarketFileMeta = { + path: string + size: number + sha256?: string + contentType?: string + language: string + tooBig: boolean +} + +export type NormalizedSkillDetail = NormalizedSkill & { + description: string + descriptionFrontmatter?: Record + license?: string + files: MarketFileMeta[] + totalSize: number +} + +export type MarketFileContent = { + path: string + content: string + language: string + size: number + truncated: boolean +} + +export type SourceStatusInfo = { + status: SourceHealthStatus + fetchedAt?: number + fromCache?: boolean + error?: string +} + +export type MarketListResponse = { + items: NormalizedSkill[] + nextCursor: string | null + sources: Record +} + +export type MarketSourceFilter = 'all' | MarketSource +export type MarketSecurityFilter = 'all' | SecurityStatus +export type MarketInstalledFilter = 'all' | 'installed' | 'installable' diff --git a/desktop/src/types/skill.ts b/desktop/src/types/skill.ts index 43511a32..e24f33a0 100644 --- a/desktop/src/types/skill.ts +++ b/desktop/src/types/skill.ts @@ -30,9 +30,20 @@ export type SkillFile = { isEntry?: boolean } +export type SkillMarketMeta = { + id: string + source: string + slug: string + version?: string + installedAt?: string + fileCount?: number +} + export type SkillDetail = { meta: SkillMeta tree: FileTreeNode[] files: SkillFile[] skillRoot: string + /** Present when the skill was installed from the Skills Market. */ + marketMeta?: SkillMarketMeta } diff --git a/src/server/__tests__/fixtures/market/clawhub-detail.json b/src/server/__tests__/fixtures/market/clawhub-detail.json new file mode 100644 index 00000000..fec56146 --- /dev/null +++ b/src/server/__tests__/fixtures/market/clawhub-detail.json @@ -0,0 +1 @@ +{"skill":{"slug":"git","displayName":"Git","summary":"Git commits, branches, rebases, merges, conflict resolution, history recovery, team workflows, and the commands needed for safe day-to-day version control. U...","description":"---\nname: Git\nslug: git\nversion: 1.0.8\ndescription: \"Git commits, branches, rebases, merges, conflict resolution, history recovery, team workflows, and the commands needed for safe day-to-day version control. Use when (1) the task touches Git, a repository, commits, branches, merges, rebases, or pull requests; (2) history safety, collaboration, or recovery matter; (3) the agent should automatically apply Git discipline instead of improvising.\"\nhomepage: https://clawic.com/skills/git\nchangelog: Simplified the skill name and kept the stateless activation guidance\nmetadata: {\"clawdbot\":{\"emoji\":\"📚\",\"requires\":{\"bins\":[\"git\"]},\"os\":[\"linux\",\"darwin\",\"win32\"]}}\n---\n\n## When to Use\n\nUse when the task involves Git repositories, branches, commits, merges, rebases, pull requests, conflict resolution, history inspection, or recovery. This skill is stateless and should be applied by default whenever Git work is part of the job.\n\n## Quick Reference\n\n| Topic | File |\n|-------|------|\n| Essential commands | `commands.md` |\n| Advanced operations | `advanced.md` |\n| Branch strategies | `branching.md` |\n| Conflict resolution | `conflicts.md` |\n| History and recovery | `history.md` |\n| Team workflows | `collaboration.md` |\n\n## Core Rules\n\n1. **Never force push to shared branches** — Use `--force-with-lease` on feature branches only\n2. **Commit early, commit often** — Small commits are easier to review, revert, and bisect\n3. **Write meaningful commit messages** — First line under 72 chars, imperative mood\n4. **Pull before push** — Always `git pull --rebase` before pushing to avoid merge commits\n5. **Clean up before merging** — Use `git rebase -i` to squash fixup commits\n\n## Team Workflows\n\n**Feature Branch Flow:**\n1. `git checkout -b feature/name` from main\n2. Make commits, push regularly\n3. Open PR, get review\n4. Squash and merge to main\n5. Delete feature branch\n\n**Hotfix Flow:**\n1. `git checkout -b hotfix/issue` from main\n2. Fix, test, commit\n3. Merge to main AND develop (if exists)\n4. Tag the release\n\n**Daily Sync:**\n```bash\ngit fetch --all --prune\ngit rebase origin/main # or merge if team prefers\n```\n\n## Commit Messages\n\n- Use conventional commit format: `type(scope): description`\n- Keep first line under 72 characters\n- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`\n\n## Push Safety\n\n- Use `git push --force-with-lease` instead of `--force` — prevents overwriting others' work\n- If push rejected, run `git pull --rebase` before retrying\n- Never force push to main/master branch\n\n## Conflict Resolution\n\n- After editing conflicted files, verify no markers remain: `grep -r \"<<<\\|>>>\\|===\" .`\n- Test that code builds before completing merge\n- If merge becomes complex, abort with `git merge --abort` and try `git rebase` instead\n\n## Branch Hygiene\n\n- Delete merged branches locally: `git branch -d branch-name`\n- Clean remote tracking: `git fetch --prune`\n- Before creating PR, rebase feature branch onto latest main\n- Use `git rebase -i` to squash messy commits before pushing\n\n## Safety Checklist\n\nBefore destructive operations (`reset --hard`, `rebase`, `force push`):\n\n- [ ] Is this a shared branch? → Don't rewrite history\n- [ ] Do I have uncommitted changes? → Stash or commit first\n- [ ] Am I on the right branch? → `git branch` to verify\n- [ ] Is remote up to date? → `git fetch` first\n\n## Common Traps\n\n- **git user.email wrong** — Verify with `git config user.email` before important commits\n- **Empty directories** — Git doesn't track them, add `.gitkeep`\n- **Submodules** — Always clone with `--recurse-submodules`\n- **Detached HEAD** — Use `git switch -` to return to previous branch\n- **Push rejected** — Usually needs `git pull --rebase` first\n- **stash pop on conflict** — Stash disappears. Use `stash apply` instead\n- **Large files** — Use Git LFS for files >50MB, never commit secrets\n- **Case sensitivity** — Mac/Windows ignore case, Linux doesn't — causes CI failures\n\n## Recovery Commands\n\n- Undo last commit keeping changes: `git reset --soft HEAD~1`\n- Discard unstaged changes: `git restore filename`\n- Find lost commits: `git reflog` (keeps ~90 days of history)\n- Recover deleted branch: `git checkout -b branch-name `\n- Use `git add -p` for partial staging when commit mixes multiple changes\n\n## Debugging with Bisect\n\nFind the commit that introduced a bug:\n```bash\ngit bisect start\ngit bisect bad # current commit is broken\ngit bisect good v1.0.0 # this version worked\n# Git checks out middle commit, test it, then:\ngit bisect good # or git bisect bad\n# Repeat until Git finds the culprit\ngit bisect reset # return to original branch\n```\n\n## Quick Summary\n\n```bash\ngit status -sb # short status with branch\ngit log --oneline -5 # last 5 commits\ngit shortlog -sn # contributors by commit count\ngit diff --stat HEAD~5 # changes summary last 5 commits\ngit branch -vv # branches with tracking info\ngit stash list # pending stashes\n```\n\n## Related Skills\nInstall with `clawhub install ` if user confirms:\n- `gitlab` — GitLab CI/CD and merge requests\n- `docker` — Containerization workflows\n- `code` — Code quality and best practices\n\n## Feedback\n\n- If useful: `clawhub star git`\n- Stay updated: `clawhub sync`\n","topics":["Git"],"tags":{"latest":"1.0.8"},"stats":{"comments":0,"downloads":16142,"installs":510,"stars":31,"versions":9},"createdAt":1770653293499,"updatedAt":1778486231449},"latestVersion":{"version":"1.0.8","createdAt":1773255795217,"changelog":"Simplified the skill name and kept the stateless activation guidance","license":"MIT-0"},"metadata":{"setup":[],"os":["linux","darwin","win32"],"systems":null},"owner":{"handle":"ivangdavila","userId":"s178jdk12x4qj3gs2se3etxf3h83h7ft","displayName":"Iván","image":"https://avatars.githubusercontent.com/u/81719670?v=4"},"moderation":null} \ No newline at end of file diff --git a/src/server/__tests__/fixtures/market/clawhub-list.json b/src/server/__tests__/fixtures/market/clawhub-list.json new file mode 100644 index 00000000..3db0dbff --- /dev/null +++ b/src/server/__tests__/fixtures/market/clawhub-list.json @@ -0,0 +1 @@ +{"items":[{"slug":"self-improving-agent","displayName":"self-improving agent","summary":"Captures learnings, errors, and corrections to enable continuous improvement for the agent.","description":"Captures learnings, errors, and corrections to enable continuous improvement. Use when: (1) A command or operation fails unexpectedly, (2) User corrects Claude ('No, that's wrong...', 'Actually...'), (3) User requests a capability that doesn't exist, (4) An external API or tool fails, (5) Claude realizes its knowledge is outdated or incorrect, (6) A better approach is discovered for a recurring task. Also review learnings before major tasks.","topics":["self-improvement"],"tags":{"latest":"4.0.1"},"stats":{"comments":53,"downloads":466224,"installs":18337,"stars":3900,"versions":38},"createdAt":1767632598365,"updatedAt":1783258083853,"latestVersion":{"version":"4.0.1","createdAt":1783258083853,"changelog":"**Automatic error detection**: the hook now sweeps each ended session's\n transcript (`/new` / `/reset`) for error patterns and logs pending,\n redacted entries to `.learnings/ERRORS.md` — no agent discipline needed.\n Opt-in: create `~/.openclaw/workspace/.learnings` to enable.\n- **Pattern-Key dedup**: every entry carries a stable `area.symptom` key\n (e.g. `deps.module-not-found`) under a controlled taxonomy; auto-detected\n errors are stamped automatically, making recurrence counting and\n promotion (to `SOUL.md` / `TOOLS.md` / `AGENTS.md`) reliable.\n- **Triage loop**: the bootstrap reminder flags auto-detected errors\n awaiting review at the start of each session.\n- **Now OpenClaw-only**: leaner skill prompt; for Claude Code/Codex/Copilot\n use the original multi-agent version (pskoett/pskoett-ai-skills).\n- Added versioned CHANGELOG with upgrade notes, uninstall guide, test\n suite + CI, and a clean skill package layout for ClawdHub.\n\n**Upgrading**: re-copy the hook\n(`cp -r ~/.openclaw/skills/self-improving-agent/hooks/openclaw ~/.openclaw/hooks/self-improvement`)\nand restart the gateway. `.learnings/` data is never touched by upgrades.","license":null},"metadata":null},{"slug":"skill-vetter","displayName":"Skill Vetter","summary":"Security-first skill vetting for AI agents. Use before installing any skill from ClawdHub, GitHub, or other sources. Checks for red flags, permission scope, and suspicious patterns.","description":null,"topics":["GitHub","Permission"],"tags":{"latest":"1.0.0"},"stats":{"comments":0,"downloads":262262,"installs":12043,"stars":1254,"versions":1},"createdAt":1769863429632,"updatedAt":1778485878349,"latestVersion":{"version":"1.0.0","createdAt":1769863429632,"changelog":"Initial release - Security-first skill vetting for AI agents","license":null},"metadata":null},{"slug":"self-improving","displayName":"Self-Improving + Proactive Agent","summary":"Self-reflection + Self-criticism + Self-learning + Self-organizing memory. Agent evaluates its own work, catches mistakes, and improves permanently. Use when...","description":null,"topics":["Self Improving","Learning"],"tags":{"latest":"1.2.16"},"stats":{"comments":0,"downloads":202609,"installs":7278,"stars":1251,"versions":22},"createdAt":1771266418607,"updatedAt":1778491556797,"latestVersion":{"version":"1.2.16","createdAt":1773329327755,"changelog":"Clarifies the setup flow for proactive follow-through and safer installation behavior.","license":null},"metadata":{"setup":[],"os":["linux","darwin","win32"],"systems":null}}],"nextCursor":"{\"v\":1,\"index\":\"by_active_stats_downloads\",\"key\":[{\"__undef\":1},202609,1778491556797,1773255917793.7744,\"r1774whtkkgc7rm5ykeen9k5y582p0w6\"]}"} \ No newline at end of file diff --git a/src/server/__tests__/fixtures/market/clawhub-search.json b/src/server/__tests__/fixtures/market/clawhub-search.json new file mode 100644 index 00000000..b50ee378 --- /dev/null +++ b/src/server/__tests__/fixtures/market/clawhub-search.json @@ -0,0 +1 @@ +{"results":[{"score":4.142919423580169,"slug":"git","displayName":"Git","summary":"Git commits, branches, rebases, merges, conflict resolution, history recovery, team workflows, and the commands needed for safe day-to-day version control. U...","version":null,"downloads":16142,"updatedAt":1778486231449,"ownerHandle":"ivangdavila","owner":{"handle":"ivangdavila","displayName":"Iván","image":"https://avatars.githubusercontent.com/u/81719670?v=4"}},{"score":2.9710389301153994,"slug":"skill-git-scm","displayName":"Git","summary":"git:interface-01 - Clone or Update","version":null,"downloads":733,"updatedAt":1780304247069,"ownerHandle":"lentiancn","owner":{"handle":"lentiancn","displayName":"Len","image":"https://avatars.githubusercontent.com/u/16249427?v=4"}},{"score":2.3815246494276923,"slug":"git2","displayName":"git","summary":"快速处理各种 Git 指令的技能。适用于:git 操作、代码提交、分支管理、合并冲突、版本回退、远程仓库同步、查看提交历史、撤销修改等场景。触发词:git、提交、推送、拉取、分支、合并、rebase、reset、revert、stash、cherry-pick、tag、diff、log、status。","version":null,"downloads":636,"updatedAt":1778492186671,"ownerHandle":"mickey0811","owner":{"handle":"mickey0811","displayName":"Mickey0811","image":"https://avatars.githubusercontent.com/u/49522921?v=4"}},{"score":3.0821504259109496,"slug":"git-workflows","displayName":"Git Workflows","summary":"Advanced git operations beyond add/commit/push. Use when rebasing, bisecting bugs, using worktrees for parallel development, recovering with reflog, managing subtrees/submodules, resolving merge conflicts, cherry-picking across branches, or working with monorepos.","version":null,"downloads":13175,"updatedAt":1778486013286,"ownerHandle":"gitgoodordietrying","owner":{"handle":"gitgoodordietrying","displayName":"gitgoodordietrying","image":"https://avatars.githubusercontent.com/u/116975874?v=4"}},{"score":3.080505278110504,"slug":"git-cli","displayName":"Git cli","summary":"Helper for using the Git CLI to inspect, stage, commit, branch, and synchronize code changes. Use when the user wants to understand or perform Git operations...","version":null,"downloads":2308,"updatedAt":1778491852854,"ownerHandle":"openlang-cn","owner":{"handle":"openlang-cn","displayName":"openlang","image":"https://avatars.githubusercontent.com/u/45782174?v=4"}},{"score":3.0749079251289366,"slug":"git-essentials","displayName":"Git Essentials","summary":"Essential Git commands and workflows for version control, branching, and collaboration.","version":null,"downloads":30204,"updatedAt":1778485868789,"ownerHandle":"arnarsson","owner":{"handle":"arnarsson","displayName":"Arnarsson","image":"https://avatars.githubusercontent.com/u/96142966?v=4"}},{"score":3.0697722101211546,"slug":"git-workflow-cn","displayName":"Git Workflow Cn","summary":"Git 工作流助手 - 分支管理、冲突解决、提交规范。适合:开发者、团队协作。","version":null,"downloads":1541,"updatedAt":1778491894093,"ownerHandle":"yang1002378395-cmyk","owner":{"handle":"yang1002378395-cmyk","displayName":"yang1002378395-cmyk","image":"https://avatars.githubusercontent.com/u/261632147?v=4"}},{"score":3.0506139957904814,"slug":"git-helper","displayName":"Git Helper","summary":"Common git operations as a skill (status, pull, push, branch, log)","version":null,"downloads":6874,"updatedAt":1778486000320,"ownerHandle":"xejrax","owner":{"handle":"xejrax","displayName":"Xejrax","image":"https://avatars.githubusercontent.com/u/148405630?v=4"}},{"score":3.04027756690979,"slug":"git-workflow","displayName":"Git Workflow","summary":"OpenClaw Git 工作流技能。 当用户提及以下任务时使用: - 提交代码或文档 - 推送到远程仓库 - 管理多个 Git 仓库 - 查看 Git 状态 核心能力: - 自动检测文件变更 - 自动生成提交信息 - 自动推送到远程仓库 - 多仓库管理","version":null,"downloads":2470,"updatedAt":1779077449681,"ownerHandle":"broommonk","owner":{"handle":"broommonk","displayName":"broommonk","image":"https://avatars.githubusercontent.com/u/46682368?v=4"}},{"score":3.0112089183188364,"slug":"cnb-cool-git","displayName":"Cnb Cool Git","summary":"CNB 云原生构建平台的 Git 操作技能。使用 git 和 CNB Open API 进行代码克隆、提交、推送、分支管理、Merge Request 管理、流水线触发、流水线结果读取等操作。首次使用需收集用户的 Git 用户名和邮箱信息。","version":null,"downloads":561,"updatedAt":1778492219006,"ownerHandle":"twksos","owner":{"handle":"twksos","displayName":"twksos","image":"https://avatars.githubusercontent.com/u/912025?v=4"}}]} \ No newline at end of file diff --git a/src/server/__tests__/fixtures/market/clawhub-version-detail.json b/src/server/__tests__/fixtures/market/clawhub-version-detail.json new file mode 100644 index 00000000..af5a15f7 --- /dev/null +++ b/src/server/__tests__/fixtures/market/clawhub-version-detail.json @@ -0,0 +1 @@ +{"skill":{"slug":"git","displayName":"Git"},"version":{"version":"1.0.8","createdAt":1773255795217,"changelog":"Simplified the skill name and kept the stateless activation guidance","changelogSource":"user","license":"MIT-0","files":[{"path":"SKILL.md","size":5408,"sha256":"61adf204109255c01af9255c47ae6612492431391e1e1b5585d1c726c135fca9","contentType":"text/markdown"},{"path":"advanced.md","size":3643,"sha256":"e385828e5bf3eca8ab1e9dde26a111b942296a7d76ca873b4c4c996ac08d8722","contentType":"text/markdown"},{"path":"branching.md","size":1802,"sha256":"7aa53aa8959d10ff19475b32c945556530df29eae23f1b5342948e9d66fe8950","contentType":"text/markdown"},{"path":"collaboration.md","size":1406,"sha256":"6bac31e64a6612f936a9b21b86fc054a6e280080fdca63b3f1a7c2e1fd7cda71","contentType":"text/markdown"},{"path":"commands.md","size":3082,"sha256":"3287a964f49fa8c27c249c1af7456520e0d448bc2358de43c453c2e14f9076a9","contentType":"text/markdown"},{"path":"conflicts.md","size":1443,"sha256":"32056d4b06ed7cfb29d42bcae3102c2a7a11247371a468d797eb41bb0a9dc117","contentType":"text/markdown"},{"path":"history.md","size":1491,"sha256":"b1154991db1fa04a5676fd0f64804bcc2ce41c03d3a2379e0d16c2833bdca30b","contentType":"text/markdown"},{"path":"skill-card.md","size":1851,"sha256":"92baf04f6804409f0f86809e1abb9e4523376d4046f15ad4070a61aa73980753","contentType":"text/markdown"}],"security":{"status":"clean","hasWarnings":true,"checkedAt":1779962572343,"model":"gpt-5.5","hasScanResult":true,"sha256hash":"73f26498dcccbf1f12ad7b20dec852eb7c9b33c84fed8ebf1a86c773f9104a8a","virustotalUrl":"https://www.virustotal.com/gui/file/73f26498dcccbf1f12ad7b20dec852eb7c9b33c84fed8ebf1a86c773f9104a8a","scanners":{"vt":{"status":"clean","verdict":"benign","normalizedStatus":"clean","analysis":"Type: OpenClaw Skill\nName: git\nVersion: 1.0.8\n\nThe Git skill bundle is a comprehensive and well-documented set of instructions for an AI agent to perform version control tasks safely. It includes detailed guides on standard Git commands, conflict resolution, and history management (e.g., in commands.md and history.md), while emphasizing safety practices like using --force-with-lease and avoiding destructive operations without checks. No evidence of malicious intent, data exfiltration, or harmful prompt injection was found.","source":"palm","checkedAt":1779162472799},"skillspector":{"status":"suspicious","normalizedStatus":"suspicious","score":30,"severity":"MEDIUM","recommendation":"CAUTION","issueCount":3,"checkedAt":1779962522054},"llm":{"status":"clean","verdict":"benign","normalizedStatus":"clean","confidence":"high","summary":"This is a Git reference skill whose risky commands are normal for version control work and are disclosed, with no hidden scripts or exfiltration behavior found.","dimensions":[{"detail":"The artifacts consistently cover Git commits, branches, rebases, merges, conflict resolution, history recovery, and collaboration, matching the stated purpose.","label":"Purpose & Capability","name":"purpose_capability","rating":"ok"},{"detail":"The skill includes destructive and remote-mutating Git commands such as reset --hard, git clean, branch/tag deletion, rebase, and force-with-lease; these are expected for a Git skill, but users should confirm context before allowing an agent to run them.","label":"Instruction Scope","name":"instruction_scope","rating":"note"},{"detail":"The package contains markdown documentation only, declares a git binary requirement, and includes no executable install script or background component.","label":"Install Mechanism","name":"install_mechanism","rating":"ok"},{"detail":"Git operations are proportionate to the purpose but may use the user's configured remotes and credentials when commands like push, fetch, clone, or delete remote tags are run.","label":"Credentials","name":"environment_proportionality","rating":"note"},{"detail":"Examples include global Git configuration and rerere settings that persist outside a single repository, but this is visible and aligned with Git workflow guidance; no privilege escalation or hidden persistence was found.","label":"Persistence & Privilege","name":"persistence_privilege","rating":"note"}],"guidance":"Safe to install as Git guidance. Before allowing an agent to run commands from it, review any reset, clean, rebase, force-push, tag deletion, or remote push/delete command, and verify the repository, branch, remote, and working-tree status first.","findings":"[SQP-2] expected: The reset --hard recovery example is a real data-loss hazard if copied blindly, but history recovery is a normal Git topic and the main skill includes a destructive-operation checklist.\n[SQP-2] expected: The reset and discard-change command catalog should be used carefully, but the commands are clearly Git-related and not hidden or purpose-mismatched.\n[SQP-2] expected: The git clean examples can delete untracked or ignored files; the section does show a preview command first, so this is cautionary guidance rather than evidence of malicious behavior.","agenticRiskFindings":null,"riskSummary":null,"model":"gpt-5.5","checkedAt":1779962572343}}}}} \ No newline at end of file diff --git a/src/server/__tests__/fixtures/market/skillhub-detail.json b/src/server/__tests__/fixtures/market/skillhub-detail.json new file mode 100644 index 00000000..741763eb --- /dev/null +++ b/src/server/__tests__/fixtures/market/skillhub-detail.json @@ -0,0 +1 @@ +{"contentZhAvailable":false,"latestVersion":{"changelog":"根据最新的监管要求进行skill的思考炼化","createdAt":1777381898516,"version":"1.0.2"},"owner":{"displayName":"Nathan Huang","handle":"user_378eb7ca","image":null},"securityReports":{"keen":{"reportUrl":"https://tix.qq.com/search/skill?keyword=0b374798839f02529b75aada90876eb5","status":"benign","statusText":"安全,无风险"},"sanbu":{"reportUrl":"https://static.cloudsec.tencent.com/html-report-v2/2026/05/26/422473_3257040caafad33a8de6f7cba5be1e07.html?q-sign-algorithm=sha1\u0026q-ak=AKIDEXAMPLE_REDACTED\u0026q-sign-time=1783275203%3B1814811203\u0026q-key-time=1783275203%3B1814811203\u0026q-header-list=host\u0026q-url-param-list=\u0026q-signature=8a2b7327d466d834933903375f2f59c1155f86eb","status":"benign","statusText":"安全,无风险"}},"skill":{"authorVerifiedHandle":null,"category":"professional","claim_state":"unclaimed","claimable":false,"claimed_user_handle":null,"createdAt":1775544400135,"displayName":"私募合规","githubAuthorLogin":null,"iconUrl":"https://cloudcache.tencent-cloud.com/qcloud/ui/static/other_external_resource/a78375f1-773e-4817-b4f3-0557b6afd777.png","isAuthorVerified":false,"labels":{"requires_api_key":"false"},"last_synced_at":null,"slug":"pe-compliance-expert-pro","source":"community","sourceUrl":null,"stats":{"comments":0,"downloads":1978,"installs":40,"stars":1,"versions":2},"subCategories":[{"key":"pro-finance","name":"金融分析"},{"key":"pro-legal","name":"法律合规"}],"summary":"15年经验私募基金合规专家。基于AMAC最新监管规则,对登记备案、募集、内控、披露等8大模块进行审查,生成Word报告。支持 --file, --text 参数。","summary_zh":"15年经验私募基金合规专家。基于AMAC最新监管规则,对登记备案、募集、内控、披露等8大模块进行审查,生成Word报告。支持 --file, --text 参数。","tags":{"latest":"1.0.2"},"updatedAt":1783500045791,"upstream_owner_login":null,"upstream_url":null,"verified":false}} diff --git a/src/server/__tests__/fixtures/market/skillhub-files.json b/src/server/__tests__/fixtures/market/skillhub-files.json new file mode 100644 index 00000000..aefca8e0 --- /dev/null +++ b/src/server/__tests__/fixtures/market/skillhub-files.json @@ -0,0 +1 @@ +{"count":5,"files":[{"path":"_meta.json","sha256":"4f76aee81c5d28e3ddbb7b95bda48158272da1daf9ba020999da907a27645b11","size":170},{"path":"package.json","sha256":"750d25f1e508ae43a800f26e1c0bc12e8bd01c44ad9499e025e7b3269e4957e5","size":429},{"path":"SKILL.md","sha256":"9aa03f88f73043a5d089b768789e240791de3733c780f1f5752e8f3bb36d8a27","size":2872},{"path":"pe_compliance.py","sha256":"3b40892451afe88920fcd344d71f2ee9c5dc6751fef8f56f0b4db98ab946933a","size":25269},{"path":"CLAUDE.md","sha256":"d198015ef59377743e9ccc00a89a38b5160c7fdbc4ba5528417a77cafae9baa7","size":9389}],"version":"1.0.2"} diff --git a/src/server/__tests__/fixtures/market/skillhub-list.json b/src/server/__tests__/fixtures/market/skillhub-list.json new file mode 100644 index 00000000..6b010ba4 --- /dev/null +++ b/src/server/__tests__/fixtures/market/skillhub-list.json @@ -0,0 +1 @@ +{"code":0,"data":{"skills":[{"category":"","claim_state":"unclaimed","claimable":false,"claimed_user_handle":null,"created_at":1783500521357,"description":"根据参保地、缴费基数、已缴年限、退休年龄和个人账户余额,生成悲观 / 中性 / 积极三档社保养老金估算报告。","description_zh":"保险顾问要做养老金测算、社保退休金估算、养老规划第一步、退休稳定现金流底座测算时使用。本 Skill 只负责估算社保养老金这条基础现金流,输出资料完整度、客户基础信息、测算口径、三档结果、代入过程、数据来源和待确认项。它不做退休生活预算、不测完整资金缺口、不生成保险销售话术,也不把估算结果写成社保部门核定金额。","downloads":0,"homepage":"https://api.skillhub.cn/user_88e00a20/chengeng-pension-basic-estimate","iconUrl":null,"installs":0,"labels":null,"last_synced_at":null,"name":"陈庚-养老金测算","ownerName":"user_88e00a20","score":0,"slug":"chengeng-pension-basic-estimate","source":"community","stars":0,"subCategories":[],"tags":null,"updated_at":1783500912006,"upstream_owner_login":null,"upstream_url":null,"verified":false,"version":"1.0.0"},{"category":"","claim_state":"unclaimed","claimable":false,"claimed_user_handle":null,"created_at":1783500574836,"description":"医药行业政策法规智能检索与结构化解读。当用户需要查询医药相关政策法规、解读政策对业务的影响、评估政策合规要求、或追踪最新政策动态时触发。支持全国通用政策与分省政策查询,自动过滤已失效政策,聚焦已发布及即将执行的新规。适用于合规专员、政府事务经理、医药业务人员的日常政策监测与影响评估场景。","description_zh":"医药行业政策法规智能检索与结构化解读。当用户需要查询医药相关政策法规、解读政策对业务的影响、评估政策合规要求、或追踪最新政策动态时触发。支持全国通用政策与分省政策查询,自动过滤已失效政策,聚焦已发布及即将执行的新规。适用于合规专员、政府事务经理、医药业务人员的日常政策监测与影响评估场景。","downloads":0,"homepage":"https://api.skillhub.cn/user_c191f8da/zcfgznjd","iconUrl":"https://cloudcache.tencent-cloud.com/qcloud/ui/static/other_external_resource/a00e31c7-8d6a-4e65-84fc-16a177f95674.png","installs":0,"labels":null,"last_synced_at":null,"name":"政策法规智能解读","ownerName":"user_c191f8da","score":0,"slug":"zcfgznjd","source":"community","stars":0,"subCategories":[],"tags":null,"updated_at":1783500882151,"upstream_owner_login":null,"upstream_url":null,"verified":false,"version":"1.0.0"},{"category":"office-efficiency","claim_state":"unclaimed","claimable":true,"claimed_user_handle":null,"created_at":1774874892574,"description":"PDF to Word converts PDF to editable Word/DOCX with AI-powered layout analysis and table recognition, built on ComPDF Conversion SDK to better preserve table...","description_zh":"PDF转Word功能将PDF转换为可编辑的Word/DOCX文档,采用AI布局分析和表格识别,基于ComPDF Conversion SDK,更好地保留表格结构。","downloads":1772,"homepage":"https://api.skillhub.cn/compdf-youna/pdf-to-word-docx","iconUrl":"https://cloudcache.tencent-cloud.com/qcloud/ui/static/other_external_resource/81015f94-5620-494e-bbd6-ac3268e7ccd3.png","installs":253,"labels":{"requires_api_key":"false"},"last_synced_at":1782308861831,"name":"PDF to Word Converter","ownerName":"compdf-youna","score":100000,"slug":"pdf-to-word-docx","source":"clawhub","stars":98,"subCategories":[{"key":"office-doc","name":"文档处理"},{"key":"office-pdf","name":"PDF 处理"}],"tags":null,"updated_at":1783500865083,"upstream_owner_login":"compdf-youna","upstream_url":"https://clawhub.ai/compdf-youna/pdf-to-word-docx","verified":false,"version":"1.2.0"}],"total":75070},"message":"success"} diff --git a/src/server/__tests__/fixtures/market/skillhub-search.json b/src/server/__tests__/fixtures/market/skillhub-search.json new file mode 100644 index 00000000..286af804 --- /dev/null +++ b/src/server/__tests__/fixtures/market/skillhub-search.json @@ -0,0 +1 @@ +{"code":0,"data":{"skills":[{"category":"content-creation","claim_state":"unclaimed","claimable":true,"claimed_user_handle":null,"created_at":1774909371479,"description":"自动转录视频音频,智能修正润色并生成逐字稿及知乎、微信、小红书多平台发布稿,支持用户个性化定制。","description_zh":"自动转录视频音频,智能校对润色,生成逐字稿并适配知乎、微信、小红书等多平台发布,支持个性化定制。","downloads":501,"homepage":"https://api.skillhub.cn/artminding/video-transcript-pro","iconUrl":"https://cloudcache.tencent-cloud.com/qcloud/ui/static/other_external_resource/2d8152d9-7618-454e-9d87-2487f1b4457f.png","installs":15,"labels":{"requires_api_key":"false"},"last_synced_at":null,"name":"video-transcript-pro","ownerName":"artminding","score":22560.2700096432,"slug":"video-transcript-pro","source":"clawhub","stars":1,"subCategories":[{"key":"content-article","name":"文章写作"},{"key":"content-social-media","name":"自媒体运营"}],"tags":null,"updated_at":1783500865040,"upstream_owner_login":"artminding","upstream_url":"https://clawhub.ai/artminding/video-transcript-pro","verified":false,"version":"2.3.0"},{"category":"data-analysis","claim_state":"unclaimed","claimable":false,"claimed_user_handle":null,"created_at":1779614483701,"description":"当用户需要做小红书评论洞察、小红书用户反馈、口碑分析、痛点总结、FAQ 整理、评论回复观察或内容讨论复盘时使用。面向内容运营、品牌调研和创作者。","description_zh":"当用户需要做小红书评论洞察、小红书用户反馈、口碑分析、痛点总结、FAQ 整理、评论回复观察或内容讨论复盘时使用。面向内容运营、品牌调研和创作者。","downloads":813,"homepage":"https://api.skillhub.cn/user_825d9e7e/xhs-comment-insights","iconUrl":"https://cloudcache.tencent-cloud.com/qcloud/ui/static/other_external_resource/e089b3bb-5a99-41bc-8881-40980028b748.png","installs":0,"labels":{"requires_api_key":"true"},"last_synced_at":null,"name":"小红书评论洞察","ownerName":"user_825d9e7e","score":60564.127290260374,"slug":"xhs-comment-insights","source":"community","stars":2,"subCategories":[{"key":"data-report","name":"报表生成"},{"key":"data-insight","name":"数据洞察"},{"key":"data-user-analysis","name":"用户分析"}],"tags":null,"updated_at":1783500946118,"upstream_owner_login":null,"upstream_url":null,"verified":false,"version":"1.0.3"},{"category":"data-analysis","claim_state":"unclaimed","claimable":false,"claimed_user_handle":null,"created_at":1779583270083,"description":"当用户需要做小红书竞品研究、小红书竞品分析、同赛道观察、内容角度对比、内容策略对比或品牌内容调研时使用。面向品牌、MCN、内容运营和创作者。","description_zh":"当用户需要做小红书竞品研究、小红书竞品分析、同赛道观察、内容角度对比、内容策略对比或品牌内容调研时使用。面向品牌、MCN、内容运营和创作者。","downloads":1017,"homepage":"https://api.skillhub.cn/user_825d9e7e/xhs-competitor-research-v2","iconUrl":"https://cloudcache.tencent-cloud.com/qcloud/ui/static/other_external_resource/a00e31c7-8d6a-4e65-84fc-16a177f95674.png","installs":0,"labels":{"requires_api_key":"true"},"last_synced_at":null,"name":"小红书竞品研究 v2","ownerName":"user_825d9e7e","score":65000,"slug":"xhs-competitor-research-v2","source":"community","stars":1,"subCategories":[{"key":"data-competitor","name":"竞品分析"}],"tags":null,"updated_at":1783500946069,"upstream_owner_login":null,"upstream_url":null,"verified":false,"version":"1.0.4"}],"total":569},"message":"success"} diff --git a/src/server/__tests__/market-api.test.ts b/src/server/__tests__/market-api.test.ts new file mode 100644 index 00000000..b450af4b --- /dev/null +++ b/src/server/__tests__/market-api.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { handleMarketApi } from '../api/market.js' +import { resetMarketCacheForTests } from '../services/market/cache.js' +import { resetInstallLocksForTests } from '../services/market/installService.js' + +const FIXTURES = path.join(import.meta.dir, 'fixtures', 'market') + +let tmpHome: string +let originalClaudeConfigDir: string | undefined +const originalFetch = globalThis.fetch + +async function fixture(name: string): Promise { + return fs.readFile(path.join(FIXTURES, name), 'utf-8') +} + +function request(pathname: string, init?: RequestInit): { req: Request; url: URL; segments: string[] } { + const url = new URL(`http://localhost:3456${pathname}`) + const req = new Request(url, init) + const segments = url.pathname.split('/').filter(Boolean) + return { req, url, segments } +} + +async function call(pathname: string, init?: RequestInit): Promise<{ status: number; body: any }> { + const { req, url, segments } = request(pathname, init) + const res = await handleMarketApi(req, url, segments) + return { status: res.status, body: await res.json() } +} + +beforeEach(async () => { + resetMarketCacheForTests() + resetInstallLocksForTests() + tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'market-api-test-')) + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude') +}) + +afterEach(async () => { + globalThis.fetch = originalFetch + if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + delete process.env.HAHA_MARKET_DISABLE_PROVIDERS + await fs.rm(tmpHome, { recursive: true, force: true }) +}) + +function stubUpstreams(handler: (url: string) => { status?: number; body: string } | undefined) { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + const result = handler(url) + if (!result) return new Response('Not found', { status: 404 }) + return new Response(result.body, { status: result.status ?? 200 }) + }) as typeof fetch +} + +describe('GET /api/market/skills', () => { + it('returns aggregated items with source statuses', async () => { + const clawhubBody = await fixture('clawhub-list.json') + const skillhubBody = await fixture('skillhub-list.json') + stubUpstreams((url) => (url.includes('clawhub.ai') ? { body: clawhubBody } : { body: skillhubBody })) + + const { status, body } = await call('/api/market/skills?limit=3') + + expect(status).toBe(200) + expect(body.items.length).toBeGreaterThan(0) + expect(body.sources.clawhub.status).toBe('ok') + expect(body.sources.skillhub.status).toBe('ok') + expect(typeof body.nextCursor === 'string' || body.nextCursor === null).toBe(true) + }) + + it('rejects invalid filters with 400', async () => { + expect((await call('/api/market/skills?source=evil')).status).toBe(400) + expect((await call('/api/market/skills?security=hacked')).status).toBe(400) + expect((await call('/api/market/skills?installed=nope')).status).toBe(400) + }) + + it('reports failed status when a provider is disabled via env', async () => { + process.env.HAHA_MARKET_DISABLE_PROVIDERS = 'skillhub' + const clawhubBody = await fixture('clawhub-list.json') + stubUpstreams((url) => (url.includes('clawhub.ai') ? { body: clawhubBody } : undefined)) + + const { status, body } = await call('/api/market/skills?limit=3') + + expect(status).toBe(200) + expect(body.items.length).toBeGreaterThan(0) + expect(['failed', 'degraded']).toContain(body.sources.skillhub.status) + }) +}) + +describe('GET /api/market/skills/{source}/{slug}', () => { + it('returns the detail payload', async () => { + const detailBody = await fixture('clawhub-detail.json') + const versionBody = await fixture('clawhub-version-detail.json') + stubUpstreams((url) => (url.includes('/versions/') ? { body: versionBody } : { body: detailBody })) + + const { status, body } = await call('/api/market/skills/clawhub/git') + + expect(status).toBe(200) + expect(body.skill.slug).toBe('git') + expect(body.skill.files.length).toBeGreaterThan(0) + expect(body.skill.installState).toBe('installable') + expect(body.sourceStatus.status).toBe('ok') + }) + + it('rejects an unknown source with 400', async () => { + expect((await call('/api/market/skills/npm/git')).status).toBe(400) + }) + + it('rejects slugs with path traversal', async () => { + expect((await call('/api/market/skills/clawhub/..%2Fetc')).status).toBe(400) + }) +}) + +describe('GET /api/market/skills/{source}/{slug}/file', () => { + it('returns file content with language and size', async () => { + stubUpstreams(() => ({ body: '# Hello world' })) + + const { status, body } = await call('/api/market/skills/clawhub/git/file?path=SKILL.md') + + expect(status).toBe(200) + expect(body.file.content).toBe('# Hello world') + expect(body.file.language).toBe('markdown') + expect(body.file.truncated).toBe(false) + }) + + it('rejects unsafe paths', async () => { + expect((await call('/api/market/skills/clawhub/git/file?path=../../etc/passwd')).status).toBe(400) + expect((await call('/api/market/skills/clawhub/git/file?path=/etc/passwd')).status).toBe(400) + expect((await call('/api/market/skills/clawhub/git/file')).status).toBe(400) + }) +}) + +describe('POST /api/market/install & uninstall', () => { + it('rejects a malformed id with 400', async () => { + const bad = await call('/api/market/install', { + method: 'POST', + body: JSON.stringify({ id: 'no-colon' }), + }) + expect(bad.status).toBe(400) + + const missing = await call('/api/market/install', { method: 'POST', body: '{}' }) + expect(missing.status).toBe(400) + + const badJson = await call('/api/market/install', { method: 'POST', body: 'not-json' }) + expect(badJson.status).toBe(400) + }) + + it('propagates typed install errors', async () => { + process.env.HAHA_MARKET_DISABLE_PROVIDERS = 'clawhub,skillhub' + + const { status, body } = await call('/api/market/install', { + method: 'POST', + body: JSON.stringify({ id: 'clawhub:demo' }), + }) + + expect(status).toBe(502) + expect(body.error).toContain('MARKET') + }) + + it('uninstall 404s when nothing is installed', async () => { + const { status } = await call('/api/market/uninstall', { + method: 'POST', + body: JSON.stringify({ id: 'clawhub:ghost' }), + }) + expect(status).toBe(404) + }) +}) + +describe('GET /api/market/status & method guard', () => { + it('returns per-source health', async () => { + const { status, body } = await call('/api/market/status') + + expect(status).toBe(200) + expect(body.sources.clawhub.status).toBeDefined() + expect(body.sources.skillhub.status).toBeDefined() + }) + + it('405s on unknown routes', async () => { + expect((await call('/api/market/nonsense')).status).toBe(405) + expect((await call('/api/market/skills', { method: 'DELETE' })).status).toBe(405) + }) +}) diff --git a/src/server/__tests__/market-install.test.ts b/src/server/__tests__/market-install.test.ts new file mode 100644 index 00000000..7f2438f3 --- /dev/null +++ b/src/server/__tests__/market-install.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { createHash } from 'node:crypto' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { resetMarketCacheForTests } from '../services/market/cache.js' +import { + installMarketSkill, + resetInstallLocksForTests, + uninstallMarketSkill, +} from '../services/market/installService.js' +import { ApiError } from '../middleware/errorHandler.js' + +let tmpHome: string +let originalClaudeConfigDir: string | undefined +const originalFetch = globalThis.fetch + +const SKILL_MD = '---\nname: demo\n---\n# Demo skill' +const HELPER_PY = 'print("hello")\n' + +function sha256(content: string): string { + return createHash('sha256').update(content, 'utf-8').digest('hex') +} + +type FileSpec = { path: string; content: string; sha256?: string | null } + +/** + * Stubs the ClawHub API surface used by install: + * detail → versions/{v} (file list) → file?path= for each file. + */ +function stubClawhub(files: FileSpec[], opts: { corruptPath?: string } = {}) { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + const parsed = new URL(url) + if (parsed.pathname.includes('/file')) { + const filePath = parsed.searchParams.get('path') + const file = files.find((f) => f.path === filePath) + if (!file) return new Response('Not found', { status: 404 }) + const content = opts.corruptPath === file.path ? `${file.content}` : file.content + return new Response(content, { status: 200, headers: { 'Content-Type': 'text/plain' } }) + } + if (parsed.pathname.includes('/versions/')) { + return Response.json({ + version: { + version: '1.0.0', + license: 'MIT', + files: files.map((f) => ({ + path: f.path, + size: Buffer.byteLength(f.content, 'utf-8'), + sha256: f.sha256 === null ? undefined : (f.sha256 ?? sha256(f.content)), + })), + }, + }) + } + // detail + return Response.json({ + skill: { slug: 'demo', displayName: 'Demo', summary: 'demo', description: SKILL_MD }, + latestVersion: { version: '1.0.0' }, + owner: { handle: 'alice' }, + }) + }) as typeof fetch +} + +beforeEach(async () => { + resetMarketCacheForTests() + resetInstallLocksForTests() + tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'market-install-test-')) + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude') +}) + +afterEach(async () => { + globalThis.fetch = originalFetch + if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + await fs.rm(tmpHome, { recursive: true, force: true }) +}) + +describe('installMarketSkill', () => { + it('downloads, verifies and installs a skill with market meta', async () => { + stubClawhub([ + { path: 'SKILL.md', content: SKILL_MD }, + { path: 'scripts/helper.py', content: HELPER_PY }, + ]) + + const result = await installMarketSkill('clawhub', 'demo') + + expect(result.skill.installState).toBe('installed') + const installed = path.join(tmpHome, '.claude', 'skills', 'demo') + expect(result.installedPath).toBe(installed) + expect(await fs.readFile(path.join(installed, 'SKILL.md'), 'utf-8')).toBe(SKILL_MD) + expect(await fs.readFile(path.join(installed, 'scripts', 'helper.py'), 'utf-8')).toBe(HELPER_PY) + const meta = JSON.parse(await fs.readFile(path.join(installed, '.market-meta.json'), 'utf-8')) + expect(meta.id).toBe('clawhub:demo') + expect(meta.version).toBe('1.0.0') + expect(meta.fileCount).toBe(2) + }) + + it('aborts on checksum mismatch and leaves no residue', async () => { + stubClawhub( + [ + { path: 'SKILL.md', content: SKILL_MD }, + { path: 'scripts/helper.py', content: HELPER_PY }, + ], + { corruptPath: 'scripts/helper.py' }, + ) + + await expect(installMarketSkill('clawhub', 'demo')).rejects.toThrow('Checksum mismatch') + const exists = await fs.stat(path.join(tmpHome, '.claude', 'skills', 'demo')).catch(() => null) + expect(exists).toBeNull() + }) + + it('skips checksum verification when the upstream provides no sha256', async () => { + stubClawhub([{ path: 'SKILL.md', content: SKILL_MD, sha256: null }]) + + const result = await installMarketSkill('clawhub', 'demo') + + expect(result.skill.installState).toBe('installed') + }) + + it('rejects a second install while the target directory already exists', async () => { + stubClawhub([{ path: 'SKILL.md', content: SKILL_MD }]) + await installMarketSkill('clawhub', 'demo') + resetMarketCacheForTests() + stubClawhub([{ path: 'SKILL.md', content: SKILL_MD }]) + + try { + await installMarketSkill('clawhub', 'demo') + expect.unreachable('should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + expect((error as ApiError).statusCode).toBe(409) + } + }) + + it('rejects concurrent installs of the same slug with 409', async () => { + stubClawhub([{ path: 'SKILL.md', content: SKILL_MD }]) + + const first = installMarketSkill('clawhub', 'demo') + const secondError = await installMarketSkill('clawhub', 'demo').catch((e) => e) + + expect(secondError).toBeInstanceOf(ApiError) + expect((secondError as ApiError).code).toBe('MARKET_INSTALL_IN_PROGRESS') + await first + }) + + it('rejects skills containing unsafe file paths', async () => { + stubClawhub([ + { path: 'SKILL.md', content: SKILL_MD }, + { path: '../escape.sh', content: 'echo pwned' }, + ]) + + try { + await installMarketSkill('clawhub', 'demo') + expect.unreachable('should have thrown') + } catch (error) { + expect((error as ApiError).statusCode).toBe(422) + } + const escaped = await fs.stat(path.join(tmpHome, '.claude', 'escape.sh')).catch(() => null) + expect(escaped).toBeNull() + }) + + it('rejects skills without SKILL.md', async () => { + stubClawhub([{ path: 'README.md', content: '# readme' }]) + + try { + await installMarketSkill('clawhub', 'demo') + expect.unreachable('should have thrown') + } catch (error) { + expect((error as ApiError).code).toBe('MARKET_NOT_INSTALLABLE') + } + }) +}) + +describe('uninstallMarketSkill', () => { + it('removes a market-installed skill', async () => { + stubClawhub([{ path: 'SKILL.md', content: SKILL_MD }]) + await installMarketSkill('clawhub', 'demo') + resetMarketCacheForTests() + stubClawhub([{ path: 'SKILL.md', content: SKILL_MD }]) + + const result = await uninstallMarketSkill('clawhub', 'demo') + + const exists = await fs.stat(path.join(tmpHome, '.claude', 'skills', 'demo')).catch(() => null) + expect(exists).toBeNull() + expect(result.skill?.installState).toBe('installable') + }) + + it('refuses to delete a directory the market did not create', async () => { + const manual = path.join(tmpHome, '.claude', 'skills', 'handmade') + await fs.mkdir(manual, { recursive: true }) + await fs.writeFile(path.join(manual, 'SKILL.md'), '# mine') + + try { + await uninstallMarketSkill('clawhub', 'handmade') + expect.unreachable('should have thrown') + } catch (error) { + expect((error as ApiError).code).toBe('MARKET_NOT_MANAGED') + } + expect(await fs.stat(manual).catch(() => null)).not.toBeNull() + }) + + it('404s for a skill that is not installed', async () => { + try { + await uninstallMarketSkill('clawhub', 'ghost') + expect.unreachable('should have thrown') + } catch (error) { + expect((error as ApiError).statusCode).toBe(404) + } + }) +}) diff --git a/src/server/__tests__/market-providers.test.ts b/src/server/__tests__/market-providers.test.ts new file mode 100644 index 00000000..da2567dd --- /dev/null +++ b/src/server/__tests__/market-providers.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { clawhubProvider, resetClawhubOwnerCacheForTests } from '../services/market/clawhubProvider.js' +import { skillhubProvider } from '../services/market/skillhubProvider.js' +import { resetMarketCacheForTests } from '../services/market/cache.js' +import { MarketUpstreamError } from '../services/market/types.js' + +const FIXTURES = path.join(import.meta.dir, 'fixtures', 'market') + +async function fixture(name: string): Promise { + return fs.readFile(path.join(FIXTURES, name), 'utf-8') +} + +type FetchStub = (url: string) => { status?: number; body: string; contentType?: string } | undefined + +let requestedUrls: string[] = [] +const originalFetch = globalThis.fetch + +function stubFetch(handler: FetchStub) { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + requestedUrls.push(url) + const result = handler(url) + if (!result) return new Response('Not found', { status: 404 }) + return new Response(result.body, { + status: result.status ?? 200, + headers: { 'Content-Type': result.contentType ?? 'application/json' }, + }) + }) as typeof fetch +} + +beforeEach(() => { + requestedUrls = [] + resetMarketCacheForTests() + resetClawhubOwnerCacheForTests() + delete process.env.HAHA_MARKET_DISABLE_PROVIDERS +}) + +afterEach(() => { + globalThis.fetch = originalFetch + delete process.env.HAHA_MARKET_DISABLE_PROVIDERS +}) + +describe('clawhubProvider', () => { + it('normalizes list items and passes through cursor pagination', async () => { + const body = await fixture('clawhub-list.json') + stubFetch(() => ({ body })) + + const page = await clawhubProvider.list({ limit: 3 }) + + expect(requestedUrls[0]).toContain('clawhub.ai/api/v1/skills') + expect(requestedUrls[0]).toContain('limit=3') + expect(page.items.length).toBeGreaterThan(0) + const first = page.items[0]! + expect(first.id).toBe(`clawhub:${first.slug}`) + expect(first.source).toBe('clawhub') + expect(first.name.length).toBeGreaterThan(0) + expect(typeof first.stats.downloads).toBe('number') + expect(first.securityStatus).toBe('unknown') + expect(page.nextCursor).toBeDefined() + }) + + it('forwards the cursor on subsequent pages', async () => { + const body = await fixture('clawhub-list.json') + stubFetch(() => ({ body })) + + await clawhubProvider.list({ limit: 3, cursor: 'abc123' }) + + expect(requestedUrls[0]).toContain('cursor=abc123') + }) + + it('returns empty items for an empty search', async () => { + stubFetch(() => ({ body: '{"results":[]}' })) + + const page = await clawhubProvider.search({ q: 'zzz-nothing', limit: 24 }) + + expect(page.items).toEqual([]) + expect(page.nextCursor).toBeUndefined() + }) + + it('normalizes search results with owner info', async () => { + const body = await fixture('clawhub-search.json') + stubFetch(() => ({ body })) + + const page = await clawhubProvider.search({ q: 'git', limit: 24 }) + + expect(page.items.length).toBeGreaterThan(0) + expect(page.items[0]!.author.handle.length).toBeGreaterThan(0) + }) + + it('builds detail with files, license and security from the version endpoint', async () => { + const detailBody = await fixture('clawhub-detail.json') + const versionBody = await fixture('clawhub-version-detail.json') + stubFetch((url) => { + if (url.includes('/versions/')) return { body: versionBody } + return { body: detailBody } + }) + + const detail = await clawhubProvider.detail('git') + + expect(detail.slug).toBe('git') + expect(detail.files.length).toBeGreaterThan(0) + expect(detail.files[0]!.path).toBe('SKILL.md') + expect(detail.files[0]!.language).toBe('markdown') + expect(detail.license).toBeDefined() + // Fixture security.status === 'clean' → benign + expect(detail.securityStatus).toBe('benign') + expect(detail.securityReports?.[0]?.vendor).toBe('clawhub-scan') + // Description frontmatter is stripped into a body + expect(detail.description).not.toStartWith('---') + expect(detail.descriptionFrontmatter).toBeDefined() + }) + + it('fetches raw file content', async () => { + stubFetch(() => ({ body: '# Hello', contentType: 'text/markdown' })) + + const file = await clawhubProvider.fetchFile('git', 'SKILL.md') + + expect(requestedUrls[0]).toContain('/api/v1/skills/git/file?path=SKILL.md') + expect(file.content).toBe('# Hello') + expect(file.size).toBe(7) + }) + + it('resolves ambiguous slugs via the 409 owner hint and remembers the owner', async () => { + const detailBody = await fixture('clawhub-detail.json') + const ambiguous = JSON.stringify({ + code: 'AMBIGUOUS_SKILL_SLUG', + slug: 'git', + matches: [{ ownerHandle: 'pskoett', slug: 'git', ref: '@pskoett/git' }], + }) + stubFetch((url) => { + const parsed = new URL(url) + if (parsed.pathname.includes('/versions/')) return { body: '{"version":{"files":[]}}' } + if (parsed.searchParams.get('owner') === 'pskoett') return { body: detailBody } + return { status: 409, body: ambiguous } + }) + + const detail = await clawhubProvider.detail('git') + + expect(detail.slug).toBe('git') + // Owner is remembered — subsequent file fetches carry ?owner= + stubFetch((url) => { + const parsed = new URL(url) + if (parsed.searchParams.get('owner') === 'pskoett') return { body: '# ok', contentType: 'text/markdown' } + return { status: 409, body: ambiguous } + }) + const file = await clawhubProvider.fetchFile('git', 'SKILL.md') + expect(file.content).toBe('# ok') + expect(requestedUrls[requestedUrls.length - 1]).toContain('owner=pskoett') + }) + + it('classifies invalid JSON as a bad-response error', async () => { + stubFetch(() => ({ body: 'oops' })) + + await expect(clawhubProvider.list({ limit: 3 })).rejects.toThrow(MarketUpstreamError) + }) + + it('fails when the provider is disabled via env', async () => { + process.env.HAHA_MARKET_DISABLE_PROVIDERS = 'clawhub' + stubFetch(() => ({ body: '{"items":[]}' })) + + await expect(clawhubProvider.list({ limit: 3 })).rejects.toThrow('disabled') + expect(requestedUrls).toEqual([]) + }) +}) + +describe('skillhubProvider', () => { + it('uses pageSize (not limit) and keyword (not q) — upstream silently ignores the wrong names', async () => { + const body = await fixture('skillhub-search.json') + stubFetch(() => ({ body })) + + await skillhubProvider.search({ q: '小红书', limit: 24 }) + + const url = new URL(requestedUrls[0]!) + expect(url.searchParams.get('pageSize')).toBe('24') + expect(url.searchParams.get('keyword')).toBe('小红书') + expect(url.searchParams.has('limit')).toBe(false) + expect(url.searchParams.has('q')).toBe(false) + }) + + it('unwraps the {code,data,message} envelope and normalizes list items', async () => { + const body = await fixture('skillhub-list.json') + stubFetch(() => ({ body })) + + const page = await skillhubProvider.list({ limit: 3 }) + + expect(page.items.length).toBeGreaterThan(0) + const first = page.items[0]! + expect(first.id).toBe(`skillhub:${first.slug}`) + expect(first.source).toBe('skillhub') + expect(typeof first.stats.downloads).toBe('number') + expect(page.total).toBeGreaterThan(0) + // total(75k+) far exceeds one page → nextCursor is the next page number + expect(page.nextCursor).toBe('2') + }) + + it('computes page-based pagination from cursor', async () => { + const body = await fixture('skillhub-list.json') + stubFetch(() => ({ body })) + + await skillhubProvider.list({ limit: 24, cursor: '3' }) + + const url = new URL(requestedUrls[0]!) + expect(url.searchParams.get('page')).toBe('3') + }) + + it('stops pagination when page * pageSize >= total', async () => { + const envelope = { code: 0, data: { skills: [{ slug: 'a', name: 'A' }], total: 3 }, message: 'ok' } + stubFetch(() => ({ body: JSON.stringify(envelope) })) + + const page = await skillhubProvider.list({ limit: 24 }) + + expect(page.nextCursor).toBeUndefined() + }) + + it('rejects a non-zero envelope code as bad response', async () => { + stubFetch(() => ({ body: '{"code":500,"data":null,"message":"boom"}' })) + + await expect(skillhubProvider.list({ limit: 3 })).rejects.toThrow('code=500') + }) + + it('parses upstream_url on clawhub mirror entries', async () => { + const envelope = { + code: 0, + data: { + skills: [{ + slug: 'baoyu-skills-wrapper', + name: 'Baoyu', + source: 'clawhub', + upstream_url: 'https://clawhub.ai/dongjie-oss/baoyu-skills-wrapper', + }], + total: 1, + }, + } + stubFetch(() => ({ body: JSON.stringify(envelope) })) + + const page = await skillhubProvider.list({ limit: 24 }) + + expect(page.items[0]!.upstream).toEqual({ source: 'clawhub', slug: 'baoyu-skills-wrapper' }) + }) + + it('maps securityReports to benign and preserves report links in detail', async () => { + const detailBody = await fixture('skillhub-detail.json') + const filesBody = await fixture('skillhub-files.json') + stubFetch((url) => { + if (url.includes('/files')) return { body: filesBody } + if (url.includes('/file?')) return { body: '---\nname: x\n---\n# Doc', contentType: 'text/markdown' } + return { body: detailBody } + }) + + const detail = await skillhubProvider.detail('pe-compliance-expert-pro') + + expect(detail.securityStatus).toBe('benign') + expect(detail.securityReports?.length).toBe(2) + expect(detail.securityReports?.[0]?.reportUrl).toContain('http') + expect(detail.files.length).toBeGreaterThan(0) + // Description comes from the fetched SKILL.md + expect(detail.description).toContain('# Doc') + }) + + it('flags a skill when any security report is non-benign', async () => { + const detail = JSON.parse(await fixture('skillhub-detail.json')) + detail.securityReports.keen.status = 'malicious' + stubFetch((url) => { + if (url.includes('/files')) return { body: '{"count":0,"files":[]}' } + return { body: JSON.stringify(detail) } + }) + + const result = await skillhubProvider.detail('pe-compliance-expert-pro') + + expect(result.securityStatus).toBe('flagged') + }) + + it('marks list items verified only via the verified field', async () => { + const envelope = { + code: 0, + data: { skills: [{ slug: 'a', name: 'A', verified: true }, { slug: 'b', name: 'B' }], total: 2 }, + } + stubFetch(() => ({ body: JSON.stringify(envelope) })) + + const page = await skillhubProvider.list({ limit: 24 }) + + expect(page.items[0]!.securityStatus).toBe('verified') + expect(page.items[1]!.securityStatus).toBe('unknown') + }) +}) diff --git a/src/server/__tests__/market-service.test.ts b/src/server/__tests__/market-service.test.ts new file mode 100644 index 00000000..f49ed55c --- /dev/null +++ b/src/server/__tests__/market-service.test.ts @@ -0,0 +1,325 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { resetMarketCacheForTests } from '../services/market/cache.js' +import { + annotateInstallState, + applyFileLimits, + decodeCursor, + dedupeSkills, + encodeCursor, + listMarketSkills, + getMarketSkillDetail, +} from '../services/market/marketService.js' +import { MARKET_LIMITS, type NormalizedSkill, type NormalizedSkillDetail } from '../services/market/types.js' + +const FIXTURES = path.join(import.meta.dir, 'fixtures', 'market') + +let tmpHome: string +let originalClaudeConfigDir: string | undefined +let requested: string[] = [] +const originalFetch = globalThis.fetch + +function stubFetch(handler: (url: string) => { status?: number; body: string } | undefined) { + globalThis.fetch = (async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url + requested.push(url) + const result = handler(url) + if (!result) return new Response('Not found', { status: 404 }) + return new Response(result.body, { + status: result.status ?? 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) as typeof fetch +} + +async function fixture(name: string): Promise { + return fs.readFile(path.join(FIXTURES, name), 'utf-8') +} + +function makeSkill(overrides: Partial = {}): NormalizedSkill { + return { + id: 'clawhub:demo', + source: 'clawhub', + slug: 'demo', + name: 'Demo', + summary: 'demo skill', + author: { handle: 'alice' }, + stats: { downloads: 10 }, + tags: [], + securityStatus: 'unknown', + installState: 'installable', + ...overrides, + } +} + +function makeDetail(overrides: Partial = {}): NormalizedSkillDetail { + return { + ...makeSkill(), + description: '# Demo', + files: [{ path: 'SKILL.md', size: 100, language: 'markdown', tooBig: false }], + totalSize: 100, + ...overrides, + } +} + +beforeEach(async () => { + requested = [] + resetMarketCacheForTests() + tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'market-service-test-')) + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = path.join(tmpHome, '.claude') + delete process.env.HAHA_MARKET_DISABLE_PROVIDERS +}) + +afterEach(async () => { + globalThis.fetch = originalFetch + if (originalClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir + delete process.env.HAHA_MARKET_DISABLE_PROVIDERS + await fs.rm(tmpHome, { recursive: true, force: true }) +}) + +describe('cursor codec', () => { + it('round-trips a merged cursor', () => { + const encoded = encodeCursor({ clawhub: 'abc', skillhub: '3' }) + expect(encoded).toBeTruthy() + expect(decodeCursor(encoded)).toEqual({ clawhub: 'abc', skillhub: '3' }) + }) + + it('returns null for an empty cursor and undefined for garbage', () => { + expect(encodeCursor({})).toBeNull() + expect(decodeCursor('!!!not-base64!!!')).toBeUndefined() + expect(decodeCursor(undefined)).toBeUndefined() + }) +}) + +describe('dedupeSkills', () => { + it('merges a SkillHub mirror into the ClawHub original', () => { + const original = makeSkill({ id: 'clawhub:git', slug: 'git', tags: [] }) + const mirror = makeSkill({ + id: 'skillhub:git-mirror', + source: 'skillhub', + slug: 'git-mirror', + upstream: { source: 'clawhub', slug: 'git' }, + iconUrl: 'https://img.example/icon.png', + securityStatus: 'benign', + tags: ['工具'], + }) + + const result = dedupeSkills([original, mirror]) + + expect(result.length).toBe(1) + expect(result[0]!.id).toBe('clawhub:git') + expect(result[0]!.mirrors).toEqual(['skillhub:git-mirror']) + expect(result[0]!.iconUrl).toBe('https://img.example/icon.png') + expect(result[0]!.securityStatus).toBe('benign') + expect(result[0]!.tags).toEqual(['工具']) + }) + + it('keeps the mirror when the original is absent from the page', () => { + const mirror = makeSkill({ + id: 'skillhub:m', + source: 'skillhub', + slug: 'm', + upstream: { source: 'clawhub', slug: 'not-on-this-page' }, + }) + + const result = dedupeSkills([mirror]) + + expect(result.length).toBe(1) + expect(result[0]!.upstream?.slug).toBe('not-on-this-page') + }) +}) + +describe('annotateInstallState', () => { + it('marks a skill installed when the market meta matches', async () => { + const dir = path.join(tmpHome, '.claude', 'skills', 'demo') + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, '.market-meta.json'), + JSON.stringify({ id: 'clawhub:demo', source: 'clawhub', slug: 'demo', version: '1.0.0', installedAt: 'x', fileCount: 1 }), + ) + + const result = await annotateInstallState(makeSkill()) + + expect(result.installState).toBe('installed') + expect(result.installedInfo?.dirName).toBe('demo') + expect(result.installedInfo?.version).toBe('1.0.0') + }) + + it('flags a name conflict for a manually created directory', async () => { + await fs.mkdir(path.join(tmpHome, '.claude', 'skills', 'demo'), { recursive: true }) + + const result = await annotateInstallState(makeSkill()) + + expect(result.installState).toBe('not-installable') + expect(result.notInstallableReason).toBe('name-conflict') + }) + + it('flags invalid slugs', async () => { + const result = await annotateInstallState(makeSkill({ slug: '../evil' })) + + expect(result.installState).toBe('not-installable') + expect(result.notInstallableReason).toBe('invalid-name') + }) + + it('leaves clean skills installable', async () => { + const result = await annotateInstallState(makeSkill()) + + expect(result.installState).toBe('installable') + }) +}) + +describe('applyFileLimits', () => { + it('rejects an empty file list', () => { + const result = applyFileLimits(makeDetail({ files: [], totalSize: 0 })) + expect(result.installState).toBe('not-installable') + expect(result.notInstallableReason).toBe('empty-file-list') + }) + + it('rejects a skill without SKILL.md', () => { + const result = applyFileLimits( + makeDetail({ files: [{ path: 'main.py', size: 10, language: 'python', tooBig: false }], totalSize: 10 }), + ) + expect(result.notInstallableReason).toBe('empty-file-list') + }) + + it('rejects oversized files and marks them tooBig', () => { + const result = applyFileLimits( + makeDetail({ + files: [ + { path: 'SKILL.md', size: 100, language: 'markdown', tooBig: false }, + { path: 'big.bin', size: MARKET_LIMITS.maxFileSize + 1, language: 'text', tooBig: false }, + ], + totalSize: MARKET_LIMITS.maxFileSize + 101, + }), + ) + expect(result.notInstallableReason).toBe('file-too-large') + expect(result.files.find((f) => f.path === 'big.bin')?.tooBig).toBe(true) + }) + + it('accepts a normal skill', () => { + const result = applyFileLimits(makeDetail()) + expect(result.installState).toBe('installable') + }) +}) + +describe('listMarketSkills', () => { + it('aggregates both sources, sorts by downloads and reports ok status', async () => { + const clawhubBody = await fixture('clawhub-list.json') + const skillhubBody = await fixture('skillhub-list.json') + stubFetch((url) => { + if (url.includes('clawhub.ai')) return { body: clawhubBody } + return { body: skillhubBody } + }) + + const result = await listMarketSkills({ source: 'all', limit: 3 }) + + expect(result.items.length).toBeGreaterThan(3) + expect(result.sources.clawhub.status).toBe('ok') + expect(result.sources.skillhub.status).toBe('ok') + // Sorted by downloads desc + const downloads = result.items.map((i) => i.stats.downloads) + expect([...downloads].sort((a, b) => b - a)).toEqual(downloads) + expect(result.nextCursor).toBeTruthy() + }) + + it('degrades gracefully when one source fails', async () => { + const clawhubBody = await fixture('clawhub-list.json') + stubFetch((url) => { + if (url.includes('clawhub.ai')) return { body: clawhubBody } + return { status: 500, body: 'oops' } + }) + + const result = await listMarketSkills({ source: 'all', limit: 3 }) + + expect(result.items.length).toBeGreaterThan(0) + expect(result.sources.clawhub.status).toBe('ok') + expect(['failed', 'degraded']).toContain(result.sources.skillhub.status) + expect(result.sources.skillhub.error).toBeTruthy() + }) + + it('serves stale cache with cached status after upstream starts failing', async () => { + const clawhubBody = await fixture('clawhub-list.json') + const skillhubBody = await fixture('skillhub-list.json') + stubFetch((url) => { + if (url.includes('clawhub.ai')) return { body: clawhubBody } + return { body: skillhubBody } + }) + await listMarketSkills({ source: 'all', limit: 3 }) + + // Now both upstreams fail — but entries are cached (fresh) so still ok/fromCache. + stubFetch(() => ({ status: 500, body: 'down' })) + const result = await listMarketSkills({ source: 'all', limit: 3 }) + + expect(result.items.length).toBeGreaterThan(0) + expect(result.sources.clawhub.fromCache).toBe(true) + }) + + it('respects the source filter', async () => { + const clawhubBody = await fixture('clawhub-list.json') + stubFetch((url) => { + if (url.includes('clawhub.ai')) return { body: clawhubBody } + return { status: 500, body: 'should not be called' } + }) + + const result = await listMarketSkills({ source: 'clawhub', limit: 3 }) + + expect(result.items.every((i) => i.source === 'clawhub')).toBe(true) + expect(requested.every((u) => u.includes('clawhub.ai'))).toBe(true) + }) + + it('filters by installed state', async () => { + const clawhubBody = await fixture('clawhub-list.json') + stubFetch((url) => (url.includes('clawhub.ai') ? { body: clawhubBody } : { status: 500, body: 'x' })) + + // Install one of the fixture skills manually with market meta + const items = JSON.parse(clawhubBody).items as Array<{ slug: string }> + const slug = items[0]!.slug + const dir = path.join(tmpHome, '.claude', 'skills', slug) + await fs.mkdir(dir, { recursive: true }) + await fs.writeFile( + path.join(dir, '.market-meta.json'), + JSON.stringify({ id: `clawhub:${slug}`, source: 'clawhub', slug, installedAt: 'x', fileCount: 1 }), + ) + + const installed = await listMarketSkills({ source: 'clawhub', limit: 3, installed: 'installed' }) + expect(installed.items.length).toBe(1) + expect(installed.items[0]!.slug).toBe(slug) + + resetMarketCacheForTests() + const notInstalled = await listMarketSkills({ source: 'clawhub', limit: 3, installed: 'installable' }) + expect(notInstalled.items.every((i) => i.slug !== slug)).toBe(true) + }) + + it('filters by security status', async () => { + const envelope = { + code: 0, + data: { skills: [{ slug: 'a', name: 'A', verified: true }, { slug: 'b', name: 'B' }], total: 2 }, + } + stubFetch((url) => (url.includes('skillhub') ? { body: JSON.stringify(envelope) } : { body: '{"items":[]}' })) + + const result = await listMarketSkills({ source: 'skillhub', limit: 24, security: 'verified' }) + + expect(result.items.length).toBe(1) + expect(result.items[0]!.slug).toBe('a') + }) +}) + +describe('getMarketSkillDetail', () => { + it('caches the detail so a second call issues no upstream requests', async () => { + const detailBody = await fixture('clawhub-detail.json') + const versionBody = await fixture('clawhub-version-detail.json') + stubFetch((url) => (url.includes('/versions/') ? { body: versionBody } : { body: detailBody })) + + await getMarketSkillDetail('clawhub', 'git') + const countAfterFirst = requested.length + const second = await getMarketSkillDetail('clawhub', 'git') + + expect(requested.length).toBe(countAfterFirst) + expect(second.sourceStatus.fromCache).toBe(true) + expect(second.skill.files.length).toBeGreaterThan(0) + }) +}) diff --git a/src/server/api/market.ts b/src/server/api/market.ts new file mode 100644 index 00000000..de3d45ae --- /dev/null +++ b/src/server/api/market.ts @@ -0,0 +1,144 @@ +/** + * Skills Market REST API + * + * GET /api/market/skills — aggregated list/search across sources + * ?q=&source=all|clawhub|skillhub&security=&installed=&cursor=&limit= + * GET /api/market/skills/{source}/{slug} — full detail (description, files, security) + * GET /api/market/skills/{source}/{slug}/file — file content ?path=SKILL.md + * POST /api/market/install — body {id: "source:slug"} + * POST /api/market/uninstall — body {id: "source:slug"} + * GET /api/market/status — per-source health + */ + +import { ApiError, errorResponse } from '../middleware/errorHandler.js' +import { installMarketSkill, uninstallMarketSkill } from '../services/market/installService.js' +import { + getMarketFileContent, + getMarketSkillDetail, + getMarketStatus, + isValidMarketFilePath, + listMarketSkills, +} from '../services/market/marketService.js' +import { + MARKET_ERROR_CODES, + MARKET_SOURCES, + MarketUpstreamError, + parseSkillId, + type MarketSource, +} from '../services/market/types.js' + +const VALID_SECURITY = new Set(['all', 'verified', 'benign', 'unknown', 'flagged']) +const VALID_INSTALLED = new Set(['all', 'installed', 'installable']) + +function parseSource(raw: string | null): 'all' | MarketSource { + if (!raw || raw === 'all') return 'all' + if (MARKET_SOURCES.includes(raw as MarketSource)) return raw as MarketSource + throw ApiError.badRequest(`Invalid source: ${raw}`) +} + +function parsePathSource(raw: string | undefined): MarketSource { + if (raw && MARKET_SOURCES.includes(raw as MarketSource)) return raw as MarketSource + throw ApiError.badRequest(`Invalid market source: ${raw ?? ''}`) +} + +function parseSlug(raw: string | undefined): string { + if (!raw) throw ApiError.badRequest('Missing skill slug') + const slug = decodeURIComponent(raw) + if (slug.includes('/') || slug.includes('\\') || slug.includes('..')) { + throw ApiError.badRequest(`Invalid skill slug: ${slug}`) + } + return slug +} + +async function parseJsonBody(req: Request): Promise> { + try { + const body = (await req.json()) as Record + if (typeof body !== 'object' || body === null) throw new Error('not an object') + return body + } catch { + throw ApiError.badRequest('Invalid JSON body') + } +} + +function parseIdFromBody(body: Record): { source: MarketSource; slug: string } { + const id = typeof body.id === 'string' ? body.id : '' + const parsed = parseSkillId(id) + if (!parsed) throw ApiError.badRequest(`Invalid skill id: ${id || '(missing)'}`) + return parsed +} + +export async function handleMarketApi( + req: Request, + url: URL, + segments: string[], +): Promise { + try { + const method = req.method + const sub = segments[2] + + if (method === 'GET' && sub === 'skills' && !segments[3]) { + const limitRaw = Number.parseInt(url.searchParams.get('limit') || '24', 10) + const limit = Math.min(100, Math.max(1, Number.isNaN(limitRaw) ? 24 : limitRaw)) + const security = url.searchParams.get('security') || 'all' + const installed = url.searchParams.get('installed') || 'all' + if (!VALID_SECURITY.has(security)) throw ApiError.badRequest(`Invalid security filter: ${security}`) + if (!VALID_INSTALLED.has(installed)) throw ApiError.badRequest(`Invalid installed filter: ${installed}`) + + const result = await listMarketSkills({ + q: url.searchParams.get('q')?.trim() || undefined, + source: parseSource(url.searchParams.get('source')), + security, + installed: installed as 'all' | 'installed' | 'installable', + cursor: url.searchParams.get('cursor') || undefined, + limit, + }) + return Response.json(result) + } + + if (method === 'GET' && sub === 'skills' && segments[3] && segments[4] && !segments[5]) { + const source = parsePathSource(segments[3]) + const slug = parseSlug(segments[4]) + const { skill, sourceStatus } = await getMarketSkillDetail(source, slug) + return Response.json({ skill, sourceStatus }) + } + + if (method === 'GET' && sub === 'skills' && segments[3] && segments[4] && segments[5] === 'file') { + const source = parsePathSource(segments[3]) + const slug = parseSlug(segments[4]) + const filePath = url.searchParams.get('path') || '' + if (!isValidMarketFilePath(filePath)) { + throw ApiError.badRequest(`Invalid file path: ${filePath}`) + } + const file = await getMarketFileContent(source, slug, filePath) + return Response.json({ file }) + } + + if (method === 'GET' && sub === 'status') { + return Response.json({ sources: getMarketStatus() }) + } + + if (method === 'POST' && sub === 'install') { + const { source, slug } = parseIdFromBody(await parseJsonBody(req)) + const result = await installMarketSkill(source, slug) + return Response.json({ ok: true, installedPath: result.installedPath, skill: result.skill }) + } + + if (method === 'POST' && sub === 'uninstall') { + const { source, slug } = parseIdFromBody(await parseJsonBody(req)) + const result = await uninstallMarketSkill(source, slug) + return Response.json({ ok: true, removedPath: result.removedPath, skill: result.skill }) + } + + throw new ApiError( + 405, + `Method ${method} not allowed on /api/market${sub ? `/${sub}` : ''}`, + 'METHOD_NOT_ALLOWED', + ) + } catch (error) { + if (error instanceof MarketUpstreamError) { + const status = error.code === MARKET_ERROR_CODES.upstreamBadResponse ? 404 : 502 + return errorResponse(new ApiError(status, error.message, error.code)) + } + return errorResponse(error) + } +} diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 2e743879..39bc7a3d 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -506,8 +506,20 @@ async function getSkillDetail(url: URL): Promise { } const { tree, files } = await buildFileTree(skillDir) + const marketMeta = await loadMarketMeta(skillDir) return Response.json({ - detail: { meta, tree, files, skillRoot: skillDir }, + detail: { meta, tree, files, skillRoot: skillDir, marketMeta }, }) } + +/** Marker written by the Skills Market installer — enables uninstall from the local detail view. */ +async function loadMarketMeta(skillDir: string): Promise | undefined> { + try { + const raw = await fs.readFile(path.join(skillDir, '.market-meta.json'), 'utf-8') + const parsed = JSON.parse(raw) as Record + return typeof parsed === 'object' && parsed !== null ? parsed : undefined + } catch { + return undefined + } +} diff --git a/src/server/router.ts b/src/server/router.ts index 8936b7e5..cd74a584 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -16,6 +16,7 @@ import { handleProvidersApi } from './api/providers.js' import { handleAdaptersApi } from './api/adapters.js' import { handlePluginsApi } from './api/plugins.js' import { handleSkillsApi } from './api/skills.js' +import { handleMarketApi } from './api/market.js' import { handleComputerUseApi } from './api/computer-use.js' import { handleHahaOAuthApi } from './api/haha-oauth.js' import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js' @@ -90,6 +91,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise() + + get(key: string): T | undefined { + const entry = this.entries.get(key) + if (!entry) return undefined + if (Date.now() > entry.expiresAt) return undefined + // LRU touch + this.entries.delete(key) + this.entries.set(key, entry) + return entry.value as T + } + + /** Returns the entry even when expired — used for stale-while-error fallback. */ + getStale(key: string): { value: T; storedAt: number } | undefined { + const entry = this.entries.get(key) + if (!entry) return undefined + return { value: entry.value as T, storedAt: entry.storedAt } + } + + set(key: string, value: unknown, ttlMs: number): void { + if (this.entries.has(key)) this.entries.delete(key) + this.entries.set(key, { value, expiresAt: Date.now() + ttlMs, storedAt: Date.now() }) + if (this.entries.size > MAX_ENTRIES) { + const oldest = this.entries.keys().next().value + if (oldest !== undefined) this.entries.delete(oldest) + } + } + + clear(): void { + this.entries.clear() + } +} + +export const marketCache = new MarketCache() + +// ─── Source health ─────────────────────────────────────────────────────────── + +type HealthRecord = { + status: SourceHealthStatus + lastOkAt?: number + lastError?: string +} + +const sourceHealth: Record = { + clawhub: { status: 'ok' }, + skillhub: { status: 'ok' }, +} + +export function recordSourceSuccess(source: MarketSource): void { + sourceHealth[source] = { status: 'ok', lastOkAt: Date.now() } +} + +export function recordSourceFailure(source: MarketSource, error: string): void { + const prev = sourceHealth[source] + sourceHealth[source] = { + // Recent success + fresh failure = degraded; repeated failure = failed + status: prev.status === 'ok' && prev.lastOkAt ? 'degraded' : 'failed', + lastOkAt: prev.lastOkAt, + lastError: error, + } +} + +export function getSourceHealth(source: MarketSource): SourceStatusInfo { + const record = sourceHealth[source] + return { + status: record.status, + fetchedAt: record.lastOkAt, + error: record.lastError, + } +} + +export function resetMarketCacheForTests(): void { + marketCache.clear() + sourceHealth.clawhub = { status: 'ok' } + sourceHealth.skillhub = { status: 'ok' } +} diff --git a/src/server/services/market/clawhubProvider.ts b/src/server/services/market/clawhubProvider.ts new file mode 100644 index 00000000..af834e0f --- /dev/null +++ b/src/server/services/market/clawhubProvider.ts @@ -0,0 +1,308 @@ +/** + * ClawHub provider (https://clawhub.ai) + * + * Endpoints (verified against the live API): + * - GET /api/v1/skills?limit=&cursor=&sort=downloads → {items, nextCursor} + * - GET /api/v1/search?q= → {results} (no pagination) + * - GET /api/v1/skills/{slug} → {skill, latestVersion, owner, metadata, moderation} + * - GET /api/v1/skills/{slug}/versions/{v} → {version:{license, files[], security}} + * - GET /api/v1/skills/{slug}/file?path= → raw file text + */ + +import { parseFrontmatter } from '../../../utils/frontmatterParser.js' +import { getProviderBase, providerFetch, providerFetchJson } from './providerFetch.js' +import { + detectMarketLanguage, + MARKET_ERROR_CODES, + MarketUpstreamError, + skillId, + type MarketProvider, + type NormalizedSkill, + type NormalizedSkillDetail, + type ProviderFileEntry, + type ProviderListPage, + type SecurityReport, + type SecurityStatus, +} from './types.js' + +type ClawhubListItem = { + slug: string + displayName?: string + summary?: string + description?: string + topics?: string[] + stats?: { downloads?: number; installs?: number; stars?: number } + updatedAt?: number + latestVersion?: { version?: string } + metadata?: unknown +} + +type ClawhubSearchResult = { + slug: string + displayName?: string + summary?: string + downloads?: number + updatedAt?: number + ownerHandle?: string + owner?: { handle?: string; displayName?: string; image?: string } +} + +type ClawhubDetail = { + skill: ClawhubListItem + latestVersion?: { version?: string; license?: string } + owner?: { handle?: string; displayName?: string; image?: string } + moderation?: unknown +} + +type ClawhubVersionDetail = { + version?: { + version?: string + license?: string + files?: Array<{ path: string; size: number; sha256?: string; contentType?: string }> + security?: { status?: string; hasWarnings?: boolean; virustotalUrl?: string } + } +} + +// ClawHub slugs are not unique across owners. Ambiguous slugs return +// 409 AMBIGUOUS_SKILL_SLUG with candidate owners; disambiguate via ?owner= +// (first match = primary listing) and remember the resolution. +const ownerCache = new Map() + +async function clawhubFetch(url: URL, slug: string): Promise { + const cachedOwner = ownerCache.get(slug) + if (cachedOwner && !url.searchParams.has('owner')) { + url.searchParams.set('owner', cachedOwner) + } + const res = await providerFetch('clawhub', url.toString()) + if (res.status !== 409) return res + + const body = (await res.json().catch(() => null)) as + | { code?: string; matches?: Array<{ ownerHandle?: string }> } + | null + const resolvedOwner = body?.code === 'AMBIGUOUS_SKILL_SLUG' ? body.matches?.[0]?.ownerHandle : undefined + if (!resolvedOwner) { + throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamError, `clawhub responded 409 for ${url.pathname}`) + } + ownerCache.set(slug, resolvedOwner) + url.searchParams.set('owner', resolvedOwner) + return providerFetch('clawhub', url.toString()) +} + +async function clawhubFetchJson(url: URL, slug: string): Promise { + const res = await clawhubFetch(url, slug) + if (!res.ok) { + throw new MarketUpstreamError( + 'clawhub', + res.status === 404 ? MARKET_ERROR_CODES.upstreamBadResponse : MARKET_ERROR_CODES.upstreamError, + `clawhub responded ${res.status} for ${url.pathname}`, + ) + } + const text = await res.text() + try { + return JSON.parse(text) as T + } catch { + throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub returned invalid JSON') + } +} + +function mapSecurity(security?: { status?: string; hasWarnings?: boolean; virustotalUrl?: string }): { + status: SecurityStatus + reports: SecurityReport[] +} { if (!security?.status) return { status: 'unknown', reports: [] } + const clean = security.status === 'clean' + return { + status: clean ? 'benign' : 'flagged', + reports: [ + { + vendor: 'clawhub-scan', + status: security.status, + statusText: clean + ? security.hasWarnings ? 'Clean (with warnings)' : 'Clean' + : `Scan status: ${security.status}`, + reportUrl: security.virustotalUrl, + }, + ], + } +} + +function normalizeListItem(item: ClawhubListItem): NormalizedSkill { + return { + id: skillId('clawhub', item.slug), + source: 'clawhub', + slug: item.slug, + name: item.displayName || item.slug, + summary: item.summary || '', + author: { handle: '' }, + stats: { + downloads: item.stats?.downloads ?? 0, + installs: item.stats?.installs, + stars: item.stats?.stars, + }, + tags: Array.isArray(item.topics) ? item.topics.filter((t): t is string => typeof t === 'string') : [], + version: item.latestVersion?.version, + updatedAt: item.updatedAt, + securityStatus: 'unknown', + installState: 'installable', + } +} + +function normalizeSearchResult(result: ClawhubSearchResult): NormalizedSkill { + return { + id: skillId('clawhub', result.slug), + source: 'clawhub', + slug: result.slug, + name: result.displayName || result.slug, + summary: result.summary || '', + author: { + handle: result.owner?.handle || result.ownerHandle || '', + displayName: result.owner?.displayName, + avatarUrl: result.owner?.image, + }, + stats: { downloads: result.downloads ?? 0 }, + tags: [], + updatedAt: result.updatedAt, + securityStatus: 'unknown', + installState: 'installable', + } +} + +export function resetClawhubOwnerCacheForTests(): void { + ownerCache.clear() +} + +export const clawhubProvider: MarketProvider = { + source: 'clawhub', + + async list({ cursor, limit }): Promise { + const base = getProviderBase('clawhub') + const url = new URL('/api/v1/skills', base) + url.searchParams.set('limit', String(limit)) + url.searchParams.set('sort', 'downloads') + if (cursor) url.searchParams.set('cursor', cursor) + const data = await providerFetchJson<{ items?: ClawhubListItem[]; nextCursor?: string }>( + 'clawhub', + url.toString(), + ) + if (!Array.isArray(data.items)) { + throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub list missing items') + } + return { + items: data.items.filter((i) => i?.slug).map(normalizeListItem), + nextCursor: data.nextCursor || undefined, + } + }, + + async search({ q, limit }): Promise { + const base = getProviderBase('clawhub') + const url = new URL('/api/v1/search', base) + url.searchParams.set('q', q) + const data = await providerFetchJson<{ results?: ClawhubSearchResult[] }>('clawhub', url.toString()) + if (!Array.isArray(data.results)) { + throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub search missing results') + } + // ClawHub search has no pagination — cap and mark exhausted. + return { items: data.results.filter((r) => r?.slug).slice(0, limit).map(normalizeSearchResult) } + }, + + async detail(slug): Promise { + const base = getProviderBase('clawhub') + const data = await clawhubFetchJson( + new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, base), + slug, + ) + if (!data.skill?.slug) { + throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamBadResponse, 'clawhub detail missing skill') + } + + const version = data.latestVersion?.version || data.skill.latestVersion?.version + let files: ProviderFileEntry[] = [] + let license: string | undefined = data.latestVersion?.license + let security: { status: SecurityStatus; reports: SecurityReport[] } = { status: 'unknown', reports: [] } + if (version) { + try { + const versionDetail = await clawhubFetchJson( + new URL(`/api/v1/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`, base), + slug, + ) + files = versionDetail.version?.files ?? [] + license = versionDetail.version?.license || license + security = mapSecurity(versionDetail.version?.security) + } catch { + // Version detail is best-effort; the skill detail is still useful without files. + } + } + + // ClawHub's description IS the SKILL.md content (frontmatter + body). + const rawDescription = data.skill.description || '' + let body = rawDescription + let frontmatter: Record | undefined + if (rawDescription.startsWith('---')) { + try { + const parsed = parseFrontmatter(rawDescription) + body = parsed.content + frontmatter = parsed.frontmatter as Record + } catch { + // Keep raw content when frontmatter parsing fails. + } + } + + const item = normalizeListItem(data.skill) + return { + ...item, + version, + author: { + handle: data.owner?.handle || '', + displayName: data.owner?.displayName, + avatarUrl: data.owner?.image, + }, + securityStatus: security.status, + securityReports: security.reports.length ? security.reports : undefined, + description: body, + descriptionFrontmatter: frontmatter, + license, + files: files.map((f) => ({ + path: f.path, + size: f.size, + sha256: f.sha256, + contentType: f.contentType, + language: detectMarketLanguage(f.path), + tooBig: false, + })), + totalSize: files.reduce((sum, f) => sum + (f.size || 0), 0), + } + }, + + async listFiles(slug, version): Promise { + const base = getProviderBase('clawhub') + let resolvedVersion = version + if (!resolvedVersion) { + const data = await clawhubFetchJson( + new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, base), + slug, + ) + resolvedVersion = data.latestVersion?.version || data.skill?.latestVersion?.version + } + if (!resolvedVersion) return [] + const versionDetail = await clawhubFetchJson( + new URL(`/api/v1/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(resolvedVersion)}`, base), + slug, + ) + return versionDetail.version?.files ?? [] + }, + + async fetchFile(slug, filePath): Promise<{ content: string; size: number }> { + const base = getProviderBase('clawhub') + const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}/file`, base) + url.searchParams.set('path', filePath) + const res = await clawhubFetch(url, slug) + if (!res.ok) { + throw new MarketUpstreamError( + 'clawhub', + res.status === 404 ? MARKET_ERROR_CODES.upstreamBadResponse : MARKET_ERROR_CODES.upstreamError, + `clawhub file fetch failed (${res.status})`, + ) + } + const content = await res.text() + return { content, size: Buffer.byteLength(content, 'utf-8') } + }, +} diff --git a/src/server/services/market/installService.ts b/src/server/services/market/installService.ts new file mode 100644 index 00000000..3529abf3 --- /dev/null +++ b/src/server/services/market/installService.ts @@ -0,0 +1,247 @@ +/** + * Skills Market — install/uninstall service. + * + * Install: download every file to a temp dir (sha256-verified), then atomically + * move it into ~/.claude/skills// with a .market-meta.json marker. + * Uninstall: only removes directories that carry the marker file. + */ + +import { createHash } from 'node:crypto' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' +import { clearSkillCaches } from '../../../skills/loadSkillsDir.js' +import { ApiError } from '../../middleware/errorHandler.js' +import { clawhubProvider } from './clawhubProvider.js' +import { skillhubProvider } from './skillhubProvider.js' +import { + annotateInstallState, + getMarketSkillsDir, + MARKET_META_FILENAME, + readMarketMeta, + resolveMarketSkill, + type MarketMeta, +} from './marketService.js' +import { marketCache } from './cache.js' +import { + MARKET_ERROR_CODES, + MARKET_LIMITS, + MarketUpstreamError, + sanitizeDirName, + type MarketProvider, + type MarketSource, + type NormalizedSkill, +} from './types.js' + +const providers: Record = { + clawhub: clawhubProvider, + skillhub: skillhubProvider, +} + +// In-flight lock: one install per slug at a time. +const inFlight = new Map>() + +function isSafeRelativeFilePath(filePath: string): boolean { + if (!filePath || filePath.length > 512) return false + if (path.isAbsolute(filePath)) return false + const normalized = path.normalize(filePath) + if (normalized.startsWith('..') || normalized.includes(`..${path.sep}`)) return false + if (filePath.includes('\0')) return false + return true +} + +function sha256Hex(content: string): string { + return createHash('sha256').update(content, 'utf-8').digest('hex') +} + +async function moveIntoPlace(tmpDir: string, target: string): Promise { + try { + await fs.rename(tmpDir, target) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'EXDEV') { + // Temp dir lives on another device — copy then clean up. + await fs.cp(tmpDir, target, { recursive: true }) + await fs.rm(tmpDir, { recursive: true, force: true }) + return + } + throw error + } +} + +export type InstallResult = { + installedPath: string + skill: NormalizedSkill +} + +export async function installMarketSkill(source: MarketSource, slug: string): Promise { + const existing = inFlight.get(slug) + if (existing) { + throw new ApiError(409, `Install already in progress for ${slug}`, MARKET_ERROR_CODES.installInProgress) + } + const task = performInstall(source, slug) + inFlight.set(slug, task.catch(() => {})) + try { + return await task + } finally { + inFlight.delete(slug) + } +} + +async function performInstall(source: MarketSource, slug: string): Promise { + const dirName = sanitizeDirName(slug) + if (!dirName) { + throw new ApiError(422, `Skill slug cannot be used as a directory name: ${slug}`, MARKET_ERROR_CODES.notInstallable) + } + + // Resolve detail (includes file list + limits + install state). + let detail + try { + detail = await resolveMarketSkill(source, slug) + } catch (error) { + throw toUpstreamApiError(error) + } + if (detail.installState === 'installed') { + throw new ApiError(409, `Skill already installed: ${slug}`, MARKET_ERROR_CODES.alreadyInstalled) + } + if (detail.installState === 'not-installable') { + throw new ApiError( + 422, + `Skill is not installable (${detail.notInstallableReason}): ${slug}`, + MARKET_ERROR_CODES.notInstallable, + ) + } + + // Re-fetch the file list at install time (detail may be cached). + let files + try { + files = await providers[source].listFiles(slug, detail.version) + } catch (error) { + throw toUpstreamApiError(error) + } + if (!files.length || !files.some((f) => f.path === 'SKILL.md')) { + throw new ApiError(422, `Skill has no installable files: ${slug}`, MARKET_ERROR_CODES.notInstallable) + } + if (files.length > MARKET_LIMITS.maxFileCount) { + throw new ApiError(422, `Skill has too many files: ${slug}`, MARKET_ERROR_CODES.notInstallable) + } + const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0) + if (files.some((f) => f.size > MARKET_LIMITS.maxFileSize) || totalSize > MARKET_LIMITS.maxTotalSize) { + throw new ApiError(422, `Skill files exceed the size limit: ${slug}`, MARKET_ERROR_CODES.notInstallable) + } + + const skillsDir = getMarketSkillsDir() + const target = path.join(skillsDir, dirName) + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'haha-market-install-')) + try { + for (const file of files) { + if (!isSafeRelativeFilePath(file.path)) { + throw new ApiError(422, `Unsafe file path in skill: ${file.path}`, MARKET_ERROR_CODES.notInstallable) + } + let fetched + try { + fetched = await providers[source].fetchFile(slug, file.path) + } catch (error) { + throw toUpstreamApiError(error) + } + if (file.sha256 && sha256Hex(fetched.content) !== file.sha256.toLowerCase()) { + throw new ApiError( + 502, + `Checksum mismatch for ${file.path} — aborting install`, + MARKET_ERROR_CODES.checksumMismatch, + ) + } + const filePath = path.join(tmpDir, file.path) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, fetched.content, 'utf-8') + } + + const meta: MarketMeta = { + id: `${source}:${slug}`, + source, + slug, + version: detail.version, + installedAt: new Date().toISOString(), + fileCount: files.length, + } + await fs.writeFile(path.join(tmpDir, MARKET_META_FILENAME), `${JSON.stringify(meta, null, 2)}\n`, 'utf-8') + + try { + await fs.mkdir(skillsDir, { recursive: true }) + } catch (error) { + throw new ApiError(500, `Cannot create skills directory: ${String(error)}`, MARKET_ERROR_CODES.diskError) + } + + // Last-moment conflict check (races with manual installs). + try { + await fs.stat(target) + throw new ApiError(409, `Skill directory already exists: ${dirName}`, MARKET_ERROR_CODES.alreadyInstalled) + } catch (error) { + if (error instanceof ApiError) throw error + // ENOENT — expected, continue. + } + + try { + await moveIntoPlace(tmpDir, target) + } catch (error) { + throw new ApiError(500, `Failed to write skill to disk: ${String(error)}`, MARKET_ERROR_CODES.diskError) + } + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + } + + clearSkillCaches() + + const annotated = await annotateInstallState(detail) + return { installedPath: target, skill: annotated } +} + +function toUpstreamApiError(error: unknown): ApiError { + if (error instanceof ApiError) return error + if (error instanceof MarketUpstreamError) { + return new ApiError(502, error.message, error.code) + } + return new ApiError(502, `Upstream download failed: ${String(error)}`, MARKET_ERROR_CODES.upstreamError) +} + +export type UninstallResult = { skill: NormalizedSkill | null; removedPath: string } + +export async function uninstallMarketSkill(source: MarketSource, slug: string): Promise { + const dirName = sanitizeDirName(slug) + if (!dirName) { + throw ApiError.badRequest(`Invalid skill slug: ${slug}`) + } + const target = path.join(getMarketSkillsDir(), dirName) + try { + await fs.stat(target) + } catch { + throw new ApiError(404, `Skill is not installed: ${slug}`, MARKET_ERROR_CODES.notInstalled) + } + const meta = await readMarketMeta(dirName) + if (!meta) { + // Never delete directories the market didn't create. + throw new ApiError( + 409, + `Skill directory was not installed from the market: ${dirName}`, + MARKET_ERROR_CODES.notManaged, + ) + } + await fs.rm(target, { recursive: true, force: true }) + clearSkillCaches() + + // Best effort: return the refreshed market entry so the UI can sync state. + let skill: NormalizedSkill | null = null + try { + skill = await resolveMarketSkill(source, slug) + } catch { + skill = null + } + return { skill, removedPath: target } +} + +export function resetInstallLocksForTests(): void { + inFlight.clear() +} + +export { marketCache } diff --git a/src/server/services/market/marketService.ts b/src/server/services/market/marketService.ts new file mode 100644 index 00000000..94379c50 --- /dev/null +++ b/src/server/services/market/marketService.ts @@ -0,0 +1,362 @@ +/** + * Skills Market — aggregation service. + * + * Merges the two upstream providers into a single paginated feed with + * cross-source dedupe, per-source health/degradation reporting, TTL caching + * (stale-while-error), and locally-computed install state. + */ + +import * as fs from 'fs/promises' +import * as path from 'path' +import { getClaudeConfigHomeDir } from '../../../utils/envUtils.js' +import { marketCache, getSourceHealth, MARKET_TTL } from './cache.js' +import { clawhubProvider } from './clawhubProvider.js' +import { skillhubProvider } from './skillhubProvider.js' +import { + MARKET_LIMITS, + MARKET_SOURCES, + detectMarketLanguage, + sanitizeDirName, + skillId, + type MarketFileContent, + type MarketListResult, + type MarketProvider, + type MarketSource, + type NormalizedSkill, + type NormalizedSkillDetail, + type ProviderListPage, + type SourceStatusInfo, +} from './types.js' + +export const MARKET_META_FILENAME = '.market-meta.json' + +const providers: Record = { + clawhub: clawhubProvider, + skillhub: skillhubProvider, +} + +// ─── Cursor (opaque, merges both providers' pagination) ───────────────────── + +type MergedCursor = Partial> + +export function encodeCursor(cursor: MergedCursor): string | null { + const keys = Object.keys(cursor) + if (keys.length === 0) return null + return Buffer.from(JSON.stringify(cursor), 'utf-8').toString('base64url') +} + +export function decodeCursor(raw: string | null | undefined): MergedCursor | undefined { + if (!raw) return undefined + try { + const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf-8')) as MergedCursor + if (typeof parsed !== 'object' || parsed === null) return undefined + const cursor: MergedCursor = {} + for (const source of MARKET_SOURCES) { + const value = parsed[source] + if (typeof value === 'string' && value) cursor[source] = value + } + return cursor + } catch { + return undefined + } +} + +// ─── Install state annotation ──────────────────────────────────────────────── + +export function getMarketSkillsDir(): string { + return path.join(getClaudeConfigHomeDir(), 'skills') +} + +export type MarketMeta = { + id: string + source: MarketSource + slug: string + version?: string + installedAt: string + fileCount: number + signatureVerified?: boolean +} + +export async function readMarketMeta(dirName: string): Promise { + try { + const raw = await fs.readFile(path.join(getMarketSkillsDir(), dirName, MARKET_META_FILENAME), 'utf-8') + return JSON.parse(raw) as MarketMeta + } catch { + return null + } +} + +async function dirExists(dirPath: string): Promise { + try { + const stat = await fs.stat(dirPath) + return stat.isDirectory() + } catch { + return false + } +} + +/** + * Computed fresh on every request (never cached): checks the local skills + * directory for an existing install or a name conflict. + */ +export async function annotateInstallState(skill: T): Promise { + const dirName = sanitizeDirName(skill.slug) + if (!dirName) { + return { ...skill, installState: 'not-installable', notInstallableReason: 'invalid-name' } + } + const target = path.join(getMarketSkillsDir(), dirName) + if (!(await dirExists(target))) { + return { ...skill, installState: 'installable', notInstallableReason: undefined, installedInfo: undefined } + } + const meta = await readMarketMeta(dirName) + if (meta && meta.slug === skill.slug) { + return { + ...skill, + installState: 'installed', + notInstallableReason: undefined, + installedInfo: { version: meta.version, installedAt: meta.installedAt, dirName }, + } + } + // Directory exists but was not installed from the market (or belongs to a + // different skill) — refuse to overwrite it. + return { ...skill, installState: 'not-installable', notInstallableReason: 'name-conflict' } +} + +/** File-level installability checks — only possible once the file list is known. */ +export function applyFileLimits(detail: NormalizedSkillDetail): NormalizedSkillDetail { + const files = detail.files.map((f) => ({ ...f, tooBig: f.size > MARKET_LIMITS.maxFileSize })) + const result: NormalizedSkillDetail = { ...detail, files } + if (result.installState !== 'installable') return result + if (files.length === 0 || !files.some((f) => f.path === 'SKILL.md')) { + return { ...result, installState: 'not-installable', notInstallableReason: 'empty-file-list' } + } + if (files.length > MARKET_LIMITS.maxFileCount) { + return { ...result, installState: 'not-installable', notInstallableReason: 'too-many-files' } + } + if (files.some((f) => f.tooBig) || result.totalSize > MARKET_LIMITS.maxTotalSize) { + return { ...result, installState: 'not-installable', notInstallableReason: 'file-too-large' } + } + return result +} + +// ─── Cross-source dedupe ───────────────────────────────────────────────────── + +/** + * SkillHub mirrors ClawHub skills (source='clawhub' + upstream_url). When a + * page contains both the mirror and the ClawHub original, merge them: the + * ClawHub entry wins (fresher data), enriched with SkillHub-only fields. + */ +export function dedupeSkills(items: NormalizedSkill[]): NormalizedSkill[] { + const byClawhubSlug = new Map() + for (const item of items) { + if (item.source === 'clawhub') byClawhubSlug.set(item.slug, item) + } + const result: NormalizedSkill[] = [] + for (const item of items) { + if (item.source === 'skillhub' && item.upstream?.slug) { + const original = byClawhubSlug.get(item.upstream.slug) + if (original) { + original.mirrors = [...(original.mirrors ?? []), item.id] + // Enrich the original with SkillHub-only data. + if (!original.iconUrl && item.iconUrl) original.iconUrl = item.iconUrl + if (original.securityStatus === 'unknown' && item.securityStatus !== 'unknown') { + original.securityStatus = item.securityStatus + } + if (item.tags.length && original.tags.length === 0) original.tags = item.tags + continue + } + } + result.push(item) + } + return result +} + +// ─── List / search ─────────────────────────────────────────────────────────── + +export type MarketListParams = { + q?: string + source: 'all' | MarketSource + security?: string + installed?: 'all' | 'installed' | 'installable' + cursor?: string + limit: number +} + +type ProviderOutcome = { + page: ProviderListPage | null + status: SourceStatusInfo +} + +async function fetchProviderPage( + source: MarketSource, + params: { q?: string; cursor?: string; limit: number }, +): Promise { + const isSearch = Boolean(params.q) + const cacheKey = isSearch + ? `search:${source}:${params.q}:${params.cursor ?? ''}:${params.limit}` + : `list:${source}:${params.cursor ?? ''}:${params.limit}` + const ttl = isSearch ? MARKET_TTL.search : MARKET_TTL.list + + const cached = marketCache.get(cacheKey) + if (cached) { + return { page: cached, status: { status: 'ok', fetchedAt: Date.now(), fromCache: true } } + } + + try { + const page = isSearch + ? await providers[source].search({ q: params.q!, cursor: params.cursor, limit: params.limit }) + : await providers[source].list({ cursor: params.cursor, limit: params.limit }) + marketCache.set(cacheKey, page, ttl) + return { page, status: { status: 'ok', fetchedAt: Date.now(), fromCache: false } } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + // Stale-while-error: fall back to an expired cache entry when available. + const stale = marketCache.getStale(cacheKey) + if (stale) { + return { + page: stale.value, + status: { status: 'cached', fetchedAt: stale.storedAt, fromCache: true, error: message }, + } + } + return { page: null, status: { ...getSourceHealth(source), fromCache: false, error: message } } + } +} + +export async function listMarketSkills(params: MarketListParams): Promise { + const cursor = decodeCursor(params.cursor) + const isFirstPage = !params.cursor + const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source] + + const outcomes = new Map() + await Promise.all( + activeSources.map(async (source) => { + // A source absent from a non-first-page cursor is exhausted. + const providerCursor = cursor?.[source] + if (!isFirstPage && !providerCursor) { + outcomes.set(source, { page: { items: [] }, status: { status: 'ok', fromCache: true } }) + return + } + const limit = params.q && source === 'clawhub' ? MARKET_LIMITS.searchResultCap : params.limit + outcomes.set(source, await fetchProviderPage(source, { q: params.q, cursor: providerCursor, limit })) + }), + ) + + let merged: NormalizedSkill[] = [] + const nextCursor: MergedCursor = {} + const sources = {} as Record + + for (const source of MARKET_SOURCES) { + const outcome = outcomes.get(source) + if (!outcome) { + sources[source] = { status: 'ok', fromCache: false } + continue + } + sources[source] = outcome.status + if (outcome.page) { + merged.push(...outcome.page.items) + if (outcome.page.nextCursor) nextCursor[source] = outcome.page.nextCursor + } + } + + merged = dedupeSkills(merged) + merged.sort((a, b) => b.stats.downloads - a.stats.downloads) + merged = await Promise.all(merged.map((item) => annotateInstallState(item))) + + if (params.security && params.security !== 'all') { + merged = merged.filter((item) => item.securityStatus === params.security) + } + if (params.installed && params.installed !== 'all') { + merged = merged.filter((item) => + params.installed === 'installed' + ? item.installState === 'installed' + : item.installState !== 'installed', + ) + } + + return { items: merged, nextCursor: encodeCursor(nextCursor), sources } +} + +// ─── Detail / file content ─────────────────────────────────────────────────── + +export async function getMarketSkillDetail( + source: MarketSource, + slug: string, +): Promise<{ skill: NormalizedSkillDetail; sourceStatus: SourceStatusInfo }> { + const cacheKey = `detail:${source}:${slug}` + let detail = marketCache.get(cacheKey) + let sourceStatus: SourceStatusInfo = { status: 'ok', fetchedAt: Date.now(), fromCache: true } + + if (!detail) { + try { + detail = await providers[source].detail(slug) + marketCache.set(cacheKey, detail, MARKET_TTL.detail) + sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: false } + } catch (error) { + const stale = marketCache.getStale(cacheKey) + if (!stale) throw error + detail = stale.value + sourceStatus = { + status: 'cached', + fetchedAt: stale.storedAt, + fromCache: true, + error: error instanceof Error ? error.message : String(error), + } + } + } + + const annotated = applyFileLimits(await annotateInstallState(detail)) + return { skill: annotated, sourceStatus } +} + +export function isValidMarketFilePath(filePath: string): boolean { + if (!filePath || filePath.length > 512) return false + if (filePath.startsWith('/') || filePath.startsWith('\\')) return false + if (filePath.includes('..') || filePath.includes('\0')) return false + return true +} + +export async function getMarketFileContent( + source: MarketSource, + slug: string, + filePath: string, +): Promise { + const cacheKey = `file:${source}:${slug}:${filePath}` + const cached = marketCache.get(cacheKey) + if (cached) return cached + + const fetched = await providers[source].fetchFile(slug, filePath) + let content = fetched.content + let truncated = false + if (Buffer.byteLength(content, 'utf-8') > MARKET_LIMITS.previewTruncateBytes) { + content = Buffer.from(content, 'utf-8').subarray(0, MARKET_LIMITS.previewTruncateBytes).toString('utf-8') + truncated = true + } + const result: MarketFileContent = { + path: filePath, + content, + language: detectMarketLanguage(filePath), + size: fetched.size, + truncated, + } + marketCache.set(cacheKey, result, MARKET_TTL.fileContent) + return result +} + +// ─── Status ────────────────────────────────────────────────────────────────── + +export function getMarketStatus(): Record { + return { + clawhub: getSourceHealth('clawhub'), + skillhub: getSourceHealth('skillhub'), + } +} + +/** Look up a single skill (used by install) — detail path, bypassing list. */ +export async function resolveMarketSkill(source: MarketSource, slug: string): Promise { + const { skill } = await getMarketSkillDetail(source, slug) + return skill +} + +export function marketSkillId(source: MarketSource, slug: string): string { + return skillId(source, slug) +} diff --git a/src/server/services/market/providerFetch.ts b/src/server/services/market/providerFetch.ts new file mode 100644 index 00000000..03fb1f3a --- /dev/null +++ b/src/server/services/market/providerFetch.ts @@ -0,0 +1,121 @@ +/** + * Skills Market — shared upstream fetch helper. + * + * Every upstream request goes through providerFetch: 10s timeout, one retry, + * network-proxy settings, source-health bookkeeping, and env-based test hooks + * (HAHA_MARKET_DISABLE_PROVIDERS / HAHA_MARKET_BASE_CLAWHUB / HAHA_MARKET_BASE_SKILLHUB). + */ + +import { + getNetworkProxyFetchOptions, + loadNetworkSettings, +} from '../networkSettings.js' +import { recordSourceFailure, recordSourceSuccess } from './cache.js' +import { MARKET_ERROR_CODES, MarketUpstreamError, type MarketSource } from './types.js' + +const DEFAULT_BASES: Record = { + clawhub: 'https://clawhub.ai', + skillhub: 'https://api.skillhub.cn', +} + +const REQUEST_TIMEOUT_MS = 10_000 + +export function getProviderBase(source: MarketSource): string { + const envKey = source === 'clawhub' ? 'HAHA_MARKET_BASE_CLAWHUB' : 'HAHA_MARKET_BASE_SKILLHUB' + return process.env[envKey] || DEFAULT_BASES[source] +} + +export function isProviderDisabled(source: MarketSource): boolean { + const disabled = process.env.HAHA_MARKET_DISABLE_PROVIDERS + if (!disabled) return false + return disabled.split(',').map((s) => s.trim()).includes(source) +} + +async function fetchOnce(source: MarketSource, url: string): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + try { + let proxyOptions: Record = {} + try { + const settings = await loadNetworkSettings() + proxyOptions = getNetworkProxyFetchOptions(settings, url) as Record + } catch { + // Proxy settings unavailable — fall back to a direct request. + } + return await fetch(url, { + headers: { Accept: 'application/json, text/plain, */*' }, + redirect: 'follow', + signal: controller.signal, + ...proxyOptions, + }) + } finally { + clearTimeout(timer) + } +} + +export async function providerFetch(source: MarketSource, url: string): Promise { + if (isProviderDisabled(source)) { + const error = new MarketUpstreamError(source, MARKET_ERROR_CODES.upstreamError, `${source} provider disabled`) + recordSourceFailure(source, error.message) + throw error + } + + let lastError: unknown + for (let attempt = 0; attempt < 2; attempt++) { + try { + const res = await fetchOnce(source, url) + if (res.status === 429 || res.status >= 500) { + lastError = new MarketUpstreamError( + source, + MARKET_ERROR_CODES.upstreamError, + `${source} responded ${res.status}`, + ) + continue + } + recordSourceSuccess(source) + return res + } catch (error) { + if (error instanceof MarketUpstreamError) { + lastError = error + continue + } + const isAbort = error instanceof Error && error.name === 'AbortError' + lastError = new MarketUpstreamError( + source, + isAbort ? MARKET_ERROR_CODES.upstreamTimeout : MARKET_ERROR_CODES.upstreamError, + isAbort ? `${source} request timed out` : `${source} request failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + const finalError = lastError instanceof MarketUpstreamError + ? lastError + : new MarketUpstreamError(source, MARKET_ERROR_CODES.upstreamError, `${source} request failed`) + recordSourceFailure(source, finalError.message) + throw finalError +} + +export async function providerFetchJson(source: MarketSource, url: string): Promise { + const res = await providerFetch(source, url) + if (!res.ok) { + const error = new MarketUpstreamError( + source, + MARKET_ERROR_CODES.upstreamError, + `${source} responded ${res.status} for ${new URL(url).pathname}`, + ) + recordSourceFailure(source, error.message) + throw error + } + const text = await res.text() + try { + return JSON.parse(text) as T + } catch { + const error = new MarketUpstreamError( + source, + MARKET_ERROR_CODES.upstreamBadResponse, + `${source} returned invalid JSON`, + ) + recordSourceFailure(source, error.message) + throw error + } +} diff --git a/src/server/services/market/skillhubProvider.ts b/src/server/services/market/skillhubProvider.ts new file mode 100644 index 00000000..88fd99cc --- /dev/null +++ b/src/server/services/market/skillhubProvider.ts @@ -0,0 +1,267 @@ +/** + * SkillHub provider (https://api.skillhub.cn) + * + * Endpoints (verified against the live API): + * - GET /api/skills?page=&pageSize=&keyword= → {code, data:{skills[], total}, message} + * NOTE: pagination param MUST be `pageSize` (a `limit` param is silently ignored) + * NOTE: search param MUST be `keyword` (a `q` param is silently ignored) + * - GET /api/v1/skills/{slug} → {skill, owner, latestVersion, securityReports} + * - GET /api/v1/skills/{slug}/files → {count, files:[{path, sha256, size}]} + * - GET /api/v1/skills/{slug}/file?path= → 302 redirect to Tencent COS (follow) + */ + +import { getProviderBase, providerFetch, providerFetchJson } from './providerFetch.js' +import { + detectMarketLanguage, + MARKET_ERROR_CODES, + MarketUpstreamError, + skillId, + type MarketProvider, + type NormalizedSkill, + type NormalizedSkillDetail, + type ProviderFileEntry, + type ProviderListPage, + type SecurityReport, + type SecurityStatus, +} from './types.js' + +type SkillhubListItem = { + slug: string + name?: string + /** Detail endpoint uses displayName/summary/summary_zh instead of name/description/description_zh */ + displayName?: string + summary?: string + summary_zh?: string + description?: string + description_zh?: string + category?: string + subCategories?: Array<{ key?: string; name?: string }> + downloads?: number + installs?: number + stars?: number + iconUrl?: string + ownerName?: string + labels?: Record + source?: string + upstream_url?: string + verified?: boolean + version?: string + updated_at?: number +} + +type SkillhubEnvelope = { code: number; data: T; message?: string } + +type SkillhubSecurityReports = Record< + string, + { status?: string; statusText?: string; reportUrl?: string } | undefined +> + +type SkillhubDetail = { + skill?: SkillhubListItem & { stats?: { downloads?: number; installs?: number; stars?: number } } + owner?: { handle?: string; displayName?: string; image?: string } + latestVersion?: { version?: string; changelog?: string } + securityReports?: SkillhubSecurityReports +} + +const BENIGN_STATUSES = new Set(['benign', 'safe', 'clean']) + +function mapSecurity( + reports: SkillhubSecurityReports | undefined, + verified: boolean | undefined, +): { status: SecurityStatus; reports: SecurityReport[] } { + const normalized: SecurityReport[] = [] + for (const [vendor, report] of Object.entries(reports ?? {})) { + if (!report?.status) continue + normalized.push({ + vendor, + status: report.status, + statusText: report.statusText || report.status, + reportUrl: report.reportUrl, + }) + } + if (normalized.length === 0) return { status: 'unknown', reports: normalized } + const anyFlagged = normalized.some((r) => !BENIGN_STATUSES.has(r.status.toLowerCase())) + if (anyFlagged) return { status: 'flagged', reports: normalized } + return { status: verified ? 'verified' : 'benign', reports: normalized } +} + +function parseUpstream(item: SkillhubListItem): NormalizedSkill['upstream'] { + if (item.source !== 'clawhub' || !item.upstream_url) return undefined + // upstream_url looks like https://clawhub.ai/{owner}/{slug} — the trailing segment is the slug. + try { + const segments = new URL(item.upstream_url).pathname.split('/').filter(Boolean) + const slug = segments[segments.length - 1] + if (slug) return { source: 'clawhub', slug } + } catch { + // Malformed upstream URL — treat as a native entry. + } + return undefined +} + +function normalizeListItem(item: SkillhubListItem): NormalizedSkill { + const tags: string[] = [] + for (const sub of item.subCategories ?? []) { + if (sub?.name) tags.push(sub.name) + } + return { + id: skillId('skillhub', item.slug), + source: 'skillhub', + slug: item.slug, + name: item.name || item.displayName || item.slug, + summary: item.description_zh || item.description || item.summary_zh || item.summary || '', + author: { handle: item.ownerName || '' }, + stats: { + downloads: item.downloads ?? 0, + installs: item.installs, + stars: item.stars, + }, + tags, + category: item.category, + version: item.version, + updatedAt: item.updated_at, + iconUrl: item.iconUrl || undefined, + // List responses carry no security reports — `verified` is the only signal; + // the detail endpoint refines this to benign/flagged via securityReports. + securityStatus: item.verified ? 'verified' : 'unknown', + requiresApiKey: item.labels?.requires_api_key === 'true', + verified: item.verified, + upstream: parseUpstream(item), + installState: 'installable', + } +} + +async function fetchPage(params: { keyword?: string; page: number; pageSize: number }): Promise { + const base = getProviderBase('skillhub') + const url = new URL('/api/skills', base) + url.searchParams.set('page', String(params.page)) + url.searchParams.set('pageSize', String(params.pageSize)) + if (params.keyword) url.searchParams.set('keyword', params.keyword) + + const envelope = await providerFetchJson>( + 'skillhub', + url.toString(), + ) + if (envelope.code !== 0 || !Array.isArray(envelope.data?.skills)) { + throw new MarketUpstreamError( + 'skillhub', + MARKET_ERROR_CODES.upstreamBadResponse, + `skillhub responded code=${envelope.code}: ${envelope.message || 'bad payload'}`, + ) + } + const items = envelope.data.skills.filter((s) => s?.slug).map(normalizeListItem) + const total = envelope.data.total ?? 0 + const hasMore = params.page * params.pageSize < total + return { + items, + nextCursor: hasMore ? String(params.page + 1) : undefined, + total, + } +} + +export const skillhubProvider: MarketProvider = { + source: 'skillhub', + + async list({ cursor, limit }): Promise { + const page = cursor ? Math.max(1, Number.parseInt(cursor, 10) || 1) : 1 + return fetchPage({ page, pageSize: limit }) + }, + + async search({ q, cursor, limit }): Promise { + const page = cursor ? Math.max(1, Number.parseInt(cursor, 10) || 1) : 1 + return fetchPage({ keyword: q, page, pageSize: limit }) + }, + + async detail(slug): Promise { + const base = getProviderBase('skillhub') + const data = await providerFetchJson( + 'skillhub', + new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, base).toString(), + ) + if (!data.skill?.slug) { + throw new MarketUpstreamError('skillhub', MARKET_ERROR_CODES.upstreamBadResponse, 'skillhub detail missing skill') + } + + const item = normalizeListItem(data.skill) + if (data.skill.stats) { + item.stats = { + downloads: data.skill.stats.downloads ?? item.stats.downloads, + installs: data.skill.stats.installs ?? item.stats.installs, + stars: data.skill.stats.stars ?? item.stats.stars, + } + } + const security = mapSecurity(data.securityReports, data.skill.verified) + const version = data.latestVersion?.version || item.version + + let files: ProviderFileEntry[] = [] + try { + files = await skillhubProvider.listFiles(slug) + } catch { + // File list is best-effort at detail time; install re-fetches it. + } + + // SkillHub detail has no full SKILL.md body — fetch it for the overview tab. + let description = '' + const skillMd = files.find((f) => f.path === 'SKILL.md') + if (skillMd) { + try { + const fetched = await skillhubProvider.fetchFile(slug, 'SKILL.md') + description = fetched.content + } catch { + description = item.summary + } + } else { + description = item.summary + } + + return { + ...item, + version, + author: { + handle: data.owner?.handle || item.author.handle, + displayName: data.owner?.displayName, + avatarUrl: data.owner?.image || undefined, + }, + securityStatus: security.status, + securityReports: security.reports.length ? security.reports : undefined, + description, + files: files.map((f) => ({ + path: f.path, + size: f.size, + sha256: f.sha256, + contentType: f.contentType, + language: detectMarketLanguage(f.path), + tooBig: false, + })), + totalSize: files.reduce((sum, f) => sum + (f.size || 0), 0), + } + }, + + async listFiles(slug): Promise { + const base = getProviderBase('skillhub') + const data = await providerFetchJson<{ count?: number; files?: Array<{ path: string; sha256?: string; size: number }> }>( + 'skillhub', + new URL(`/api/v1/skills/${encodeURIComponent(slug)}/files`, base).toString(), + ) + if (!Array.isArray(data.files)) { + throw new MarketUpstreamError('skillhub', MARKET_ERROR_CODES.upstreamBadResponse, 'skillhub files missing list') + } + return data.files + }, + + async fetchFile(slug, filePath): Promise<{ content: string; size: number }> { + const base = getProviderBase('skillhub') + const url = new URL(`/api/v1/skills/${encodeURIComponent(slug)}/file`, base) + url.searchParams.set('path', filePath) + // 302 → Tencent COS; providerFetch follows redirects. + const res = await providerFetch('skillhub', url.toString()) + if (!res.ok) { + throw new MarketUpstreamError( + 'skillhub', + res.status === 404 ? MARKET_ERROR_CODES.upstreamBadResponse : MARKET_ERROR_CODES.upstreamError, + `skillhub file fetch failed (${res.status})`, + ) + } + const content = await res.text() + return { content, size: Buffer.byteLength(content, 'utf-8') } + }, +} diff --git a/src/server/services/market/types.ts b/src/server/services/market/types.ts new file mode 100644 index 00000000..844d26f9 --- /dev/null +++ b/src/server/services/market/types.ts @@ -0,0 +1,200 @@ +/** + * Skills Market — shared types for the market aggregation layer. + * + * Upstream sources: + * - ClawHub (https://clawhub.ai) — cursor pagination, no security audits + * - SkillHub (https://api.skillhub.cn) — page/pageSize pagination, security reports + */ + +export type MarketSource = 'clawhub' | 'skillhub' + +export const MARKET_SOURCES: MarketSource[] = ['clawhub', 'skillhub'] + +export type SecurityStatus = 'verified' | 'benign' | 'unknown' | 'flagged' + +export type InstallState = 'installed' | 'installable' | 'not-installable' + +export type SourceHealthStatus = 'ok' | 'degraded' | 'failed' | 'cached' + +export type NotInstallableReason = + | 'empty-file-list' + | 'file-too-large' + | 'too-many-files' + | 'invalid-name' + | 'name-conflict' + | 'source-unavailable' + +export type SecurityReport = { + vendor: string + status: string + statusText: string + reportUrl?: string +} + +export type NormalizedSkill = { + /** `${source}:${slug}` — globally unique */ + id: string + source: MarketSource + slug: string + name: string + summary: string + author: { handle: string; displayName?: string; avatarUrl?: string } + stats: { downloads: number; installs?: number; stars?: number } + tags: string[] + category?: string + version?: string + updatedAt?: number + iconUrl?: string + securityStatus: SecurityStatus + securityReports?: SecurityReport[] + requiresApiKey?: boolean + verified?: boolean + /** Set on SkillHub entries that mirror a ClawHub skill */ + upstream?: { source: MarketSource; slug: string } + /** After dedupe: ids of merged duplicate entries from other sources */ + mirrors?: string[] + installState: InstallState + notInstallableReason?: NotInstallableReason + installedInfo?: { version?: string; installedAt?: string; dirName: string } +} + +export type MarketFileMeta = { + path: string + size: number + sha256?: string + contentType?: string + language: string + /** File exceeds the preview/install size limit */ + tooBig: boolean +} + +export type NormalizedSkillDetail = NormalizedSkill & { + /** Full SKILL.md body (markdown, frontmatter stripped) */ + description: string + /** Raw frontmatter parsed from SKILL.md description, when present */ + descriptionFrontmatter?: Record + license?: string + files: MarketFileMeta[] + totalSize: number +} + +export type MarketFileContent = { + path: string + content: string + language: string + size: number + truncated: boolean +} + +export type SourceStatusInfo = { + status: SourceHealthStatus + fetchedAt?: number + fromCache?: boolean + error?: string +} + +export type MarketListResult = { + items: NormalizedSkill[] + nextCursor: string | null + sources: Record +} + +// ─── Provider layer ────────────────────────────────────────────────────────── + +export type ProviderListPage = { + items: NormalizedSkill[] + /** Provider-native cursor for the next page; undefined = exhausted */ + nextCursor?: string + total?: number +} + +export type ProviderFileEntry = { + path: string + size: number + sha256?: string + contentType?: string +} + +export interface MarketProvider { + readonly source: MarketSource + list(params: { cursor?: string; limit: number }): Promise + search(params: { q: string; cursor?: string; limit: number }): Promise + detail(slug: string): Promise + listFiles(slug: string, version?: string): Promise + fetchFile(slug: string, filePath: string): Promise<{ content: string; size: number }> +} + +// ─── Error codes ───────────────────────────────────────────────────────────── + +export const MARKET_ERROR_CODES = { + upstreamError: 'MARKET_UPSTREAM_ERROR', + upstreamTimeout: 'MARKET_UPSTREAM_TIMEOUT', + upstreamBadResponse: 'MARKET_UPSTREAM_BAD_RESPONSE', + installInProgress: 'MARKET_INSTALL_IN_PROGRESS', + alreadyInstalled: 'MARKET_ALREADY_INSTALLED', + notInstallable: 'MARKET_NOT_INSTALLABLE', + checksumMismatch: 'MARKET_CHECKSUM_MISMATCH', + diskError: 'MARKET_DISK_ERROR', + notInstalled: 'MARKET_NOT_INSTALLED', + notManaged: 'MARKET_NOT_MANAGED', +} as const + +export class MarketUpstreamError extends Error { + constructor( + public source: MarketSource, + public code: string, + message: string, + ) { + super(message) + this.name = 'MarketUpstreamError' + } +} + +// ─── Limits ────────────────────────────────────────────────────────────────── + +export const MARKET_LIMITS = { + /** Max bytes for a single skill file (install + preview) */ + maxFileSize: 5 * 1024 * 1024, + /** Max total bytes for an installable skill */ + maxTotalSize: 20 * 1024 * 1024, + /** Max file count for an installable skill */ + maxFileCount: 200, + /** File preview content is truncated beyond this many bytes */ + previewTruncateBytes: 300 * 1024, + /** ClawHub search has no pagination — cap merged search results */ + searchResultCap: 50, +} as const + +export function skillId(source: MarketSource, slug: string): string { + return `${source}:${slug}` +} + +export function parseSkillId(id: string): { source: MarketSource; slug: string } | null { + const idx = id.indexOf(':') + if (idx <= 0) return null + const source = id.slice(0, idx) + const slug = id.slice(idx + 1) + if (!MARKET_SOURCES.includes(source as MarketSource) || !slug) return null + return { source: source as MarketSource, slug } +} + +/** Directory-name whitelist: lowercase alnum, dash, underscore, dot (no leading dot). */ +export function sanitizeDirName(slug: string): string | null { + const name = slug.toLowerCase() + if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) return null + if (name.includes('..')) return null + return name +} + +const LANG_MAP: Record = { + md: 'markdown', ts: 'typescript', tsx: 'typescript', + js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', + json: 'json', yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash', zsh: 'bash', + py: 'python', toml: 'toml', css: 'css', html: 'html', + txt: 'text', xml: 'xml', sql: 'sql', rs: 'rust', go: 'go', rb: 'ruby', +} + +export function detectMarketLanguage(filename: string): string { + const ext = filename.split('.').pop()?.toLowerCase() || '' + return LANG_MAP[ext] || 'text' +}