Skip to content

Commit 4097fc6

Browse files
committed
feat(market): curated skills catalog with categories and redesigned detail
Open the skills market on a bundled catalog of 398 curated ClawHub and SkillHub skills in 13 categories instead of querying both registries live. Live search stays available as an explicit "search all markets" scope whose results are marked as not curated. - Server: catalog scope (default) with category filter, filtering before pagination and batched install state; catalog metadata overlaid on live detail; per-scanner ClawHub reports, changelog and page URL; manual catalog refresh script. - Pin ClawHub reads and installs to the card's owner so a same-slug copy by another author is never shown or installed in its place. - Desktop: category chips, curated cards with tags, locale-aware summaries, detail page with stats, security report, changelog, capability panel and triggers; install confirmation requires acknowledgement for unaudited or flagged skills. - Docs: rewrite the skills market section and refresh screenshots.
1 parent 6b9d2ae commit 4097fc6

61 files changed

Lines changed: 14295 additions & 480 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎desktop/src/api/market.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
MarketFileContent,
44
MarketInstalledFilter,
55
MarketListResponse,
6+
MarketScope,
67
MarketSecurityFilter,
78
MarketSource,
89
MarketSourceFilter,
@@ -12,6 +13,9 @@ import type {
1213
} from '../types/market'
1314

1415
export type MarketListParams = {
16+
scope?: MarketScope
17+
/** Catalog category key; ignored by the server in `market` scope. */
18+
category?: string
1519
q?: string
1620
source?: MarketSourceFilter
1721
security?: MarketSecurityFilter
@@ -23,6 +27,8 @@ export type MarketListParams = {
2327
export const marketApi = {
2428
list: (params: MarketListParams = {}) => {
2529
const search = new URLSearchParams()
30+
if (params.scope) search.set('scope', params.scope)
31+
if (params.category && params.category !== 'all') search.set('category', params.category)
2632
if (params.q) search.set('q', params.q)
2733
if (params.source && params.source !== 'all') search.set('source', params.source)
2834
if (params.security && params.security !== 'all') search.set('security', params.security)
@@ -33,22 +39,30 @@ export const marketApi = {
3339
return api.get<MarketListResponse>(`/api/market/skills${query ? `?${query}` : ''}`, { timeout: 30_000 })
3440
},
3541

36-
detail: (source: MarketSource, slug: string) =>
42+
/**
43+
* `owner` pins a ClawHub read to one publisher: ClawHub slugs are not unique,
44+
* so the slug alone can resolve to a different skill than the card showed.
45+
*/
46+
detail: (source: MarketSource, slug: string, options: { owner?: string } = {}) =>
3747
api.get<{ skill: NormalizedSkillDetail; sourceStatus: SourceStatusInfo }>(
38-
`/api/market/skills/${source}/${encodeURIComponent(slug)}`,
48+
`/api/market/skills/${source}/${encodeURIComponent(slug)}${
49+
options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ''
50+
}`,
3951
{ timeout: 30_000 },
4052
),
4153

42-
fileContent: (source: MarketSource, slug: string, path: string) =>
54+
fileContent: (source: MarketSource, slug: string, path: string, owner?: string) =>
4355
api.get<{ file: MarketFileContent }>(
44-
`/api/market/skills/${source}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`,
56+
`/api/market/skills/${source}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}${
57+
owner ? `&owner=${encodeURIComponent(owner)}` : ''
58+
}`,
4559
{ timeout: 30_000 },
4660
),
4761

48-
install: (id: string) =>
62+
install: (id: string, owner?: string) =>
4963
api.post<{ ok: boolean; installedPath: string; skill: NormalizedSkill }>(
5064
'/api/market/install',
51-
{ id },
65+
owner ? { id, owner } : { id },
5266
{ timeout: 120_000 },
5367
),
5468

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { fireEvent, render, screen, within } from '@testing-library/react'
3+
import '@testing-library/jest-dom'
4+
5+
import { useSettingsStore } from '../../stores/settingsStore'
6+
import type { Capability } from '../../lib/skillInsights'
7+
import { CapabilityPanel } from './CapabilityPanel'
8+
9+
const CAPABILITIES: Capability[] = [
10+
{ kind: 'shell', level: 'high', evidence: ['scripts/extract-skill.sh', 'mkdir', 'printf'] },
11+
{ kind: 'hooks', level: 'high', evidence: ['hooks/', '~/.openclaw/hooks'] },
12+
{ kind: 'binaries', level: 'low', evidence: ['git'] },
13+
]
14+
15+
beforeEach(() => {
16+
useSettingsStore.setState({ locale: 'en' })
17+
})
18+
19+
describe('CapabilityPanel', () => {
20+
it('names each capability with its evidence and risk level', () => {
21+
render(<CapabilityPanel capabilities={CAPABILITIES} />)
22+
23+
expect(screen.getByRole('heading', { name: 'Before installing: what this skill does' })).toBeInTheDocument()
24+
const shell = screen.getByTestId('market-capability-shell')
25+
expect(within(shell).getByText('Runs commands or scripts')).toBeInTheDocument()
26+
// Commands join with a slash, everything else with the list separator.
27+
expect(shell).toHaveTextContent('scripts/extract-skill.sh / mkdir / printf')
28+
expect(shell).toHaveTextContent('High')
29+
expect(screen.getByTestId('market-capability-hooks')).toHaveTextContent('Touches hooks/、~/.openclaw/hooks')
30+
expect(screen.getByTestId('market-capability-binaries')).toHaveTextContent('Low')
31+
})
32+
33+
it('links to the full report only when asked to', () => {
34+
const onViewReport = vi.fn()
35+
const { rerender } = render(<CapabilityPanel capabilities={CAPABILITIES} onViewReport={onViewReport} />)
36+
37+
fireEvent.click(screen.getByRole('button', { name: 'Full report →' }))
38+
expect(onViewReport).toHaveBeenCalledTimes(1)
39+
40+
rerender(<CapabilityPanel capabilities={CAPABILITIES} />)
41+
expect(screen.queryByRole('button', { name: 'Full report →' })).not.toBeInTheDocument()
42+
})
43+
44+
it('renders nothing when no rule fired', () => {
45+
render(<CapabilityPanel capabilities={[]} />)
46+
47+
expect(screen.queryByTestId('market-capability-panel')).not.toBeInTheDocument()
48+
})
49+
})
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { useMemo } from 'react'
2+
import { TriangleAlert } from 'lucide-react'
3+
import { useTranslation } from '../../i18n'
4+
import { Badge, type Tone } from '@/components/ui/Badge'
5+
import { Button } from '@/components/ui/Button'
6+
import {
7+
detectCapabilities,
8+
extractTriggers,
9+
type Capability,
10+
type CapabilityLevel,
11+
} from '../../lib/skillInsights'
12+
import type { NormalizedSkillDetail } from '../../types/market'
13+
14+
type Translate = ReturnType<typeof useTranslation>
15+
16+
export const CAPABILITY_LEVEL_TONES: Record<CapabilityLevel, Tone> = {
17+
high: 'danger',
18+
medium: 'warning',
19+
low: 'neutral',
20+
}
21+
22+
/** One capability, phrased with its evidence. */
23+
export function capabilityText(t: Translate, capability: Capability): { title: string; detail: string } {
24+
const evidence = capability.evidence.join(capability.kind === 'shell' ? ' / ' : '、')
25+
return {
26+
title: t(`market.cap.${capability.kind}`),
27+
detail: t(`market.cap.${capability.kind}.detail`, { evidence }),
28+
}
29+
}
30+
31+
/** Capabilities and triggers, read once per detail off its own SKILL.md and file list. */
32+
export function useSkillInsights(detail: NormalizedSkillDetail | null | undefined): {
33+
capabilities: Capability[]
34+
triggers: string[]
35+
} {
36+
return useMemo(() => {
37+
if (!detail) return { capabilities: [], triggers: [] }
38+
const description = detail.descriptionFrontmatter?.description
39+
return {
40+
capabilities: detectCapabilities({
41+
markdown: detail.description,
42+
frontmatter: detail.descriptionFrontmatter,
43+
files: detail.files,
44+
}),
45+
triggers: extractTriggers(typeof description === 'string' ? description : undefined),
46+
}
47+
}, [detail])
48+
}
49+
50+
/**
51+
* "Before installing: what this skill does."
52+
*
53+
* Every card cites the file, command, variable or host it came from, so the
54+
* reader can check the claim rather than trust it. Renders nothing when no
55+
* rule fired: an empty "nothing risky here" panel would be a verdict these
56+
* rules cannot make.
57+
*/
58+
export function CapabilityPanel({
59+
capabilities,
60+
onViewReport,
61+
}: {
62+
capabilities: readonly Capability[]
63+
/** Shown as the header link; omitted where the panel already sits in the report. */
64+
onViewReport?: () => void
65+
}) {
66+
const t = useTranslation()
67+
if (capabilities.length === 0) return null
68+
69+
return (
70+
<section
71+
aria-labelledby="market-capabilities-title"
72+
data-testid="market-capability-panel"
73+
className="flex-shrink-0 rounded-[var(--radius-xl)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-5 py-[18px]"
74+
>
75+
<header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
76+
<h2
77+
id="market-capabilities-title"
78+
className="flex items-center gap-2 text-[15px] font-semibold text-[var(--color-on-warning-container)]"
79+
>
80+
<TriangleAlert className="h-[17px] w-[17px] flex-shrink-0" strokeWidth={1.8} aria-hidden="true" />
81+
{t('market.cap.title')}
82+
</h2>
83+
{onViewReport && (
84+
<Button variant="link" size="sm" data-testid="market-capability-view-report" onClick={onViewReport}>
85+
{t('market.cap.viewReport')}
86+
</Button>
87+
)}
88+
</header>
89+
<ul className="mt-3.5 grid gap-2.5 grid-cols-[repeat(auto-fill,minmax(200px,1fr))]">
90+
{capabilities.map((capability) => {
91+
const text = capabilityText(t, capability)
92+
return (
93+
<li
94+
key={capability.kind}
95+
data-testid={`market-capability-${capability.kind}`}
96+
className="flex min-w-0 flex-col gap-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 py-3"
97+
>
98+
<span className="flex items-center justify-between gap-2">
99+
<strong className="text-[13.5px] font-semibold text-[var(--color-text-primary)]">{text.title}</strong>
100+
<Badge tone={CAPABILITY_LEVEL_TONES[capability.level]} size="xs" pill={false}>
101+
{t(`market.cap.level.${capability.level}`)}
102+
</Badge>
103+
</span>
104+
<span className="break-words font-mono text-xs leading-5 text-[var(--color-text-secondary)]">
105+
{text.detail}
106+
</span>
107+
</li>
108+
)
109+
})}
110+
</ul>
111+
<p className="mt-3 text-xs leading-5 text-[var(--color-on-warning-container)]">{t('market.cap.note')}</p>
112+
</section>
113+
)
114+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { fireEvent, render, screen } from '@testing-library/react'
3+
import '@testing-library/jest-dom'
4+
5+
import { useSettingsStore } from '../../stores/settingsStore'
6+
import type { MarketCategory } from '../../types/market'
7+
import { CategoryBar } from './CategoryBar'
8+
9+
const CATEGORIES: MarketCategory[] = [
10+
{ key: 'dev', name: '开发编程', nameEn: 'Development', count: 40 },
11+
{ key: 'office', name: '办公文档', nameEn: 'Office', count: 36 },
12+
]
13+
14+
beforeEach(() => {
15+
useSettingsStore.setState({ locale: 'en' })
16+
})
17+
18+
describe('CategoryBar', () => {
19+
it('renders "All" plus one counted chip per catalog category', () => {
20+
render(<CategoryBar categories={CATEGORIES} value="all" onChange={vi.fn()} />)
21+
22+
const group = screen.getByRole('radiogroup', { name: 'Category' })
23+
expect(group).toBeInTheDocument()
24+
expect(screen.getAllByRole('radio')).toHaveLength(3)
25+
expect(screen.getByRole('radio', { name: 'All' })).toHaveAttribute('aria-checked', 'true')
26+
expect(screen.getByRole('radio', { name: 'Development 40' })).toBeInTheDocument()
27+
})
28+
29+
it('uses the Chinese category names for Chinese readers', () => {
30+
useSettingsStore.setState({ locale: 'zh-TW' })
31+
render(<CategoryBar categories={CATEGORIES} value="dev" onChange={vi.fn()} />)
32+
33+
expect(screen.getByRole('radio', { name: '全部' })).toBeInTheDocument()
34+
expect(screen.getByRole('radio', { name: '开发编程 40' })).toHaveAttribute('aria-checked', 'true')
35+
})
36+
37+
it('reports the picked category key', () => {
38+
const onChange = vi.fn()
39+
render(<CategoryBar categories={CATEGORIES} value="all" onChange={onChange} />)
40+
41+
fireEvent.click(screen.getByRole('radio', { name: 'Office 36' }))
42+
expect(onChange).toHaveBeenCalledWith('office')
43+
})
44+
45+
it('renders nothing before the server sent any categories', () => {
46+
render(<CategoryBar categories={[]} value="all" onChange={vi.fn()} />)
47+
48+
expect(screen.queryByTestId('market-category-bar')).not.toBeInTheDocument()
49+
})
50+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useTranslation } from '../../i18n'
2+
import { SegmentedControl } from '@/components/ui/SegmentedControl'
3+
import type { MarketCategory } from '../../types/market'
4+
import { categoryLabel, useMarketLocale } from './catalogLocale'
5+
6+
/**
7+
* Category chips above the curated catalog.
8+
*
9+
* The categories are the catalog's own, so every chip has skills behind it and
10+
* its count is exact (static over the whole catalog, not the current filter).
11+
* Nothing renders until the server has sent them; a bar of "All" alone would
12+
* be a control with nothing to choose.
13+
*
14+
* Chips stay clickable while a page loads: the store drops superseded
15+
* responses, and dimming the whole row on every switch of a local list would
16+
* flicker for nothing.
17+
*/
18+
export function CategoryBar({
19+
categories,
20+
value,
21+
onChange,
22+
}: {
23+
categories: readonly MarketCategory[]
24+
value: string
25+
onChange: (key: string) => void
26+
}) {
27+
const t = useTranslation()
28+
const locale = useMarketLocale()
29+
if (categories.length === 0) return null
30+
31+
const items = [
32+
{ value: 'all', label: t('market.category.all') },
33+
...categories.map((category) => ({
34+
value: category.key,
35+
label: (
36+
<>
37+
{categoryLabel(category, locale)}
38+
<span className="text-[11px] font-normal tabular-nums opacity-60">{category.count}</span>
39+
</>
40+
),
41+
})),
42+
]
43+
44+
return (
45+
<div data-testid="market-category-bar">
46+
<SegmentedControl
47+
items={items}
48+
value={value}
49+
onChange={onChange}
50+
label={t('market.category.label')}
51+
appearance="chip"
52+
/>
53+
</div>
54+
)
55+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { beforeEach, describe, expect, it } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import '@testing-library/jest-dom'
4+
5+
import { useSettingsStore } from '../../stores/settingsStore'
6+
import { ChangelogPanel } from './ChangelogPanel'
7+
8+
beforeEach(() => {
9+
useSettingsStore.setState({ locale: 'en' })
10+
})
11+
12+
describe('ChangelogPanel', () => {
13+
it('shows the release version, date and note as written', () => {
14+
render(
15+
<ChangelogPanel
16+
changelog={{ version: '4.0.2', text: 'Hook is now opt-in.\nFixed paths.', publishedAt: Date.UTC(2026, 7, 6) }}
17+
pageUrl="https://clawhub.ai/x"
18+
/>,
19+
)
20+
21+
const panel = screen.getByTestId('market-changelog-panel')
22+
expect(panel).toHaveTextContent('v4.0.2')
23+
expect(panel).toHaveTextContent('2026-08-06')
24+
expect(screen.getByText(/Hook is now opt-in\./)).toHaveClass('whitespace-pre-wrap')
25+
expect(screen.getByRole('link', { name: /Source page/ })).toHaveAttribute('href', 'https://clawhub.ai/x')
26+
})
27+
28+
it('falls back to the skill version and does not double the v prefix', () => {
29+
const { unmount } = render(<ChangelogPanel changelog={{ text: 'Notes' }} version="1.2.0" />)
30+
expect(screen.getByTestId('market-changelog-panel')).toHaveTextContent('v1.2.0')
31+
unmount()
32+
33+
render(<ChangelogPanel changelog={{ version: 'v2.0.0', text: 'Notes' }} />)
34+
expect(screen.getByTestId('market-changelog-panel')).toHaveTextContent('v2.0.0')
35+
expect(screen.getByTestId('market-changelog-panel')).not.toHaveTextContent('vv2.0.0')
36+
})
37+
38+
it('says there is no note, and drops a link that is not http(s)', () => {
39+
render(<ChangelogPanel pageUrl="file:///etc/passwd" />)
40+
41+
expect(screen.getByText('Upstream has no release note for this version.')).toBeInTheDocument()
42+
expect(screen.queryByRole('link')).not.toBeInTheDocument()
43+
})
44+
})

0 commit comments

Comments
 (0)