mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat(desktop): 新增技能市场功能,聚合 ClawHub 与 SkillHub
- server 层新增 market 服务:聚合两个上游、跨来源去重、TTL 缓存与降级、原子安装/卸载 - 桌面端新增市场页面(搜索/筛选/分页/详情/文件预览/安装确认),挂载为侧边栏顶层 Tab - 本地已装技能详情页改造为与市场详情共用同一套展示组件,并支持卸载 - 5 语言 i18n 补齐,新增 server/desktop 测试覆盖
This commit is contained in:
parent
a94e1a1694
commit
536b4584d3
@ -365,7 +365,6 @@ describe('Settings > Skills tab', () => {
|
||||
render(<Settings />)
|
||||
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(<Settings />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -253,10 +253,10 @@ describe('Settings > Skills tab', () => {
|
||||
render(<Settings />)
|
||||
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()
|
||||
})
|
||||
|
||||
64
desktop/src/api/market.ts
Normal file
64
desktop/src/api/market.ts
Normal file
@ -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<MarketListResponse>(`/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<MarketSource, SourceStatusInfo> }>('/api/market/status'),
|
||||
}
|
||||
@ -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 = <Settings />
|
||||
} else if (activeTabType === 'scheduled') {
|
||||
page = <ScheduledTasks />
|
||||
} else if (activeTabType === 'market') {
|
||||
page = <Market />
|
||||
} else if (activeTabType === 'trace') {
|
||||
const traceSessionId = tabs.find((t) => t.sessionId === activeTabId)?.traceSessionId
|
||||
page = traceSessionId ? <TraceSession sessionId={traceSessionId} /> : <EmptySession />
|
||||
|
||||
@ -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')}
|
||||
</NavItem>
|
||||
)}
|
||||
<NavItem
|
||||
active={activeTabId === MARKET_TAB_ID}
|
||||
collapsed={!expanded}
|
||||
label={t('sidebar.market')}
|
||||
touchFriendly={isMobile}
|
||||
onClick={() => {
|
||||
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.market'), 'market')
|
||||
closeMobileDrawer()
|
||||
}}
|
||||
icon={<StorefrontIcon />}
|
||||
>
|
||||
{t('sidebar.market')}
|
||||
</NavItem>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
@ -1945,6 +1958,17 @@ function ClockIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function StorefrontIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 9l1.5-5h15L21 9" />
|
||||
<path d="M4 9v11h16V9" />
|
||||
<path d="M4 9c0 1.5 1.3 2.5 2.8 2.5S9.7 10.5 9.7 9c0 1.5 1.3 2.5 2.8 2.5s2.8-1 2.8-2.5c0 1.5 1.3 2.5 2.8 2.5S21 10.5 21 9" />
|
||||
<path d="M9 20v-6h6v6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
191
desktop/src/components/market/FilePreview.tsx
Normal file
191
desktop/src/components/market/FilePreview.tsx
Normal file
@ -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<string, string> = {
|
||||
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<PreviewFileContent>
|
||||
initialPath?: string
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
const defaultPath = initialPath ?? files.find((f) => f.path === 'SKILL.md')?.path ?? files[0]?.path ?? null
|
||||
const [activePath, setActivePath] = useState<string | null>(defaultPath)
|
||||
const [state, setState] = useState<LoadState>({ kind: 'idle' })
|
||||
const cacheRef = useRef(new Map<string, PreviewFileContent>())
|
||||
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 (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-12 text-center">
|
||||
<span className="material-symbols-outlined mb-2 block text-[32px] text-[var(--color-text-tertiary)]">folder_off</span>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">{t('market.file.noFiles')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeFile = files.find((f) => f.path === activePath)
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4 lg:grid-cols-[240px_minmax(0,1fr)]" data-testid="market-file-preview">
|
||||
<div className="flex max-h-[520px] flex-col gap-0.5 overflow-y-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5">
|
||||
{files.map((file) => {
|
||||
const active = file.path === activePath
|
||||
return (
|
||||
<button
|
||||
key={file.path}
|
||||
type="button"
|
||||
data-testid={`market-file-item-${file.path}`}
|
||||
onClick={() => void open(file.path)}
|
||||
className={`flex items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined flex-shrink-0 text-[16px]" aria-hidden>
|
||||
{LANG_ICONS[file.language] || 'draft'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium">{file.path}</span>
|
||||
<span className={`block text-[10px] ${active ? 'opacity-80' : 'text-[var(--color-text-tertiary)]'}`}>
|
||||
{file.language} · {formatSize(file.size)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{activeFile && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-2.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span className="font-mono font-medium text-[var(--color-text-secondary)]">{activeFile.path}</span>
|
||||
<span>{activeFile.language}</span>
|
||||
<span>{formatSize(activeFile.size)}</span>
|
||||
{state.kind === 'loaded' && state.file.truncated && (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--color-warning)]">
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden>content_cut</span>
|
||||
{t('market.file.truncated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[480px] overflow-y-auto p-4">
|
||||
{state.kind === 'loading' && (
|
||||
<div className="flex justify-center py-10" data-testid="market-file-loading">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
{state.kind === 'error' && (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-center" data-testid="market-file-error">
|
||||
<span className="material-symbols-outlined text-[28px] text-[var(--color-error)]">error</span>
|
||||
<p className="text-sm text-[var(--color-text-primary)]">{t('market.file.loadError')}</p>
|
||||
<p className="max-w-md break-words text-xs text-[var(--color-text-tertiary)]">{state.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => activePath && void open(activePath)}
|
||||
className="mt-1 inline-flex min-h-8 items-center gap-1 rounded-lg border border-[var(--color-border)] px-3 text-xs text-[var(--color-text-primary)] hover:border-[var(--color-border-focus)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
||||
{t('market.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{state.kind === 'idle' && (
|
||||
<p className="py-10 text-center text-sm text-[var(--color-text-tertiary)]">{t('market.file.empty')}</p>
|
||||
)}
|
||||
{state.kind === 'loaded' &&
|
||||
(state.file.language === 'markdown' ? (
|
||||
<MarkdownRenderer content={state.file.content} variant="document" />
|
||||
) : (
|
||||
<CodeViewer
|
||||
code={state.file.content}
|
||||
language={state.file.language}
|
||||
showLineNumbers
|
||||
wrapLongLines
|
||||
maxLines={500}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
99
desktop/src/components/market/FilterBar.tsx
Normal file
99
desktop/src/components/market/FilterBar.tsx
Normal file
@ -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 (
|
||||
<span
|
||||
className={`inline-flex min-h-9 items-center gap-1.5 rounded-xl border px-3 text-xs transition-colors ${
|
||||
active
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[var(--color-text-tertiary)]">{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden>
|
||||
expand_more
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 = <K extends keyof MarketFilters>(
|
||||
items: Array<{ value: MarketFilters[K]; label: string }>,
|
||||
value: MarketFilters[K],
|
||||
) => items.find((i) => i.value === value)?.label ?? String(value)
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 ${className}`} data-testid="market-filter-bar">
|
||||
<Dropdown
|
||||
items={sourceItems}
|
||||
value={filters.source}
|
||||
onChange={(value) => setFilter('source', value)}
|
||||
width={220}
|
||||
trigger={
|
||||
<FilterTrigger
|
||||
label={t('market.filter.source')}
|
||||
value={labelFor(sourceItems, filters.source)}
|
||||
active={filters.source !== 'all'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
items={securityItems}
|
||||
value={filters.security}
|
||||
onChange={(value) => setFilter('security', value)}
|
||||
width={220}
|
||||
trigger={
|
||||
<FilterTrigger
|
||||
label={t('market.filter.security')}
|
||||
value={labelFor(securityItems, filters.security)}
|
||||
active={filters.security !== 'all'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
items={installedItems}
|
||||
value={filters.installed}
|
||||
onChange={(value) => setFilter('installed', value)}
|
||||
width={220}
|
||||
trigger={
|
||||
<FilterTrigger
|
||||
label={t('market.filter.installed')}
|
||||
value={labelFor(installedItems, filters.installed)}
|
||||
active={filters.installed !== 'all'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
desktop/src/components/market/InstallConfirmDialog.test.tsx
Normal file
90
desktop/src/components/market/InstallConfirmDialog.test.tsx
Normal file
@ -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> = {}): 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(
|
||||
<InstallConfirmDialog skill={makeSkill()} open installing={false} onConfirm={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<InstallConfirmDialog
|
||||
skill={makeSkill({ securityStatus: 'flagged' })}
|
||||
open
|
||||
installing={false}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/flagged this skill as potentially risky/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warns for unaudited skills', () => {
|
||||
render(
|
||||
<InstallConfirmDialog
|
||||
skill={makeSkill({ securityStatus: 'unknown' })}
|
||||
open
|
||||
installing={false}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/has not been security-audited/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('confirms and cancels', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<InstallConfirmDialog skill={makeSkill()} open installing={false} onConfirm={onConfirm} onClose={onClose} />)
|
||||
|
||||
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(<InstallConfirmDialog skill={makeSkill()} open installing onConfirm={vi.fn()} onClose={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('market-install-confirm-button')).toBeDisabled()
|
||||
expect(screen.getByText('Cancel').closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
114
desktop/src/components/market/InstallConfirmDialog.tsx
Normal file
114
desktop/src/components/market/InstallConfirmDialog.tsx
Normal file
@ -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 (
|
||||
<Modal open={open} onClose={installing ? () => {} : onClose} title={t('market.installConfirm.title')} width={480}>
|
||||
<div className="flex flex-col gap-4" data-testid="market-install-confirm">
|
||||
<p className="text-sm text-[var(--color-text-primary)]">
|
||||
{t('market.installConfirm.message', { name: skill.name, source: t(`market.source.${skill.source}`) })}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[var(--color-text-tertiary)]">{t('market.filter.source')}</span>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{t(`market.source.${skill.source}`)}</span>
|
||||
</div>
|
||||
{skill.version && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[var(--color-text-tertiary)]">{t('market.detail.version')}</span>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">v{skill.version}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[var(--color-text-tertiary)]">{t('market.filter.security')}</span>
|
||||
<SecurityBadge status={skill.securityStatus} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[var(--color-text-tertiary)]">{t('market.installConfirm.location')}</span>
|
||||
<span className="truncate font-mono text-[11px] text-[var(--color-text-secondary)]">
|
||||
~/.claude/skills/{skill.slug}/
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-xl px-3.5 py-2.5 text-xs leading-5 ${
|
||||
skill.securityStatus === 'flagged'
|
||||
? 'border border-[var(--color-error)]/40 bg-[var(--color-error-container)]/40 text-[var(--color-text-primary)]'
|
||||
: risky
|
||||
? 'border border-[var(--color-warning)]/40 bg-[var(--color-warning-container)]/40 text-[var(--color-text-primary)]'
|
||||
: 'border border-[var(--color-success)]/30 bg-[var(--color-success-container)]/40 text-[var(--color-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined mt-0.5 text-[16px] ${
|
||||
skill.securityStatus === 'flagged'
|
||||
? 'text-[var(--color-error)]'
|
||||
: risky
|
||||
? 'text-[var(--color-warning)]'
|
||||
: 'text-[var(--color-success)]'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{skill.securityStatus === 'flagged' ? 'gpp_maybe' : risky ? 'shield_question' : 'gpp_good'}
|
||||
</span>
|
||||
<span>{t(RISK_KEYS[skill.securityStatus])}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-5 text-[var(--color-text-tertiary)]">{t('market.installConfirm.effectNote')}</p>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={installing}
|
||||
onClick={onClose}
|
||||
className="inline-flex min-h-9 items-center rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{t('market.installConfirm.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-install-confirm-button"
|
||||
disabled={installing}
|
||||
onClick={onConfirm}
|
||||
className={`inline-flex min-h-9 items-center gap-1.5 rounded-xl px-4 text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50 ${
|
||||
skill.securityStatus === 'flagged' ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
|
||||
}`}
|
||||
>
|
||||
{installing && (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
)}
|
||||
{installing ? t('market.install.installing') : t('market.installConfirm.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
39
desktop/src/components/market/InstallStateBadge.tsx
Normal file
39
desktop/src/components/market/InstallStateBadge.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { InstallState } from '../../types/market'
|
||||
|
||||
const STYLES: Record<InstallState, { icon: string; className: string }> = {
|
||||
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<InstallState, 'market.install.state.installed' | 'market.install.state.installable' | 'market.install.state.notInstallable'> = {
|
||||
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 (
|
||||
<span
|
||||
data-testid={`install-badge-${state}`}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${style.className} ${className}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden>
|
||||
{style.icon}
|
||||
</span>
|
||||
{t(LABEL_KEYS[state])}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
160
desktop/src/components/market/MarketHome.tsx
Normal file
160
desktop/src/components/market/MarketHome.tsx
Normal file
@ -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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 px-6 py-6">
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex items-center gap-2.5">
|
||||
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">storefront</span>
|
||||
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">{t('market.title')}</h2>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">{t('market.subtitle')}</p>
|
||||
</div>
|
||||
<SourceStatusBar sources={sources} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<div className="flex min-h-10 min-w-[260px] flex-1 items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/20">
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">search</span>
|
||||
<input
|
||||
data-testid="market-search-input"
|
||||
value={query}
|
||||
onChange={(event) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('market.clearSearch')}
|
||||
onClick={() => setQuery('')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<FilterBar />
|
||||
</div>
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<p className="mt-3 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('market.resultCount', { count: String(items.length) })}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center gap-3 py-16" data-testid="market-loading">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">{t('market.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && error && (
|
||||
<div
|
||||
data-testid="market-error"
|
||||
className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-[var(--color-error)]/40 bg-[var(--color-error-container)]/30 px-6 py-12 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[36px] text-[var(--color-error)]">cloud_off</span>
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">{t('market.error.list')}</p>
|
||||
<p className="max-w-md break-words text-xs text-[var(--color-text-tertiary)]">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void fetchList({ reset: true })}
|
||||
className="mt-1 inline-flex min-h-9 items-center gap-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-border-focus)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
{t('market.retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && items.length === 0 && (
|
||||
<div
|
||||
data-testid="market-empty"
|
||||
className="rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-16 text-center"
|
||||
>
|
||||
<span className="material-symbols-outlined mb-2 block text-[40px] text-[var(--color-text-tertiary)]">
|
||||
{hasQuery || hasActiveFilters ? 'search_off' : 'storefront'}
|
||||
</span>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{hasQuery || hasActiveFilters ? t('market.emptySearch') : t('market.empty')}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
{hasQuery || hasActiveFilters ? t('market.emptySearchHint') : t('market.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3" data-testid="market-grid">
|
||||
{items.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onOpen={(id) => void useMarketStore.getState().openDetail(id)}
|
||||
onInstall={onRequestInstall}
|
||||
installing={installingIds.has(skill.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nextCursor && (
|
||||
<div className="flex justify-center pb-4">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-load-more"
|
||||
disabled={isLoadingMore}
|
||||
onClick={() => void loadMore()}
|
||||
className="inline-flex min-h-10 items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 text-sm text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-border-focus)] disabled:opacity-60"
|
||||
>
|
||||
{isLoadingMore && (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
)}
|
||||
{isLoadingMore ? t('market.loadingMore') : t('market.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
201
desktop/src/components/market/MarketSkillDetail.tsx
Normal file
201
desktop/src/components/market/MarketSkillDetail.tsx
Normal file
@ -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<SkillDetailMetaItem[]>(() => {
|
||||
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: <span className="material-symbols-outlined text-[16px] text-[var(--color-warning)]">key</span>,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}, [detail, t])
|
||||
|
||||
if (!selectedId) return null
|
||||
|
||||
if (isDetailLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-20" data-testid="market-detail-loading">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">{t('market.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (detailError || !detail) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-20 text-center" data-testid="market-detail-error">
|
||||
<span className="material-symbols-outlined text-[36px] text-[var(--color-error)]">error</span>
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">{t('market.detail.loadError')}</p>
|
||||
{detailError && <p className="max-w-md break-words text-xs text-[var(--color-text-tertiary)]">{detailError}</p>}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshDetail(selectedId)}
|
||||
className="inline-flex min-h-9 items-center gap-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 text-sm text-[var(--color-text-primary)] hover:border-[var(--color-border-focus)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
{t('market.retry')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToList}
|
||||
className="inline-flex min-h-9 items-center gap-1.5 rounded-xl px-4 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
{t('market.detail.back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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' && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-install-button"
|
||||
disabled={installing}
|
||||
onClick={() => onRequestInstall(detail.id)}
|
||||
className="inline-flex min-h-10 items-center gap-1.5 rounded-xl bg-[var(--color-brand)] px-5 text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[18px]" aria-hidden>download</span>
|
||||
)}
|
||||
{installing ? t('market.install.installing') : t('market.install.action')}
|
||||
</button>
|
||||
)}
|
||||
{detail.installState === 'installed' && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="market-uninstall-button"
|
||||
disabled={installing}
|
||||
onClick={() => onRequestUninstall(detail.id)}
|
||||
className="inline-flex min-h-10 items-center gap-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 text-sm text-[var(--color-error)] transition-colors hover:border-[var(--color-error)]/50 disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[18px]" aria-hidden>delete</span>
|
||||
)}
|
||||
{installing ? t('market.uninstall.uninstalling') : t('market.uninstall.action')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const banner = (
|
||||
<>
|
||||
{mirrorSource && (
|
||||
<p className="mt-3 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('market.detail.mirror', { source: t(`market.source.${mirrorSource as 'clawhub' | 'skillhub'}`) })}
|
||||
</p>
|
||||
)}
|
||||
{installError && installError.id === detail.id && (
|
||||
<div
|
||||
data-testid="market-install-error"
|
||||
className="mt-4 flex items-start gap-2 rounded-xl border border-[var(--color-error)]/30 bg-[var(--color-error-container)]/40 px-3.5 py-2.5 text-sm text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-error)]" aria-hidden>error</span>
|
||||
<span className="break-words">
|
||||
{installError.kind === 'generic'
|
||||
? t('market.installError.generic', { message: installError.message })
|
||||
: t(`market.installError.${installError.kind}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<SkillDetailView
|
||||
name={detail.name}
|
||||
version={detail.version}
|
||||
iconUrl={detail.iconUrl}
|
||||
sourceLabel={t(`market.source.${detail.source}`)}
|
||||
summary={detail.summary}
|
||||
securityStatus={detail.securityStatus}
|
||||
securityReports={detail.securityReports}
|
||||
installState={detail.installState}
|
||||
notInstallableReason={detail.notInstallableReason}
|
||||
actions={actions}
|
||||
banner={banner}
|
||||
meta={meta}
|
||||
description={detail.description}
|
||||
files={detail.files.map((f) => ({ path: f.path, size: f.size, language: f.language, tooBig: f.tooBig }))}
|
||||
loadFile={loadFile}
|
||||
onBack={backToList}
|
||||
backLabel={t('market.detail.back')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
37
desktop/src/components/market/SecurityBadge.tsx
Normal file
37
desktop/src/components/market/SecurityBadge.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SecurityStatus } from '../../types/market'
|
||||
|
||||
const STYLES: Record<SecurityStatus, { icon: string; className: string }> = {
|
||||
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 (
|
||||
<span
|
||||
data-testid={`security-badge-${status}`}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium whitespace-nowrap ${style.className} ${className}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden>
|
||||
{style.icon}
|
||||
</span>
|
||||
{t(`market.security.${status}`)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
84
desktop/src/components/market/SkillCard.test.tsx
Normal file
84
desktop/src/components/market/SkillCard.test.tsx
Normal file
@ -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> = {}): 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(<SkillCard skill={makeSkill()} onOpen={vi.fn()} />)
|
||||
|
||||
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(<SkillCard skill={makeSkill({ installState: 'installed' })} onOpen={vi.fn()} onInstall={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('install-badge-installed')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Install')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the not-installable badge', () => {
|
||||
render(<SkillCard skill={makeSkill({ installState: 'not-installable' })} onOpen={vi.fn()} />)
|
||||
|
||||
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(<SkillCard skill={makeSkill()} onOpen={onOpen} onInstall={onInstall} />)
|
||||
|
||||
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(<SkillCard skill={makeSkill()} onOpen={vi.fn()} onInstall={vi.fn()} installing />)
|
||||
|
||||
expect(screen.getByText('Installing…').closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('flags risky skills visibly', () => {
|
||||
render(<SkillCard skill={makeSkill({ securityStatus: 'flagged' })} onOpen={vi.fn()} />)
|
||||
|
||||
expect(screen.getByTestId('security-badge-flagged')).toBeInTheDocument()
|
||||
expect(screen.getByText('Flagged')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
139
desktop/src/components/market/SkillCard.tsx
Normal file
139
desktop/src/components/market/SkillCard.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
data-testid={`market-skill-card-${skill.id}`}
|
||||
className="group relative flex min-w-0 cursor-pointer flex-col rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 transition-all hover:border-[var(--color-border-focus)] hover:shadow-[var(--shadow-button-primary)]"
|
||||
style={{ contentVisibility: 'auto', containIntrinsicSize: '190px' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(skill.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpen(skill.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{skill.iconUrl ? (
|
||||
<img
|
||||
src={skill.iconUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-9 w-9 flex-shrink-0 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[18px]">auto_awesome</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{skill.name}</span>
|
||||
{skill.version && (
|
||||
<span className="flex-shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
||||
v{skill.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden>
|
||||
{skill.source === 'clawhub' ? 'public' : 'language'}
|
||||
</span>
|
||||
{t(`market.source.${skill.source}`)}
|
||||
</span>
|
||||
{skill.author.handle && (
|
||||
<span className="truncate">{t('market.card.by', { author: skill.author.displayName || skill.author.handle })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2.5 line-clamp-2 min-h-[2.5rem] text-xs leading-5 text-[var(--color-text-secondary)] break-words">
|
||||
{skill.summary || t('market.detail.noDescription')}
|
||||
</p>
|
||||
|
||||
{skill.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{skill.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{extraTags > 0 && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{t('market.card.moreTags', { count: String(extraTags) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex flex-wrap items-center justify-between gap-x-2 gap-y-1.5 pt-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<SecurityBadge status={skill.securityStatus} />
|
||||
<InstallStateBadge state={skill.installState} />
|
||||
</div>
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span className="inline-flex items-center gap-0.5" title={t('market.detail.downloads')}>
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden>download</span>
|
||||
{formatCount(skill.stats.downloads)}
|
||||
</span>
|
||||
{typeof skill.stats.stars === 'number' && skill.stats.stars > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5" title={t('market.detail.stars')}>
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden>star</span>
|
||||
{formatCount(skill.stats.stars)}
|
||||
</span>
|
||||
)}
|
||||
{onInstall && skill.installState === 'installable' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={installing}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onInstall(skill.id)
|
||||
}}
|
||||
className="inline-flex min-h-7 items-center gap-1 rounded-lg bg-[var(--color-brand)] px-2.5 text-[11px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{installing ? (
|
||||
<span className="h-3 w-3 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[13px]" aria-hidden>download</span>
|
||||
)}
|
||||
{installing ? t('market.install.installing') : t('market.install.action')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
desktop/src/components/market/SkillDetailView.test.tsx
Normal file
118
desktop/src/components/market/SkillDetailView.test.tsx
Normal file
@ -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 }) => (
|
||||
<div data-testid="markdown-renderer" data-content={content} data-variant={variant} />
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../chat/CodeViewer', () => ({
|
||||
CodeViewer: ({ code, showLineNumbers }: { code: string; showLineNumbers?: boolean }) => (
|
||||
<div data-testid="code-viewer" data-line-numbers={String(showLineNumbers)}>{code}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
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<PreviewFileContent> {
|
||||
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<Parameters<typeof SkillDetailView>[0]> = {}) {
|
||||
return render(
|
||||
<SkillDetailView
|
||||
name="Demo Skill"
|
||||
version="1.0.0"
|
||||
sourceLabel="ClawHub"
|
||||
summary="A demo"
|
||||
securityStatus="benign"
|
||||
installState="installable"
|
||||
meta={[{ label: 'Author', value: 'Alice' }]}
|
||||
description="# Body"
|
||||
files={FILES}
|
||||
loadFile={loadFileFromMemory}
|
||||
onBack={vi.fn()}
|
||||
backLabel="Back"
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
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: <button type="button">Install now</button> })
|
||||
|
||||
expect(screen.getByText('Install now')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
202
desktop/src/components/market/SkillDetailView.tsx
Normal file
202
desktop/src/components/market/SkillDetailView.tsx
Normal file
@ -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<PreviewFileContent>
|
||||
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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto" data-testid="skill-detail-view">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5 px-6 py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onBack}
|
||||
className="inline-flex w-fit items-center gap-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
|
||||
{props.backLabel}
|
||||
</button>
|
||||
|
||||
{/* Install decision area */}
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3.5">
|
||||
{props.iconUrl ? (
|
||||
<img
|
||||
src={props.iconUrl}
|
||||
alt=""
|
||||
className="h-12 w-12 flex-shrink-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[24px]">auto_awesome</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="break-all text-xl font-semibold text-[var(--color-text-primary)]">{props.name}</h2>
|
||||
{props.version && (
|
||||
<span className="rounded-full bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-tertiary)]">
|
||||
v{props.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
|
||||
{props.sourceLabel}
|
||||
</span>
|
||||
{props.securityStatus && <SecurityBadge status={props.securityStatus} />}
|
||||
{props.installState && <InstallStateBadge state={props.installState} />}
|
||||
</div>
|
||||
{props.summary && (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--color-text-secondary)] break-words">
|
||||
{props.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{props.actions && <div className="flex flex-shrink-0 items-center gap-2">{props.actions}</div>}
|
||||
</div>
|
||||
|
||||
{props.installState === 'not-installable' && props.notInstallableReason && (
|
||||
<div
|
||||
data-testid="market-not-installable-reason"
|
||||
className="mt-4 flex items-start gap-2 rounded-xl border border-[var(--color-error)]/30 bg-[var(--color-error-container)]/40 px-3.5 py-2.5 text-sm text-[var(--color-text-primary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-[var(--color-error)]" aria-hidden>
|
||||
block
|
||||
</span>
|
||||
<span>{t(`market.reason.${props.notInstallableReason}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.securityReports && props.securityReports.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2" data-testid="market-security-reports">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">
|
||||
{t('market.detail.securityReport')}
|
||||
</span>
|
||||
{props.securityReports.map((report) => (
|
||||
<span
|
||||
key={report.vendor}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{report.vendor}</span>
|
||||
{report.statusText}
|
||||
{report.reportUrl && (
|
||||
<a
|
||||
href={report.reportUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[var(--color-brand)] hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('market.detail.viewReport')}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.banner}
|
||||
</section>
|
||||
|
||||
{/* Meta grid */}
|
||||
{props.meta.length > 0 && (
|
||||
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{props.meta.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">{item.label}</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-[var(--color-text-primary)]">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-[var(--color-border)]">
|
||||
{(['overview', 'files'] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
data-testid={`skill-detail-tab-${key}`}
|
||||
onClick={() => setTab(key)}
|
||||
className={`relative -mb-px inline-flex min-h-10 items-center gap-1.5 border-b-2 px-3.5 text-sm transition-colors ${
|
||||
tab === key
|
||||
? 'border-[var(--color-brand)] font-medium text-[var(--color-text-primary)]'
|
||||
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden>
|
||||
{key === 'overview' ? 'article' : 'folder'}
|
||||
</span>
|
||||
{t(`market.detail.${key}`)}
|
||||
{key === 'files' && (
|
||||
<span className="rounded-full bg-[var(--color-surface-container-high)] px-1.5 text-[10px] text-[var(--color-text-tertiary)]">
|
||||
{props.files.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-5" data-testid="skill-detail-overview">
|
||||
{props.description.trim() ? (
|
||||
<MarkdownRenderer content={props.description} variant="document" className="mx-auto max-w-[72ch]" />
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-[var(--color-text-tertiary)]">{t('market.detail.noDescription')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'files' && <FilePreview files={props.files} loadFile={props.loadFile} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
53
desktop/src/components/market/SourceStatusBar.tsx
Normal file
53
desktop/src/components/market/SourceStatusBar.tsx
Normal file
@ -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<SourceStatusInfo['status'], string> = {
|
||||
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<Record<MarketSource, SourceStatusInfo>>
|
||||
className?: string
|
||||
}) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-2 ${className}`} data-testid="market-source-status">
|
||||
{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 (
|
||||
<span
|
||||
key={source}
|
||||
data-testid={`market-source-status-${source}`}
|
||||
title={info.error || undefined}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full border border-[var(--color-border)] ${DOT_CLASSES[info.status]}`} aria-hidden />
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{t(`market.source.${source}`)}</span>
|
||||
<span>{statusLabel}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<string>('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<PreviewFileContent> => {
|
||||
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<SkillDetailMetaItem[]>(() => {
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 min-w-0">
|
||||
<div>
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
|
||||
{t('settings.skills.back')}
|
||||
</button>
|
||||
</div>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
|
||||
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
|
||||
{t('settings.skills.entryEyebrow')}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
|
||||
{meta.displayName || meta.name}
|
||||
</h3>
|
||||
<MetaPill>{t(`settings.skills.source.${meta.source}`)}</MetaPill>
|
||||
{meta.version && <MetaPill>v{meta.version}</MetaPill>}
|
||||
{meta.userInvocable && <MetaPill>{t('settings.skills.slashCommand')}</MetaPill>}
|
||||
</div>
|
||||
<p className="max-w-4xl text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
{meta.description}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>{t('settings.skills.tokenEstimate', { count: String(Math.ceil(meta.contentLength / 4)) })}</span>
|
||||
<span>
|
||||
{files.length} {t('settings.skills.files')}
|
||||
</span>
|
||||
<span>{currentFile?.isEntry ? t('settings.skills.entryFile') : currentFile?.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-2">
|
||||
<DetailStat
|
||||
label={t('settings.skills.summary.totalFiles')}
|
||||
value={String(files.length)}
|
||||
icon="folder_open"
|
||||
/>
|
||||
<DetailStat
|
||||
label={t('settings.skills.summary.tokens')}
|
||||
value={t('settings.skills.tokenEstimateShort', { count: String(Math.ceil(meta.contentLength / 4)) })}
|
||||
icon="notes"
|
||||
/>
|
||||
<DetailStat
|
||||
label={t('settings.skills.summary.source')}
|
||||
value={t(`settings.skills.source.${meta.source}`)}
|
||||
icon="layers"
|
||||
/>
|
||||
<DetailStat
|
||||
label={t('settings.skills.summary.entry')}
|
||||
value={files.some((file) => file.isEntry) ? 'SKILL.md' : '—'}
|
||||
icon="article"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{metaEntries.length > 0 && (
|
||||
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
||||
tune
|
||||
</span>
|
||||
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{t('settings.skills.metaTitle')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{metaEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-3 min-w-0"
|
||||
>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||
{formatMetaKey(key)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[var(--color-text-primary)] break-words">
|
||||
{formatMetaValue(value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
const actions = marketMeta ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="local-skill-uninstall-button"
|
||||
disabled={uninstalling}
|
||||
onClick={() => setConfirmUninstall(true)}
|
||||
className="inline-flex min-h-10 items-center gap-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 text-sm text-[var(--color-error)] transition-colors hover:border-[var(--color-error)]/50 disabled:opacity-50"
|
||||
>
|
||||
{uninstalling ? (
|
||||
<span className="h-3.5 w-3.5 animate-spin rounded-full border border-current border-t-transparent" aria-hidden />
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-[18px]" aria-hidden>delete</span>
|
||||
)}
|
||||
{uninstalling ? t('market.uninstall.uninstalling') : t('market.uninstall.action')}
|
||||
</button>
|
||||
) : undefined
|
||||
|
||||
<section className="flex flex-1 min-h-0 min-w-0 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<aside className="hidden w-[250px] flex-shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-container-low)] lg:flex lg:flex-col">
|
||||
<div className="border-b border-[var(--color-border)] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.filesPanel')}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.filesPanelHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<TreeView
|
||||
nodes={tree}
|
||||
selectedPath={normalizedSelection}
|
||||
onSelect={setSelectedFile}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-[var(--color-text-secondary)] break-all">
|
||||
{currentFile?.path}
|
||||
</span>
|
||||
{currentFile?.isEntry && <MetaPill>{t('settings.skills.entryFile')}</MetaPill>}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.readingMode', {
|
||||
mode:
|
||||
currentFile?.language === 'markdown'
|
||||
? t('settings.skills.docMode')
|
||||
: t('settings.skills.codeMode'),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] border border-[var(--color-border)]">
|
||||
{currentFile?.language}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden border-b border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 overflow-x-auto">
|
||||
<div className="flex gap-2 min-w-max">
|
||||
{files.map((file) => {
|
||||
const active = file.path === normalizedSelection
|
||||
return (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => setSelectedFile(file.path)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
|
||||
active
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
{file.path}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]">
|
||||
{currentFile && (
|
||||
<div className={currentFile.language === 'markdown' ? 'px-6 py-5 lg:px-8' : 'p-4'}>
|
||||
{currentFile.language === 'markdown' ? (
|
||||
<MarkdownRenderer
|
||||
content={currentFile.body ?? currentFile.content}
|
||||
variant="document"
|
||||
className="mx-auto max-w-[72ch]"
|
||||
/>
|
||||
) : (
|
||||
<CodeViewer
|
||||
code={currentFile.content}
|
||||
language={currentFile.language}
|
||||
maxLines={9999}
|
||||
showLineNumbers
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeView({
|
||||
nodes,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
nodes: FileTreeNode[]
|
||||
selectedPath: string
|
||||
onSelect: (path: string) => void
|
||||
depth: number
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth}
|
||||
/>
|
||||
))}
|
||||
<SkillDetailView
|
||||
name={skillMeta.displayName || skillMeta.name}
|
||||
version={skillMeta.version}
|
||||
sourceLabel={t(`settings.skills.source.${skillMeta.source}`)}
|
||||
summary={skillMeta.description}
|
||||
installState={marketMeta ? 'installed' : undefined}
|
||||
actions={actions}
|
||||
meta={meta}
|
||||
description={description}
|
||||
files={selectedSkill.files.map((f) => ({
|
||||
path: f.path,
|
||||
size: f.content.length,
|
||||
language: f.language,
|
||||
}))}
|
||||
loadFile={loadFile}
|
||||
onBack={handleBack}
|
||||
backLabel={t('settings.skills.back')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmUninstall}
|
||||
onClose={() => 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 (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => (isDir ? setExpanded(!expanded) : onSelect(node.path))}
|
||||
className={`flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] ${
|
||||
isSelected
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
style={{ marginLeft: `${depth * 12}px`, width: `calc(100% - ${depth * 12}px)` }}
|
||||
>
|
||||
{isDir ? (
|
||||
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">
|
||||
{expanded ? 'expand_more' : 'chevron_right'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ width: 12 }} />
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
|
||||
{isDir && expanded && node.children && (
|
||||
<TreeView
|
||||
nodes={node.children}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailStat({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
icon: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined text-[14px]">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaPill({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1972,4 +1972,96 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'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}',
|
||||
}
|
||||
|
||||
@ -1972,4 +1972,96 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'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}',
|
||||
}
|
||||
|
||||
@ -1972,4 +1972,96 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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}',
|
||||
}
|
||||
|
||||
@ -1972,4 +1972,96 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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}',
|
||||
}
|
||||
|
||||
116
desktop/src/pages/Market.tsx
Normal file
116
desktop/src/pages/Market.tsx
Normal file
@ -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<NormalizedSkill | null>(null)
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<NormalizedSkill | null>(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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface-container-lowest,var(--color-surface))]">
|
||||
{selectedId ? (
|
||||
<MarketSkillDetail onRequestInstall={requestInstall} onRequestUninstall={requestUninstall} />
|
||||
) : (
|
||||
<MarketHome onRequestInstall={requestInstall} />
|
||||
)}
|
||||
|
||||
<InstallConfirmDialog
|
||||
skill={confirmInstall}
|
||||
open={confirmInstall !== null}
|
||||
installing={confirmInstall !== null && installingIds.has(confirmInstall.id)}
|
||||
onConfirm={() => void runInstall()}
|
||||
onClose={() => setConfirmInstall(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmUninstall !== null}
|
||||
onClose={() => 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)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
desktop/src/stores/marketStore.test.ts
Normal file
244
desktop/src/stores/marketStore.test.ts
Normal file
@ -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> = {}): 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> = {}): 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')
|
||||
})
|
||||
})
|
||||
313
desktop/src/stores/marketStore.ts
Normal file
313
desktop/src/stores/marketStore.ts
Normal file
@ -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<Record<MarketSource, SourceStatusInfo>>
|
||||
query: string
|
||||
filters: MarketFilters
|
||||
isLoading: boolean
|
||||
isLoadingMore: boolean
|
||||
error: string | null
|
||||
|
||||
selectedId: string | null
|
||||
detail: NormalizedSkillDetail | null
|
||||
isDetailLoading: boolean
|
||||
detailError: string | null
|
||||
detailCache: Map<string, NormalizedSkillDetail>
|
||||
|
||||
activeFilePath: string | null
|
||||
fileCache: Map<string, MarketFileContent>
|
||||
|
||||
installingIds: Set<string>
|
||||
installError: { id: string; kind: MarketInstallErrorKind; message: string } | null
|
||||
|
||||
fetchList: (options?: { reset?: boolean }) => Promise<void>
|
||||
loadMore: () => Promise<void>
|
||||
setQuery: (q: string) => void
|
||||
setFilter: <K extends keyof MarketFilters>(key: K, value: MarketFilters[K]) => void
|
||||
openDetail: (id: string) => Promise<void>
|
||||
refreshDetail: (id: string) => Promise<void>
|
||||
/** Fetch a file's content with session-level caching. Throws on failure. */
|
||||
fetchFileContent: (id: string, path: string) => Promise<MarketFileContent>
|
||||
install: (id: string) => Promise<boolean>
|
||||
uninstall: (id: string) => Promise<boolean>
|
||||
backToList: () => void
|
||||
}
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | 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<MarketStore> {
|
||||
const items = state.items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item))
|
||||
const patch: Partial<MarketStore> = { 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<MarketStore>((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<NormalizedSkill> = {
|
||||
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 })
|
||||
},
|
||||
}))
|
||||
@ -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<TabStore>((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) {
|
||||
|
||||
90
desktop/src/types/market.ts
Normal file
90
desktop/src/types/market.ts
Normal file
@ -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<string, unknown>
|
||||
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<MarketSource, SourceStatusInfo>
|
||||
}
|
||||
|
||||
export type MarketSourceFilter = 'all' | MarketSource
|
||||
export type MarketSecurityFilter = 'all' | SecurityStatus
|
||||
export type MarketInstalledFilter = 'all' | 'installed' | 'installable'
|
||||
@ -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
|
||||
}
|
||||
|
||||
1
src/server/__tests__/fixtures/market/clawhub-detail.json
Normal file
1
src/server/__tests__/fixtures/market/clawhub-detail.json
Normal file
File diff suppressed because one or more lines are too long
1
src/server/__tests__/fixtures/market/clawhub-list.json
Normal file
1
src/server/__tests__/fixtures/market/clawhub-list.json
Normal file
@ -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\"]}"}
|
||||
1
src/server/__tests__/fixtures/market/clawhub-search.json
Normal file
1
src/server/__tests__/fixtures/market/clawhub-search.json
Normal file
@ -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"}}]}
|
||||
File diff suppressed because one or more lines are too long
@ -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}}
|
||||
1
src/server/__tests__/fixtures/market/skillhub-files.json
Normal file
1
src/server/__tests__/fixtures/market/skillhub-files.json
Normal file
@ -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"}
|
||||
1
src/server/__tests__/fixtures/market/skillhub-list.json
Normal file
1
src/server/__tests__/fixtures/market/skillhub-list.json
Normal file
@ -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"}
|
||||
@ -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"}
|
||||
183
src/server/__tests__/market-api.test.ts
Normal file
183
src/server/__tests__/market-api.test.ts
Normal file
@ -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<string> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
211
src/server/__tests__/market-install.test.ts
Normal file
211
src/server/__tests__/market-install.test.ts
Normal file
@ -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}<tampered>` : 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
287
src/server/__tests__/market-providers.test.ts
Normal file
287
src/server/__tests__/market-providers.test.ts
Normal file
@ -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<string> {
|
||||
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: '<html>oops</html>' }))
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
325
src/server/__tests__/market-service.test.ts
Normal file
325
src/server/__tests__/market-service.test.ts
Normal file
@ -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<string> {
|
||||
return fs.readFile(path.join(FIXTURES, name), 'utf-8')
|
||||
}
|
||||
|
||||
function makeSkill(overrides: Partial<NormalizedSkill> = {}): 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> = {}): 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)
|
||||
})
|
||||
})
|
||||
144
src/server/api/market.ts
Normal file
144
src/server/api/market.ts
Normal file
@ -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<Record<string, unknown>> {
|
||||
try {
|
||||
const body = (await req.json()) as Record<string, unknown>
|
||||
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<string, unknown>): { 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<Response> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -506,8 +506,20 @@ async function getSkillDetail(url: URL): Promise<Response> {
|
||||
}
|
||||
|
||||
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<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(skillDir, '.market-meta.json'), 'utf-8')
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Response
|
||||
case 'skills':
|
||||
return handleSkillsApi(req, url, segments)
|
||||
|
||||
case 'market':
|
||||
return handleMarketApi(req, url, segments)
|
||||
|
||||
case 'mcp':
|
||||
return handleMcpApi(req, url, segments)
|
||||
|
||||
|
||||
103
src/server/services/market/cache.ts
Normal file
103
src/server/services/market/cache.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Skills Market — in-memory TTL cache with stale-while-error support,
|
||||
* plus per-source health tracking.
|
||||
*
|
||||
* The cache stores normalized upstream data only. Install state is computed
|
||||
* per-request on top of cached data and is never cached here.
|
||||
*/
|
||||
|
||||
import type { MarketSource, SourceHealthStatus, SourceStatusInfo } from './types.js'
|
||||
|
||||
type CacheEntry = {
|
||||
value: unknown
|
||||
expiresAt: number
|
||||
storedAt: number
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 500
|
||||
|
||||
export const MARKET_TTL = {
|
||||
list: 5 * 60_000,
|
||||
search: 2 * 60_000,
|
||||
detail: 10 * 60_000,
|
||||
files: 10 * 60_000,
|
||||
fileContent: 30 * 60_000,
|
||||
} as const
|
||||
|
||||
class MarketCache {
|
||||
private entries = new Map<string, CacheEntry>()
|
||||
|
||||
get<T>(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<T>(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<MarketSource, HealthRecord> = {
|
||||
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' }
|
||||
}
|
||||
308
src/server/services/market/clawhubProvider.ts
Normal file
308
src/server/services/market/clawhubProvider.ts
Normal file
@ -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<string, string>()
|
||||
|
||||
async function clawhubFetch(url: URL, slug: string): Promise<Response> {
|
||||
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<T>(url: URL, slug: string): Promise<T> {
|
||||
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<ProviderListPage> {
|
||||
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<ProviderListPage> {
|
||||
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<NormalizedSkillDetail> {
|
||||
const base = getProviderBase('clawhub')
|
||||
const data = await clawhubFetchJson<ClawhubDetail>(
|
||||
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<ClawhubVersionDetail>(
|
||||
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<string, unknown> | undefined
|
||||
if (rawDescription.startsWith('---')) {
|
||||
try {
|
||||
const parsed = parseFrontmatter(rawDescription)
|
||||
body = parsed.content
|
||||
frontmatter = parsed.frontmatter as Record<string, unknown>
|
||||
} 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<ProviderFileEntry[]> {
|
||||
const base = getProviderBase('clawhub')
|
||||
let resolvedVersion = version
|
||||
if (!resolvedVersion) {
|
||||
const data = await clawhubFetchJson<ClawhubDetail>(
|
||||
new URL(`/api/v1/skills/${encodeURIComponent(slug)}`, base),
|
||||
slug,
|
||||
)
|
||||
resolvedVersion = data.latestVersion?.version || data.skill?.latestVersion?.version
|
||||
}
|
||||
if (!resolvedVersion) return []
|
||||
const versionDetail = await clawhubFetchJson<ClawhubVersionDetail>(
|
||||
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') }
|
||||
},
|
||||
}
|
||||
247
src/server/services/market/installService.ts
Normal file
247
src/server/services/market/installService.ts
Normal file
@ -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/<slug>/ 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<MarketSource, MarketProvider> = {
|
||||
clawhub: clawhubProvider,
|
||||
skillhub: skillhubProvider,
|
||||
}
|
||||
|
||||
// In-flight lock: one install per slug at a time.
|
||||
const inFlight = new Map<string, Promise<unknown>>()
|
||||
|
||||
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<void> {
|
||||
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<InstallResult> {
|
||||
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<InstallResult> {
|
||||
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<UninstallResult> {
|
||||
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 }
|
||||
362
src/server/services/market/marketService.ts
Normal file
362
src/server/services/market/marketService.ts
Normal file
@ -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<MarketSource, MarketProvider> = {
|
||||
clawhub: clawhubProvider,
|
||||
skillhub: skillhubProvider,
|
||||
}
|
||||
|
||||
// ─── Cursor (opaque, merges both providers' pagination) ─────────────────────
|
||||
|
||||
type MergedCursor = Partial<Record<MarketSource, string>>
|
||||
|
||||
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<MarketMeta | null> {
|
||||
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<boolean> {
|
||||
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<T extends NormalizedSkill>(skill: T): Promise<T> {
|
||||
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<string, NormalizedSkill>()
|
||||
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<ProviderOutcome> {
|
||||
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<ProviderListPage>(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<ProviderListPage>(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<MarketListResult> {
|
||||
const cursor = decodeCursor(params.cursor)
|
||||
const isFirstPage = !params.cursor
|
||||
const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source]
|
||||
|
||||
const outcomes = new Map<MarketSource, ProviderOutcome>()
|
||||
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<MarketSource, SourceStatusInfo>
|
||||
|
||||
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<NormalizedSkillDetail>(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<NormalizedSkillDetail>(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<MarketFileContent> {
|
||||
const cacheKey = `file:${source}:${slug}:${filePath}`
|
||||
const cached = marketCache.get<MarketFileContent>(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<MarketSource, SourceStatusInfo> {
|
||||
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<NormalizedSkillDetail> {
|
||||
const { skill } = await getMarketSkillDetail(source, slug)
|
||||
return skill
|
||||
}
|
||||
|
||||
export function marketSkillId(source: MarketSource, slug: string): string {
|
||||
return skillId(source, slug)
|
||||
}
|
||||
121
src/server/services/market/providerFetch.ts
Normal file
121
src/server/services/market/providerFetch.ts
Normal file
@ -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<MarketSource, string> = {
|
||||
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<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||
try {
|
||||
let proxyOptions: Record<string, unknown> = {}
|
||||
try {
|
||||
const settings = await loadNetworkSettings()
|
||||
proxyOptions = getNetworkProxyFetchOptions(settings, url) as Record<string, unknown>
|
||||
} 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<Response> {
|
||||
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<T>(source: MarketSource, url: string): Promise<T> {
|
||||
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
|
||||
}
|
||||
}
|
||||
267
src/server/services/market/skillhubProvider.ts
Normal file
267
src/server/services/market/skillhubProvider.ts
Normal file
@ -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<string, string>
|
||||
source?: string
|
||||
upstream_url?: string
|
||||
verified?: boolean
|
||||
version?: string
|
||||
updated_at?: number
|
||||
}
|
||||
|
||||
type SkillhubEnvelope<T> = { 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<ProviderListPage> {
|
||||
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<SkillhubEnvelope<{ skills?: SkillhubListItem[]; total?: number }>>(
|
||||
'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<ProviderListPage> {
|
||||
const page = cursor ? Math.max(1, Number.parseInt(cursor, 10) || 1) : 1
|
||||
return fetchPage({ page, pageSize: limit })
|
||||
},
|
||||
|
||||
async search({ q, cursor, limit }): Promise<ProviderListPage> {
|
||||
const page = cursor ? Math.max(1, Number.parseInt(cursor, 10) || 1) : 1
|
||||
return fetchPage({ keyword: q, page, pageSize: limit })
|
||||
},
|
||||
|
||||
async detail(slug): Promise<NormalizedSkillDetail> {
|
||||
const base = getProviderBase('skillhub')
|
||||
const data = await providerFetchJson<SkillhubDetail>(
|
||||
'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<ProviderFileEntry[]> {
|
||||
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') }
|
||||
},
|
||||
}
|
||||
200
src/server/services/market/types.ts
Normal file
200
src/server/services/market/types.ts
Normal file
@ -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<string, unknown>
|
||||
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<MarketSource, SourceStatusInfo>
|
||||
}
|
||||
|
||||
// ─── 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<ProviderListPage>
|
||||
search(params: { q: string; cursor?: string; limit: number }): Promise<ProviderListPage>
|
||||
detail(slug: string): Promise<NormalizedSkillDetail>
|
||||
listFiles(slug: string, version?: string): Promise<ProviderFileEntry[]>
|
||||
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<string, string> = {
|
||||
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'
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user