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.
This commit is contained in:
程序员阿江(Relakkes)
2026-10-02 02:25:23 +08:00
parent 6b9d2aec1d
commit 4097fc6506
61 changed files with 14296 additions and 481 deletions
+20 -6
View File
@@ -3,6 +3,7 @@ import type {
MarketFileContent, MarketFileContent,
MarketInstalledFilter, MarketInstalledFilter,
MarketListResponse, MarketListResponse,
MarketScope,
MarketSecurityFilter, MarketSecurityFilter,
MarketSource, MarketSource,
MarketSourceFilter, MarketSourceFilter,
@@ -12,6 +13,9 @@ import type {
} from '../types/market' } from '../types/market'
export type MarketListParams = { export type MarketListParams = {
scope?: MarketScope
/** Catalog category key; ignored by the server in `market` scope. */
category?: string
q?: string q?: string
source?: MarketSourceFilter source?: MarketSourceFilter
security?: MarketSecurityFilter security?: MarketSecurityFilter
@@ -23,6 +27,8 @@ export type MarketListParams = {
export const marketApi = { export const marketApi = {
list: (params: MarketListParams = {}) => { list: (params: MarketListParams = {}) => {
const search = new URLSearchParams() const search = new URLSearchParams()
if (params.scope) search.set('scope', params.scope)
if (params.category && params.category !== 'all') search.set('category', params.category)
if (params.q) search.set('q', params.q) if (params.q) search.set('q', params.q)
if (params.source && params.source !== 'all') search.set('source', params.source) if (params.source && params.source !== 'all') search.set('source', params.source)
if (params.security && params.security !== 'all') search.set('security', params.security) if (params.security && params.security !== 'all') search.set('security', params.security)
@@ -33,22 +39,30 @@ export const marketApi = {
return api.get<MarketListResponse>(`/api/market/skills${query ? `?${query}` : ''}`, { timeout: 30_000 }) return api.get<MarketListResponse>(`/api/market/skills${query ? `?${query}` : ''}`, { timeout: 30_000 })
}, },
detail: (source: MarketSource, slug: string) => /**
* `owner` pins a ClawHub read to one publisher: ClawHub slugs are not unique,
* so the slug alone can resolve to a different skill than the card showed.
*/
detail: (source: MarketSource, slug: string, options: { owner?: string } = {}) =>
api.get<{ skill: NormalizedSkillDetail; sourceStatus: SourceStatusInfo }>( api.get<{ skill: NormalizedSkillDetail; sourceStatus: SourceStatusInfo }>(
`/api/market/skills/${source}/${encodeURIComponent(slug)}`, `/api/market/skills/${source}/${encodeURIComponent(slug)}${
options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ''
}`,
{ timeout: 30_000 }, { timeout: 30_000 },
), ),
fileContent: (source: MarketSource, slug: string, path: string) => fileContent: (source: MarketSource, slug: string, path: string, owner?: string) =>
api.get<{ file: MarketFileContent }>( api.get<{ file: MarketFileContent }>(
`/api/market/skills/${source}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`, `/api/market/skills/${source}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}${
owner ? `&owner=${encodeURIComponent(owner)}` : ''
}`,
{ timeout: 30_000 }, { timeout: 30_000 },
), ),
install: (id: string) => install: (id: string, owner?: string) =>
api.post<{ ok: boolean; installedPath: string; skill: NormalizedSkill }>( api.post<{ ok: boolean; installedPath: string; skill: NormalizedSkill }>(
'/api/market/install', '/api/market/install',
{ id }, owner ? { id, owner } : { id },
{ timeout: 120_000 }, { timeout: 120_000 },
), ),
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { useSettingsStore } from '../../stores/settingsStore'
import type { Capability } from '../../lib/skillInsights'
import { CapabilityPanel } from './CapabilityPanel'
const CAPABILITIES: Capability[] = [
{ kind: 'shell', level: 'high', evidence: ['scripts/extract-skill.sh', 'mkdir', 'printf'] },
{ kind: 'hooks', level: 'high', evidence: ['hooks/', '~/.openclaw/hooks'] },
{ kind: 'binaries', level: 'low', evidence: ['git'] },
]
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
describe('CapabilityPanel', () => {
it('names each capability with its evidence and risk level', () => {
render(<CapabilityPanel capabilities={CAPABILITIES} />)
expect(screen.getByRole('heading', { name: 'Before installing: what this skill does' })).toBeInTheDocument()
const shell = screen.getByTestId('market-capability-shell')
expect(within(shell).getByText('Runs commands or scripts')).toBeInTheDocument()
// Commands join with a slash, everything else with the list separator.
expect(shell).toHaveTextContent('scripts/extract-skill.sh / mkdir / printf')
expect(shell).toHaveTextContent('High')
expect(screen.getByTestId('market-capability-hooks')).toHaveTextContent('Touches hooks/、~/.openclaw/hooks')
expect(screen.getByTestId('market-capability-binaries')).toHaveTextContent('Low')
})
it('links to the full report only when asked to', () => {
const onViewReport = vi.fn()
const { rerender } = render(<CapabilityPanel capabilities={CAPABILITIES} onViewReport={onViewReport} />)
fireEvent.click(screen.getByRole('button', { name: 'Full report →' }))
expect(onViewReport).toHaveBeenCalledTimes(1)
rerender(<CapabilityPanel capabilities={CAPABILITIES} />)
expect(screen.queryByRole('button', { name: 'Full report →' })).not.toBeInTheDocument()
})
it('renders nothing when no rule fired', () => {
render(<CapabilityPanel capabilities={[]} />)
expect(screen.queryByTestId('market-capability-panel')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,114 @@
import { useMemo } from 'react'
import { TriangleAlert } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Badge, type Tone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import {
detectCapabilities,
extractTriggers,
type Capability,
type CapabilityLevel,
} from '../../lib/skillInsights'
import type { NormalizedSkillDetail } from '../../types/market'
type Translate = ReturnType<typeof useTranslation>
export const CAPABILITY_LEVEL_TONES: Record<CapabilityLevel, Tone> = {
high: 'danger',
medium: 'warning',
low: 'neutral',
}
/** One capability, phrased with its evidence. */
export function capabilityText(t: Translate, capability: Capability): { title: string; detail: string } {
const evidence = capability.evidence.join(capability.kind === 'shell' ? ' / ' : '、')
return {
title: t(`market.cap.${capability.kind}`),
detail: t(`market.cap.${capability.kind}.detail`, { evidence }),
}
}
/** Capabilities and triggers, read once per detail off its own SKILL.md and file list. */
export function useSkillInsights(detail: NormalizedSkillDetail | null | undefined): {
capabilities: Capability[]
triggers: string[]
} {
return useMemo(() => {
if (!detail) return { capabilities: [], triggers: [] }
const description = detail.descriptionFrontmatter?.description
return {
capabilities: detectCapabilities({
markdown: detail.description,
frontmatter: detail.descriptionFrontmatter,
files: detail.files,
}),
triggers: extractTriggers(typeof description === 'string' ? description : undefined),
}
}, [detail])
}
/**
* "Before installing: what this skill does."
*
* Every card cites the file, command, variable or host it came from, so the
* reader can check the claim rather than trust it. Renders nothing when no
* rule fired: an empty "nothing risky here" panel would be a verdict these
* rules cannot make.
*/
export function CapabilityPanel({
capabilities,
onViewReport,
}: {
capabilities: readonly Capability[]
/** Shown as the header link; omitted where the panel already sits in the report. */
onViewReport?: () => void
}) {
const t = useTranslation()
if (capabilities.length === 0) return null
return (
<section
aria-labelledby="market-capabilities-title"
data-testid="market-capability-panel"
className="flex-shrink-0 rounded-[var(--radius-xl)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-5 py-[18px]"
>
<header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<h2
id="market-capabilities-title"
className="flex items-center gap-2 text-[15px] font-semibold text-[var(--color-on-warning-container)]"
>
<TriangleAlert className="h-[17px] w-[17px] flex-shrink-0" strokeWidth={1.8} aria-hidden="true" />
{t('market.cap.title')}
</h2>
{onViewReport && (
<Button variant="link" size="sm" data-testid="market-capability-view-report" onClick={onViewReport}>
{t('market.cap.viewReport')}
</Button>
)}
</header>
<ul className="mt-3.5 grid gap-2.5 grid-cols-[repeat(auto-fill,minmax(200px,1fr))]">
{capabilities.map((capability) => {
const text = capabilityText(t, capability)
return (
<li
key={capability.kind}
data-testid={`market-capability-${capability.kind}`}
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"
>
<span className="flex items-center justify-between gap-2">
<strong className="text-[13.5px] font-semibold text-[var(--color-text-primary)]">{text.title}</strong>
<Badge tone={CAPABILITY_LEVEL_TONES[capability.level]} size="xs" pill={false}>
{t(`market.cap.level.${capability.level}`)}
</Badge>
</span>
<span className="break-words font-mono text-xs leading-5 text-[var(--color-text-secondary)]">
{text.detail}
</span>
</li>
)
})}
</ul>
<p className="mt-3 text-xs leading-5 text-[var(--color-on-warning-container)]">{t('market.cap.note')}</p>
</section>
)
}
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { useSettingsStore } from '../../stores/settingsStore'
import type { MarketCategory } from '../../types/market'
import { CategoryBar } from './CategoryBar'
const CATEGORIES: MarketCategory[] = [
{ key: 'dev', name: '开发编程', nameEn: 'Development', count: 40 },
{ key: 'office', name: '办公文档', nameEn: 'Office', count: 36 },
]
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
describe('CategoryBar', () => {
it('renders "All" plus one counted chip per catalog category', () => {
render(<CategoryBar categories={CATEGORIES} value="all" onChange={vi.fn()} />)
const group = screen.getByRole('radiogroup', { name: 'Category' })
expect(group).toBeInTheDocument()
expect(screen.getAllByRole('radio')).toHaveLength(3)
expect(screen.getByRole('radio', { name: 'All' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Development 40' })).toBeInTheDocument()
})
it('uses the Chinese category names for Chinese readers', () => {
useSettingsStore.setState({ locale: 'zh-TW' })
render(<CategoryBar categories={CATEGORIES} value="dev" onChange={vi.fn()} />)
expect(screen.getByRole('radio', { name: '全部' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: '开发编程 40' })).toHaveAttribute('aria-checked', 'true')
})
it('reports the picked category key', () => {
const onChange = vi.fn()
render(<CategoryBar categories={CATEGORIES} value="all" onChange={onChange} />)
fireEvent.click(screen.getByRole('radio', { name: 'Office 36' }))
expect(onChange).toHaveBeenCalledWith('office')
})
it('renders nothing before the server sent any categories', () => {
render(<CategoryBar categories={[]} value="all" onChange={vi.fn()} />)
expect(screen.queryByTestId('market-category-bar')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,55 @@
import { useTranslation } from '../../i18n'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
import type { MarketCategory } from '../../types/market'
import { categoryLabel, useMarketLocale } from './catalogLocale'
/**
* Category chips above the curated catalog.
*
* The categories are the catalog's own, so every chip has skills behind it and
* its count is exact (static over the whole catalog, not the current filter).
* Nothing renders until the server has sent them; a bar of "All" alone would
* be a control with nothing to choose.
*
* Chips stay clickable while a page loads: the store drops superseded
* responses, and dimming the whole row on every switch of a local list would
* flicker for nothing.
*/
export function CategoryBar({
categories,
value,
onChange,
}: {
categories: readonly MarketCategory[]
value: string
onChange: (key: string) => void
}) {
const t = useTranslation()
const locale = useMarketLocale()
if (categories.length === 0) return null
const items = [
{ value: 'all', label: t('market.category.all') },
...categories.map((category) => ({
value: category.key,
label: (
<>
{categoryLabel(category, locale)}
<span className="text-[11px] font-normal tabular-nums opacity-60">{category.count}</span>
</>
),
})),
]
return (
<div data-testid="market-category-bar">
<SegmentedControl
items={items}
value={value}
onChange={onChange}
label={t('market.category.label')}
appearance="chip"
/>
</div>
)
}
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { useSettingsStore } from '../../stores/settingsStore'
import { ChangelogPanel } from './ChangelogPanel'
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
describe('ChangelogPanel', () => {
it('shows the release version, date and note as written', () => {
render(
<ChangelogPanel
changelog={{ version: '4.0.2', text: 'Hook is now opt-in.\nFixed paths.', publishedAt: Date.UTC(2026, 7, 6) }}
pageUrl="https://clawhub.ai/x"
/>,
)
const panel = screen.getByTestId('market-changelog-panel')
expect(panel).toHaveTextContent('v4.0.2')
expect(panel).toHaveTextContent('2026-08-06')
expect(screen.getByText(/Hook is now opt-in\./)).toHaveClass('whitespace-pre-wrap')
expect(screen.getByRole('link', { name: /Source page/ })).toHaveAttribute('href', 'https://clawhub.ai/x')
})
it('falls back to the skill version and does not double the v prefix', () => {
const { unmount } = render(<ChangelogPanel changelog={{ text: 'Notes' }} version="1.2.0" />)
expect(screen.getByTestId('market-changelog-panel')).toHaveTextContent('v1.2.0')
unmount()
render(<ChangelogPanel changelog={{ version: 'v2.0.0', text: 'Notes' }} />)
expect(screen.getByTestId('market-changelog-panel')).toHaveTextContent('v2.0.0')
expect(screen.getByTestId('market-changelog-panel')).not.toHaveTextContent('vv2.0.0')
})
it('says there is no note, and drops a link that is not http(s)', () => {
render(<ChangelogPanel pageUrl="file:///etc/passwd" />)
expect(screen.getByText('Upstream has no release note for this version.')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,66 @@
import { ExternalLink } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Badge } from '@/components/ui/Badge'
import { Card } from '@/components/ui/Card'
import type { NormalizedSkillDetail } from '../../types/market'
import { formatIsoDate, safeUrl } from './marketFormat'
/**
* The latest release note, as upstream published it. Upstream keeps only the
* newest one; the registry page has the rest, so it is linked when known.
*/
export function ChangelogPanel({
changelog,
version,
pageUrl,
}: {
changelog?: NormalizedSkillDetail['changelog']
/** The skill's current version, used when the note does not name its own. */
version?: string
pageUrl?: string
}) {
const t = useTranslation()
const href = safeUrl(pageUrl)
const releaseVersion = changelog?.version || version
const published = formatIsoDate(changelog?.publishedAt)
return (
<Card
radius="xl"
surface="base"
padding="none"
className="px-6 py-5 sm:px-[30px]"
data-testid="market-changelog-panel"
>
<h2 className="text-[15px] font-semibold text-[var(--color-text-primary)]">{t('market.detail.changelog')}</h2>
{changelog ? (
<article className="mt-3.5">
<header className="flex flex-wrap items-center gap-2.5">
{releaseVersion && (
<Badge variant="outline" size="sm" pill={false} mono>
{releaseVersion.startsWith('v') ? releaseVersion : `v${releaseVersion}`}
</Badge>
)}
{published && <span className="font-mono text-xs text-[var(--color-text-tertiary)]">{published}</span>}
</header>
<p className="mt-2.5 whitespace-pre-wrap break-words text-sm leading-[1.7] text-[var(--color-text-secondary)]">
{changelog.text}
</p>
</article>
) : (
<p className="mt-3 text-sm text-[var(--color-text-tertiary)]">{t('market.detail.noChangelog')}</p>
)}
{href && (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="mt-4 inline-flex w-fit items-center gap-1 rounded-[var(--radius-sm)] text-[13px] font-medium text-[var(--color-brand)] underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t('market.detail.sourcePage')}
<ExternalLink className="h-3 w-3" strokeWidth={1.8} aria-hidden="true" />
</a>
)}
</Card>
)
}
@@ -4,7 +4,7 @@ import '@testing-library/jest-dom'
import { InstallConfirmDialog } from './InstallConfirmDialog' import { InstallConfirmDialog } from './InstallConfirmDialog'
import { useSettingsStore } from '../../stores/settingsStore' import { useSettingsStore } from '../../stores/settingsStore'
import type { NormalizedSkill } from '../../types/market' import type { NormalizedSkill, NormalizedSkillDetail } from '../../types/market'
function makeSkill(overrides: Partial<NormalizedSkill> = {}): NormalizedSkill { function makeSkill(overrides: Partial<NormalizedSkill> = {}): NormalizedSkill {
return { return {
@@ -88,3 +88,92 @@ describe('InstallConfirmDialog', () => {
expect(screen.getByText('Cancel').closest('button')).toBeDisabled() expect(screen.getByText('Cancel').closest('button')).toBeDisabled()
}) })
}) })
describe('InstallConfirmDialog acknowledgement gate', () => {
it('arms a clean skill straight away, with no acknowledgement to tick', () => {
for (const status of ['benign', 'verified'] as const) {
const { unmount } = render(
<InstallConfirmDialog skill={makeSkill({ securityStatus: status })} open installing={false} onConfirm={vi.fn()} onClose={vi.fn()} />,
)
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
expect(screen.getByTestId('market-install-confirm-button')).toBeEnabled()
unmount()
}
})
it.each(['flagged', 'unknown'] as const)('keeps confirm disabled for a %s skill until acknowledged', (status) => {
const onConfirm = vi.fn()
render(
<InstallConfirmDialog skill={makeSkill({ securityStatus: status })} open installing={false} onConfirm={onConfirm} onClose={vi.fn()} />,
)
const confirm = screen.getByTestId('market-install-confirm-button')
expect(confirm).toBeDisabled()
fireEvent.click(
screen.getByRole('checkbox', {
name: 'I have read SKILL.md and the security report and understand these permissions',
}),
)
expect(confirm).toBeEnabled()
fireEvent.click(confirm)
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('resets the acknowledgement when the dialog moves to another skill', () => {
const props = { open: true, installing: false, onConfirm: vi.fn(), onClose: vi.fn() }
const { rerender } = render(<InstallConfirmDialog skill={makeSkill({ securityStatus: 'flagged' })} {...props} />)
fireEvent.click(screen.getByRole('checkbox'))
expect(screen.getByTestId('market-install-confirm-button')).toBeEnabled()
rerender(<InstallConfirmDialog skill={makeSkill({ id: 'skillhub:other', slug: 'other', securityStatus: 'flagged' })} {...props} />)
expect(screen.getByRole('checkbox')).not.toBeChecked()
expect(screen.getByTestId('market-install-confirm-button')).toBeDisabled()
})
it('resets the acknowledgement when the same skill is confirmed again later', () => {
const skill = makeSkill({ securityStatus: 'unknown' })
const props = { installing: false, onConfirm: vi.fn(), onClose: vi.fn() }
const { rerender } = render(<InstallConfirmDialog skill={skill} open {...props} />)
fireEvent.click(screen.getByRole('checkbox'))
rerender(<InstallConfirmDialog skill={skill} open={false} {...props} />)
rerender(<InstallConfirmDialog skill={skill} open {...props} />)
expect(screen.getByTestId('market-install-confirm-button')).toBeDisabled()
})
it('lists what the skill will be able to do when its detail is loaded', () => {
const skill = makeSkill({ securityStatus: 'flagged' })
const detail: NormalizedSkillDetail = {
...skill,
description: '```bash\nmkdir -p .learnings\n```',
files: [
{ path: 'SKILL.md', size: 10, language: 'markdown', tooBig: false },
{ path: 'scripts/extract.sh', size: 10, language: 'bash', tooBig: false },
],
totalSize: 20,
}
render(<InstallConfirmDialog skill={skill} detail={detail} open installing={false} onConfirm={vi.fn()} onClose={vi.fn()} />)
const list = screen.getByTestId('market-install-capabilities')
expect(list).toHaveTextContent('This skill will be able to')
expect(list).toHaveTextContent('Runs commands or scripts')
expect(list).toHaveTextContent('scripts/extract.sh')
expect(list).toHaveTextContent('High')
expect(list).toHaveTextContent('Writes files')
})
it('ignores a detail that belongs to another skill', () => {
const detail: NormalizedSkillDetail = {
...makeSkill({ id: 'skillhub:other' }),
description: '```bash\nmkdir -p out\n```',
files: [],
totalSize: 0,
}
render(<InstallConfirmDialog skill={makeSkill()} detail={detail} open installing={false} onConfirm={vi.fn()} onClose={vi.fn()} />)
expect(screen.queryByTestId('market-install-capabilities')).not.toBeInTheDocument()
})
})
@@ -1,8 +1,12 @@
import { useEffect, useState } from 'react'
import { ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react' import { ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import type { NormalizedSkill } from '../../types/market' import type { NormalizedSkill, NormalizedSkillDetail } from '../../types/market'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Modal } from '@/components/ui/Modal' import { Modal } from '@/components/ui/Modal'
import { CAPABILITY_LEVEL_TONES, capabilityText, useSkillInsights } from './CapabilityPanel'
import { SecurityBadge } from './SecurityBadge' import { SecurityBadge } from './SecurityBadge'
const RISK_KEYS = { const RISK_KEYS = {
@@ -12,23 +16,48 @@ const RISK_KEYS = {
flagged: 'market.installConfirm.riskFlagged', flagged: 'market.installConfirm.riskFlagged',
} as const } as const
/**
* Install confirmation.
*
* A skill that did not scan clean (flagged or unknown) needs an explicit
* acknowledgement before the confirm button arms: installing is a trust
* decision about a third party, and the dialog makes it one rather than a
* reflex click. The acknowledgement belongs to one skill and one opening, so
* it resets whenever either changes.
*
* When the dialog is opened from the detail page the skill's files are known,
* and with them what it will be able to do; from a catalog card they are not,
* and the list is simply absent rather than guessed.
*/
export function InstallConfirmDialog({ export function InstallConfirmDialog({
skill, skill,
detail,
open, open,
installing, installing,
onConfirm, onConfirm,
onClose, onClose,
}: { }: {
skill: NormalizedSkill | null skill: NormalizedSkill | null
/** The loaded detail for this skill, when there is one. */
detail?: NormalizedSkillDetail | null
open: boolean open: boolean
installing: boolean installing: boolean
onConfirm: () => void onConfirm: () => void
onClose: () => void onClose: () => void
}) { }) {
const t = useTranslation() const t = useTranslation()
const [acknowledged, setAcknowledged] = useState(false)
const { capabilities } = useSkillInsights(detail && skill && detail.id === skill.id ? detail : null)
const skillId = skill?.id
useEffect(() => {
setAcknowledged(false)
}, [skillId, open])
if (!skill) return null if (!skill) return null
const risky = skill.securityStatus === 'flagged' || skill.securityStatus === 'unknown' const risky = skill.securityStatus === 'flagged' || skill.securityStatus === 'unknown'
const armed = !risky || acknowledged
const RiskIcon = const RiskIcon =
skill.securityStatus === 'flagged' ? ShieldAlert : risky ? ShieldQuestion : ShieldCheck skill.securityStatus === 'flagged' ? ShieldAlert : risky ? ShieldQuestion : ShieldCheck
@@ -78,8 +107,44 @@ export function InstallConfirmDialog({
<span>{t(RISK_KEYS[skill.securityStatus])}</span> <span>{t(RISK_KEYS[skill.securityStatus])}</span>
</div> </div>
{capabilities.length > 0 && (
<section data-testid="market-install-capabilities">
<h3 className="text-[13px] font-semibold text-[var(--color-text-primary)]">
{t('market.installConfirm.willGet')}
</h3>
<ul className="mt-2 divide-y divide-[var(--color-border-separator)] rounded-[var(--radius-lg)] border border-[var(--color-border)]">
{capabilities.map((capability) => {
const text = capabilityText(t, capability)
return (
<li key={capability.kind} className="flex items-center gap-3 px-3.5 py-2.5">
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-[13px] text-[var(--color-text-primary)]">{text.title}</span>
<span className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={text.detail}>
{text.detail}
</span>
</span>
<Badge tone={CAPABILITY_LEVEL_TONES[capability.level]} size="xs" pill={false}>
{t(`market.cap.level.${capability.level}`)}
</Badge>
</li>
)
})}
</ul>
</section>
)}
<p className="text-[11px] leading-5 text-[var(--color-text-tertiary)]">{t('market.installConfirm.effectNote')}</p> <p className="text-[11px] leading-5 text-[var(--color-text-tertiary)]">{t('market.installConfirm.effectNote')}</p>
{risky && (
<Checkbox
data-testid="market-install-ack"
label={t('market.installConfirm.ack')}
checked={acknowledged}
disabled={installing}
onChange={(event) => setAcknowledged(event.currentTarget.checked)}
/>
)}
<div className="flex items-center justify-end gap-2 pt-1"> <div className="flex items-center justify-end gap-2 pt-1">
<Button variant="secondary" disabled={installing} onClick={onClose}> <Button variant="secondary" disabled={installing} onClick={onClose}>
{t('market.installConfirm.cancel')} {t('market.installConfirm.cancel')}
@@ -91,6 +156,7 @@ export function InstallConfirmDialog({
variant={skill.securityStatus === 'flagged' ? 'danger' : 'primary'} variant={skill.securityStatus === 'flagged' ? 'danger' : 'primary'}
data-testid="market-install-confirm-button" data-testid="market-install-confirm-button"
loading={installing} loading={installing}
disabled={!armed}
onClick={onConfirm} onClick={onConfirm}
> >
{installing ? t('market.install.installing') : t('market.installConfirm.confirm')} {installing ? t('market.install.installing') : t('market.installConfirm.confirm')}
@@ -28,7 +28,7 @@ export function MarketDisclaimer() {
<div <div
role="note" role="note"
data-testid="market-disclaimer" data-testid="market-disclaimer"
className="mt-6 flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-[18px] py-3.5" className="flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-[18px] py-3.5"
> >
<ShieldAlert className="mt-0.5 h-[17px] w-[17px] flex-shrink-0 text-[var(--color-brand)]" strokeWidth={1.5} aria-hidden="true" /> <ShieldAlert className="mt-0.5 h-[17px] w-[17px] flex-shrink-0 text-[var(--color-brand)]" strokeWidth={1.5} aria-hidden="true" />
{/* Foreground is the darkened pair, never `--color-brand`: terracotta on {/* Foreground is the darkened pair, never `--color-brand`: terracotta on
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react' import { act, fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { useSettingsStore } from '../../stores/settingsStore' import { useSettingsStore } from '../../stores/settingsStore'
@@ -109,7 +109,13 @@ beforeEach(() => {
clawhub: { status: 'ok' }, clawhub: { status: 'ok' },
skillhub: { status: 'cached', fetchedAt: 1_700_000_000_000 }, skillhub: { status: 'cached', fetchedAt: 1_700_000_000_000 },
}, },
scope: 'catalog',
category: 'all',
categories: [],
total: null,
catalogGeneratedAt: null,
query: '', query: '',
liveQuery: '',
filters: { source: 'all', security: 'all', installed: 'all' }, filters: { source: 'all', security: 'all', installed: 'all' },
isLoading: false, isLoading: false,
isLoadingMore: false, isLoadingMore: false,
@@ -126,17 +132,18 @@ afterEach(() => {
}) })
describe('MarketHome', () => { describe('MarketHome', () => {
it('renders the compact catalog header, command bar, sources and semantic cards', () => { it('renders the catalog header, command bar and semantic cards', () => {
render(<MarketHome onRequestInstall={vi.fn()} />) render(<MarketHome onRequestInstall={vi.fn()} />)
expect(screen.getByRole('heading', { name: 'Skills Market' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Skills Market' })).toBeInTheDocument()
expect(screen.getByTestId('market-search-input')).toBeInTheDocument() expect(screen.getByTestId('market-search-input')).toBeInTheDocument()
expect(screen.getByTestId('market-filter-bar')).toBeInTheDocument() expect(screen.getByTestId('market-filter-bar')).toBeInTheDocument()
expect(screen.getByTestId('market-source-status-clawhub')).toHaveTextContent('Online')
expect(screen.getByTestId('market-source-status-skillhub')).toHaveTextContent('Cached')
expect(screen.getByTestId('market-grid')).toContainElement(screen.getByRole('article')) expect(screen.getByTestId('market-grid')).toContainElement(screen.getByRole('article'))
expect(screen.getByRole('button', { name: 'Demo Skill' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Demo Skill' })).toBeInTheDocument()
expect(screen.getByText('1 skills')).toBeInTheDocument() expect(screen.getByText('1 skills')).toBeInTheDocument()
// The catalog is a shipped snapshot: live source health says nothing about it.
expect(screen.queryByTestId('market-source-status')).not.toBeInTheDocument()
expect(screen.queryByTestId('market-not-curated')).not.toBeInTheDocument()
}) })
it('uses a catalog-shaped skeleton while the first page is loading', () => { it('uses a catalog-shaped skeleton while the first page is loading', () => {
@@ -165,6 +172,20 @@ describe('MarketHome', () => {
expect(skeletonCardCount('market-loading')).toBe(15) expect(skeletonCardCount('market-loading')).toBe(15)
}) })
it('pins a ClawHub card\'s open and install to its owner', () => {
// ClawHub slugs are not unique; the card's publisher is what makes it this skill.
const openDetail = vi.fn()
const onRequestInstall = vi.fn()
useMarketStore.setState({ openDetail })
render(<MarketHome onRequestInstall={onRequestInstall} />)
fireEvent.click(screen.getByRole('button', { name: 'Demo Skill' }))
expect(openDetail).toHaveBeenCalledWith('clawhub:demo', 'alice')
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
expect(onRequestInstall).toHaveBeenCalledWith('clawhub:demo', 'alice')
})
it('opens the installed-skills browser from the header', () => { it('opens the installed-skills browser from the header', () => {
render(<MarketHome onRequestInstall={vi.fn()} />) render(<MarketHome onRequestInstall={vi.fn()} />)
@@ -263,3 +284,112 @@ it('keeps featured skill packages inside the existing scroll surface alongside s
expect(screen.getByTestId('market-search-input')).toBeInTheDocument() expect(screen.getByTestId('market-search-input')).toBeInTheDocument()
expect(screen.getByTestId('market-installed-entry')).toBeInTheDocument() expect(screen.getByTestId('market-installed-entry')).toBeInTheDocument()
}) })
describe('MarketHome catalog and live scope', () => {
const CATEGORIES = [
{ key: 'dev', name: '开发编程', nameEn: 'Development', count: 40 },
{ key: 'office', name: '办公文档', nameEn: 'Office', count: 36 },
]
it('summarises the curated catalog with its total, snapshot date and category chips', () => {
const setCategory = vi.fn()
useMarketStore.setState({
categories: CATEGORIES,
total: 398,
catalogGeneratedAt: Date.UTC(2026, 9, 1),
setCategory,
})
render(<MarketHome onRequestInstall={vi.fn()} />)
expect(screen.getByTestId('market-result-summary')).toHaveTextContent('398 curated skills')
expect(screen.getByTestId('market-result-summary')).toHaveTextContent('list updated 2026-10-01')
fireEvent.click(screen.getByRole('radio', { name: 'Development 40' }))
expect(setCategory).toHaveBeenCalledWith('dev')
})
it('offers the live market search for a catalog query and runs it on request', () => {
const searchAllMarkets = vi.fn()
useMarketStore.setState({ query: 'pdf', searchAllMarkets })
render(<MarketHome onRequestInstall={vi.fn()} />)
expect(screen.getByTestId('market-scope-hint')).toHaveTextContent('Searching curated skills only.')
fireEvent.click(screen.getByRole('button', { name: 'Search all markets for “pdf”' }))
expect(searchAllMarkets).toHaveBeenCalledTimes(1)
})
it('turns an empty catalog search into a way out to the live market', () => {
const searchAllMarkets = vi.fn()
useMarketStore.setState({ items: [], query: 'obscure', searchAllMarkets, fetchList: vi.fn() })
render(<MarketHome onRequestInstall={vi.fn()} />)
const empty = screen.getByTestId('market-empty')
fireEvent.click(within(empty).getByRole('button', { name: 'Search all markets for “obscure”' }))
expect(searchAllMarkets).toHaveBeenCalledTimes(1)
})
it('labels live results as not curated, hides the category chips and shows source health', () => {
const backToCatalog = vi.fn()
useMarketStore.setState({
scope: 'market',
query: 'pdf',
liveQuery: 'pdf',
categories: CATEGORIES,
backToCatalog,
})
render(<MarketHome onRequestInstall={vi.fn()} />)
expect(screen.getByTestId('market-not-curated')).toHaveTextContent('Not curated')
expect(screen.getByTestId('market-result-summary')).toHaveTextContent('1 live results')
expect(screen.queryByTestId('market-category-bar')).not.toBeInTheDocument()
expect(screen.getByTestId('market-source-status-clawhub')).toHaveTextContent('Online')
expect(screen.getByTestId('market-source-status-skillhub')).toHaveTextContent('Cached')
fireEvent.click(screen.getByRole('button', { name: 'Back to curated' }))
expect(backToCatalog).toHaveBeenCalledTimes(1)
})
it('submits on Enter but not on the Enter that confirms an IME candidate', () => {
const submitQuery = vi.fn()
const setQuery = vi.fn()
useMarketStore.setState({ scope: 'market', query: 'pdf', liveQuery: 'pdf', submitQuery, setQuery })
render(<MarketHome onRequestInstall={vi.fn()} />)
const input = screen.getByTestId('market-search-input')
fireEvent.compositionStart(input)
fireEvent.change(input, { target: { value: 'wendang' } })
// The in-progress pinyin is shown but never becomes a query.
expect(input).toHaveValue('wendang')
expect(setQuery).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter', isComposing: true })
expect(submitQuery).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: '文档' } })
fireEvent.compositionEnd(input)
expect(setQuery).toHaveBeenCalledTimes(1)
expect(setQuery).toHaveBeenCalledWith('文档')
// Safari: the confirming keydown arrives as 229 once `isComposing` cleared.
fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 })
expect(submitQuery).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: 'Enter' })
expect(submitQuery).toHaveBeenCalledTimes(1)
})
it('tells the reader that live search waits for Enter', () => {
useMarketStore.setState({ scope: 'market', query: 'pdf', liveQuery: 'pdf' })
render(<MarketHome onRequestInstall={vi.fn()} />)
expect(screen.getByTestId('market-search-input')).toHaveAttribute(
'placeholder',
'Search all markets — press Enter to search',
)
})
})
+175 -71
View File
@@ -1,16 +1,19 @@
import { forwardRef, useCallback, useEffect, useRef, useState, type ReactNode } from 'react' import { forwardRef, useCallback, useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { ArrowUpRight, CloudOff, PackageSearch, RefreshCw, Search, Sparkles, Store, X } from 'lucide-react' import { ArrowUpRight, CloudOff, PackageSearch, RefreshCw, Search, Sparkles, Store, X } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState' import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState' import { ErrorState } from '@/components/ui/ErrorState'
import { IconButton } from '@/components/ui/IconButton' import { IconButton } from '@/components/ui/IconButton'
import { SkeletonCards } from '@/components/ui/Skeleton' import { SkeletonCards } from '@/components/ui/Skeleton'
import { useMarketStore } from '../../stores/marketStore' import { marketOwnerOf, useMarketStore } from '../../stores/marketStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore' import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore' import { useUIStore } from '../../stores/uiStore'
import { CategoryBar } from './CategoryBar'
import { FilterBar } from './FilterBar' import { FilterBar } from './FilterBar'
import { MarketDisclaimer } from './MarketDisclaimer' import { MarketDisclaimer } from './MarketDisclaimer'
import { formatIsoDate } from './marketFormat'
import { SkillCard } from './SkillCard' import { SkillCard } from './SkillCard'
import { SourceStatusBar } from './SourceStatusBar' import { SourceStatusBar } from './SourceStatusBar'
import { import {
@@ -28,12 +31,17 @@ const PREFETCH_MARGIN = '400px'
const CATALOG_GRID_STYLE = { gridTemplateColumns: CATALOG_GRID_TEMPLATE, gap: CATALOG_GAP } const CATALOG_GRID_STYLE = { gridTemplateColumns: CATALOG_GRID_TEMPLATE, gap: CATALOG_GAP }
export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (id: string) => void, featured?: ReactNode }) { export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (id: string, owner?: string) => void, featured?: ReactNode }) {
const t = useTranslation() const t = useTranslation()
const { const {
items, items,
nextCursor, nextCursor,
sources, sources,
scope,
category,
categories,
total,
catalogGeneratedAt,
query, query,
filters, filters,
isLoading, isLoading,
@@ -43,6 +51,10 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (
fetchList, fetchList,
loadMore, loadMore,
setQuery, setQuery,
submitQuery,
searchAllMarkets,
backToCatalog,
setCategory,
installingIds, installingIds,
} = useMarketStore() } = useMarketStore()
@@ -88,38 +100,71 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings') useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}, [t]) }, [t])
const catalog = scope === 'catalog'
const hasActiveFilters = const hasActiveFilters =
filters.source !== 'all' || filters.security !== 'all' || filters.installed !== 'all' filters.source !== 'all' ||
const hasQuery = query.trim().length > 0 filters.security !== 'all' ||
filters.installed !== 'all' ||
(catalog && category !== 'all')
const trimmedQuery = query.trim()
const hasQuery = trimmedQuery.length > 0
/**
* The box is driven from a local draft so an IME composition can show its
* in-progress text without becoming a query: pinyin like "wendang" would
* otherwise search the catalog for each Latin keystroke on the way to 文档.
* The composed text is committed once, on `compositionend`.
*/
const [draft, setDraft] = useState(query)
const composingRef = useRef(false)
useEffect(() => {
if (!composingRef.current) setDraft(query)
}, [query])
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Enter') return
// Enter that confirms an IME candidate is not a submit. Safari reports the
// confirming keydown as keyCode 229 after `isComposing` has already cleared.
if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return
event.preventDefault()
submitQuery()
}
const searchMarketLabel = t('market.scope.searchMarket', { q: trimmedQuery })
const catalogUpdated = catalog && catalogGeneratedAt ? formatIsoDate(catalogGeneratedAt) : ''
const searchLabel = catalog ? t('market.searchPlaceholder') : t('market.scope.livePlaceholder')
let emptyAction: { label: string; onClick: () => void }
if (catalog && hasQuery) emptyAction = { label: searchMarketLabel, onClick: searchAllMarkets }
else if (!catalog) emptyAction = { label: t('market.scope.backToCatalog'), onClick: backToCatalog }
else emptyAction = { label: t('market.retry'), onClick: () => void fetchList({ reset: true }) }
return ( return (
<div <div
ref={scrollRef} ref={scrollRef}
data-testid="market-scroll" data-testid="market-scroll"
className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface)]" className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface-container-low)]"
> >
<div className="mx-auto flex w-full max-w-[1280px] flex-col px-6 pb-10 pt-7 lg:px-10"> {/* The top band holds everything that decides *what* is listed — title,
<header className="flex flex-wrap items-start gap-x-[18px] gap-y-4"> disclaimer, categories, search and filters — on the page surface; the
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)] shadow-[var(--shadow-card)]"> cards sit on the tinted canvas below it. */}
<Store className="h-[26px] w-[26px]" strokeWidth={1.4} aria-hidden="true" /> <div className="flex-shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface)]">
</span> <div className="mx-auto flex w-full max-w-[1280px] flex-col gap-4 px-6 pb-5 pt-6 lg:px-10">
<div className="min-w-0 flex-1"> <header className="flex flex-wrap items-start gap-x-3.5 gap-y-3">
<h1 <span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-[var(--radius-lg)] bg-[var(--color-btn-primary-bg)] text-[var(--color-btn-primary-fg)]">
style={{ fontFamily: 'var(--font-headline)' }} <Store className="h-[22px] w-[22px]" strokeWidth={1.6} aria-hidden="true" />
className="text-[26px] font-bold leading-9 tracking-[-0.012em] text-[var(--color-text-primary)]" </span>
> <div className="min-w-0 flex-1">
{t('market.title')} <h1
</h1> style={{ fontFamily: 'var(--font-headline)' }}
<p className="mt-1 max-w-2xl text-[15px] leading-6 text-[var(--color-text-secondary)]"> className="text-[26px] font-bold leading-[34px] tracking-[-0.012em] text-[var(--color-text-primary)]"
{t('market.subtitle')} >
</p> {t('market.title')}
</div> </h1>
{/* The live-source dots and the way out of the catalogue read as one <p className="mt-1 max-w-[62ch] text-sm leading-[21px] text-[var(--color-text-secondary)]">
cluster: what the shelves are serving on top, what is already on {t('market.subtitle')}
the reader's machine under it, both flush right so the header </p>
keeps a single trailing edge. */} </div>
<div className="flex flex-col items-end gap-2.5 pt-2">
<SourceStatusBar sources={sources} />
<Button <Button
variant="secondary" variant="secondary"
size="md" size="md"
@@ -138,54 +183,117 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (
aria-hidden="true" aria-hidden="true"
/> />
</Button> </Button>
</div> </header>
</header>
<MarketDisclaimer /> <MarketDisclaimer />
<div className="mt-[22px] flex flex-wrap items-center gap-3"> {/* Live results have no catalog categories to pick from. */}
{/* Kept hand-rolled rather than moved onto `SearchField`: the command {catalog && <CategoryBar categories={categories} value={category} onChange={setCategory} />}
bar is a 44px field on the `--radius-lg` step, and the shared
component tops out at h-10 / `--radius-md`. Overriding both from a <div className="flex flex-wrap items-center gap-3">
className is the class fight components/AGENTS.md §3.6 warns about. */} {/* Kept hand-rolled rather than moved onto `SearchField`: the command
<div className="flex min-h-11 min-w-[240px] flex-1 items-center gap-2.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-focus-ring)]"> bar is a 44px field on the `--radius-lg` step, and the shared
<Search className="h-[15px] w-[15px] flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.6} aria-hidden="true" /> component tops out at h-10 / `--radius-md`. Overriding both from a
<input className is the class fight components/AGENTS.md §3.6 warns about. */}
data-testid="market-search-input" <div className="flex min-h-11 min-w-[240px] flex-1 items-center gap-2.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-focus-ring)]">
value={query} <Search className="h-[15px] w-[15px] flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.6} aria-hidden="true" />
onChange={(event) => setQuery(event.target.value)} <input
placeholder={t('market.searchPlaceholder')} data-testid="market-search-input"
aria-label={t('market.searchPlaceholder')} value={draft}
className="min-w-0 flex-1 bg-transparent text-[14.5px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" onChange={(event) => {
/> setDraft(event.target.value)
{query && ( if (!composingRef.current) setQuery(event.target.value)
<IconButton }}
icon={<X className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />} onCompositionStart={() => {
label={t('market.clearSearch')} composingRef.current = true
size="sm" }}
tone="muted" onCompositionEnd={(event) => {
onClick={() => setQuery('')} composingRef.current = false
setQuery(event.currentTarget.value)
}}
onKeyDown={handleSearchKeyDown}
enterKeyHint="search"
placeholder={searchLabel}
aria-label={searchLabel}
className="min-w-0 flex-1 bg-transparent text-[14.5px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/> />
)} {draft && (
<IconButton
icon={<X className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
label={t('market.clearSearch')}
size="sm"
tone="muted"
onClick={() => {
composingRef.current = false
setDraft('')
setQuery('')
}}
/>
)}
</div>
<FilterBar />
</div> </div>
<FilterBar /> </div>
</div>
<div className="mx-auto flex w-full max-w-[1280px] flex-1 flex-col px-6 pb-10 pt-5 lg:px-10">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-x-3 gap-y-2">
<p
data-testid="market-result-summary"
aria-live="polite"
className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1 text-sm font-semibold tabular-nums text-[var(--color-text-primary)]"
>
{/* No count while the first page is in flight: "0 results" would be a claim. */}
{!isLoading && !error && (
<span>
{catalog
? total !== null
? t('market.catalog.summary', { count: String(total) })
: t('market.resultCount', { count: String(items.length) })
: t('market.catalog.liveResults', { count: String(items.length) })}
</span>
)}
{catalogUpdated && (
<span className="text-xs font-normal text-[var(--color-text-tertiary)]">
{t('market.catalog.updated', { date: catalogUpdated })}
</span>
)}
{!catalog && (
<Badge tone="warning" size="sm" pill={false} data-testid="market-not-curated" className="self-center">
{t('market.scope.notCurated')}
</Badge>
)}
</p>
{/* Source health only describes live reads; the catalog is a shipped snapshot. */}
{!catalog && <SourceStatusBar sources={sources} />}
</div> </div>
{featured} {hasQuery && (
<p
{!isLoading && items.length > 0 && ( data-testid="market-scope-hint"
<p className="mb-4 mt-5 text-sm tabular-nums text-[var(--color-text-secondary)]"> className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-[var(--color-text-secondary)]"
{t('market.resultCount', { count: String(items.length) })} >
<span>{catalog ? t('market.scope.catalogHint') : t('market.scope.marketHint')}</span>
<Button
variant="link"
size="sm"
data-testid="market-scope-switch"
onClick={catalog ? searchAllMarkets : backToCatalog}
>
{catalog ? searchMarketLabel : t('market.scope.backToCatalog')}
</Button>
</p> </p>
)} )}
{featured}
{isLoading && ( {isLoading && (
<MarketGridSkeleton <MarketGridSkeleton
ref={measureRef} ref={measureRef}
label={t('market.loading')} label={t('market.loading')}
count={skeletonCount} count={skeletonCount}
testId="market-loading" testId="market-loading"
className="mt-5" className="mt-4"
/> />
)} )}
@@ -200,7 +308,7 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (
<div <div
role="alert" role="alert"
data-testid="market-error" data-testid="market-error"
className="mt-5 flex flex-col items-center gap-3 rounded-[var(--radius-xl)] border border-dashed border-[var(--color-error-soft-hover)] bg-[var(--color-error-soft)] px-6 py-14 text-center" className="mt-4 flex flex-col items-center gap-3 rounded-[var(--radius-xl)] border border-dashed border-[var(--color-error-soft-hover)] bg-[var(--color-error-soft)] px-6 py-14 text-center"
> >
<CloudOff className="h-8 w-8 text-[var(--color-error)]" strokeWidth={1.7} aria-hidden="true" /> <CloudOff className="h-8 w-8 text-[var(--color-error)]" strokeWidth={1.7} aria-hidden="true" />
<p className="text-sm font-medium text-[var(--color-text-primary)]">{t('market.error.list')}</p> <p className="text-sm font-medium text-[var(--color-text-primary)]">{t('market.error.list')}</p>
@@ -217,7 +325,7 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (
)} )}
{!isLoading && !error && items.length === 0 && ( {!isLoading && !error && items.length === 0 && (
<div data-testid="market-empty" className="mt-5"> <div data-testid="market-empty" className="mt-4">
<EmptyState <EmptyState
size="lg" size="lg"
icon={ icon={
@@ -227,11 +335,7 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (
} }
title={hasQuery || hasActiveFilters ? t('market.emptySearch') : t('market.empty')} title={hasQuery || hasActiveFilters ? t('market.emptySearch') : t('market.empty')}
description={hasQuery || hasActiveFilters ? t('market.emptySearchHint') : t('market.emptyHint')} description={hasQuery || hasActiveFilters ? t('market.emptySearchHint') : t('market.emptyHint')}
action={ action={emptyAction}
hasQuery
? { label: t('market.clearSearch'), onClick: () => setQuery('') }
: { label: t('market.retry'), onClick: () => void fetchList({ reset: true }) }
}
/> />
</div> </div>
)} )}
@@ -240,7 +344,7 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (
<> <>
<div <div
ref={measureRef} ref={measureRef}
className="grid" className="mt-4 grid"
style={CATALOG_GRID_STYLE} style={CATALOG_GRID_STYLE}
data-testid="market-grid" data-testid="market-grid"
> >
@@ -248,8 +352,8 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (
<SkillCard <SkillCard
key={skill.id} key={skill.id}
skill={skill} skill={skill}
onOpen={(id) => void useMarketStore.getState().openDetail(id)} onOpen={(id) => void useMarketStore.getState().openDetail(id, marketOwnerOf(skill))}
onInstall={onRequestInstall} onInstall={(id) => onRequestInstall(id, marketOwnerOf(skill))}
installing={installingIds.has(skill.id)} installing={installingIds.has(skill.id)}
/> />
))} ))}
@@ -1,20 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* The view is the shared layout and has its own suite; here it only lays the
* slots out flat, so each test reads what the market page put into them.
*/
vi.mock('./SkillDetailView', () => ({ vi.mock('./SkillDetailView', () => ({
SkillDetailView: ({ meta, actions, banner, onBack, backLabel }: { SkillDetailView: (props: {
meta: Array<{ label: string; value: string }> meta: Array<{ label: string; value: React.ReactNode }>
actions?: React.ReactNode actions?: React.ReactNode
banner?: React.ReactNode banner?: React.ReactNode
metaLine?: React.ReactNode
chips?: React.ReactNode
summary?: string
stats?: Array<{ label: string; value: string }>
extraTabs?: Array<{ key: string; label: string; badge?: React.ReactNode; content: React.ReactNode }>
activeTab?: string
onTabChange?: (tab: string) => void
overviewLead?: React.ReactNode
docHeader?: React.ReactNode
sideCards?: React.ReactNode
actionsPlacement?: string
onBack: () => void onBack: () => void
backLabel: string backLabel: string
}) => ( }) => (
<div data-testid="detail-view"> <div data-testid="detail-view" data-active-tab={props.activeTab} data-actions={props.actionsPlacement}>
{meta.map((item) => <span key={item.label}>{`${item.label}=${item.value}`}</span>)} {props.meta.map((item) => (
{actions} <span key={item.label}>
{banner} {item.label}={item.value}
<button type="button" data-testid="detail-back" onClick={onBack}>{backLabel}</button> </span>
))}
<div data-testid="slot-summary">{props.summary}</div>
<div data-testid="slot-meta-line">{props.metaLine}</div>
<div data-testid="slot-chips">{props.chips}</div>
<div data-testid="slot-stats">
{props.stats?.map((stat) => <span key={stat.label}>{`${stat.label}=${stat.value}`}</span>)}
</div>
<div data-testid="slot-tabs">
{props.extraTabs?.map((tab) => (
<button key={tab.key} type="button" data-testid={`slot-tab-${tab.key}`} onClick={() => props.onTabChange?.(tab.key)}>
{tab.label}
{tab.badge}
</button>
))}
</div>
<div data-testid="slot-tab-content">
{props.extraTabs?.find((tab) => tab.key === props.activeTab)?.content}
</div>
<div data-testid="slot-overview-lead">{props.overviewLead}</div>
<div data-testid="slot-doc-header">{props.docHeader}</div>
<div data-testid="slot-side">{props.sideCards}</div>
{props.actions}
{props.banner}
<button type="button" data-testid="detail-back" onClick={props.onBack}>{props.backLabel}</button>
</div> </div>
), ),
})) }))
@@ -34,6 +73,7 @@ function setStore(partial: Record<string, unknown>) {
detailError: null, detailError: null,
installingIds: new Set<string>(), installingIds: new Set<string>(),
installError: null, installError: null,
categories: [{ key: 'agent', name: '智能体增强', nameEn: 'Agent boost', count: 30 }],
backToList, backToList,
refreshDetail, refreshDetail,
fetchFileContent: vi.fn(), fetchFileContent: vi.fn(),
@@ -49,11 +89,39 @@ function makeDetail(overrides: Record<string, unknown> = {}) {
stats: { downloads: 12_500 }, stats: { downloads: 12_500 },
files: [], files: [],
source: 'clawhub', source: 'clawhub',
slug: 'weather',
summary: 'Forecasts',
tags: [],
description: '# Weather',
totalSize: 0,
securityStatus: 'benign',
installState: 'installable', installState: 'installable',
...overrides, ...overrides,
} }
} }
const SELF_IMPROVING = {
securityStatus: 'flagged',
securityNote: 'Hook is opt-in; reviewed by the curator.',
securityReports: [
{ vendor: 'VirusTotal', status: 'clean', statusText: 'clean', reportUrl: 'https://www.virustotal.com/x' },
{ vendor: 'LLM review', status: 'suspicious', statusText: 'suspicious', summary: 'Reads transcripts.', reportUrl: 'javascript:alert(1)' },
],
description: '```bash\nmkdir -p .learnings\n```\nCopy the hook to ~/.openclaw/hooks.',
descriptionFrontmatter: {
description: 'Captures learnings. Use when: (1) A command fails unexpectedly, (2) User corrects Claude',
},
files: [
{ path: 'SKILL.md', size: 21_000, language: 'markdown', tooBig: false },
{ path: 'scripts/extract-skill.sh', size: 400, language: 'bash', tooBig: false },
{ path: 'hooks/openclaw/handler.js', size: 300, language: 'javascript', tooBig: false },
],
stats: { downloads: 482_069, installs: 18_500, stars: 4_012 },
license: 'MIT-0',
updatedAt: Date.UTC(2026, 7, 6),
pageUrl: 'https://clawhub.ai/pskoett/self-improving-agent',
}
describe('MarketSkillDetail', () => { describe('MarketSkillDetail', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -86,13 +154,22 @@ describe('MarketSkillDetail', () => {
expect(screen.getByTestId('detail-view')).toHaveTextContent('12.5k') expect(screen.getByTestId('detail-view')).toHaveTextContent('12.5k')
}) })
it('requests install for the selected skill', () => { it('requests install for the selected skill, pinned to the owner it was opened with', () => {
const onRequestInstall = vi.fn() const onRequestInstall = vi.fn()
setStore({ detail: makeDetail() }) setStore({ detail: makeDetail(), selectedOwner: 'ada' })
render(<MarketSkillDetail onRequestInstall={onRequestInstall} onRequestUninstall={vi.fn()} />) render(<MarketSkillDetail onRequestInstall={onRequestInstall} onRequestUninstall={vi.fn()} />)
fireEvent.click(screen.getByTestId('market-install-button')) fireEvent.click(screen.getByTestId('market-install-button'))
expect(onRequestInstall).toHaveBeenCalledWith('skill-1') expect(onRequestInstall).toHaveBeenCalledWith('skill-1', 'ada')
})
it('requests an unpinned install when the detail has no owner', () => {
const onRequestInstall = vi.fn()
setStore({ detail: makeDetail({ source: 'skillhub' }), selectedOwner: null })
render(<MarketSkillDetail onRequestInstall={onRequestInstall} onRequestUninstall={vi.fn()} />)
fireEvent.click(screen.getByTestId('market-install-button'))
expect(onRequestInstall).toHaveBeenCalledWith('skill-1', undefined)
}) })
it('offers uninstall instead once installed', () => { it('offers uninstall instead once installed', () => {
@@ -121,3 +198,140 @@ describe('MarketSkillDetail', () => {
expect(backToList).toHaveBeenCalled() expect(backToList).toHaveBeenCalled()
}) })
}) })
describe('MarketSkillDetail catalog redesign', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
})
it('fills the hero: meta line, chips with the resolved category, and the stats strip', () => {
setStore({
detail: makeDetail({ ...SELF_IMPROVING, featured: true, curated: true, category: 'agent', tags: ['自我改进'] }),
})
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
// Separators are their own nodes, spaced by the flex gap.
expect(screen.getByTestId('slot-meta-line')).toHaveTextContent(/^by Ada·ClawHub·MIT-0·Updated 2026-08-06$/)
const chips = screen.getByTestId('slot-chips')
expect(within(chips).getByTestId('security-badge-flagged')).toBeInTheDocument()
expect(within(chips).getByTestId('market-detail-featured')).toHaveTextContent('Featured')
expect(chips).toHaveTextContent('Agent boost')
// Curated tags are Chinese; English readers do not get them.
expect(chips).not.toHaveTextContent('自我改进')
expect(screen.getByTestId('slot-stats')).toHaveTextContent('Downloads=482.1k')
expect(screen.getByTestId('slot-stats')).toHaveTextContent('Installs=18.5k')
expect(screen.getByTestId('slot-stats')).toHaveTextContent('Stars=4.0k')
expect(screen.getByTestId('slot-stats')).toHaveTextContent('Files=3')
expect(screen.getByTestId('detail-view')).toHaveAttribute('data-actions', 'hero')
})
it('reads the summary in the reader\'s language', () => {
const curated = { curated: true, summary: '把失败与纠正记录到本地', summaryEn: 'Log failures and corrections' }
setStore({ detail: makeDetail(curated) })
const { unmount } = render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.getByTestId('slot-summary')).toHaveTextContent('Log failures and corrections')
unmount()
useSettingsStore.setState({ locale: 'zh' })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.getByTestId('slot-summary')).toHaveTextContent('把失败与纠正记录到本地')
})
it('marks the security tab of a flagged skill and lists every scanner with safe links only', () => {
setStore({ detail: makeDetail(SELF_IMPROVING) })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
const securityTab = screen.getByTestId('slot-tab-security')
expect(within(securityTab).getByTestId('market-detail-flagged-dot')).toHaveTextContent('Flagged by the security scan')
fireEvent.click(securityTab)
const panel = screen.getByTestId('slot-tab-content')
expect(within(panel).getAllByTestId('market-security-report')).toHaveLength(2)
expect(within(panel).getByText('Reads transcripts.')).toBeInTheDocument()
// The `javascript:` report URL from upstream must not become a link.
const links = within(panel).getAllByRole('link', { name: /View report/ })
expect(links).toHaveLength(1)
expect(links[0]).toHaveAttribute('href', 'https://www.virustotal.com/x')
expect(within(panel).getByTestId('market-security-note')).toHaveTextContent('Hook is opt-in')
})
it('puts no warning dot on a skill that scanned clean', () => {
setStore({ detail: makeDetail() })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.queryByTestId('market-detail-flagged-dot')).not.toBeInTheDocument()
})
it('leads the overview with the capability panel, whose report link opens the security tab', () => {
setStore({ detail: makeDetail(SELF_IMPROVING) })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
const lead = screen.getByTestId('slot-overview-lead')
expect(within(lead).getByText('Before installing: what this skill does')).toBeInTheDocument()
expect(within(lead).getByTestId('market-capability-shell')).toHaveTextContent('scripts/extract-skill.sh')
expect(within(lead).getByTestId('market-capability-hooks')).toBeInTheDocument()
expect(screen.getByTestId('detail-view')).toHaveAttribute('data-active-tab', 'overview')
fireEvent.click(within(lead).getByTestId('market-capability-view-report'))
expect(screen.getByTestId('detail-view')).toHaveAttribute('data-active-tab', 'security')
})
it('renders no capability panel when no rule fires', () => {
setStore({ detail: makeDetail({ description: '# Writing guide\nBe concise.' }) })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.queryByTestId('market-capability-panel')).not.toBeInTheDocument()
})
it('lists when the skill triggers in the side rail', () => {
setStore({ detail: makeDetail(SELF_IMPROVING) })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
const side = screen.getByTestId('slot-side')
expect(within(side).getByText('When it triggers')).toBeInTheDocument()
expect(within(side).getByText('A command fails unexpectedly')).toBeInTheDocument()
expect(within(side).getByText('User corrects Claude')).toBeInTheDocument()
})
it('shows the SKILL.md size and reading time above the document', () => {
setStore({ detail: makeDetail(SELF_IMPROVING) })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.getByTestId('slot-doc-header')).toHaveTextContent('SKILL.md')
expect(screen.getByTestId('slot-doc-header')).toHaveTextContent('min read')
})
it('adds a changelog tab only when upstream sent a release note', () => {
setStore({ detail: makeDetail() })
const { unmount } = render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.queryByTestId('slot-tab-changelog')).not.toBeInTheDocument()
unmount()
setStore({
detail: makeDetail({
version: '4.0.2',
changelog: { version: '4.0.2', text: 'Opt-in hook', publishedAt: Date.UTC(2026, 7, 6) },
pageUrl: 'https://clawhub.ai/x',
}),
})
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
fireEvent.click(screen.getByTestId('slot-tab-changelog'))
const panel = within(screen.getByTestId('slot-tab-content')).getByTestId('market-changelog-panel')
expect(panel).toHaveTextContent('v4.0.2')
expect(panel).toHaveTextContent('2026-08-06')
expect(panel).toHaveTextContent('Opt-in hook')
expect(within(panel).getByRole('link', { name: /Source page/ })).toHaveAttribute('href', 'https://clawhub.ai/x')
})
it('links the registry page beside the install action', () => {
setStore({ detail: makeDetail(SELF_IMPROVING) })
render(<MarketSkillDetail onRequestInstall={vi.fn()} onRequestUninstall={vi.fn()} />)
expect(screen.getByTestId('market-source-page-link')).toHaveAttribute(
'href',
'https://clawhub.ai/pskoett/self-improving-agent',
)
})
})
@@ -1,37 +1,52 @@
import { useCallback, useMemo } from 'react' import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { ArrowLeft, CircleAlert, Download, KeyRound, RefreshCw, Trash2 } from 'lucide-react' import {
ArrowLeft,
CircleAlert,
Download,
ExternalLink,
History,
KeyRound,
RefreshCw,
ShieldCheck,
Trash2,
} from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Badge, StatusDot } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton' import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton'
import { formatBytes } from '../../lib/formatBytes'
import { readingMinutes } from '../../lib/skillInsights'
import { useMarketStore } from '../../stores/marketStore' import { useMarketStore } from '../../stores/marketStore'
import { SkillDetailView, type SkillDetailMetaItem } from './SkillDetailView' import { CapabilityPanel, useSkillInsights } from './CapabilityPanel'
import { ChangelogPanel } from './ChangelogPanel'
import { resolveCategory, skillSummary, useMarketLocale, visibleTags } from './catalogLocale'
import { formatCount, formatIsoDate, safeUrl } from './marketFormat'
import { SecurityBadge } from './SecurityBadge'
import { SecurityReportPanel } from './SecurityReportPanel'
import {
SkillDetailView,
type SkillDetailMetaItem,
type SkillDetailStat,
type SkillDetailTab,
} from './SkillDetailView'
function formatCount(value?: number): string { /** Tags beside the category in the hero; the card shows three, the hero has less room to spare. */
if (value === undefined) return '—' const HERO_TAGS = 2
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({ export function MarketSkillDetail({
onRequestInstall, onRequestInstall,
onRequestUninstall, onRequestUninstall,
}: { }: {
onRequestInstall: (id: string) => void onRequestInstall: (id: string, owner?: string) => void
onRequestUninstall: (id: string) => void onRequestUninstall: (id: string) => void
}) { }) {
const t = useTranslation() const t = useTranslation()
const locale = useMarketLocale()
const selectedId = useMarketStore((s) => s.selectedId) const selectedId = useMarketStore((s) => s.selectedId)
const selectedOwner = useMarketStore((s) => s.selectedOwner)
const detail = useMarketStore((s) => s.detail) const detail = useMarketStore((s) => s.detail)
const categories = useMarketStore((s) => s.categories)
const isDetailLoading = useMarketStore((s) => s.isDetailLoading) const isDetailLoading = useMarketStore((s) => s.isDetailLoading)
const detailError = useMarketStore((s) => s.detailError) const detailError = useMarketStore((s) => s.detailError)
const installingIds = useMarketStore((s) => s.installingIds) const installingIds = useMarketStore((s) => s.installingIds)
@@ -39,33 +54,35 @@ export function MarketSkillDetail({
const backToList = useMarketStore((s) => s.backToList) const backToList = useMarketStore((s) => s.backToList)
const refreshDetail = useMarketStore((s) => s.refreshDetail) const refreshDetail = useMarketStore((s) => s.refreshDetail)
const fetchFileContent = useMarketStore((s) => s.fetchFileContent) const fetchFileContent = useMarketStore((s) => s.fetchFileContent)
const { capabilities, triggers } = useSkillInsights(detail)
// Tab state is owned here, not by the view: the capability panel's "full
// report" link switches tabs from inside the overview.
const [tab, setTab] = useState('overview')
useEffect(() => setTab('overview'), [selectedId])
const loadFile = useCallback( const loadFile = useCallback(
(path: string) => { (path: string) => {
if (!selectedId) return Promise.reject(new Error('No skill selected')) if (!selectedId) return Promise.reject(new Error('No skill selected'))
return fetchFileContent(selectedId, path) return fetchFileContent(selectedId, path, selectedOwner ?? undefined)
}, },
[selectedId, fetchFileContent], [selectedId, selectedOwner, fetchFileContent],
) )
const categoryName = detail ? resolveCategory(detail, categories, locale) : undefined
const updated = formatIsoDate(detail?.updatedAt)
const author = detail ? detail.author.displayName || detail.author.handle : ''
const meta = useMemo<SkillDetailMetaItem[]>(() => { const meta = useMemo<SkillDetailMetaItem[]>(() => {
if (!detail) return [] if (!detail) return []
const items: SkillDetailMetaItem[] = [ const items: SkillDetailMetaItem[] = [
{ { label: t('market.detail.author'), value: author || '—' },
label: t('market.detail.author'), { label: t('market.filter.source'), value: t(`market.source.${detail.source}`) },
value: detail.author.displayName || detail.author.handle || '—',
},
{ label: t('market.detail.downloads'), value: formatCount(detail.stats.downloads) },
] ]
if (detail.stats.installs !== undefined) { if (categoryName) items.push({ label: t('market.detail.category'), value: categoryName })
items.push({ label: t('market.detail.installs'), value: formatCount(detail.stats.installs) }) if (detail.version) items.push({ label: t('market.detail.version'), value: `v${detail.version}` })
}
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.license) items.push({ label: t('market.detail.license'), value: detail.license })
items.push({ label: t('market.detail.updated'), value: updated || '—' })
if (detail.requiresApiKey) { if (detail.requiresApiKey) {
items.push({ items.push({
label: t('market.detail.requiresApiKey'), label: t('market.detail.requiresApiKey'),
@@ -73,7 +90,7 @@ export function MarketSkillDetail({
}) })
} }
return items return items
}, [detail, t]) }, [detail, t, author, categoryName, updated])
if (!selectedId) return null if (!selectedId) return null
@@ -91,16 +108,17 @@ export function MarketSkillDetail({
</Button> </Button>
<SkeletonGroup label={t('market.loading')} className="mt-[18px]"> <SkeletonGroup label={t('market.loading')} className="mt-[18px]">
<div className="flex items-start gap-6 pb-6"> <div className="flex items-start gap-6 pb-6">
<Skeleton shape="block" width="92px" height="92px" radius="lg" tone="strong" className="flex-shrink-0" /> <Skeleton shape="block" width="72px" height="72px" radius="lg" tone="strong" className="flex-shrink-0" />
<div className="min-w-0 flex-1 pt-1"> <div className="min-w-0 flex-1 pt-1">
<Skeleton height="0.75rem" className="w-32" /> <Skeleton height="2rem" tone="strong" className="w-64 max-w-full" />
<Skeleton height="2rem" tone="strong" className="mt-3 w-64 max-w-full" /> <Skeleton height="0.75rem" className="mt-3 w-48" />
<Skeleton height="0.75rem" className="mt-4 w-[min(100%,36rem)]" /> <Skeleton height="0.75rem" className="mt-4 w-[min(100%,36rem)]" />
</div> </div>
</div> </div>
<div className="h-[74px] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]" />
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px] lg:gap-9"> <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px] lg:gap-9">
<div> <div>
<Skeleton height="2.75rem" className="w-52" /> <Skeleton height="2.75rem" className="w-72" />
<div className="mt-[22px] h-72 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]" /> <div className="mt-[22px] h-72 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]" />
</div> </div>
<div className="order-first h-72 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] lg:order-none" /> <div className="order-first h-72 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] lg:order-none" />
@@ -134,14 +152,106 @@ export function MarketSkillDetail({
} }
const installing = installingIds.has(detail.id) const installing = installingIds.has(detail.id)
const pageUrl = safeUrl(detail.pageUrl)
const flagged = detail.securityStatus === 'flagged'
const skillMd = detail.files.find((file) => file.path === 'SKILL.md')
const mirrorSource = detail.mirrors?.length const mirrorSource = detail.mirrors?.length
? detail.mirrors[0]!.split(':')[0] ? detail.mirrors[0]!.split(':')[0]
: detail.upstream : detail.upstream
? detail.upstream.source ? detail.upstream.source
: null : null
const metaFacts: ReactNode[] = [
author ? <span key="author">{t('market.detail.by', { author })}</span> : null,
<span key="source">{t(`market.source.${detail.source}`)}</span>,
detail.license ? <span key="license">{detail.license}</span> : null,
updated ? <span key="updated">{t('market.detail.updatedOn', { date: updated })}</span> : null,
].filter(Boolean)
const metaLine = metaFacts.map((fact, index) => (
<Fragment key={index}>
{index > 0 && <span aria-hidden="true">·</span>}
{fact}
</Fragment>
))
const chips = (
<>
<SecurityBadge status={detail.securityStatus} />
{detail.featured && (
<Badge tone="info" size="md" pill={false} data-testid="market-detail-featured">
{t('market.featured')}
</Badge>
)}
{categoryName && (
<Badge size="md" pill={false}>
{categoryName}
</Badge>
)}
{visibleTags(detail, locale).slice(0, HERO_TAGS).map((tag) => (
<Badge key={tag} size="md" pill={false}>
{tag}
</Badge>
))}
</>
)
const stats: SkillDetailStat[] = [
{ label: t('market.detail.downloads'), value: formatCount(detail.stats.downloads) },
...(detail.stats.installs === undefined
? []
: [{ label: t('market.detail.installs'), value: formatCount(detail.stats.installs) }]),
...(detail.stats.stars === undefined
? []
: [{ label: t('market.detail.stars'), value: formatCount(detail.stats.stars) }]),
{ label: t('market.detail.files'), value: String(detail.files.length) },
]
const extraTabs: SkillDetailTab[] = [
{
key: 'security',
label: t('market.detail.security'),
icon: ShieldCheck,
badge: flagged ? (
<span data-testid="market-detail-flagged-dot" className="inline-flex items-center">
<StatusDot tone="warning" size="md" />
<span className="sr-only">{t('market.detail.flaggedDot')}</span>
</span>
) : undefined,
content: (
<SecurityReportPanel
capabilities={capabilities}
reports={detail.securityReports ?? []}
securityNote={detail.securityNote}
/>
),
},
// A tab that can only say "nothing here" is not worth the slot.
...(detail.changelog
? [
{
key: 'changelog',
label: t('market.detail.changelog'),
icon: History,
content: <ChangelogPanel changelog={detail.changelog} version={detail.version} pageUrl={pageUrl} />,
},
]
: []),
]
const actions = ( const actions = (
<> <>
{pageUrl && (
<a
href={pageUrl}
target="_blank"
rel="noreferrer noopener"
data-testid="market-source-page-link"
className="inline-flex items-center gap-1.5 rounded-[var(--radius-sm)] px-1 text-sm font-medium text-[var(--color-text-secondary)] underline-offset-2 transition-colors hover:text-[var(--color-text-primary)] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t('market.detail.sourcePage')}
<ExternalLink className="h-3.5 w-3.5" strokeWidth={1.8} aria-hidden="true" />
</a>
)}
{detail.installState === 'installable' && ( {detail.installState === 'installable' && (
<Button <Button
size="lg" size="lg"
@@ -149,7 +259,7 @@ export function MarketSkillDetail({
data-market-skill-action-id={detail.id} data-market-skill-action-id={detail.id}
loading={installing} loading={installing}
icon={<Download className="h-4 w-4" strokeWidth={2} aria-hidden="true" />} icon={<Download className="h-4 w-4" strokeWidth={2} aria-hidden="true" />}
onClick={() => onRequestInstall(detail.id)} onClick={() => onRequestInstall(detail.id, selectedOwner ?? undefined)}
> >
{installing ? t('market.install.installing') : t('market.install.action')} {installing ? t('market.install.installing') : t('market.install.action')}
</Button> </Button>
@@ -193,20 +303,54 @@ export function MarketSkillDetail({
</> </>
) )
const sideCards = triggers.length > 0 && (
<Card radius="xl" surface="base" padding="none" className="px-[18px] py-4" data-testid="market-detail-triggers">
<h2 className="text-sm font-semibold text-[var(--color-text-primary)]">{t('market.detail.whenToUse')}</h2>
<ol className="mt-3 flex flex-col gap-2.5">
{triggers.map((trigger, index) => (
<li key={trigger} className="flex items-start gap-2.5 text-[13px] leading-5 text-[var(--color-text-secondary)]">
<Badge size="xs" pill={false} mono className="mt-px">
{index + 1}
</Badge>
<span className="min-w-0 break-words">{trigger}</span>
</li>
))}
</ol>
</Card>
)
return ( return (
<SkillDetailView <SkillDetailView
name={detail.name} name={detail.name}
version={detail.version} version={detail.version}
iconUrl={detail.iconUrl} iconUrl={detail.iconUrl}
sourceLabel={t(`market.source.${detail.source}`)} sourceLabel={t(`market.source.${detail.source}`)}
summary={detail.summary} summary={skillSummary(detail, locale)}
securityStatus={detail.securityStatus} securityStatus={detail.securityStatus}
securityReports={detail.securityReports}
installState={detail.installState} installState={detail.installState}
notInstallableReason={detail.notInstallableReason} notInstallableReason={detail.notInstallableReason}
actions={actions} actions={actions}
actionsPlacement="hero"
banner={banner} banner={banner}
meta={meta} meta={meta}
metaTitle={t('market.detail.info')}
metaLine={metaLine}
chips={chips}
stats={stats}
extraTabs={extraTabs}
activeTab={tab}
onTabChange={setTab}
overviewLead={<CapabilityPanel capabilities={capabilities} onViewReport={() => setTab('security')} />}
docHeader={
<>
<span className="font-mono">SKILL.md</span>
<span>
{skillMd ? `${formatBytes(skillMd.size)} · ` : ''}
{t('market.detail.readingTime', { minutes: String(readingMinutes(detail.description)) })}
</span>
</>
}
sideCards={sideCards || undefined}
description={detail.description} description={detail.description}
descriptionFrontmatter={detail.descriptionFrontmatter} descriptionFrontmatter={detail.descriptionFrontmatter}
files={detail.files.map((f) => ({ path: f.path, size: f.size, language: f.language, tooBig: f.tooBig }))} files={detail.files.map((f) => ({ path: f.path, size: f.size, language: f.language, tooBig: f.tooBig }))}
@@ -28,7 +28,20 @@ const ICONS: Record<SecurityStatus, LucideIcon> = {
flagged: ShieldAlert, flagged: ShieldAlert,
} }
export function SecurityBadge({ status, className = '' }: { status: SecurityStatus; className?: string }) { /**
* `short` is the card chip: one or two words in the chip row's size, where the
* full label would push the featured mark and tags off the line. The tooltip
* still carries the full explanation either way.
*/
export function SecurityBadge({
status,
short = false,
className = '',
}: {
status: SecurityStatus
short?: boolean
className?: string
}) {
const t = useTranslation() const t = useTranslation()
const Icon = ICONS[status] const Icon = ICONS[status]
return ( return (
@@ -36,13 +49,13 @@ export function SecurityBadge({ status, className = '' }: { status: SecurityStat
data-testid={`security-badge-${status}`} data-testid={`security-badge-${status}`}
title={t(`market.securityHint.${status}`)} title={t(`market.securityHint.${status}`)}
tone={TONES[status]} tone={TONES[status]}
size="md" size={short ? 'sm' : 'md'}
pill={false} pill={false}
bordered bordered={!short}
className={className} className={className}
icon={<Icon className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />} icon={<Icon className={short ? 'h-3 w-3' : 'h-3.5 w-3.5'} strokeWidth={2} aria-hidden="true" />}
> >
{t(`market.security.${status}`)} {short ? t(`market.securityShort.${status}`) : t(`market.security.${status}`)}
</Badge> </Badge>
) )
} }
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { useSettingsStore } from '../../stores/settingsStore'
import type { Capability } from '../../lib/skillInsights'
import { SecurityReportPanel } from './SecurityReportPanel'
const SHELL: Capability = { kind: 'shell', level: 'high', evidence: ['scripts/run.sh'] }
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
describe('SecurityReportPanel', () => {
it('lists each scanner with its verdict, explanation and report link', () => {
render(
<SecurityReportPanel
capabilities={[]}
reports={[
{ vendor: 'VirusTotal', status: 'clean', statusText: 'clean', reportUrl: 'https://www.virustotal.com/r' },
{ vendor: 'LLM review', status: 'suspicious', statusText: 'suspicious', summary: 'Reads transcripts.' },
]}
/>,
)
const reports = screen.getAllByTestId('market-security-report')
expect(reports).toHaveLength(2)
expect(within(reports[0]!).getByRole('link', { name: /View report/ })).toHaveAttribute(
'href',
'https://www.virustotal.com/r',
)
expect(within(reports[1]!).getByText('Reads transcripts.')).toBeInTheDocument()
expect(within(reports[1]!).queryByRole('link')).not.toBeInTheDocument()
})
it('never turns a non-http report URL into a link', () => {
render(
<SecurityReportPanel
capabilities={[]}
reports={[{ vendor: 'scan', status: 'suspicious', statusText: 'x', reportUrl: 'javascript:alert(1)' }]}
/>,
)
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('says so when upstream has no scan, and always carries the disclaimer', () => {
render(<SecurityReportPanel capabilities={[]} reports={[]} />)
expect(screen.getByText('Upstream has no security scan for this skill.')).toBeInTheDocument()
expect(screen.getByText(/does not vouch for them/)).toBeInTheDocument()
})
it('shows the capability list and the curator note when there are any', () => {
render(<SecurityReportPanel capabilities={[SHELL]} reports={[]} securityNote="Hook is opt-in." />)
expect(screen.getByTestId('market-capability-shell')).toHaveTextContent('scripts/run.sh')
// Already inside the report: no "full report" link pointing at itself.
expect(screen.queryByTestId('market-capability-view-report')).not.toBeInTheDocument()
expect(screen.getByTestId('market-security-note')).toHaveTextContent('Curator note')
expect(screen.getByTestId('market-security-note')).toHaveTextContent('Hook is opt-in.')
})
})
@@ -0,0 +1,98 @@
import { ExternalLink, Info } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Badge } from '@/components/ui/Badge'
import { Card } from '@/components/ui/Card'
import type { Capability } from '../../lib/skillInsights'
import type { SecurityReport } from '../../types/market'
import { CapabilityPanel } from './CapabilityPanel'
import { safeUrl } from './marketFormat'
/** Scanner verdicts that read as a pass; everything else is worth a second look. */
const CLEAN_STATUS = /^(clean|benign|safe|pass)/i
/**
* The security tab: what the skill will do (rule-based, from its own files),
* then each upstream scanner's verdict with its own explanation, then — for a
* curated skill shipped despite a flag — the curator's reason.
*
* Report links come from upstream, so only absolute http(s) URLs become an
* `href`; anything else is dropped rather than rendered as a dead or hostile link.
*/
export function SecurityReportPanel({
capabilities,
reports,
securityNote,
}: {
capabilities: readonly Capability[]
reports: readonly SecurityReport[]
securityNote?: string
}) {
const t = useTranslation()
return (
<div className="flex flex-col gap-4" data-testid="market-security-panel">
<CapabilityPanel capabilities={capabilities} />
{securityNote && (
<div
role="note"
data-testid="market-security-note"
className="flex items-start gap-2.5 rounded-[var(--radius-lg)] border border-[var(--color-info)] bg-[var(--color-info-container)] px-4 py-3 text-[13px] leading-5 text-[var(--color-on-info-container)]"
>
<Info className="mt-0.5 h-4 w-4 flex-shrink-0" strokeWidth={1.8} aria-hidden="true" />
<p className="min-w-0 break-words">
<span className="font-semibold">{t('market.detail.securityNote')}</span>
{' · '}
{securityNote}
</p>
</div>
)}
<Card radius="xl" surface="base" padding="none" className="px-6 py-5 sm:px-[30px]">
<h2 className="text-[15px] font-semibold text-[var(--color-text-primary)]">{t('market.detail.scanReports')}</h2>
{reports.length > 0 ? (
<ul className="mt-2 divide-y divide-[var(--color-border-separator)]">
{reports.map((report) => {
const url = safeUrl(report.reportUrl)
return (
<li
key={`${report.vendor}:${report.status}`}
data-testid="market-security-report"
className="flex flex-col gap-1.5 py-3.5"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{report.vendor}</span>
<Badge tone={CLEAN_STATUS.test(report.status) ? 'success' : 'warning'} size="sm" pill={false} wrap>
{report.statusText}
</Badge>
</div>
{report.summary && (
<p className="break-words text-[13px] leading-[1.6] text-[var(--color-text-secondary)]">
{report.summary}
</p>
)}
{url && (
<a
href={url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex w-fit items-center gap-1 rounded-[var(--radius-sm)] text-[13px] font-medium text-[var(--color-brand)] underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t('market.detail.viewReport')}
<ExternalLink className="h-3 w-3" strokeWidth={1.8} aria-hidden="true" />
</a>
)}
</li>
)
})}
</ul>
) : (
<p className="mt-3 text-sm text-[var(--color-text-tertiary)]">{t('market.detail.noReports')}</p>
)}
<p className="mt-3 border-t border-[var(--color-border-separator)] pt-3 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('market.detail.scanDisclaimer')}
</p>
</Card>
</div>
)
}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent, within } from '@testing-library/react'
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { SkillCard } from './SkillCard' import { SkillCard } from './SkillCard'
@@ -34,7 +34,7 @@ describe('SkillCard', () => {
expect(screen.getByText('Demo Skill')).toBeInTheDocument() expect(screen.getByText('Demo Skill')).toBeInTheDocument()
expect(screen.getByText('Does demo things')).toBeInTheDocument() expect(screen.getByText('Does demo things')).toBeInTheDocument()
expect(screen.getByText('ClawHub')).toBeInTheDocument() expect(screen.getByText('ClawHub')).toBeInTheDocument()
expect(screen.getByText('by Alice')).toBeInTheDocument() expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('12.3k')).toBeInTheDocument() expect(screen.getByText('12.3k')).toBeInTheDocument()
expect(screen.getByTestId('security-badge-benign')).toBeInTheDocument() expect(screen.getByTestId('security-badge-benign')).toBeInTheDocument()
expect(screen.getByTestId('install-badge-installable')).toBeInTheDocument() expect(screen.getByTestId('install-badge-installable')).toBeInTheDocument()
@@ -90,8 +90,7 @@ describe('SkillCard', () => {
it('flags risky skills visibly', () => { it('flags risky skills visibly', () => {
render(<SkillCard skill={makeSkill({ securityStatus: 'flagged' })} onOpen={vi.fn()} />) render(<SkillCard skill={makeSkill({ securityStatus: 'flagged' })} onOpen={vi.fn()} />)
expect(screen.getByTestId('security-badge-flagged')).toBeInTheDocument() expect(screen.getByTestId('security-badge-flagged')).toHaveTextContent('Flagged')
expect(screen.getByText('Flagged')).toBeInTheDocument()
}) })
it('hides the redundant installable badge when the quick-install button is shown', () => { it('hides the redundant installable badge when the quick-install button is shown', () => {
@@ -124,6 +123,49 @@ describe('SkillCard', () => {
expect(screen.queryByTestId('skill-avatar-fallback')).not.toBeInTheDocument() expect(screen.queryByTestId('skill-avatar-fallback')).not.toBeInTheDocument()
}) })
it('puts the short scan verdict, the featured mark and tags in one chip row', () => {
render(<SkillCard skill={makeSkill({ featured: true, tags: ['workflow'] })} onOpen={vi.fn()} />)
const chips = screen.getByTestId('market-card-chips')
expect(within(chips).getByTestId('security-badge-benign')).toHaveTextContent('Scan passed')
expect(within(chips).getByTestId('market-card-featured')).toHaveTextContent('Featured')
expect(within(chips).getByText('workflow')).toBeInTheDocument()
})
it('shows no featured mark on an ordinary skill', () => {
render(<SkillCard skill={makeSkill()} onOpen={vi.fn()} />)
expect(screen.queryByTestId('market-card-featured')).not.toBeInTheDocument()
})
it('reads a curated skill in English from summaryEn and drops its Chinese tags', () => {
const curated = makeSkill({
curated: true,
summary: '用文件持久化跟踪多步任务',
summaryEn: 'Track multi-step tasks in files',
tags: ['任务规划', '文件记忆'],
})
render(<SkillCard skill={curated} onOpen={vi.fn()} />)
expect(screen.getByText('Track multi-step tasks in files')).toBeInTheDocument()
expect(screen.queryByText('用文件持久化跟踪多步任务')).not.toBeInTheDocument()
expect(screen.queryByText('任务规划')).not.toBeInTheDocument()
})
it('reads a curated skill in Chinese with its Chinese summary and tags', () => {
useSettingsStore.setState({ locale: 'zh' })
const curated = makeSkill({
curated: true,
summary: '用文件持久化跟踪多步任务',
summaryEn: 'Track multi-step tasks in files',
tags: ['任务规划'],
})
render(<SkillCard skill={curated} onOpen={vi.fn()} />)
expect(screen.getByText('用文件持久化跟踪多步任务')).toBeInTheDocument()
expect(screen.getByText('任务规划')).toBeInTheDocument()
})
it('explains the security status via tooltip', () => { it('explains the security status via tooltip', () => {
render(<SkillCard skill={makeSkill({ securityStatus: 'unknown' })} onOpen={vi.fn()} />) render(<SkillCard skill={makeSkill({ securityStatus: 'unknown' })} onOpen={vi.fn()} />)
+80 -60
View File
@@ -1,20 +1,32 @@
import { Download, Star } from 'lucide-react' import { Download, Star } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card' import { Card } from '@/components/ui/Card'
import type { NormalizedSkill } from '../../types/market' import type { NormalizedSkill } from '../../types/market'
import { skillSummary, useMarketLocale, visibleTags } from './catalogLocale'
import { InstallStateBadge } from './InstallStateBadge' import { InstallStateBadge } from './InstallStateBadge'
import { formatCount } from './marketFormat'
import { SecurityBadge } from './SecurityBadge' import { SecurityBadge } from './SecurityBadge'
import { SkillAvatar } from './SkillAvatar' import { SkillAvatar } from './SkillAvatar'
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 const MAX_VISIBLE_TAGS = 3
/**
* One catalog card: identity (avatar, name, version pill, source · author), a
* two-line summary, a single chip row (scan verdict, featured mark, tags) and
* a footer with the counts and the install action.
*
* The structure is fixed top to bottom with the footer pinned by `mt-auto`,
* so a grid row lines up even when one card has no tags. The chip row is
* clipped to one line for the same reason.
*
* The open affordance is one stretched `<button>` under the content: it is
* focusable and keyboard-operable, and the card never pretends a div is a
* link. The content above it is `pointer-events-none`, and the install button
* opts back in — drop either half and the card stops opening or the button
* stops installing.
*/
export function SkillCard({ export function SkillCard({
skill, skill,
onOpen, onOpen,
@@ -27,18 +39,22 @@ export function SkillCard({
installing?: boolean installing?: boolean
}) { }) {
const t = useTranslation() const t = useTranslation()
const extraTags = Math.max(0, skill.tags.length - MAX_VISIBLE_TAGS) const locale = useMarketLocale()
const tags = visibleTags(skill, locale)
const extraTags = Math.max(0, tags.length - MAX_VISIBLE_TAGS)
const showInstallButton = Boolean(onInstall) && skill.installState === 'installable' const showInstallButton = Boolean(onInstall) && skill.installState === 'installable'
const author = skill.author.displayName || skill.author.handle
const summary = skillSummary(skill, locale)
return ( return (
<Card <Card
as="article" as="article"
radius="xl" radius="xl"
surface="base" surface="base"
padding="lg" padding="none"
interactive interactive
lift lift
className="group relative isolate flex min-h-[232px] min-w-0 flex-col gap-3" className="group relative isolate flex min-h-[208px] min-w-0 flex-col gap-3 px-[18px] pb-4 pt-[18px]"
> >
<button <button
type="button" type="button"
@@ -48,83 +64,87 @@ export function SkillCard({
className="absolute inset-0 z-0 rounded-[var(--radius-xl)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]" className="absolute inset-0 z-0 rounded-[var(--radius-xl)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
/> />
<div className="pointer-events-none relative z-10 flex items-center gap-3.5"> <div className="pointer-events-none relative z-10 flex min-w-0 items-center gap-3">
<SkillAvatar skill={skill} size={46} /> <SkillAvatar skill={skill} size={44} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2.5"> <div className="flex min-w-0 items-center gap-2">
<h3 className="min-w-0 flex-1 truncate text-[15.5px] font-bold leading-6 tracking-[-0.01em] text-[var(--color-text-primary)]"> <h3 className="min-w-0 truncate text-[15px] font-semibold leading-[22px] text-[var(--color-text-primary)]">
{skill.name} {skill.name}
</h3> </h3>
{skill.version && ( {skill.version && (
<span className="flex-shrink-0 font-mono text-xs text-[var(--color-text-tertiary)]"> <Badge variant="outline" size="xs" pill={false} mono>
v{skill.version} v{skill.version}
</span> </Badge>
)} )}
</div> </div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]"> <p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs leading-[18px] text-[var(--color-text-tertiary)]">
<span className="flex-shrink-0">{t(`market.source.${skill.source}`)}</span> <span className="flex-shrink-0">{t(`market.source.${skill.source}`)}</span>
{skill.author.handle && ( {author && (
<> <>
<span aria-hidden>·</span> <span aria-hidden="true">·</span>
<span className="truncate font-normal normal-case tracking-normal"> <span className="truncate">{author}</span>
{t('market.card.by', { author: skill.author.displayName || skill.author.handle })}
</span>
</> </>
)} )}
</div> </p>
</div> </div>
</div> </div>
<p className="pointer-events-none relative z-10 line-clamp-2 min-h-[44px] break-words text-[13.5px] leading-[1.65] text-[var(--color-text-secondary)]"> <p className="pointer-events-none relative z-10 line-clamp-2 min-h-[42px] break-words text-[13px] leading-[21px] text-[var(--color-text-secondary)]">
{skill.summary || t('market.detail.noDescription')} {summary || t('market.detail.noDescription')}
</p> </p>
{skill.tags.length > 0 && ( <div
<div className="pointer-events-none relative z-10 flex min-h-5 flex-wrap items-center gap-x-2.5 gap-y-1 text-[12.5px] text-[var(--color-text-tertiary)]"> data-testid="market-card-chips"
{skill.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => ( className="pointer-events-none relative z-10 flex max-h-6 min-w-0 flex-wrap items-center gap-1.5 overflow-hidden"
<span key={tag}>#{tag}</span> >
))} <SecurityBadge status={skill.securityStatus} short />
{extraTags > 0 && <span>{t('market.card.moreTags', { count: String(extraTags) })}</span>} {skill.featured && (
</div> <Badge tone="info" size="sm" pill={false} data-testid="market-card-featured">
)} {t('market.featured')}
</Badge>
)}
{tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => (
<Badge key={tag} size="sm" pill={false}>
{tag}
</Badge>
))}
{extraTags > 0 && (
<Badge size="sm" pill={false}>
{t('market.card.moreTags', { count: String(extraTags) })}
</Badge>
)}
</div>
<footer className="pointer-events-none relative z-10 mt-auto flex flex-wrap items-center gap-x-2 gap-y-2 border-t border-[var(--color-border)] pt-3.5"> <footer className="pointer-events-none relative z-10 mt-auto flex min-h-11 items-center justify-between gap-2.5 border-t border-[var(--color-border-separator)] pt-3">
<div className="flex min-w-0 flex-wrap items-center gap-1.5"> <div className="flex min-w-0 items-center gap-3.5 font-mono text-xs tabular-nums text-[var(--color-text-secondary)]">
<SecurityBadge status={skill.securityStatus} />
{/* The quick-install button already communicates "installable" — skip the badge when the button renders. */}
{!(skill.installState === 'installable' && showInstallButton) && (
<InstallStateBadge state={skill.installState} />
)}
</div>
<div className="ml-auto flex flex-shrink-0 items-center gap-2.5 text-[12.5px] tabular-nums text-[var(--color-text-secondary)]">
<span className="inline-flex items-center gap-1.5" title={t('market.detail.downloads')}> <span className="inline-flex items-center gap-1.5" title={t('market.detail.downloads')}>
<Download className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" /> <Download className="h-3 w-3" strokeWidth={1.6} aria-hidden="true" />
{formatCount(skill.stats.downloads)} {formatCount(skill.stats.downloads)}
</span> </span>
{typeof skill.stats.stars === 'number' && skill.stats.stars > 0 && ( {typeof skill.stats.stars === 'number' && skill.stats.stars > 0 && (
<span className="inline-flex items-center gap-1.5" title={t('market.detail.stars')}> <span className="inline-flex items-center gap-1.5" title={t('market.detail.stars')}>
<Star className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" /> <Star className="h-3 w-3" strokeWidth={1.6} aria-hidden="true" />
{formatCount(skill.stats.stars)} {formatCount(skill.stats.stars)}
</span> </span>
)} )}
{showInstallButton && (
// The card's overlay link sets `pointer-events-none` on this
// footer, so the button has to opt back in and sit above it.
// `tonal` is the soft terracotta fill; the border is the handoff's
// terracotta hairline around it.
<Button
variant="tonal"
size="base"
className="pointer-events-auto relative z-20 border border-[var(--color-primary-fixed-dim)]"
loading={installing}
data-market-skill-action-id={skill.id}
icon={<Download className="h-3 w-3" strokeWidth={1.6} aria-hidden="true" />}
onClick={() => onInstall?.(skill.id)}
>
{installing ? t('market.install.installing') : t('market.install.action')}
</Button>
)}
</div> </div>
{showInstallButton ? (
// The footer is `pointer-events-none` for the stretched open button
// underneath, so the install button opts back in and sits above it.
<Button
variant="primary"
size="base"
className="pointer-events-auto relative z-20 flex-shrink-0"
loading={installing}
data-market-skill-action-id={skill.id}
icon={<Download className="h-3.5 w-3.5" strokeWidth={1.8} aria-hidden="true" />}
onClick={() => onInstall?.(skill.id)}
>
{installing ? t('market.install.installing') : t('market.install.action')}
</Button>
) : (
<InstallStateBadge state={skill.installState} />
)}
</footer> </footer>
</Card> </Card>
) )
@@ -216,3 +216,126 @@ describe('SkillDetailView', () => {
expect(preview.className).not.toContain('max-h-[520px]') expect(preview.className).not.toContain('max-h-[520px]')
}) })
}) })
describe('SkillDetailView market extensions', () => {
it('keeps the installed-skill layout when none of the optional props are passed', async () => {
// The installed page passes none of them: source label above the title,
// default badges, actions in the side rail, and the rail on every tab.
renderView({ actions: <button type="button">Uninstall</button> })
expect(screen.getByText('ClawHub')).toBeInTheDocument()
expect(screen.queryByTestId('skill-detail-meta-line')).not.toBeInTheDocument()
expect(screen.queryByTestId('skill-detail-stats')).not.toBeInTheDocument()
expect(screen.queryByTestId('skill-detail-hero-actions')).not.toBeInTheDocument()
expect(within(screen.getByTestId('skill-detail-sidebar')).getByText('Uninstall')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('skill-detail-tab-files'))
expect(await screen.findByTestId('market-file-preview')).toBeInTheDocument()
expect(screen.getByTestId('skill-detail-sidebar')).toBeInTheDocument()
})
it('shows the version as a pill beside the title', () => {
renderView()
const heading = screen.getByRole('heading', { level: 1, name: 'Demo Skill' })
expect(heading.parentElement).toContainElement(screen.getByTestId('skill-detail-version'))
expect(screen.getByTestId('skill-detail-version')).toHaveTextContent('v1.0.0')
})
it('renders the meta line, custom chips and the stats strip', () => {
renderView({
metaLine: <span>by Alice · ClawHub</span>,
chips: <span data-testid="custom-chip">Featured</span>,
stats: [
{ label: 'Downloads', value: '482.1k' },
{ label: 'Files', value: '15' },
],
})
expect(screen.getByTestId('skill-detail-meta-line')).toHaveTextContent('by Alice · ClawHub')
expect(screen.getByTestId('custom-chip')).toBeInTheDocument()
// Custom chips replace the default badge row rather than adding to it.
expect(screen.queryByTestId('security-badge-benign')).not.toBeInTheDocument()
const stats = screen.getByTestId('skill-detail-stats')
expect(within(stats).getByText('Downloads')).toBeInTheDocument()
expect(within(stats).getByText('482.1k')).toBeInTheDocument()
})
it('adds extra tabs with their badges and switches to them', () => {
renderView({
extraTabs: [
{ key: 'security', label: 'Security report', badge: <span data-testid="flag-dot" />, content: <p>Scan results</p> },
],
})
const securityTab = screen.getByRole('tab', { name: 'Security report' })
expect(within(securityTab).getByTestId('flag-dot')).toBeInTheDocument()
fireEvent.click(securityTab)
expect(securityTab).toHaveAttribute('aria-selected', 'true')
expect(screen.getByTestId('skill-detail-security')).toHaveTextContent('Scan results')
expect(screen.queryByTestId('skill-detail-overview')).not.toBeInTheDocument()
})
it('follows a controlled tab and reports clicks without switching by itself', () => {
const onTabChange = vi.fn()
const { rerender } = renderView({
activeTab: 'overview',
onTabChange,
extraTabs: [{ key: 'changelog', label: 'Changelog', content: <p>v2 notes</p> }],
})
fireEvent.click(screen.getByRole('tab', { name: 'Changelog' }))
expect(onTabChange).toHaveBeenCalledWith('changelog')
expect(screen.getByTestId('skill-detail-overview')).toBeInTheDocument()
rerender(
<SkillDetailView
name="Demo Skill"
sourceLabel="ClawHub"
meta={[]}
description="# Body"
files={FILES}
loadFile={loadFileFromMemory}
onBack={vi.fn()}
backLabel="Back"
activeTab="changelog"
onTabChange={onTabChange}
extraTabs={[{ key: 'changelog', label: 'Changelog', content: <p>v2 notes</p> }]}
/>,
)
expect(screen.getByTestId('skill-detail-changelog')).toHaveTextContent('v2 notes')
})
it('puts hero actions in the header and gives non-overview tabs the full width', () => {
renderView({
actionsPlacement: 'hero',
actions: <button type="button">Install</button>,
extraTabs: [{ key: 'security', label: 'Security report', content: <p>Scan results</p> }],
})
expect(within(screen.getByTestId('skill-detail-hero-actions')).getByText('Install')).toBeInTheDocument()
expect(within(screen.getByTestId('skill-detail-sidebar')).queryByText('Install')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Security report' }))
expect(screen.queryByTestId('skill-detail-sidebar')).not.toBeInTheDocument()
})
it('renders the overview lead above the document, the doc header and the side cards', () => {
renderView({
overviewLead: <div data-testid="lead-panel" />,
docHeader: <span>SKILL.md · 2 KB</span>,
sideCards: <section data-testid="side-card" />,
metaTitle: 'Info',
})
const lead = screen.getByTestId('lead-panel')
const overview = screen.getByTestId('skill-detail-overview')
expect(lead.compareDocumentPosition(overview) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(within(overview).getByTestId('skill-detail-doc-header')).toHaveTextContent('SKILL.md · 2 KB')
const sidebar = screen.getByTestId('skill-detail-sidebar')
expect(within(sidebar).getByTestId('side-card')).toBeInTheDocument()
expect(within(sidebar).getByRole('heading', { name: 'Info' })).toBeInTheDocument()
})
})
+223 -88
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ArrowLeft, CircleSlash2, FileText, Folder } from 'lucide-react' import { ArrowLeft, CircleSlash2, FileText, Folder, type LucideIcon } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Badge } from '@/components/ui/Badge' import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -23,6 +23,20 @@ export type SkillDetailMetaItem = {
value: ReactNode value: ReactNode
} }
export type SkillDetailTab = {
key: string
label: string
icon?: LucideIcon
/** Small trailing marker: a count, or a warning dot. */
badge?: ReactNode
content: ReactNode
}
export type SkillDetailStat = {
label: string
value: string
}
export type SkillDetailViewProps = { export type SkillDetailViewProps = {
name: string name: string
version?: string version?: string
@@ -48,6 +62,34 @@ export type SkillDetailViewProps = {
loadFile: (path: string) => Promise<PreviewFileContent> loadFile: (path: string) => Promise<PreviewFileContent>
onBack: () => void onBack: () => void
backLabel: string backLabel: string
// Everything below is optional and only the market detail passes it. The
// installed-skill page renders exactly as it did before these existed.
/** One line of facts under the title ("by … · source · license · updated"). Replaces the source label above it. */
metaLine?: ReactNode
/** Replaces the default security / install badge row under the title. */
chips?: ReactNode
/** Headline numbers in a strip under the header. */
stats?: SkillDetailStat[]
/** Tabs after Overview and Files. */
extraTabs?: SkillDetailTab[]
/** Controlled tab; pair with `onTabChange`. Uncontrolled when omitted. */
activeTab?: string
onTabChange?: (tab: string) => void
/** Rendered above the document in the overview tab. */
overviewLead?: ReactNode
/** A header row inside the overview document card (file name, size, reading time). */
docHeader?: ReactNode
/** Cards stacked above the meta card in the side rail. */
sideCards?: ReactNode
/** Heading for the meta card. */
metaTitle?: string
/**
* `hero` puts the actions at the header's trailing edge. The side rail then
* belongs to the overview alone, and the other tabs take the full width.
*/
actionsPlacement?: 'sidebar' | 'hero'
} }
/** /**
@@ -57,7 +99,12 @@ export type SkillDetailViewProps = {
*/ */
export function SkillDetailView(props: SkillDetailViewProps) { export function SkillDetailView(props: SkillDetailViewProps) {
const t = useTranslation() const t = useTranslation()
const [tab, setTab] = useState<'overview' | 'files'>('overview') const [uncontrolledTab, setUncontrolledTab] = useState('overview')
const tab = props.activeTab ?? uncontrolledTab
const setTab = (next: string) => {
if (props.activeTab === undefined) setUncontrolledTab(next)
props.onTabChange?.(next)
}
const headingRef = useRef<HTMLHeadingElement>(null) const headingRef = useRef<HTMLHeadingElement>(null)
useEffect(() => { useEffect(() => {
@@ -69,6 +116,23 @@ export function SkillDetailView(props: SkillDetailViewProps) {
const overview = useMemo(() => splitFrontmatter(props.description), [props.description]) const overview = useMemo(() => splitFrontmatter(props.description), [props.description])
const skillFrontmatter = overview.frontmatter ?? props.descriptionFrontmatter const skillFrontmatter = overview.frontmatter ?? props.descriptionFrontmatter
const heroActions = props.actionsPlacement === 'hero'
const sidebarActions = heroActions ? undefined : props.actions
const showAside = !heroActions || tab === 'overview'
const extraTabs = props.extraTabs ?? []
const activeExtra = extraTabs.find((extra) => extra.key === tab)
const tabs: Array<{ key: string; label: string; icon: LucideIcon; badge?: ReactNode }> = [
{ key: 'overview', label: t('market.detail.overview'), icon: FileText },
{
key: 'files',
label: t('market.detail.files'),
icon: Folder,
badge: <Badge pill={false} size="sm" className="leading-5">{props.files.length}</Badge>,
},
...extraTabs.map((extra) => ({ key: extra.key, label: extra.label, icon: extra.icon ?? FileText, badge: extra.badge })),
]
return ( return (
<div <div
className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface)]" className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface)]"
@@ -95,36 +159,56 @@ export function SkillDetailView(props: SkillDetailViewProps) {
</Button> </Button>
<header className="mt-[18px] flex-shrink-0"> <header className="mt-[18px] flex-shrink-0">
<div className="flex min-w-0 items-start gap-5 sm:gap-6"> <div className="flex min-w-0 flex-wrap items-start gap-5 sm:flex-nowrap sm:gap-6">
<SkillAvatar skill={{ name: props.name, iconUrl: props.iconUrl }} size={92} /> <SkillAvatar skill={{ name: props.name, iconUrl: props.iconUrl }} size={props.metaLine ? 72 : 92} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]"> {!props.metaLine && (
<span>{props.sourceLabel}</span> <div className="font-mono text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{props.sourceLabel}
</div>
)}
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1">
<h1
ref={headingRef}
tabIndex={-1}
style={{ fontFamily: 'var(--font-headline)' }}
className="min-w-0 break-words text-[26px] font-bold leading-tight tracking-[-0.012em] text-[var(--color-text-primary)] outline-none sm:text-[30px]"
>
{props.name}
</h1>
{props.version && ( {props.version && (
<> <Badge variant="outline" size="sm" pill={false} mono data-testid="skill-detail-version">
<span aria-hidden>·</span> v{props.version}
<span className="normal-case">v{props.version}</span> </Badge>
</>
)} )}
</div> </div>
<h1 {props.metaLine && (
ref={headingRef} <p
tabIndex={-1} data-testid="skill-detail-meta-line"
style={{ fontFamily: 'var(--font-headline)' }} className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[13px] text-[var(--color-text-tertiary)]"
className="mt-1 break-words text-[26px] font-bold leading-tight tracking-[-0.012em] text-[var(--color-text-primary)] outline-none sm:text-[32px]" >
> {props.metaLine}
{props.name} </p>
</h1> )}
<div className="mt-3 flex flex-wrap items-center gap-2">
{props.securityStatus && <SecurityBadge status={props.securityStatus} />}
{props.installState && <InstallStateBadge state={props.installState} />}
</div>
{props.summary && ( {props.summary && (
<p className="mt-3.5 max-w-3xl break-words text-[15px] leading-relaxed text-[var(--color-text-secondary)] sm:text-base"> <p className="mt-3 max-w-3xl break-words text-[15px] leading-relaxed text-[var(--color-text-secondary)]">
{props.summary} {props.summary}
</p> </p>
)} )}
<div className="mt-3 flex flex-wrap items-center gap-2">
{props.chips ?? (
<>
{props.securityStatus && <SecurityBadge status={props.securityStatus} />}
{props.installState && <InstallStateBadge state={props.installState} />}
</>
)}
</div>
</div> </div>
{heroActions && props.actions && (
<div data-testid="skill-detail-hero-actions" className="flex flex-shrink-0 flex-wrap items-center gap-2.5">
{props.actions}
</div>
)}
</div> </div>
{props.installState === 'not-installable' && props.notInstallableReason && ( {props.installState === 'not-installable' && props.notInstallableReason && (
@@ -165,29 +249,51 @@ export function SkillDetailView(props: SkillDetailViewProps) {
</div> </div>
)} )}
{props.stats && props.stats.length > 0 && (
// Hairline grid: the 1px gap shows the separator colour through,
// so the dividers stay right however many cells wrap per row.
<dl
data-testid="skill-detail-stats"
className="mt-6 grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-px overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-border-separator)]"
>
{props.stats.map((stat) => (
<div key={stat.label} className="min-w-0 bg-[var(--color-surface)] px-[18px] py-3.5">
<dt className="text-xs text-[var(--color-text-tertiary)]">{stat.label}</dt>
<dd className="mt-1 truncate font-mono text-xl font-semibold tabular-nums text-[var(--color-text-primary)]">
{stat.value}
</dd>
</div>
))}
</dl>
)}
{props.banner} {props.banner}
</header> </header>
<div className="mt-6 grid gap-6 lg:min-h-0 lg:flex-1 lg:grid-cols-[minmax(0,1fr)_300px] lg:items-stretch lg:gap-9"> <div
className={`mt-6 grid gap-6 lg:min-h-0 lg:flex-1 lg:items-stretch lg:gap-9 ${
showAside ? 'lg:grid-cols-[minmax(0,1fr)_300px]' : ''
}`}
>
<main className="flex min-w-0 flex-col lg:min-h-0"> <main className="flex min-w-0 flex-col lg:min-h-0">
<div <div
role="tablist" role="tablist"
aria-label={props.name} aria-label={props.name}
className="flex flex-shrink-0 items-center gap-[26px] border-b border-[var(--color-border)]" className="flex flex-shrink-0 flex-wrap items-center gap-x-[26px] border-b border-[var(--color-border)]"
> >
{(['overview', 'files'] as const).map((key) => { {tabs.map((entry) => {
const active = tab === key const active = tab === entry.key
const Icon = key === 'overview' ? FileText : Folder const Icon = entry.icon
return ( return (
<button <button
key={key} key={entry.key}
id={`skill-detail-tab-${key}-trigger`} id={`skill-detail-tab-${entry.key}-trigger`}
type="button" type="button"
role="tab" role="tab"
aria-selected={active} aria-selected={active}
aria-controls={`skill-detail-${key}-panel`} aria-controls={`skill-detail-${entry.key}-panel`}
data-testid={`skill-detail-tab-${key}`} data-testid={`skill-detail-tab-${entry.key}`}
onClick={() => setTab(key)} onClick={() => setTab(entry.key)}
className={`relative -mb-px inline-flex min-h-11 items-center gap-2 border-b-[3px] px-1 text-[15px] font-semibold transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${ className={`relative -mb-px inline-flex min-h-11 items-center gap-2 border-b-[3px] px-1 text-[15px] font-semibold transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active active
? 'border-[var(--color-brand)] text-[var(--color-text-primary)]' ? 'border-[var(--color-brand)] text-[var(--color-text-primary)]'
@@ -195,29 +301,38 @@ export function SkillDetailView(props: SkillDetailViewProps) {
}`} }`}
> >
<Icon className="h-[15px] w-[15px]" strokeWidth={1.5} aria-hidden="true" /> <Icon className="h-[15px] w-[15px]" strokeWidth={1.5} aria-hidden="true" />
{t(`market.detail.${key}`)} {entry.label}
{key === 'files' && ( {entry.badge}
<Badge pill={false} size="sm" className="leading-5">{props.files.length}</Badge>
)}
</button> </button>
) )
})} })}
</div> </div>
{tab === 'overview' && ( {tab === 'overview' && (
<section <div className="mt-[22px] flex flex-col gap-4 lg:min-h-0 lg:flex-1">
id="skill-detail-overview-panel" {props.overviewLead}
role="tabpanel" <section
aria-labelledby="skill-detail-tab-overview-trigger" id="skill-detail-overview-panel"
className="mt-[22px] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-7 sm:px-[34px] sm:py-[30px] lg:min-h-0 lg:flex-1 lg:overflow-y-auto" role="tabpanel"
data-testid="skill-detail-overview" aria-labelledby="skill-detail-tab-overview-trigger"
> className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-7 sm:px-[34px] sm:py-[30px] lg:min-h-0 lg:flex-1 lg:overflow-y-auto"
{overview.body.trim() ? ( data-testid="skill-detail-overview"
<MarkdownRenderer content={overview.body} variant="document" className="mx-auto max-w-[72ch]" /> >
) : ( {props.docHeader && (
<p className="py-6 text-center text-sm text-[var(--color-text-tertiary)]">{t('market.detail.noDescription')}</p> <header
)} data-testid="skill-detail-doc-header"
</section> className="mb-6 flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border-separator)] pb-3 text-xs text-[var(--color-text-tertiary)]"
>
{props.docHeader}
</header>
)}
{overview.body.trim() ? (
<MarkdownRenderer content={overview.body} 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>
</div>
)} )}
{tab === 'files' && ( {tab === 'files' && (
@@ -231,46 +346,66 @@ export function SkillDetailView(props: SkillDetailViewProps) {
<FilePreview files={props.files} loadFile={props.loadFile} /> <FilePreview files={props.files} loadFile={props.loadFile} />
</section> </section>
)} )}
{activeExtra && (
<section
id={`skill-detail-${activeExtra.key}-panel`}
role="tabpanel"
aria-labelledby={`skill-detail-tab-${activeExtra.key}-trigger`}
className="mt-[22px] flex flex-col gap-4"
data-testid={`skill-detail-${activeExtra.key}`}
>
{activeExtra.content}
</section>
)}
</main> </main>
<aside {showAside && (
data-testid="skill-detail-sidebar" <aside
className="order-first min-w-0 lg:order-none lg:max-h-full lg:self-start lg:overflow-y-auto" data-testid="skill-detail-sidebar"
> className="order-first flex min-w-0 flex-col gap-4 lg:order-none lg:max-h-full lg:self-start lg:overflow-y-auto"
<Card radius="xl" surface="base" padding="none" className="overflow-hidden"> >
{props.actions && ( {props.sideCards}
<div className="px-[18px] pt-[18px] [&>button]:w-full [&>button]:justify-center"> <Card radius="xl" surface="base" padding="none" className="overflow-hidden">
{props.actions} {props.metaTitle && (
</div> <h2 className="px-[18px] pt-4 text-sm font-semibold text-[var(--color-text-primary)]">
)} {props.metaTitle}
{props.meta.length > 0 && ( </h2>
<dl className="px-[18px]"> )}
{props.meta.map((item) => ( {sidebarActions && (
<div <div className="px-[18px] pt-[18px] [&>button]:w-full [&>button]:justify-center">
key={item.label} {sidebarActions}
className="flex min-w-0 items-baseline justify-between gap-4 border-b border-[var(--color-border)] py-[13px] last:border-b-0" </div>
> )}
<dt className="text-sm leading-5 text-[var(--color-text-secondary)]">{item.label}</dt> {props.meta.length > 0 && (
<dd className="max-w-[62%] break-words text-right text-[14.5px] font-bold leading-5 text-[var(--color-text-primary)]"> <dl className="px-[18px]">
{item.value} {props.meta.map((item) => (
</dd> <div
</div> key={item.label}
))} className="flex min-w-0 items-baseline justify-between gap-4 border-b border-[var(--color-border)] py-[13px] last:border-b-0"
</dl> >
)} <dt className="text-sm leading-5 text-[var(--color-text-secondary)]">{item.label}</dt>
{/* <dd className="max-w-[62%] break-words text-right text-[14.5px] font-bold leading-5 text-[var(--color-text-primary)]">
The skill's own declared attributes belong with the market ones {item.value}
above — same kind of data, different source. Keeping them here </dd>
leaves the overview tab free to answer "what is this skill?" </div>
first, which is what a reader opens the page for. ))}
*/} </dl>
<FrontmatterPanel )}
frontmatter={skillFrontmatter} {/*
variant="sidebar" The skill's own declared attributes belong with the market ones
className={props.actions || props.meta.length > 0 ? 'border-t border-[var(--color-border)]' : ''} above — same kind of data, different source. Keeping them here
/> leaves the overview tab free to answer "what is this skill?"
</Card> first, which is what a reader opens the page for.
</aside> */}
<FrontmatterPanel
frontmatter={skillFrontmatter}
variant="sidebar"
className={sidebarActions || props.meta.length > 0 ? 'border-t border-[var(--color-border)]' : ''}
/>
</Card>
</aside>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import type { MarketCategory } from '../../types/market'
import { categoryLabel, isChineseLocale, resolveCategory, skillSummary, visibleTags } from './catalogLocale'
import { formatCount, formatIsoDate, safeUrl } from './marketFormat'
const CATEGORIES: MarketCategory[] = [
{ key: 'dev', name: '开发编程', nameEn: 'Development', count: 40 },
{ key: 'office', name: '办公文档', nameEn: '', count: 36 },
]
describe('catalog locale', () => {
it('treats both Chinese scripts as Chinese readers', () => {
expect(isChineseLocale('zh')).toBe(true)
expect(isChineseLocale('zh-TW')).toBe(true)
expect(isChineseLocale('en')).toBe(false)
expect(isChineseLocale('jp')).toBe(false)
expect(isChineseLocale('kr')).toBe(false)
})
it('shows the edited zh summary to Chinese readers and the upstream one to everyone else', () => {
const skill = { summary: '用文件跟踪任务', summaryEn: 'Track tasks in files' }
expect(skillSummary(skill, 'zh-TW')).toBe('用文件跟踪任务')
expect(skillSummary(skill, 'en')).toBe('Track tasks in files')
expect(skillSummary(skill, 'kr')).toBe('Track tasks in files')
// No English copy: the Chinese summary beats an empty card.
expect(skillSummary({ summary: '只有中文' }, 'jp')).toBe('只有中文')
})
it('hides curated (Chinese) tags outside Chinese locales but keeps upstream tags', () => {
expect(visibleTags({ tags: ['任务规划'], curated: true }, 'en')).toEqual([])
expect(visibleTags({ tags: ['任务规划'], curated: true }, 'zh')).toEqual(['任务规划'])
expect(visibleTags({ tags: ['git'], curated: false }, 'en')).toEqual(['git'])
})
it('labels categories by locale and falls back to the zh name', () => {
expect(categoryLabel(CATEGORIES[0]!, 'zh')).toBe('开发编程')
expect(categoryLabel(CATEGORIES[0]!, 'en')).toBe('Development')
expect(categoryLabel(CATEGORIES[1]!, 'en')).toBe('办公文档')
})
it('resolves catalog keys only on curated skills and passes raw upstream strings through', () => {
expect(resolveCategory({ category: 'dev', curated: true }, CATEGORIES, 'en')).toBe('Development')
expect(resolveCategory({ category: 'unknown-key', curated: true }, CATEGORIES, 'zh')).toBeUndefined()
expect(resolveCategory({ category: 'AI 工具', curated: false }, CATEGORIES, 'en')).toBe('AI 工具')
expect(resolveCategory({ curated: true }, CATEGORIES, 'zh')).toBeUndefined()
})
})
describe('market formatting', () => {
it('compacts counts', () => {
expect(formatCount(999)).toBe('999')
expect(formatCount(482_069)).toBe('482.1k')
expect(formatCount(2_400_000)).toBe('2.4M')
})
it('renders epoch millis as an ISO date and ignores garbage', () => {
expect(formatIsoDate(Date.UTC(2026, 9, 1))).toBe('2026-10-01')
expect(formatIsoDate(undefined)).toBe('')
expect(formatIsoDate(Number.NaN)).toBe('')
})
it('only lets http(s) links through', () => {
expect(safeUrl('https://clawhub.ai/x')).toBe('https://clawhub.ai/x')
expect(safeUrl('javascript:alert(1)')).toBeUndefined()
expect(safeUrl(undefined)).toBeUndefined()
})
})
@@ -0,0 +1,53 @@
import type { Locale } from '../../i18n/locale'
import { useSettingsStore } from '../../stores/settingsStore'
import type { MarketCategory, NormalizedSkill } from '../../types/market'
/**
* Which copy of the curated catalog a reader sees.
*
* The catalog is edited in Simplified Chinese: summaries, tags and category
* names. Chinese readers (zh and zh-TW) get those; everyone else gets the
* upstream English summary and the category's English name, and no tags —
* a row of Chinese chips under an English summary reads as noise, not
* metadata. Live (non-curated) results carry the upstream's own text, which is
* shown as it is in every locale.
*/
export function isChineseLocale(locale: Locale): boolean {
return locale === 'zh' || locale === 'zh-TW'
}
export function skillSummary(skill: Pick<NormalizedSkill, 'summary' | 'summaryEn'>, locale: Locale): string {
if (isChineseLocale(locale)) return skill.summary
return skill.summaryEn || skill.summary
}
export function visibleTags(skill: Pick<NormalizedSkill, 'tags' | 'curated'>, locale: Locale): string[] {
if (skill.curated && !isChineseLocale(locale)) return []
return skill.tags
}
export function categoryLabel(category: Pick<MarketCategory, 'name' | 'nameEn'>, locale: Locale): string {
if (isChineseLocale(locale)) return category.name
return category.nameEn || category.name
}
/**
* Display name of a skill's category. On a curated skill `category` is a
* catalog key, resolved through the category list; on anything else it is the
* upstream's raw string and is shown as is. An unknown key resolves to nothing
* rather than leaking the key.
*/
export function resolveCategory(
skill: Pick<NormalizedSkill, 'category' | 'curated'>,
categories: readonly MarketCategory[],
locale: Locale,
): string | undefined {
if (!skill.category) return undefined
if (!skill.curated) return skill.category
const match = categories.find((category) => category.key === skill.category)
return match ? categoryLabel(match, locale) : undefined
}
export function useMarketLocale(): Locale {
return useSettingsStore((state) => state.locale)
}
@@ -0,0 +1,21 @@
/** Compact counts: 482069 → 482.1k. Numeric and language-neutral. */
export 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)
}
/**
* Upstream timestamps are epoch millis; rendered as an ISO date so the same
* skill reads the same in every locale and in the catalog's "updated" line.
*/
export function formatIsoDate(timestamp: number | undefined): string {
if (timestamp === undefined) return ''
const date = new Date(timestamp)
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)
}
/** Only absolute http(s) links from upstream may become an `href`. */
export function safeUrl(url: string | undefined): string | undefined {
return url !== undefined && /^https?:\/\//i.test(url) ? url : undefined
}
@@ -17,8 +17,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
*/ */
export const CATALOG_COLUMN_FLOOR = 340 export const CATALOG_COLUMN_FLOOR = 340
export const CATALOG_GAP = 18 export const CATALOG_GAP = 18
/** `SkillCard`'s `min-h-[232px]`. */ /** `SkillCard`'s `min-h-[208px]`. */
export const CATALOG_CARD_MIN_HEIGHT = 232 export const CATALOG_CARD_MIN_HEIGHT = 208
/** /**
* `min(100%, …)` rather than the floor verbatim: the same bundle serves the * `min(100%, …)` rather than the floor verbatim: the same bundle serves the
@@ -95,8 +95,10 @@ export function SkillDetail({ embedded = false }: { embedded?: boolean }) {
void fetchSkills(selectedSkillContext || undefined) void fetchSkills(selectedSkillContext || undefined)
// Keep the market list in sync when it has this skill loaded. // Keep the market list in sync when it has this skill loaded.
const market = useMarketStore.getState() const market = useMarketStore.getState()
const detailCache = new Map(market.detailCache) // ClawHub entries are cached per owner, so drop every copy of this id.
detailCache.delete(marketMeta.id) const detailCache = new Map(
[...market.detailCache].filter(([, cached]) => cached.id !== marketMeta.id),
)
useMarketStore.setState({ useMarketStore.setState({
detailCache, detailCache,
items: market.items.map((item) => items: market.items.map((item) =>
@@ -85,8 +85,8 @@ describe('SegmentedControl', () => {
expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'C' })) expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'C' }))
}) })
it('gives every segment a focus ring in all three appearances', () => { it('gives every segment a focus ring in all four appearances', () => {
for (const appearance of ['solid', 'raised', 'underline'] as const) { for (const appearance of ['solid', 'raised', 'underline', 'chip'] as const) {
const { unmount } = render( const { unmount } = render(
<SegmentedControl items={ITEMS} value="all" onChange={() => {}} label="Filter" appearance={appearance} />, <SegmentedControl items={ITEMS} value="all" onChange={() => {}} label="Filter" appearance={appearance} />,
) )
@@ -94,4 +94,41 @@ describe('SegmentedControl', () => {
unmount() unmount()
} }
}) })
describe('chip appearance', () => {
it('wraps separate pills instead of pinning one framed track height', () => {
// A long category list in a single fixed-height track clips or forces a
// sideways scroll; chips have to be free to wrap onto the next line.
render(<SegmentedControl items={ITEMS} value="all" onChange={() => {}} label="Category" appearance="chip" />)
const group = screen.getByRole('radiogroup', { name: 'Category' })
expect(group.className).toContain('flex-wrap')
expect(group.className).not.toMatch(/\bh-8\b/)
for (const radio of screen.getAllByRole('radio')) {
expect(radio.className).toContain('rounded-full')
expect(radio.className).toMatch(/\bh-8\b/)
}
})
it('keeps the radiogroup semantics and arrow-key movement', () => {
const onChange = vi.fn()
render(<SegmentedControl items={ITEMS} value="active" onChange={onChange} label="Category" appearance="chip" />)
const active = screen.getByRole('radio', { name: 'Active' })
expect(active).toHaveAttribute('aria-checked', 'true')
expect(active).toHaveAttribute('tabindex', '0')
active.focus()
fireEvent.keyDown(active, { key: 'ArrowRight' })
expect(document.activeElement).toBe(screen.getByRole('radio', { name: 'Done' }))
expect(onChange).toHaveBeenCalledWith('done')
})
it('fills the picked chip with the primary ink pair', () => {
render(<SegmentedControl items={ITEMS} value="all" onChange={() => {}} label="Category" appearance="chip" />)
expect(screen.getByRole('radio', { name: 'All' }).className).toContain('bg-[var(--color-btn-primary-bg)]')
expect(screen.getByRole('radio', { name: 'Done' }).className).toContain('bg-[var(--color-surface)]')
})
})
}) })
+29 -7
View File
@@ -23,7 +23,13 @@ export type SegmentedControlProps<T extends string> = {
* latter, and only a tablist implies associated panels. * latter, and only a tablist implies associated panels.
*/ */
as?: 'radiogroup' | 'tablist' as?: 'radiogroup' | 'tablist'
appearance?: 'solid' | 'raised' | 'underline' /**
* `chip` is a wrapping row of separate pill buttons rather than one framed
* track — for option sets too long for a single line (the market's catalog
* categories). It wraps instead of scrolling sideways: a hidden horizontal
* overflow hides options from anyone who never thinks to scroll.
*/
appearance?: 'solid' | 'raised' | 'underline' | 'chip'
size?: 'sm' | 'md' size?: 'sm' | 'md'
/** `fill` stretches segments equally; `auto` sizes each to its content. */ /** `fill` stretches segments equally; `auto` sizes each to its content. */
layout?: 'fill' | 'auto' layout?: 'fill' | 'auto'
@@ -40,6 +46,12 @@ const SEGMENT_PADDING = {
md: 'px-3 gap-1.5', md: 'px-3 gap-1.5',
} as const } as const
/** Chips size themselves; the wrapping row has no single height to pin. */
const CHIP_SIZE_CLASSES = {
sm: 'h-7 px-3 gap-1 text-xs',
md: 'h-8 px-3.5 gap-1.5 text-[13px]',
} as const
/** /**
* A row of mutually exclusive options. * A row of mutually exclusive options.
* *
@@ -86,6 +98,7 @@ export function SegmentedControl<T extends string>({
} }
const isTabs = as === 'tablist' const isTabs = as === 'tablist'
const isChip = appearance === 'chip'
return ( return (
<div <div
@@ -94,12 +107,12 @@ export function SegmentedControl<T extends string>({
aria-label={label} aria-label={label}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cx( className={cx(
'inline-flex items-center', isChip ? 'flex flex-wrap items-center gap-1.5' : 'inline-flex items-center',
appearance === 'underline' appearance === 'underline'
? 'gap-1 border-b border-[var(--color-border)]' ? 'gap-1 border-b border-[var(--color-border)]'
: 'gap-0.5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-0.5', : !isChip && 'gap-0.5 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-0.5',
layout === 'fill' && 'flex w-full', layout === 'fill' && 'flex w-full',
SIZE_CLASSES[size], !isChip && SIZE_CLASSES[size],
className, className,
)} )}
> >
@@ -118,13 +131,22 @@ export function SegmentedControl<T extends string>({
title={item.title} title={item.title}
onClick={() => onChange(item.value)} onClick={() => onChange(item.value)}
className={cx( className={cx(
'inline-flex h-full items-center justify-center font-medium whitespace-nowrap', 'inline-flex items-center justify-center font-medium whitespace-nowrap',
'transition-colors duration-150 cursor-pointer', 'transition-colors duration-150 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
'disabled:cursor-not-allowed disabled:opacity-50', 'disabled:cursor-not-allowed disabled:opacity-50',
SEGMENT_PADDING[size], isChip ? CHIP_SIZE_CLASSES[size] : cx('h-full', SEGMENT_PADDING[size]),
layout === 'fill' && 'flex-1', layout === 'fill' && 'flex-1',
appearance === 'underline' isChip
? cx(
'rounded-full border',
// The ink fill of the primary button, so the picked chip
// reads as the same "on" state as the page's main action.
selected
? 'border-[var(--color-btn-primary-bg)] bg-[var(--color-btn-primary-bg)] text-[var(--color-btn-primary-fg)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)]',
)
: appearance === 'underline'
? cx( ? cx(
'rounded-none border-b-2 -mb-px', 'rounded-none border-b-2 -mb-px',
selected selected
+17
View File
@@ -662,6 +662,23 @@ export function ComponentGallery() {
appearance="underline" appearance="underline"
/> />
</div> </div>
<div className="max-w-xl">
<SegmentedControl
items={[
{ value: 'all', label: 'All' },
{ value: 'active', label: <>Active <span className="text-[11px] opacity-60">40</span></> },
{ value: 'done', label: <>Done <span className="text-[11px] opacity-60">36</span></> },
{ value: 'research', label: 'Research' },
{ value: 'office', label: 'Office documents' },
{ value: 'media', label: 'Design & media' },
{ value: 'data', label: 'Data analysis' },
]}
value={segment}
onChange={setSegment}
label="Filter (chip, wraps)"
appearance="chip"
/>
</div>
</Section> </Section>
<Section title="States"> <Section title="States">
+52 -2
View File
@@ -3742,7 +3742,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
// Skills Market // Skills Market
'sidebar.market': 'Skills Market', 'sidebar.market': 'Skills Market',
'market.title': 'Skills Market', 'market.title': 'Skills Market',
'market.subtitle': 'Browse, preview and install skills from ClawHub and SkillHub.', 'market.subtitle': 'Curated skills from ClawHub and SkillHub, with a live search across both markets when you need more.',
'market.searchPlaceholder': 'Search skills by name, keyword…', 'market.searchPlaceholder': 'Search skills by name, keyword…',
'market.clearSearch': 'Clear search', 'market.clearSearch': 'Clear search',
'market.resultCount': '{count} skills', 'market.resultCount': '{count} skills',
@@ -3753,7 +3753,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'market.installedSkillsHint': 'View the skills installed on this machine', 'market.installedSkillsHint': 'View the skills installed on this machine',
'market.loading': 'Loading skills…', 'market.loading': 'Loading skills…',
'market.empty': 'No skills available', 'market.empty': 'No skills available',
'market.emptyHint': 'Both sources returned no results. Check the source status above.', 'market.emptyHint': 'The curated catalog returned nothing. Try again in a moment.',
'market.emptySearch': 'No skills match your search', 'market.emptySearch': 'No skills match your search',
'market.emptySearchHint': 'Try different keywords or clear the filters.', 'market.emptySearchHint': 'Try different keywords or clear the filters.',
'market.error.list': 'Failed to load the skills market', 'market.error.list': 'Failed to load the skills market',
@@ -3843,6 +3843,56 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'market.installError.disk': 'Install failed: could not write to disk. Check permissions and free space.', '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.notInstallable': 'Install failed: this skill cannot be installed.',
'market.installError.generic': 'Install failed: {message}', 'market.installError.generic': 'Install failed: {message}',
'market.catalog.summary': '{count} curated skills',
'market.catalog.updated': 'list updated {date}',
'market.catalog.liveResults': '{count} live results',
'market.scope.catalogHint': 'Searching curated skills only.',
'market.scope.marketHint': 'Searching all of ClawHub and SkillHub live; these results are not curated.',
'market.scope.searchMarket': 'Search all markets for “{q}”',
'market.scope.backToCatalog': 'Back to curated',
'market.scope.notCurated': 'Not curated',
'market.scope.livePlaceholder': 'Search all markets — press Enter to search',
'market.category.label': 'Category',
'market.category.all': 'All',
'market.featured': 'Featured',
'market.securityShort.verified': 'Verified',
'market.securityShort.benign': 'Scan passed',
'market.securityShort.unknown': 'Not scanned',
'market.securityShort.flagged': 'Flagged',
'market.detail.security': 'Security report',
'market.detail.changelog': 'Changelog',
'market.detail.whenToUse': 'When it triggers',
'market.detail.info': 'Info',
'market.detail.sourcePage': 'Source page',
'market.detail.updatedOn': 'Updated {date}',
'market.detail.by': 'by {author}',
'market.detail.readingTime': '~{minutes} min read',
'market.detail.scanReports': 'Scan results',
'market.detail.noReports': 'Upstream has no security scan for this skill.',
'market.detail.scanDisclaimer': 'Verdicts come from the upstream registry; this app only shows them and does not vouch for them. Installing means you trust the author.',
'market.detail.securityNote': 'Curator note',
'market.detail.noChangelog': 'Upstream has no release note for this version.',
'market.detail.flaggedDot': 'Flagged by the security scan',
'market.cap.title': 'Before installing: what this skill does',
'market.cap.viewReport': 'Full report →',
'market.cap.note': 'Detected by rules from SKILL.md and the file list; for reference only. Installing runs no scripts.',
'market.cap.shell': 'Runs commands or scripts',
'market.cap.shell.detail': '{evidence}',
'market.cap.hooks': 'Registers hooks',
'market.cap.hooks.detail': 'Touches {evidence}',
'market.cap.secrets': 'Needs secrets',
'market.cap.secrets.detail': 'Reads {evidence}',
'market.cap.network': 'Calls network services',
'market.cap.network.detail': 'Requests {evidence}',
'market.cap.writes': 'Writes files',
'market.cap.writes.detail': 'Creates or changes {evidence}',
'market.cap.binaries': 'Needs local commands',
'market.cap.binaries.detail': 'Requires {evidence}',
'market.cap.level.high': 'High',
'market.cap.level.medium': 'Medium',
'market.cap.level.low': 'Low',
'market.installConfirm.willGet': 'This skill will be able to',
'market.installConfirm.ack': 'I have read SKILL.md and the security report and understand these permissions',
// ─── Window controls / diagram + code viewers ────────────────────────────────────── // ─── Window controls / diagram + code viewers ──────────────────────────────────────
'windowControls.minimize': 'Minimize window', 'windowControls.minimize': 'Minimize window',
+52 -2
View File
@@ -3743,7 +3743,7 @@ export const jp: Record<TranslationKey, string> = {
// スキルマーケット // スキルマーケット
'sidebar.market': 'スキルマーケット', 'sidebar.market': 'スキルマーケット',
'market.title': 'スキルマーケット', 'market.title': 'スキルマーケット',
'market.subtitle': 'ClawHub と SkillHub のスキルを閲覧・プレビュー・インストールできます。', 'market.subtitle': 'ClawHub と SkillHub から厳選したスキルを閲覧できます。必要なら両マーケットをリアルタイム検索できます。',
'market.searchPlaceholder': 'スキル名・キーワードで検索…', 'market.searchPlaceholder': 'スキル名・キーワードで検索…',
'market.clearSearch': '検索をクリア', 'market.clearSearch': '検索をクリア',
'market.resultCount': '{count} 件のスキル', 'market.resultCount': '{count} 件のスキル',
@@ -3754,7 +3754,7 @@ export const jp: Record<TranslationKey, string> = {
'market.installedSkillsHint': 'この端末にインストール済みのスキルを表示', 'market.installedSkillsHint': 'この端末にインストール済みのスキルを表示',
'market.loading': 'スキルを読み込み中…', 'market.loading': 'スキルを読み込み中…',
'market.empty': '利用可能なスキルがありません', 'market.empty': '利用可能なスキルがありません',
'market.emptyHint': 'どちらのソースからも結果が返りませんでした。上部のソース状態を確認してください。', 'market.emptyHint': '厳選リストが空でした。しばらくしてから再試行してください。',
'market.emptySearch': '検索に一致するスキルがありません', 'market.emptySearch': '検索に一致するスキルがありません',
'market.emptySearchHint': '別のキーワードを試すか、フィルタをクリアしてください。', 'market.emptySearchHint': '別のキーワードを試すか、フィルタをクリアしてください。',
'market.error.list': 'スキルマーケットの読み込みに失敗しました', 'market.error.list': 'スキルマーケットの読み込みに失敗しました',
@@ -3844,6 +3844,56 @@ export const jp: Record<TranslationKey, string> = {
'market.installError.disk': 'インストール失敗:ディスクへの書き込みに失敗しました。権限と空き容量を確認してください。', 'market.installError.disk': 'インストール失敗:ディスクへの書き込みに失敗しました。権限と空き容量を確認してください。',
'market.installError.notInstallable': 'インストール失敗:このスキルはインストールできません。', 'market.installError.notInstallable': 'インストール失敗:このスキルはインストールできません。',
'market.installError.generic': 'インストール失敗:{message}', 'market.installError.generic': 'インストール失敗:{message}',
'market.catalog.summary': '厳選スキル {count} 件',
'market.catalog.updated': 'リスト更新日 {date}',
'market.catalog.liveResults': 'リアルタイム結果 {count} 件',
'market.scope.catalogHint': '厳選スキルのみを検索しています。',
'market.scope.marketHint': 'ClawHub と SkillHub の全スキルをリアルタイム検索中です。結果は厳選されていません。',
'market.scope.searchMarket': 'すべてのマーケットで「{q}」を検索',
'market.scope.backToCatalog': '厳選に戻る',
'market.scope.notCurated': '未厳選',
'market.scope.livePlaceholder': 'すべてのマーケットを検索 — Enter で検索',
'market.category.label': 'カテゴリ',
'market.category.all': 'すべて',
'market.featured': 'おすすめ',
'market.securityShort.verified': '認証済み',
'market.securityShort.benign': 'スキャン合格',
'market.securityShort.unknown': '未スキャン',
'market.securityShort.flagged': '要注意',
'market.detail.security': 'セキュリティレポート',
'market.detail.changelog': '更新履歴',
'market.detail.whenToUse': '発動する場面',
'market.detail.info': '情報',
'market.detail.sourcePage': 'ソースページ',
'market.detail.updatedOn': '{date} 更新',
'market.detail.by': '作者 {author}',
'market.detail.readingTime': '約 {minutes} 分で読めます',
'market.detail.scanReports': 'スキャン結果',
'market.detail.noReports': 'このスキルのセキュリティスキャン結果は上流にありません。',
'market.detail.scanDisclaimer': 'スキャン結果は上流レジストリによるもので、本アプリは表示するだけで保証はしません。インストールは作者を信頼することを意味します。',
'market.detail.securityNote': 'キュレーターのメモ',
'market.detail.noChangelog': 'このバージョンのリリースノートは上流にありません。',
'market.detail.flaggedDot': 'セキュリティスキャンで要注意と判定',
'market.cap.title': 'インストール前に確認:このスキルができること',
'market.cap.viewReport': '詳細レポート →',
'market.cap.note': 'SKILL.md とファイル一覧からルールで検出したもので、参考情報です。インストール時にスクリプトは実行されません。',
'market.cap.shell': 'コマンドやスクリプトを実行',
'market.cap.shell.detail': '{evidence}',
'market.cap.hooks': 'フックを登録',
'market.cap.hooks.detail': '対象:{evidence}',
'market.cap.secrets': 'シークレットが必要',
'market.cap.secrets.detail': '読み取り:{evidence}',
'market.cap.network': 'ネットワークサービスにアクセス',
'market.cap.network.detail': 'リクエスト先:{evidence}',
'market.cap.writes': 'ファイルを書き込み',
'market.cap.writes.detail': '作成・変更:{evidence}',
'market.cap.binaries': 'ローカルコマンドに依存',
'market.cap.binaries.detail': '必要:{evidence}',
'market.cap.level.high': '高',
'market.cap.level.medium': '中',
'market.cap.level.low': '低',
'market.installConfirm.willGet': 'このスキルができること',
'market.installConfirm.ack': 'SKILL.md とセキュリティレポートを確認し、上記の権限を理解しました',
// ─── Window controls / diagram + code viewers ────────────────────────────────────── // ─── Window controls / diagram + code viewers ──────────────────────────────────────
'windowControls.minimize': 'ウィンドウを最小化', 'windowControls.minimize': 'ウィンドウを最小化',
+52 -2
View File
@@ -3745,7 +3745,7 @@ export const kr: Record<TranslationKey, string> = {
// 스킬 마켓 // 스킬 마켓
'sidebar.market': '스킬 마켓', 'sidebar.market': '스킬 마켓',
'market.title': '스킬 마켓', 'market.title': '스킬 마켓',
'market.subtitle': 'ClawHub와 SkillHub의 스킬을 탐색·미리보기·설치할 수 있습니다.', 'market.subtitle': 'ClawHub와 SkillHub의 엄선된 스킬을 둘러보고, 필요하면 두 마켓 전체를 실시간으로 검색하세요.',
'market.searchPlaceholder': '스킬 이름, 키워드로 검색…', 'market.searchPlaceholder': '스킬 이름, 키워드로 검색…',
'market.clearSearch': '검색 지우기', 'market.clearSearch': '검색 지우기',
'market.resultCount': '{count}개의 스킬', 'market.resultCount': '{count}개의 스킬',
@@ -3756,7 +3756,7 @@ export const kr: Record<TranslationKey, string> = {
'market.installedSkillsHint': '이 기기에 설치된 스킬 보기', 'market.installedSkillsHint': '이 기기에 설치된 스킬 보기',
'market.loading': '스킬을 불러오는 중…', 'market.loading': '스킬을 불러오는 중…',
'market.empty': '사용 가능한 스킬이 없습니다', 'market.empty': '사용 가능한 스킬이 없습니다',
'market.emptyHint': '두 소스 모두 결과를 반환하지 않았습니다. 위의 소스 상태를 확인하세요.', 'market.emptyHint': '엄선 목록이 비어 있습니다. 잠시 후 다시 시도하세요.',
'market.emptySearch': '검색과 일치하는 스킬이 없습니다', 'market.emptySearch': '검색과 일치하는 스킬이 없습니다',
'market.emptySearchHint': '다른 키워드를 시도하거나 필터를 지워보세요.', 'market.emptySearchHint': '다른 키워드를 시도하거나 필터를 지워보세요.',
'market.error.list': '스킬 마켓을 불러오지 못했습니다', 'market.error.list': '스킬 마켓을 불러오지 못했습니다',
@@ -3846,6 +3846,56 @@ export const kr: Record<TranslationKey, string> = {
'market.installError.disk': '설치 실패: 디스크에 쓸 수 없습니다. 권한과 여유 공간을 확인하세요.', 'market.installError.disk': '설치 실패: 디스크에 쓸 수 없습니다. 권한과 여유 공간을 확인하세요.',
'market.installError.notInstallable': '설치 실패: 이 스킬은 설치할 수 없습니다.', 'market.installError.notInstallable': '설치 실패: 이 스킬은 설치할 수 없습니다.',
'market.installError.generic': '설치 실패: {message}', 'market.installError.generic': '설치 실패: {message}',
'market.catalog.summary': '엄선된 스킬 {count}개',
'market.catalog.updated': '목록 업데이트 {date}',
'market.catalog.liveResults': '실시간 결과 {count}개',
'market.scope.catalogHint': '엄선된 스킬에서만 검색하고 있습니다.',
'market.scope.marketHint': 'ClawHub와 SkillHub 전체를 실시간으로 검색하고 있습니다. 결과는 엄선되지 않았습니다.',
'market.scope.searchMarket': '모든 마켓에서 “{q}” 검색',
'market.scope.backToCatalog': '엄선 목록으로',
'market.scope.notCurated': '엄선되지 않음',
'market.scope.livePlaceholder': '모든 마켓 검색 — Enter로 검색',
'market.category.label': '카테고리',
'market.category.all': '전체',
'market.featured': '추천',
'market.securityShort.verified': '인증됨',
'market.securityShort.benign': '스캔 통과',
'market.securityShort.unknown': '미스캔',
'market.securityShort.flagged': '주의',
'market.detail.security': '보안 보고서',
'market.detail.changelog': '변경 기록',
'market.detail.whenToUse': '실행되는 경우',
'market.detail.info': '정보',
'market.detail.sourcePage': '소스 페이지',
'market.detail.updatedOn': '{date} 업데이트',
'market.detail.by': '작성자 {author}',
'market.detail.readingTime': '약 {minutes}분 분량',
'market.detail.scanReports': '스캔 결과',
'market.detail.noReports': '이 스킬에 대한 보안 스캔 결과가 상위 소스에 없습니다.',
'market.detail.scanDisclaimer': '스캔 결과는 상위 레지스트리에서 제공하며, 이 앱은 표시만 할 뿐 보증하지 않습니다. 설치는 작성자를 신뢰한다는 뜻입니다.',
'market.detail.securityNote': '큐레이터 메모',
'market.detail.noChangelog': '이 버전에 대한 릴리스 노트가 상위 소스에 없습니다.',
'market.detail.flaggedDot': '보안 스캔에서 주의로 표시됨',
'market.cap.title': '설치 전 확인: 이 스킬이 하는 일',
'market.cap.viewReport': '전체 보고서 →',
'market.cap.note': 'SKILL.md와 파일 목록에서 규칙으로 감지한 참고 정보입니다. 설치 시 스크립트는 실행되지 않습니다.',
'market.cap.shell': '명령 또는 스크립트 실행',
'market.cap.shell.detail': '{evidence}',
'market.cap.hooks': '훅 등록',
'market.cap.hooks.detail': '대상: {evidence}',
'market.cap.secrets': '비밀 값 필요',
'market.cap.secrets.detail': '읽기: {evidence}',
'market.cap.network': '네트워크 서비스 호출',
'market.cap.network.detail': '요청: {evidence}',
'market.cap.writes': '파일 쓰기',
'market.cap.writes.detail': '생성 또는 변경: {evidence}',
'market.cap.binaries': '로컬 명령 필요',
'market.cap.binaries.detail': '필요: {evidence}',
'market.cap.level.high': '높음',
'market.cap.level.medium': '중간',
'market.cap.level.low': '낮음',
'market.installConfirm.willGet': '이 스킬이 할 수 있는 일',
'market.installConfirm.ack': 'SKILL.md와 보안 보고서를 확인했으며 위 권한을 이해했습니다',
// ─── Window controls / diagram + code viewers ────────────────────────────────────── // ─── Window controls / diagram + code viewers ──────────────────────────────────────
'windowControls.minimize': '창 최소화', 'windowControls.minimize': '창 최소화',
+52 -2
View File
@@ -3742,7 +3742,7 @@ export const zh: Record<TranslationKey, string> = {
// 技能市集 // 技能市集
'sidebar.market': '技能市集', 'sidebar.market': '技能市集',
'market.title': '技能市集', 'market.title': '技能市集',
'market.subtitle': '瀏覽、預覽並安裝來自 ClawHub 與 SkillHub 的技能。', 'market.subtitle': '瀏覽 ClawHub 與 SkillHub 的精選技能,需要更多時可即時搜尋全部市集。',
'market.searchPlaceholder': '依名稱、關鍵字搜尋技能…', 'market.searchPlaceholder': '依名稱、關鍵字搜尋技能…',
'market.clearSearch': '清除搜尋', 'market.clearSearch': '清除搜尋',
'market.resultCount': '{count} 個技能', 'market.resultCount': '{count} 個技能',
@@ -3753,7 +3753,7 @@ export const zh: Record<TranslationKey, string> = {
'market.installedSkillsHint': '查看本機已安裝的技能', 'market.installedSkillsHint': '查看本機已安裝的技能',
'market.loading': '正在載入技能…', 'market.loading': '正在載入技能…',
'market.empty': '暫無可用技能', 'market.empty': '暫無可用技能',
'market.emptyHint': '兩個來源都沒有回傳結果,請檢查上方的來源狀態。', 'market.emptyHint': '精選清單暫時沒有內容,稍後再試。',
'market.emptySearch': '沒有符合的技能', 'market.emptySearch': '沒有符合的技能',
'market.emptySearchHint': '換個關鍵字試試,或清除篩選條件。', 'market.emptySearchHint': '換個關鍵字試試,或清除篩選條件。',
'market.error.list': '技能市集載入失敗', 'market.error.list': '技能市集載入失敗',
@@ -3843,6 +3843,56 @@ export const zh: Record<TranslationKey, string> = {
'market.installError.disk': '安裝失敗:寫入磁碟失敗,請檢查權限與剩餘空間。', 'market.installError.disk': '安裝失敗:寫入磁碟失敗,請檢查權限與剩餘空間。',
'market.installError.notInstallable': '安裝失敗:該技能不可安裝。', 'market.installError.notInstallable': '安裝失敗:該技能不可安裝。',
'market.installError.generic': '安裝失敗:{message}', 'market.installError.generic': '安裝失敗:{message}',
'market.catalog.summary': '共 {count} 個精選技能',
'market.catalog.updated': '清單更新於 {date}',
'market.catalog.liveResults': '{count} 個即時結果',
'market.scope.catalogHint': '目前只在精選技能中搜尋。',
'market.scope.marketHint': '正在即時搜尋 ClawHub 與 SkillHub 全部技能,結果未經精選審核。',
'market.scope.searchMarket': '在全部市集中搜尋「{q}」',
'market.scope.backToCatalog': '回到精選',
'market.scope.notCurated': '未經精選',
'market.scope.livePlaceholder': '搜尋全部市集,按 Enter 搜尋',
'market.category.label': '分類',
'market.category.all': '全部',
'market.featured': '精選',
'market.securityShort.verified': '已認證',
'market.securityShort.benign': '掃描通過',
'market.securityShort.unknown': '未掃描',
'market.securityShort.flagged': '可疑',
'market.detail.security': '安全報告',
'market.detail.changelog': '更新日誌',
'market.detail.whenToUse': '何時觸發',
'market.detail.info': '資訊',
'market.detail.sourcePage': '原始倉庫',
'market.detail.updatedOn': '更新於 {date}',
'market.detail.by': '作者 {author}',
'market.detail.readingTime': '約 {minutes} 分鐘閱讀',
'market.detail.scanReports': '掃描結果',
'market.detail.noReports': '上游沒有提供安全掃描結果。',
'market.detail.scanDisclaimer': '掃描結論來自上游平台,本應用只做展示,不對其作擔保;安裝即表示你信任該作者。',
'market.detail.securityNote': '精選說明',
'market.detail.noChangelog': '上游沒有提供這個版本的更新說明。',
'market.detail.flaggedDot': '安全掃描標記為可疑',
'market.cap.title': '安裝前請了解:這個技能會做什麼',
'market.cap.viewReport': '查看完整報告 →',
'market.cap.note': '以上依規則從 SKILL.md 和檔案列表中識別,僅供參考;安裝本身不會執行任何指令碼。',
'market.cap.shell': '執行命令或指令碼',
'market.cap.shell.detail': '{evidence}',
'market.cap.hooks': '註冊 Hook',
'market.cap.hooks.detail': '涉及 {evidence}',
'market.cap.secrets': '需要金鑰',
'market.cap.secrets.detail': '讀取 {evidence}',
'market.cap.network': '存取網路服務',
'market.cap.network.detail': '請求 {evidence}',
'market.cap.writes': '寫入檔案',
'market.cap.writes.detail': '建立或修改 {evidence}',
'market.cap.binaries': '依賴本機命令',
'market.cap.binaries.detail': '需要安裝 {evidence}',
'market.cap.level.high': '高',
'market.cap.level.medium': '中',
'market.cap.level.low': '低',
'market.installConfirm.willGet': '這個技能將獲得',
'market.installConfirm.ack': '我已查看 SKILL.md 與安全報告,了解上述權限',
// ─── Window controls / diagram + code viewers ────────────────────────────────────── // ─── Window controls / diagram + code viewers ──────────────────────────────────────
'windowControls.minimize': '最小化視窗', 'windowControls.minimize': '最小化視窗',
+52 -2
View File
@@ -3741,7 +3741,7 @@ export const zh: Record<TranslationKey, string> = {
// 技能市场 // 技能市场
'sidebar.market': '技能市场', 'sidebar.market': '技能市场',
'market.title': '技能市场', 'market.title': '技能市场',
'market.subtitle': '浏览、预览并安装来自 ClawHub 与 SkillHub 的技能。', 'market.subtitle': '浏览 ClawHub 与 SkillHub 的精选技能,需要更多时可实时搜索全部市场。',
'market.searchPlaceholder': '按名称、关键词搜索技能…', 'market.searchPlaceholder': '按名称、关键词搜索技能…',
'market.clearSearch': '清除搜索', 'market.clearSearch': '清除搜索',
'market.resultCount': '{count} 个技能', 'market.resultCount': '{count} 个技能',
@@ -3752,7 +3752,7 @@ export const zh: Record<TranslationKey, string> = {
'market.installedSkillsHint': '查看本机已安装的技能', 'market.installedSkillsHint': '查看本机已安装的技能',
'market.loading': '正在加载技能…', 'market.loading': '正在加载技能…',
'market.empty': '暂无可用技能', 'market.empty': '暂无可用技能',
'market.emptyHint': '两个来源都没有返回结果,请检查上方的来源状态。', 'market.emptyHint': '精选清单暂时没有内容,稍后再试。',
'market.emptySearch': '没有匹配的技能', 'market.emptySearch': '没有匹配的技能',
'market.emptySearchHint': '换个关键词试试,或清除筛选条件。', 'market.emptySearchHint': '换个关键词试试,或清除筛选条件。',
'market.error.list': '技能市场加载失败', 'market.error.list': '技能市场加载失败',
@@ -3842,6 +3842,56 @@ export const zh: Record<TranslationKey, string> = {
'market.installError.disk': '安装失败:写入磁盘失败,请检查权限与剩余空间。', 'market.installError.disk': '安装失败:写入磁盘失败,请检查权限与剩余空间。',
'market.installError.notInstallable': '安装失败:该技能不可安装。', 'market.installError.notInstallable': '安装失败:该技能不可安装。',
'market.installError.generic': '安装失败:{message}', 'market.installError.generic': '安装失败:{message}',
'market.catalog.summary': '共 {count} 个精选技能',
'market.catalog.updated': '清单更新于 {date}',
'market.catalog.liveResults': '{count} 个实时结果',
'market.scope.catalogHint': '当前只在精选技能中搜索。',
'market.scope.marketHint': '正在实时搜索 ClawHub 与 SkillHub 全部技能,结果未经精选审核。',
'market.scope.searchMarket': '在全部市场中搜索「{q}」',
'market.scope.backToCatalog': '回到精选',
'market.scope.notCurated': '未经精选',
'market.scope.livePlaceholder': '搜索全部市场,按 Enter 搜索',
'market.category.label': '分类',
'market.category.all': '全部',
'market.featured': '精选',
'market.securityShort.verified': '已认证',
'market.securityShort.benign': '扫描通过',
'market.securityShort.unknown': '未扫描',
'market.securityShort.flagged': '可疑',
'market.detail.security': '安全报告',
'market.detail.changelog': '更新日志',
'market.detail.whenToUse': '何时触发',
'market.detail.info': '信息',
'market.detail.sourcePage': '源仓库',
'market.detail.updatedOn': '更新于 {date}',
'market.detail.by': '作者 {author}',
'market.detail.readingTime': '约 {minutes} 分钟阅读',
'market.detail.scanReports': '扫描结果',
'market.detail.noReports': '上游没有提供安全扫描结果。',
'market.detail.scanDisclaimer': '扫描结论来自上游平台,本应用只做展示,不对其作担保;安装即表示你信任该作者。',
'market.detail.securityNote': '精选说明',
'market.detail.noChangelog': '上游没有提供这个版本的更新说明。',
'market.detail.flaggedDot': '安全扫描标记为可疑',
'market.cap.title': '安装前请了解:这个技能会做什么',
'market.cap.viewReport': '查看完整报告 →',
'market.cap.note': '以上按规则从 SKILL.md 和文件列表中识别,仅供参考;安装本身不会执行任何脚本。',
'market.cap.shell': '执行命令或脚本',
'market.cap.shell.detail': '{evidence}',
'market.cap.hooks': '注册 Hook',
'market.cap.hooks.detail': '涉及 {evidence}',
'market.cap.secrets': '需要密钥',
'market.cap.secrets.detail': '读取 {evidence}',
'market.cap.network': '访问网络服务',
'market.cap.network.detail': '请求 {evidence}',
'market.cap.writes': '写入文件',
'market.cap.writes.detail': '创建或修改 {evidence}',
'market.cap.binaries': '依赖本地命令',
'market.cap.binaries.detail': '需要安装 {evidence}',
'market.cap.level.high': '高',
'market.cap.level.medium': '中',
'market.cap.level.low': '低',
'market.installConfirm.willGet': '这个技能将获得',
'market.installConfirm.ack': '我已查看 SKILL.md 与安全报告,了解上述权限',
// ─── Window controls / diagram + code viewers ────────────────────────────────────── // ─── Window controls / diagram + code viewers ──────────────────────────────────────
'windowControls.minimize': '最小化窗口', 'windowControls.minimize': '最小化窗口',
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import { detectCapabilities, extractTriggers, readingMinutes } from './skillInsights'
// Ported from dsh-skills-hub `tests/skill-insights.test.mjs`.
describe('detectCapabilities', () => {
it('cites its evidence: scripts, commands, hooks, writes', () => {
const markdown = [
'# Self-Improvement',
'```bash',
'mkdir -p .learnings',
'[ -f .learnings/LEARNINGS.md ] || printf "# Learnings" > .learnings/LEARNINGS.md',
'```',
'Optionally copy the hook to ~/.openclaw/hooks and restart the gateway.',
].join('\n')
const found = detectCapabilities({
markdown,
files: [
{ path: 'SKILL.md' },
{ path: 'scripts/extract-skill.sh' },
{ path: 'scripts/tests/extract.test.sh' },
{ path: 'hooks/openclaw/handler.js' },
{ path: 'hooks/openclaw/handler.test.js' },
],
})
const byKind = Object.fromEntries(found.map((capability) => [capability.kind, capability]))
// One entry script plus the commands it runs; hook handlers are their own finding.
expect(byKind.shell?.evidence).toEqual(['scripts/extract-skill.sh', 'mkdir', 'printf'])
expect(byKind.shell?.level).toBe('high')
expect(byKind.hooks?.evidence).toEqual(['hooks/', '~/.openclaw/hooks'])
expect(byKind.writes?.evidence).toEqual(['.learnings/'])
})
it('reports declared requirements, secrets and called hosts, but not doc links', () => {
const found = detectCapabilities({
markdown: 'Set `TAVILY_API_KEY`.\n```js\nfetch("https://api.tavily.com/search")\n// see https://github.com/x/y\n```',
frontmatter: { metadata: '{"clawdbot":{"requires":{"bins":["git"],"env":["GH_TOKEN"]}}}' },
files: [{ path: 'SKILL.md' }],
})
const byKind = Object.fromEntries(found.map((capability) => [capability.kind, capability.evidence]))
expect(byKind.binaries).toEqual(['git'])
expect(byKind.secrets).toEqual(['GH_TOKEN', 'TAVILY_API_KEY'])
expect(byKind.network).toEqual(['api.tavily.com'])
})
it('makes no capability claims at all for a plain prose skill', () => {
// An empty "nothing risky" verdict is a claim the rules cannot back.
expect(detectCapabilities({ markdown: '# Writing guide\nBe concise.', files: [{ path: 'SKILL.md' }] })).toEqual([])
})
it('orders findings by risk, highest first', () => {
const found = detectCapabilities({
markdown: '```bash\nmkdir -p out\ncurl https://api.example.org/v1\n```\nNeeds `OPENAI_API_KEY`.',
files: [{ path: 'SKILL.md' }, { path: 'hooks/pre.js' }],
})
expect(found.map((capability) => capability.kind)).toEqual(['shell', 'hooks', 'secrets', 'network', 'writes'])
})
})
describe('extractTriggers', () => {
it('reads "Use when (1)… (2)…" and Chinese 当…时 phrasing', () => {
expect(
extractTriggers('Captures learnings. Use when: (1) A command fails unexpectedly, (2) User corrects Claude, (3) An external API fails'),
).toEqual(['A command fails unexpectedly', 'User corrects Claude', 'An external API fails'])
expect(extractTriggers('当用户需要查询医药政策、解读政策影响时使用')).toEqual(['用户需要查询医药政策', '解读政策影响'])
})
it('returns nothing for a description without a trigger clause', () => {
expect(extractTriggers('A plain description.')).toEqual([])
expect(extractTriggers(undefined)).toEqual([])
})
})
describe('readingMinutes', () => {
it('counts CJK characters and Latin words', () => {
expect(readingMinutes('word '.repeat(440))).toBe(2)
expect(readingMinutes('字'.repeat(800))).toBe(2)
expect(readingMinutes('')).toBe(1)
})
})
+166
View File
@@ -0,0 +1,166 @@
/**
* What a skill will do, read off its own files — before anyone installs it.
*
* Ported from dsh-skills-hub `src/client/skill-insights.ts`; keep the two in
* step when either changes its rules.
*
* Everything here is rule-based and explainable: each finding points at the
* file, command, variable or host it came from, and nothing is inferred that
* the SKILL.md and file list do not show. When no rule fires, the panel is not
* rendered at all; an empty "this skill does nothing risky" claim would be a
* verdict this module cannot make.
*/
import type { MarketFileMeta } from '../types/market'
export type CapabilityKind = 'shell' | 'hooks' | 'network' | 'secrets' | 'binaries' | 'writes'
export type CapabilityLevel = 'high' | 'medium' | 'low'
export type Capability = {
kind: CapabilityKind
level: CapabilityLevel
/** The evidence, verbatim: paths, commands, variable names or hosts. */
evidence: string[]
}
const SCRIPT_EXTENSIONS = /\.(sh|bash|zsh|py|js|mjs|cjs|ts|rb|ps1|pl|php|go|rs)$/i
const SHELL_FENCE = /```(?:bash|sh|shell|zsh|console|terminal)\s*\n([\s\S]*?)```/gi
const ANY_FENCE = /```[^\n]*\n([\s\S]*?)```/g
const ENV_SECRET = /\b([A-Z][A-Z0-9]*_(?:API_KEY|APIKEY|KEY|TOKEN|SECRET|ACCESS_KEY|SECRET_KEY|PASSWORD))\b/g
const URL_HOST = /https?:\/\/([a-z0-9.-]+\.[a-z]{2,})(?::\d+)?[/\w.?=&%-]*/gi
/** Hosts that are documentation links rather than something the skill calls. */
const DOC_HOSTS = /(^|\.)(github\.com|githubusercontent\.com|clawhub\.ai|skillhub\.cn|npmjs\.com|pypi\.org|wikipedia\.org|example\.com|localhost)$/i
const WRITE_COMMAND = /(^|[\s;&|])(mkdir|touch|tee|cp|mv|rm|printf[^\n]*>|echo[^\n]*>|cat\s*>)\b/m
/** Shell builtins and plumbing that say nothing about what a skill does. */
const SHELL_NOISE = new Set(['cd', 'echo', 'export', 'set', 'then', 'fi', 'do', 'done', 'else', 'if', 'test', 'true', 'false', 'source', 'cat', 'ls'])
const LEVEL_OF: Record<CapabilityKind, CapabilityLevel> = {
shell: 'high',
hooks: 'high',
secrets: 'medium',
network: 'medium',
writes: 'medium',
binaries: 'low',
}
function unique(values: Iterable<string>, limit: number): string[] {
return [...new Set(values)].slice(0, limit)
}
/** `metadata.<any agent>.requires.{bins,env}` — the common SKILL.md convention. */
function declaredRequirements(frontmatter: Record<string, unknown> | undefined): { bins: string[]; env: string[] } {
const bins: string[] = []
const env: string[] = []
let metadata = frontmatter?.['metadata']
if (typeof metadata === 'string') {
try {
metadata = JSON.parse(metadata)
} catch {
metadata = undefined
}
}
const visit = (value: unknown, depth: number): void => {
if (depth > 4 || typeof value !== 'object' || value === null) return
const record = value as Record<string, unknown>
const requires = record['requires']
if (typeof requires === 'object' && requires !== null) {
const req = requires as Record<string, unknown>
for (const bin of Array.isArray(req['bins']) ? req['bins'] : []) if (typeof bin === 'string') bins.push(bin)
for (const name of Array.isArray(req['env']) ? req['env'] : []) if (typeof name === 'string') env.push(name)
}
for (const child of Object.values(record)) visit(child, depth + 1)
}
visit(metadata, 0)
return { bins, env }
}
export function detectCapabilities(input: {
markdown: string
frontmatter?: Record<string, unknown>
files: readonly Pick<MarketFileMeta, 'path'>[]
}): Capability[] {
const { markdown, files } = input
const found = new Map<CapabilityKind, string[]>()
const add = (kind: CapabilityKind, evidence: string[]): void => {
if (evidence.length > 0) found.set(kind, unique([...(found.get(kind) ?? []), ...evidence], 3))
}
// Tests and hook handlers are not what a reader runs; the scripts they would
// run come first, shortest path first (top-level entry points before helpers).
const scripts = files
.map((file) => file.path)
.filter((path) => SCRIPT_EXTENSIONS.test(path) && !/(^|[/.])(test|spec)s?([/.]|$)/i.test(path))
.filter((path) => !/(^|\/)hooks?\//i.test(path))
.sort((a, b) => a.split('/').length - b.split('/').length || a.length - b.length)
const shellBlocks = [...markdown.matchAll(SHELL_FENCE)].map((match) => match[1] ?? '')
const firstCommands = shellBlocks
.flatMap((block) => block.split('\n'))
.map((line) => line.replace(/^\s*\$\s*/, '').trim())
.filter((line) => line !== '' && !line.startsWith('#'))
.flatMap((line) => line.split(/\s*(?:\|\||&&|;|\|)\s*/))
.map((segment) => segment.replace(/^\[.*?\]\s*/, '').split(/\s+/)[0] ?? '')
.filter((command) => /^[a-z][\w.-]*$/i.test(command) && !SHELL_NOISE.has(command))
add('shell', [...scripts.slice(0, 1), ...unique(firstCommands, 2)])
// A hook directory is one finding, not one per file inside it.
const hookDirs = files
.map((file) => /^(.*?(?:^|\/)hooks?\/)/i.exec(file.path)?.[1])
.filter((dir): dir is string => dir !== undefined)
add('hooks', unique(hookDirs, 1))
// A whole home- or project-relative path (`~/.openclaw/hooks`), never a fragment of one.
const hookTarget = /(?:^|[\s`'"(])((?:~|\.)\/[\w./-]*?hooks)\b/i.exec(markdown)?.[1]
if (hookTarget !== undefined && /hook/i.test(markdown)) add('hooks', [hookTarget])
const declared = declaredRequirements(input.frontmatter)
add('binaries', declared.bins)
add('secrets', [...declared.env, ...[...markdown.matchAll(ENV_SECRET)].map((match) => match[1] ?? '')])
const code = [...markdown.matchAll(ANY_FENCE)].map((match) => match[1] ?? '').join('\n')
const hosts = [...code.matchAll(URL_HOST)]
.map((match) => (match[1] ?? '').toLowerCase())
.filter((host) => host !== '' && !DOC_HOSTS.test(host))
add('network', hosts)
if (shellBlocks.some((block) => WRITE_COMMAND.test(block))) {
// Report where writes land (the directory), not every file written there.
const places = shellBlocks
.flatMap((block) => [...block.matchAll(/(?:mkdir\s+(?:-p\s+)?|>\s*)([.\w~/-]+)/g)].map((match) => match[1] ?? ''))
.filter((path) => path !== '' && path !== '/dev/null')
.map((path) => (path.includes('/') ? `${path.split('/').slice(0, -1).join('/')}/` : path.startsWith('.') ? `${path}/` : path))
add('writes', places.length > 0 ? unique(places, 2) : ['files'])
}
const order: CapabilityKind[] = ['shell', 'hooks', 'secrets', 'network', 'writes', 'binaries']
return order.filter((kind) => found.has(kind)).map((kind) => ({ kind, level: LEVEL_OF[kind], evidence: found.get(kind) ?? [] }))
}
/**
* The "when to use" list a SKILL.md description usually carries:
* `Use when (1) …, (2) …` / `Use when: …; …` / `当……时使用`.
*/
export function extractTriggers(description: string | undefined): string[] {
if (!description) return []
const text = description.replace(/\s+/g, ' ').trim()
const english = /\b(?:use|activate|trigger(?:ed)?)\s+(?:it\s+|this\s+)?when\b:?\s*(.+)$/i.exec(text)
let body = english?.[1]
if (body === undefined) {
const chinese = /(?:适用于|使用场景[::]|触发场景[::]|当)(.+?)(?:时(?:使用|触发|调用)|$)/.exec(text)
body = chinese?.[1]
}
if (body === undefined) return []
const numbered = body.split(/\s*\(\d+\)\s*|\s*(\d+)\s*|\s*\d+[.、)]\s+/).map((item) => item.trim()).filter(Boolean)
const items = numbered.length > 1 ? numbered : body.split(/[;;。]|,\s*or\s+|、/)
return items
.map((item) => item.replace(/^(?:or|and|或|以及)\s+/i, '').replace(/[,,;;.。]+$/, '').trim())
.filter((item) => item.length >= 3)
.map((item) => (item.length > 64 ? `${item.slice(0, 63)}…` : item))
.slice(0, 5)
}
/** Rough reading time: ~400 CJK characters or ~220 Latin words per minute. */
export function readingMinutes(markdown: string): number {
const cjk = (markdown.match(/[㐀-鿿]/g) ?? []).length
const words = (markdown.replace(/[㐀-鿿]/g, ' ').match(/[A-Za-z0-9_]+/g) ?? []).length
return Math.max(1, Math.round(cjk / 400 + words / 220))
}
+16 -8
View File
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react'
import { useTranslation } from '../i18n' import { useTranslation } from '../i18n'
import { useMarketStore } from '../stores/marketStore' import { marketOwnerOf, marketSkillKey, useMarketStore } from '../stores/marketStore'
import { useSkillStore } from '../stores/skillStore' import { useSkillStore } from '../stores/skillStore'
import { useUIStore } from '../stores/uiStore' import { useUIStore } from '../stores/uiStore'
import { InstallConfirmDialog } from '../components/market/InstallConfirmDialog' import { InstallConfirmDialog } from '../components/market/InstallConfirmDialog'
@@ -13,18 +13,25 @@ export function Market({ featured }: { featured?: ReactNode } = {}) {
const t = useTranslation() const t = useTranslation()
const selectedId = useMarketStore((s) => s.selectedId) const selectedId = useMarketStore((s) => s.selectedId)
const installingIds = useMarketStore((s) => s.installingIds) const installingIds = useMarketStore((s) => s.installingIds)
const detailCache = useMarketStore((s) => s.detailCache)
const [confirmInstall, setConfirmInstall] = useState<NormalizedSkill | null>(null) const [confirmInstall, setConfirmInstall] = useState<NormalizedSkill | null>(null)
/** The ClawHub owner the pending install is pinned to (slugs are not unique there). */
const [confirmOwner, setConfirmOwner] = useState<string | undefined>(undefined)
const [confirmUninstall, setConfirmUninstall] = useState<NormalizedSkill | null>(null) const [confirmUninstall, setConfirmUninstall] = useState<NormalizedSkill | null>(null)
const findSkill = (id: string): NormalizedSkill | null => { /** `owner` narrows a ClawHub id to the card that was clicked; without it the first match wins. */
const findSkill = (id: string, owner?: string): NormalizedSkill | null => {
const state = useMarketStore.getState() const state = useMarketStore.getState()
if (state.detail?.id === id) return state.detail const matches = (skill: NormalizedSkill) => skill.id === id && (!owner || marketOwnerOf(skill) === owner)
return state.items.find((item) => item.id === id) ?? state.detailCache.get(id) ?? null if (state.detail && matches(state.detail)) return state.detail
return state.items.find(matches) ?? state.detailCache.get(marketSkillKey(id, owner)) ?? null
} }
const requestInstall = (id: string) => { const requestInstall = (id: string, owner?: string) => {
const skill = findSkill(id) const skill = findSkill(id, owner)
if (skill) setConfirmInstall(skill) if (!skill) return
setConfirmOwner(owner)
setConfirmInstall(skill)
} }
const requestUninstall = (id: string) => { const requestUninstall = (id: string) => {
@@ -35,7 +42,7 @@ export function Market({ featured }: { featured?: ReactNode } = {}) {
const runInstall = async () => { const runInstall = async () => {
const skill = confirmInstall const skill = confirmInstall
if (!skill) return if (!skill) return
const ok = await useMarketStore.getState().install(skill.id) const ok = await useMarketStore.getState().install(skill.id, confirmOwner)
setConfirmInstall(null) setConfirmInstall(null)
if (ok) { if (ok) {
useUIStore.getState().addToast({ useUIStore.getState().addToast({
@@ -87,6 +94,7 @@ export function Market({ featured }: { featured?: ReactNode } = {}) {
<InstallConfirmDialog <InstallConfirmDialog
skill={confirmInstall} skill={confirmInstall}
detail={confirmInstall ? detailCache.get(marketSkillKey(confirmInstall.id, confirmOwner)) : null}
open={confirmInstall !== null} open={confirmInstall !== null}
installing={confirmInstall !== null && installingIds.has(confirmInstall.id)} installing={confirmInstall !== null && installingIds.has(confirmInstall.id)}
onConfirm={() => void runInstall()} onConfirm={() => void runInstall()}
+243 -2
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('../api/market', () => ({ vi.mock('../api/market', () => ({
marketApi: { marketApi: {
@@ -12,7 +12,7 @@ vi.mock('../api/market', () => ({
})) }))
import { marketApi } from '../api/market' import { marketApi } from '../api/market'
import { useMarketStore, classifyInstallError } from './marketStore' import { useMarketStore, classifyInstallError, marketOwnerOf, marketSkillKey } from './marketStore'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import type { MarketListResponse, NormalizedSkill, NormalizedSkillDetail } from '../types/market' import type { MarketListResponse, NormalizedSkill, NormalizedSkillDetail } from '../types/market'
@@ -71,13 +71,20 @@ beforeEach(() => {
items: [], items: [],
nextCursor: null, nextCursor: null,
sources: {}, sources: {},
scope: 'catalog',
category: 'all',
total: null,
categories: [],
catalogGeneratedAt: null,
query: '', query: '',
liveQuery: '',
filters: { source: 'all', security: 'all', installed: 'all' }, filters: { source: 'all', security: 'all', installed: 'all' },
isLoading: false, isLoading: false,
isLoadingMore: false, isLoadingMore: false,
error: null, error: null,
loadMoreError: null, loadMoreError: null,
selectedId: null, selectedId: null,
selectedOwner: null,
detail: null, detail: null,
isDetailLoading: false, isDetailLoading: false,
detailError: null, detailError: null,
@@ -217,6 +224,240 @@ describe('marketStore list', () => {
}) })
}) })
describe('marketStore catalog and live scope', () => {
beforeEach(() => {
vi.useFakeTimers()
mockedApi.list.mockResolvedValue(listResponse([]))
})
afterEach(() => {
vi.useRealTimers()
})
it('opens on the curated catalog and passes scope and category through', async () => {
mockedApi.list.mockResolvedValue({
...listResponse([makeSkill({ curated: true, category: 'dev' })]),
scope: 'catalog',
total: 398,
categories: [{ key: 'dev', name: '开发编程', nameEn: 'Development', count: 40 }],
catalogGeneratedAt: 1_780_000_000_000,
})
await useMarketStore.getState().fetchList({ reset: true })
expect(mockedApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'catalog', category: 'all' }))
const state = useMarketStore.getState()
expect(state.total).toBe(398)
expect(state.categories.map((c) => c.key)).toEqual(['dev'])
expect(state.catalogGeneratedAt).toBe(1_780_000_000_000)
useMarketStore.getState().setCategory('dev')
expect(mockedApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'catalog', category: 'dev' }))
})
it('debounces catalog search and flushes it on Enter', async () => {
useMarketStore.getState().setQuery('pdf')
expect(mockedApi.list).not.toHaveBeenCalled()
useMarketStore.getState().submitQuery()
expect(mockedApi.list).toHaveBeenCalledTimes(1)
expect(mockedApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'catalog', q: 'pdf' }))
// The flushed debounce must not fire a second time.
await vi.advanceTimersByTimeAsync(400)
expect(mockedApi.list).toHaveBeenCalledTimes(1)
})
it('runs a typed catalog search after the debounce window', async () => {
useMarketStore.getState().setQuery('p')
useMarketStore.getState().setQuery('pd')
await vi.advanceTimersByTimeAsync(300)
expect(mockedApi.list).toHaveBeenCalledTimes(1)
expect(mockedApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'pd' }))
})
it('switches to the live market explicitly, without the catalog category', () => {
useMarketStore.setState({ query: 'pdf', category: 'dev' })
useMarketStore.getState().searchAllMarkets()
expect(useMarketStore.getState().scope).toBe('market')
expect(useMarketStore.getState().liveQuery).toBe('pdf')
expect(mockedApi.list).toHaveBeenLastCalledWith(
expect.objectContaining({ scope: 'market', q: 'pdf', category: undefined }),
)
})
it('does not request while typing in live scope, only on Enter', async () => {
useMarketStore.setState({ scope: 'market', query: 'pdf', liveQuery: 'pdf' })
useMarketStore.getState().setQuery('pdf tools')
await vi.advanceTimersByTimeAsync(1_000)
expect(mockedApi.list).not.toHaveBeenCalled()
useMarketStore.getState().submitQuery()
expect(mockedApi.list).toHaveBeenCalledTimes(1)
expect(mockedApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'market', q: 'pdf tools' }))
})
it('keeps paging the submitted live query, not the one still being typed', async () => {
useMarketStore.setState({ scope: 'market', query: 'pdf edits', liveQuery: 'pdf', nextCursor: 'next' })
await useMarketStore.getState().loadMore()
expect(mockedApi.list).toHaveBeenLastCalledWith(
expect.objectContaining({ scope: 'market', q: 'pdf', cursor: 'next' }),
)
})
it('returns to the catalog at once when the query is cleared', () => {
useMarketStore.setState({ scope: 'market', query: 'pdf', liveQuery: 'pdf' })
useMarketStore.getState().setQuery('')
expect(useMarketStore.getState().scope).toBe('catalog')
expect(useMarketStore.getState().liveQuery).toBe('')
expect(mockedApi.list).toHaveBeenCalledTimes(1)
expect(mockedApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'catalog', q: undefined }))
})
it('goes back to the catalog with the same query', () => {
useMarketStore.setState({ scope: 'market', query: 'pdf', liveQuery: 'pdf', category: 'dev' })
useMarketStore.getState().backToCatalog()
expect(useMarketStore.getState().scope).toBe('catalog')
expect(mockedApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: 'catalog', q: 'pdf', category: 'dev' }))
})
it('drops a catalog response that lands after switching to the live market', async () => {
const catalogPage = deferred<MarketListResponse>()
mockedApi.list
.mockImplementationOnce(() => catalogPage.promise)
.mockResolvedValueOnce({ ...listResponse([makeSkill({ id: 'skillhub:live', source: 'skillhub' })]), scope: 'market' })
useMarketStore.setState({ query: 'pdf' })
const catalogRequest = useMarketStore.getState().fetchList({ reset: true })
useMarketStore.getState().searchAllMarkets()
await vi.waitFor(() => expect(useMarketStore.getState().isLoading).toBe(false))
catalogPage.resolve({ ...listResponse([makeSkill({ id: 'clawhub:stale' })]), scope: 'catalog', total: 1 })
await catalogRequest
expect(useMarketStore.getState().items.map((item) => item.id)).toEqual(['skillhub:live'])
expect(useMarketStore.getState().total).toBeNull()
})
it('keeps the known category list when live results carry none', async () => {
const categories = [{ key: 'dev', name: '开发编程', nameEn: 'Development', count: 40 }]
useMarketStore.setState({ query: 'pdf', categories })
useMarketStore.getState().searchAllMarkets()
await vi.waitFor(() => expect(useMarketStore.getState().isLoading).toBe(false))
expect(useMarketStore.getState().categories).toEqual(categories)
})
})
describe('marketStore ClawHub owner pinning', () => {
// ClawHub slugs are not unique: two publishers can both ship `pdf`.
const fromAlice = makeDetail({ id: 'clawhub:pdf', slug: 'pdf', author: { handle: 'alice' }, summary: 'Alice pdf' })
const fromBob = makeDetail({ id: 'clawhub:pdf', slug: 'pdf', author: { handle: 'bob' }, summary: 'Bob pdf' })
it('opens a ClawHub result pinned to the clicked card\'s owner', async () => {
mockedApi.detail.mockResolvedValue({ skill: fromAlice, sourceStatus: { status: 'ok' } })
await useMarketStore.getState().openDetail('clawhub:pdf', 'alice')
expect(mockedApi.detail).toHaveBeenCalledWith('clawhub', 'pdf', { owner: 'alice' })
expect(useMarketStore.getState().selectedOwner).toBe('alice')
})
it('never serves one owner\'s cached detail for the other owner\'s card', async () => {
mockedApi.detail
.mockResolvedValueOnce({ skill: fromAlice, sourceStatus: { status: 'ok' } })
.mockResolvedValueOnce({ skill: fromBob, sourceStatus: { status: 'ok' } })
await useMarketStore.getState().openDetail('clawhub:pdf', 'alice')
useMarketStore.getState().backToList()
await useMarketStore.getState().openDetail('clawhub:pdf', 'bob')
expect(mockedApi.detail).toHaveBeenCalledTimes(2)
expect(mockedApi.detail).toHaveBeenLastCalledWith('clawhub', 'pdf', { owner: 'bob' })
expect(useMarketStore.getState().detail?.summary).toBe('Bob pdf')
// Each owner keeps its own cache entry.
useMarketStore.getState().backToList()
await useMarketStore.getState().openDetail('clawhub:pdf', 'alice')
expect(mockedApi.detail).toHaveBeenCalledTimes(2)
expect(useMarketStore.getState().detail?.summary).toBe('Alice pdf')
})
it('keeps the owner when the open detail is refreshed', async () => {
mockedApi.detail.mockResolvedValue({ skill: fromBob, sourceStatus: { status: 'ok' } })
await useMarketStore.getState().openDetail('clawhub:pdf', 'bob')
await useMarketStore.getState().refreshDetail('clawhub:pdf')
expect(mockedApi.detail).toHaveBeenCalledTimes(2)
expect(mockedApi.detail).toHaveBeenLastCalledWith('clawhub', 'pdf', { owner: 'bob' })
})
it('caches file contents per owner and passes the owner through', async () => {
mockedApi.fileContent.mockImplementation(async (_source, _slug, path, owner) => ({
file: { path, content: `# ${owner}`, language: 'markdown', size: 3, truncated: false },
}))
const alice = await useMarketStore.getState().fetchFileContent('clawhub:pdf', 'SKILL.md', 'alice')
const bob = await useMarketStore.getState().fetchFileContent('clawhub:pdf', 'SKILL.md', 'bob')
expect(alice.content).toBe('# alice')
expect(bob.content).toBe('# bob')
expect(mockedApi.fileContent).toHaveBeenCalledWith('clawhub', 'pdf', 'SKILL.md', 'alice')
expect(mockedApi.fileContent).toHaveBeenCalledWith('clawhub', 'pdf', 'SKILL.md', 'bob')
})
it('installs the owner\'s skill, and only that card flips to installed', async () => {
useMarketStore.setState({
items: [
makeSkill({ id: 'clawhub:pdf', slug: 'pdf', author: { handle: 'alice' } }),
makeSkill({ id: 'clawhub:pdf', slug: 'pdf', author: { handle: 'bob' } }),
],
})
mockedApi.install.mockResolvedValue({
ok: true,
installedPath: '/tmp/skills/pdf',
skill: makeSkill({ id: 'clawhub:pdf', slug: 'pdf', author: { handle: 'bob' }, installState: 'installed' }),
})
await useMarketStore.getState().install('clawhub:pdf', 'bob')
expect(mockedApi.install).toHaveBeenCalledWith('clawhub:pdf', 'bob')
expect(useMarketStore.getState().items.map((item) => [item.author.handle, item.installState])).toEqual([
['alice', 'installable'],
['bob', 'installed'],
])
})
it('sends no owner for SkillHub, whose ids are unique', async () => {
const skillhub = makeDetail({ id: 'skillhub:pdf', slug: 'pdf', source: 'skillhub', author: { handle: 'carol' } })
mockedApi.detail.mockResolvedValue({ skill: skillhub, sourceStatus: { status: 'ok' } })
expect(marketOwnerOf(skillhub)).toBeUndefined()
await useMarketStore.getState().openDetail('skillhub:pdf', marketOwnerOf(skillhub))
expect(mockedApi.detail).toHaveBeenCalledWith('skillhub', 'pdf', {})
expect(useMarketStore.getState().selectedOwner).toBeNull()
})
it('takes the owner only from a ClawHub card with a handle', () => {
expect(marketOwnerOf(makeSkill({ author: { handle: 'alice' } }))).toBe('alice')
expect(marketOwnerOf(makeSkill({ author: { handle: '' } }))).toBeUndefined()
expect(marketSkillKey('clawhub:pdf', 'alice')).toBe('clawhub:pdf@alice')
expect(marketSkillKey('skillhub:pdf')).toBe('skillhub:pdf')
})
})
describe('marketStore detail cache', () => { describe('marketStore detail cache', () => {
it('fetches detail once and serves the second open from cache', async () => { it('fetches detail once and serves the second open from cache', async () => {
mockedApi.detail.mockResolvedValue({ skill: makeDetail(), sourceStatus: { status: 'ok' } }) mockedApi.detail.mockResolvedValue({ skill: makeDetail(), sourceStatus: { status: 'ok' } })
+189 -61
View File
@@ -2,8 +2,10 @@ import { create } from 'zustand'
import { marketApi } from '../api/market' import { marketApi } from '../api/market'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import type { import type {
MarketCategory,
MarketFileContent, MarketFileContent,
MarketInstalledFilter, MarketInstalledFilter,
MarketScope,
MarketSecurityFilter, MarketSecurityFilter,
MarketSource, MarketSource,
MarketSourceFilter, MarketSourceFilter,
@@ -49,7 +51,27 @@ type MarketStore = {
items: NormalizedSkill[] items: NormalizedSkill[]
nextCursor: string | null nextCursor: string | null
sources: Partial<Record<MarketSource, SourceStatusInfo>> sources: Partial<Record<MarketSource, SourceStatusInfo>>
/**
* `catalog` is the curated snapshot the server ships (no network); `market`
* searches both registries live. The home page opens on the catalog, and the
* live search is only ever entered on purpose.
*/
scope: MarketScope
/** Catalog category key, or `'all'`. Only sent in catalog scope. */
category: string
/** Catalog scope: matches across all pages. `null` when the server sent none. */
total: number | null
/** Catalog category bar, kept across scope switches so it never flickers. */
categories: MarketCategory[]
catalogGeneratedAt: number | null
/** What the search box holds right now. */
query: string query: string
/**
* The query a live search was last *submitted* with. Live search runs on
* Enter only, so the box may already hold something else — and the next
* page must keep paging the search the reader actually asked for.
*/
liveQuery: string
filters: MarketFilters filters: MarketFilters
isLoading: boolean isLoading: boolean
isLoadingMore: boolean isLoadingMore: boolean
@@ -63,9 +85,12 @@ type MarketStore = {
loadMoreError: string | null loadMoreError: string | null
selectedId: string | null selectedId: string | null
/** ClawHub owner the open detail is pinned to; refreshes and file reads reuse it. */
selectedOwner: string | null
detail: NormalizedSkillDetail | null detail: NormalizedSkillDetail | null
isDetailLoading: boolean isDetailLoading: boolean
detailError: string | null detailError: string | null
/** Keyed by `marketSkillKey(id, owner)`, never by bare id for a ClawHub skill. */
detailCache: Map<string, NormalizedSkillDetail> detailCache: Map<string, NormalizedSkillDetail>
activeFilePath: string | null activeFilePath: string | null
@@ -76,13 +101,26 @@ type MarketStore = {
fetchList: (options?: { reset?: boolean }) => Promise<void> fetchList: (options?: { reset?: boolean }) => Promise<void>
loadMore: () => Promise<void> loadMore: () => Promise<void>
/**
* Catalog scope searches as the reader types (debounced, local server only).
* Live scope never does: each keystroke would be two upstream requests.
* An emptied box always returns to the catalog at once.
*/
setQuery: (q: string) => void setQuery: (q: string) => void
/** Enter in the search box: flushes the catalog debounce, or runs the live search. */
submitQuery: () => void
/** The explicit "search all markets for q" action. */
searchAllMarkets: () => void
backToCatalog: () => void
setCategory: (category: string) => void
setFilter: <K extends keyof MarketFilters>(key: K, value: MarketFilters[K]) => void setFilter: <K extends keyof MarketFilters>(key: K, value: MarketFilters[K]) => void
openDetail: (id: string) => Promise<void> /** `owner` comes from the clicked card (see `marketOwnerOf`). */
openDetail: (id: string, owner?: string) => Promise<void>
/** Re-reads the open detail, pinned to the owner it was opened with. */
refreshDetail: (id: string) => Promise<void> refreshDetail: (id: string) => Promise<void>
/** Fetch a file's content with session-level caching. Throws on failure. */ /** Fetch a file's content with session-level caching. Throws on failure. */
fetchFileContent: (id: string, path: string) => Promise<MarketFileContent> fetchFileContent: (id: string, path: string, owner?: string) => Promise<MarketFileContent>
install: (id: string) => Promise<boolean> install: (id: string, owner?: string) => Promise<boolean>
uninstall: (id: string) => Promise<boolean> uninstall: (id: string) => Promise<boolean>
backToList: () => void backToList: () => void
} }
@@ -91,44 +129,78 @@ let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let listRequestSeq = 0 let listRequestSeq = 0
let detailRequestSeq = 0 let detailRequestSeq = 0
function fileCacheKey(id: string, path: string): string { function listParams(state: MarketStore) {
return `${id}:${path}` const catalog = state.scope === 'catalog'
const q = (catalog ? state.query : state.liveQuery).trim()
return {
scope: state.scope,
category: catalog ? state.category : undefined,
q: q || undefined,
source: state.filters.source,
security: state.filters.security,
installed: state.filters.installed,
limit: PAGE_SIZE,
}
}
function cancelSearchDebounce() {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
/**
* The publisher a ClawHub read has to be pinned to. ClawHub slugs are not
* unique — two owners can both publish `pdf` — so `id` alone can open, preview
* or install a different skill than the card the reader clicked. SkillHub ids
* are unique and never carry one.
*/
export function marketOwnerOf(skill: Pick<NormalizedSkill, 'source' | 'author'> | null | undefined): string | undefined {
if (!skill || skill.source !== 'clawhub') return undefined
return skill.author?.handle || undefined
}
/** Cache identity of one skill: the id, narrowed by owner when there is one. */
export function marketSkillKey(id: string, owner?: string | null): string {
return owner ? `${id}@${owner}` : id
}
function fileCacheKey(id: string, owner: string | null | undefined, path: string): string {
return `${marketSkillKey(id, owner)}:${path}`
}
/** Same skill: same id, and the same owner whenever both sides name one. */
function sameSkill(a: NormalizedSkill, b: NormalizedSkill): boolean {
if (a.id !== b.id) return false
const ownerA = marketOwnerOf(a)
const ownerB = marketOwnerOf(b)
return !ownerA || !ownerB || ownerA === ownerB
} }
/** Replace an item in place (list + detail) after install/uninstall state changes. */ /** Replace an item in place (list + detail) after install/uninstall state changes. */
function mergeSkillUpdate(state: MarketStore, updated: NormalizedSkill): Partial<MarketStore> { function mergeSkillUpdate(state: MarketStore, updated: NormalizedSkill): Partial<MarketStore> {
const installPatch = {
installState: updated.installState,
notInstallableReason: updated.notInstallableReason,
installedInfo: updated.installedInfo,
}
const items = state.items const items = state.items
.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)) .map((item) => (sameSkill(item, updated) ? { ...item, ...updated } : item))
.filter((item) => { .filter((item) => {
if (state.filters.installed === 'installed') return item.installState === 'installed' if (state.filters.installed === 'installed') return item.installState === 'installed'
if (state.filters.installed === 'installable') return item.installState !== 'installed' if (state.filters.installed === 'installable') return item.installState !== 'installed'
return true return true
}) })
const patch: Partial<MarketStore> = { items } const patch: Partial<MarketStore> = { items }
if (state.detail && state.detail.id === updated.id) { if (state.detail && sameSkill(state.detail, updated)) {
const detail = { patch.detail = { ...state.detail, ...installPatch }
...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
}
} }
let cache: Map<string, NormalizedSkillDetail> | null = null
for (const [key, cached] of state.detailCache) {
if (!sameSkill(cached, updated)) continue
cache ??= new Map(state.detailCache)
cache.set(key, { ...cached, ...installPatch })
}
if (cache) patch.detailCache = cache
return patch return patch
} }
@@ -136,7 +208,13 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
items: [], items: [],
nextCursor: null, nextCursor: null,
sources: {}, sources: {},
scope: 'catalog',
category: 'all',
total: null,
categories: [],
catalogGeneratedAt: null,
query: '', query: '',
liveQuery: '',
filters: { source: 'all', security: 'all', installed: 'all' }, filters: { source: 'all', security: 'all', installed: 'all' },
isLoading: false, isLoading: false,
isLoadingMore: false, isLoadingMore: false,
@@ -144,6 +222,7 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
loadMoreError: null, loadMoreError: null,
selectedId: null, selectedId: null,
selectedOwner: null,
detail: null, detail: null,
isDetailLoading: false, isDetailLoading: false,
detailError: null, detailError: null,
@@ -157,7 +236,7 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
fetchList: async ({ reset = true } = {}) => { fetchList: async ({ reset = true } = {}) => {
const seq = ++listRequestSeq const seq = ++listRequestSeq
const { query, filters } = get() const params = listParams(get())
set({ set({
isLoading: true, isLoading: true,
isLoadingMore: false, isLoadingMore: false,
@@ -166,18 +245,17 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
...(reset ? { items: [], nextCursor: null } : {}), ...(reset ? { items: [], nextCursor: null } : {}),
}) })
try { try {
const result = await marketApi.list({ const result = await marketApi.list(params)
q: query.trim() || undefined,
source: filters.source,
security: filters.security,
installed: filters.installed,
limit: PAGE_SIZE,
})
if (seq !== listRequestSeq) return if (seq !== listRequestSeq) return
set({ set({
items: result.items, items: result.items,
nextCursor: result.nextCursor, nextCursor: result.nextCursor,
sources: result.sources, sources: result.sources,
total: result.total ?? null,
// Live results carry no category list; keep the catalog's so going
// back does not rebuild the chip row from nothing.
...(result.categories ? { categories: result.categories } : {}),
...(result.catalogGeneratedAt !== undefined ? { catalogGeneratedAt: result.catalogGeneratedAt } : {}),
isLoading: false, isLoading: false,
}) })
} catch (err) { } catch (err) {
@@ -187,19 +265,12 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
}, },
loadMore: async () => { loadMore: async () => {
const { nextCursor, isLoadingMore, isLoading, query, filters } = get() const { nextCursor, isLoadingMore, isLoading } = get()
if (!nextCursor || isLoadingMore || isLoading) return if (!nextCursor || isLoadingMore || isLoading) return
const seq = listRequestSeq const seq = listRequestSeq
set({ isLoadingMore: true, loadMoreError: null }) set({ isLoadingMore: true, loadMoreError: null })
try { try {
const result = await marketApi.list({ const result = await marketApi.list({ ...listParams(get()), cursor: nextCursor })
q: query.trim() || undefined,
source: filters.source,
security: filters.security,
installed: filters.installed,
cursor: nextCursor,
limit: PAGE_SIZE,
})
if (seq !== listRequestSeq) return if (seq !== listRequestSeq) return
const currentItems = get().items const currentItems = get().items
const seen = new Set(currentItems.map((i) => i.id)) const seen = new Set(currentItems.map((i) => i.id))
@@ -218,22 +289,68 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
}, },
setQuery: (q) => { setQuery: (q) => {
cancelSearchDebounce()
if (q.trim() === '') {
const wasEmpty = get().scope === 'catalog' && get().query.trim() === ''
set({ query: q, scope: 'catalog', liveQuery: '' })
if (!wasEmpty) void get().fetchList({ reset: true })
return
}
set({ query: q }) set({ query: q })
if (searchDebounceTimer) clearTimeout(searchDebounceTimer) if (get().scope !== 'catalog') return
searchDebounceTimer = setTimeout(() => { searchDebounceTimer = setTimeout(() => {
searchDebounceTimer = null
void get().fetchList({ reset: true }) void get().fetchList({ reset: true })
}, SEARCH_DEBOUNCE_MS) }, SEARCH_DEBOUNCE_MS)
}, },
submitQuery: () => {
const { scope, query } = get()
if (scope === 'catalog') {
// Nothing pending means the list already reflects the box.
if (!searchDebounceTimer) return
cancelSearchDebounce()
void get().fetchList({ reset: true })
return
}
const q = query.trim()
if (q === '') {
get().backToCatalog()
return
}
set({ liveQuery: q })
void get().fetchList({ reset: true })
},
searchAllMarkets: () => {
const q = get().query.trim()
if (q === '') return
cancelSearchDebounce()
set({ scope: 'market', liveQuery: q })
void get().fetchList({ reset: true })
},
backToCatalog: () => {
cancelSearchDebounce()
set({ scope: 'catalog', liveQuery: '' })
void get().fetchList({ reset: true })
},
setCategory: (category) => {
if (category === get().category) return
set({ category })
void get().fetchList({ reset: true })
},
setFilter: (key, value) => { setFilter: (key, value) => {
set({ filters: { ...get().filters, [key]: value } }) set({ filters: { ...get().filters, [key]: value } })
void get().fetchList({ reset: true }) void get().fetchList({ reset: true })
}, },
openDetail: async (id) => { openDetail: async (id, owner) => {
const { detailCache } = get() const selectedOwner = owner || null
const cached = detailCache.get(id) const cached = get().detailCache.get(marketSkillKey(id, selectedOwner))
set({ selectedId: id, detailError: null, activeFilePath: null, installError: null }) set({ selectedId: id, selectedOwner, detailError: null, activeFilePath: null, installError: null })
if (cached) { if (cached) {
set({ detail: cached, isDetailLoading: false }) set({ detail: cached, isDetailLoading: false })
return return
@@ -246,15 +363,19 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
const seq = ++detailRequestSeq const seq = ++detailRequestSeq
const [source, ...slugParts] = id.split(':') const [source, ...slugParts] = id.split(':')
const slug = slugParts.join(':') const slug = slugParts.join(':')
// Only the open detail has a known owner; a refresh of anything else is unpinned.
const owner = get().selectedId === id ? get().selectedOwner : null
const isCurrent = () =>
seq === detailRequestSeq && get().selectedId === id && get().selectedOwner === owner
set({ isDetailLoading: get().detail?.id !== id, detailError: null }) set({ isDetailLoading: get().detail?.id !== id, detailError: null })
try { try {
const { skill } = await marketApi.detail(source as MarketSource, slug) const { skill } = await marketApi.detail(source as MarketSource, slug, owner ? { owner } : {})
if (seq !== detailRequestSeq || get().selectedId !== id) return if (!isCurrent()) return
const cache = new Map(get().detailCache) const cache = new Map(get().detailCache)
cache.set(id, skill) cache.set(marketSkillKey(id, owner), skill)
set({ detail: skill, detailCache: cache, isDetailLoading: false, detailError: null }) set({ detail: skill, detailCache: cache, isDetailLoading: false, detailError: null })
} catch (err) { } catch (err) {
if (seq !== detailRequestSeq || get().selectedId !== id) return if (!isCurrent()) return
set({ set({
detailError: err instanceof Error ? err.message : String(err), detailError: err instanceof Error ? err.message : String(err),
isDetailLoading: false, isDetailLoading: false,
@@ -262,25 +383,25 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
} }
}, },
fetchFileContent: async (id, path) => { fetchFileContent: async (id, path, owner) => {
const key = fileCacheKey(id, path) const key = fileCacheKey(id, owner, path)
const cached = get().fileCache.get(key) const cached = get().fileCache.get(key)
if (cached) return cached if (cached) return cached
const [source, ...slugParts] = id.split(':') const [source, ...slugParts] = id.split(':')
const slug = slugParts.join(':') const slug = slugParts.join(':')
const { file } = await marketApi.fileContent(source as MarketSource, slug, path) const { file } = await marketApi.fileContent(source as MarketSource, slug, path, owner || undefined)
const cache = new Map(get().fileCache) const cache = new Map(get().fileCache)
cache.set(key, file) cache.set(key, file)
set({ fileCache: cache }) set({ fileCache: cache })
return file return file
}, },
install: async (id) => { install: async (id, owner) => {
const { installingIds } = get() const { installingIds } = get()
if (installingIds.has(id)) return false if (installingIds.has(id)) return false
set({ installingIds: new Set(installingIds).add(id), installError: null }) set({ installingIds: new Set(installingIds).add(id), installError: null })
try { try {
const result = await marketApi.install(id) const result = await marketApi.install(id, owner || undefined)
const state = get() const state = get()
const next = new Set(state.installingIds) const next = new Set(state.installingIds)
next.delete(id) next.delete(id)
@@ -333,6 +454,13 @@ export const useMarketStore = create<MarketStore>((set, get) => ({
backToList: () => { backToList: () => {
detailRequestSeq += 1 detailRequestSeq += 1
set({ selectedId: null, detail: null, detailError: null, activeFilePath: null, installError: null }) set({
selectedId: null,
selectedOwner: null,
detail: null,
detailError: null,
activeFilePath: null,
installError: null,
})
}, },
})) }))
+39
View File
@@ -20,6 +20,8 @@ export type SecurityReport = {
vendor: string vendor: string
status: string status: string
statusText: string statusText: string
/** The scanner's own explanation of its verdict, when it gives one. */
summary?: string
reportUrl?: string reportUrl?: string
} }
@@ -45,6 +47,17 @@ export type NormalizedSkill = {
installState: InstallState installState: InstallState
notInstallableReason?: NotInstallableReason notInstallableReason?: NotInstallableReason
installedInfo?: { version?: string; installedAt?: string; dirName: string } installedInfo?: { version?: string; installedAt?: string; dirName: string }
/** Editor's pick in the curated catalog. */
featured?: boolean
/**
* The entry is (or matches) a curated catalog skill. Only then is `category`
* a catalog category key; otherwise it is SkillHub's raw category string.
*/
curated?: boolean
/** Original upstream summary, for non-Chinese readers (`summary` is zh-CN for curated skills). */
summaryEn?: string
/** Why a curated skill ships despite a flagged verdict. */
securityNote?: string
} }
export type MarketFileMeta = { export type MarketFileMeta = {
@@ -62,6 +75,10 @@ export type NormalizedSkillDetail = NormalizedSkill & {
license?: string license?: string
files: MarketFileMeta[] files: MarketFileMeta[]
totalSize: number totalSize: number
/** Release note of the latest version, when upstream has a meaningful one. */
changelog?: { version?: string; text: string; publishedAt?: number }
/** The skill's page on its registry website. */
pageUrl?: string
} }
export type MarketFileContent = { export type MarketFileContent = {
@@ -79,10 +96,32 @@ export type SourceStatusInfo = {
error?: string error?: string
} }
/**
* `catalog`: the curated snapshot shipped with the app (no network).
* `market`: live list/search across both upstream registries.
*/
export type MarketScope = 'catalog' | 'market'
export type MarketCategory = {
key: string
/** zh-CN display name */
name: string
nameEn: string
/** Skills in this category across the whole catalog (static, not filtered). */
count: number
}
export type MarketListResponse = { export type MarketListResponse = {
items: NormalizedSkill[] items: NormalizedSkill[]
nextCursor: string | null nextCursor: string | null
sources: Record<MarketSource, SourceStatusInfo> sources: Record<MarketSource, SourceStatusInfo>
scope?: MarketScope
/** Catalog scope: matching skills across all pages. */
total?: number
/** Catalog scope: category bar entries. */
categories?: MarketCategory[]
/** Catalog scope: epoch millis of the upstream reads behind the snapshot. */
catalogGeneratedAt?: number
} }
export type MarketSourceFilter = 'all' | MarketSource export type MarketSourceFilter = 'all' | MarketSource
+29 -16
View File
@@ -13,48 +13,61 @@ order: 4
## 技能市场 ## 技能市场
![技能市场:卡片列表、安全徽标和来源筛选](../images/app/zh-CN/skill-market.webp) ![技能市场:分类、精选卡片和安全标记](../images/app/zh-CN/skill-market.webp)
点侧边栏的「技能市场」。它同时聚合 **ClawHub** 和 **SkillHub** 两个来源,列表往下滚会自动加载更多,没有「加载更多」按钮。 点侧边栏的「技能·连接器」,再切到顶部的「技能」。
顶部三个筛选: ClawHub 和 SkillHub 上加起来有十几万个技能,大部分是重复的、搬运的或者早就没人维护了。所以市场首页不直接列出它们,而是一份**精选清单**:从两个来源里挑出来的近 400 个常用技能,分成智能体增强、搜索调研、开发编程、办公文档等 13 个分类,每个技能配一句中文简介和一两个标签,标了「精选」的是编辑推荐。清单随应用一起发布,打开就有,不用等网络。清单顶部会写更新日期,卡片上的下载量和星标是那天的数据。
- **来源** — 全部 / ClawHub / SkillHub。 - **分类** — 点上方的分类按钮只看这一类,按钮上的数字是这一类有多少个技能。
- **安全状态** — 见下。 - **搜索** — 搜索框在精选清单里按名称、简介、标签和作者匹配。
- **安装状态** — 全部技能 / 已安装 / 未安装。 - **筛选** — 来源(ClawHub / SkillHub)、安全状态、安装状态。
搜索框按名称和关键词搜。 界面语言是英文、日文或韩文时,卡片显示作者的原始简介,不显示中文标签。
### 安全徽标怎么读 ### 在全部市场中搜索
每张卡片右上角有一个安全标记,它反映的是**来源方**的扫描结果,不是本应用的审计结论: 精选清单里找不到想要的,就点搜索框下方的「在全部市场中搜索」,直接查 ClawHub 和 SkillHub 的全部技能,这时输入关键词后按 Enter 才会搜索。这种结果**没有经过精选**,页面会单独标出来,装之前更要仔细看。清空搜索框就回到精选清单。
| 徽标 | 含义 | ### 安全标记怎么读
每张卡片上有一个安全标记,它反映的是**来源方**的扫描结果,不是本应用的审计结论:
| 标记 | 含义 |
|---|---| |---|---|
| 已认证 | 发布者已认证,且通过来源方安全扫描 | | 已认证 | 发布者已认证,且通过来源方安全扫描 |
| 扫描无风险 | 来源方安全扫描未发现风险 | | 扫描无风险 | 来源方安全扫描未发现风险 |
| 未审计 | 来源方没提供安全审计数据 | | 未审计 | 来源方没提供安全审计数据 |
| 存在风险 | 来源方扫描标记它有潜在风险 | | 存在风险 | 来源方扫描标记它有潜在风险 |
「未审计」不等于不安全,只是没人查过。「存在风险」的技能除非你自己看懂了它在干什么,否则别装。 「未审计」不等于不安全,只是没人查过。「存在风险」的技能除非你自己看懂了它在干什么,否则别装。精选清单里只有少数几个广为推荐的技能带着「存在风险」标记,它们被标记的原因会在详情页写明。
## 安装之前 ## 安装之前
技能会带来新的工具、脚本和外部依赖,装上以后 Claude 就可能去执行它们。市场页面顶部那条免责声明说的是实话:这些技能来自社区第三方来源,本应用不对内容做安全审计。 技能会带来新的工具、脚本和外部依赖,装上以后 Claude 就可能去执行它们。这些技能来自社区第三方作者,「精选」只是推荐,不是安全担保。
安装前建议: 点开一个技能,详情页分几块:
1. 在详情页切到「文件」标签,把 `SKILL.md` 和配套脚本读一遍。 - **概览** — 顶部的「安装前请了解:这个技能会做什么」会列出它要执行的命令或脚本、注册的 Hook、读取的密钥、访问的网络地址和写入的文件。这是按规则从 `SKILL.md` 和文件列表里识别出来的,只能参考,不能代替你自己读一遍。右侧「何时触发」是作者写的使用场景。
- **文件** — 逐个读 `SKILL.md` 和配套脚本。
- **安全报告** — 来源方每个扫描器的结论和完整报告链接。标记为「存在风险」时,这个标签旁边会有一个提示点。
- **更新日志** — 作者为最新版本写的说明(有的话)。
详情、文件和安装都会重新向来源方读取最新版本,不会用清单里的旧数据。
建议:
1. 至少读一遍 `SKILL.md` 和配套脚本。
2. 拿不准就让 Claude 先扫一遍——「帮我看看这个技能的文件里有没有可疑的东西」。 2. 拿不准就让 Claude 先扫一遍——「帮我看看这个技能的文件里有没有可疑的东西」。
3. 确认你真的需要它。装得多不等于更强,每个技能都会占上下文。 3. 确认你真的需要它。装得多不等于更强,每个技能都会占上下文。
点「安装」会弹确认框,里面写清了安装位置、安全提示和「安装完成后,技能将在新会话中生效」。**已经开着的会话不会立刻拿到新技能**,需要新建一条。 点「安装」会弹确认框,里面写清了安装位置、这个技能会获得的能力,以及「安装完成后,技能将在新会话中生效」。对「未审计」或「存在风险」的技能,要先勾选「我已查看 SKILL.md 与安全报告」才能确认安装。**已经开着的会话不会立刻拿到新技能**,需要新建一条。安装只是下载文件,不会执行技能里的任何脚本。
卸载在同一个位置,会删掉本地对应目录下的文件。 卸载在同一个位置,会删掉本地对应目录下的文件。
## 已安装的技能 ## 已安装的技能
设置 → 技能 里能看到本机所有可用技能,按来源分组: 技能页右上角的「我的技能」,或者 设置 → 技能,都能看到本机所有可用技能,按来源分组:
- **用户** — 你装的,在 `~/.claude/skills/`。 - **用户** — 你装的,在 `~/.claude/skills/`。
- **项目** — 跟着仓库来的。 - **项目** — 跟着仓库来的。
+25 -12
View File
@@ -13,17 +13,21 @@ A skill is a procedure someone else already worked out. Install it and Claude fo
## The Skills Market ## The Skills Market
![The Skills Market: cards, security badges, source filters (Chinese interface)](../../images/app/en/skill-market.webp) ![The Skills Market: categories, curated cards and security badges](../../images/app/en/skill-market.webp)
Click **Skills Market** in the sidebar. It aggregates two sources, **ClawHub** and **SkillHub**, and loads more as you scroll — there is no "load more" button. Click **Skills · Connectors** in the sidebar, then switch to **Skills** at the top.
Three filters across the top: ClawHub and SkillHub hold well over a hundred thousand skills between them, and most are duplicates, copies or abandoned experiments. So the market home doesn't list them directly. It opens on a **curated catalogue** instead: about 400 popular skills picked from both sources, sorted into 13 categories such as Agent Boost, Search & Research, Development and Office & Docs. Skills marked **Featured** are editor's picks. The catalogue ships with the app, so it appears instantly without waiting on the network. Its update date is shown at the top; download and star counts on the cards are from that date.
- **Source** — all sources, ClawHub, or SkillHub. - **Categories** — click a category to see only that kind of skill. The number on each button is how many skills it holds.
- **Security** — see below. - **Search** — the search box matches names, summaries, tags and authors within the catalogue.
- **Install status** — all skills, installed, or not installed. - **Filters** — source (ClawHub / SkillHub), security status, and install status.
The search box matches names and keywords. In an English, Japanese or Korean interface, cards show the author's original summary and hide the catalogue's Chinese tags.
### Search all markets
If the catalogue doesn't have what you need, click **Search all markets** under the search box to query every skill on ClawHub and SkillHub; in this mode a search runs when you press Enter. These results are **not curated**, and the page labels them that way — read them more carefully before installing. Clear the search box to return to the catalogue.
### Reading the security badges ### Reading the security badges
@@ -36,25 +40,34 @@ Each card carries a security badge. It reports what the **source** scanned, not
| Not audited | The source provided no security audit data | | Not audited | The source provided no security audit data |
| Flagged | The source's scan flagged potential risk | | Flagged | The source's scan flagged potential risk |
"Not audited" doesn't mean unsafe — it means nobody checked. "Flagged" means don't install it unless you've read the files and understand exactly what they do. "Not audited" doesn't mean unsafe — it means nobody checked. "Flagged" means don't install it unless you've read the files and understand exactly what they do. The catalogue includes only a few flagged skills, all widely recommended, and the reason each one is flagged is written on its detail page.
## Before you install ## Before you install
A skill can bring new tools, scripts, and external dependencies, and once installed Claude may run them. The disclaimer at the top of the market means what it says: these skills come from third-party community sources and this app does not audit their contents. A skill can bring new tools, scripts, and external dependencies, and once installed Claude may run them. These skills come from third-party community authors; **Featured** is a recommendation, not a security guarantee.
A skill's detail page has several parts:
- **Overview** — at the top, **Before installing: what this skill does** lists the commands or scripts it runs, hooks it registers, secrets it reads, network hosts it contacts and files it writes. It is detected by rules from `SKILL.md` and the file list, so treat it as a hint, not a substitute for reading the files. **When it triggers** on the right is the author's own list of situations.
- **Files** — read `SKILL.md` and any accompanying scripts.
- **Security report** — each of the source's scanners, with its verdict and a link to the full report. A flagged skill shows a dot next to this tab.
- **Changelog** — the author's notes for the latest version, when there are any.
Detail, files and installation always re-read the latest version from the source rather than the catalogue's snapshot.
Recommended: Recommended:
1. Open the **Files** tab on the detail page and read `SKILL.md` and any accompanying scripts. 1. Read `SKILL.md` and any accompanying scripts at least once.
2. If you're unsure, have Claude look first — "check this skill's files for anything suspicious". 2. If you're unsure, have Claude look first — "check this skill's files for anything suspicious".
3. Confirm you actually need it. More skills isn't more capability; every skill costs context. 3. Confirm you actually need it. More skills isn't more capability; every skill costs context.
**Install** opens a confirmation with the install location, the security note, and a reminder that the skill takes effect in new sessions. **Sessions already open won't pick it up** — start a new one. **Install** opens a confirmation with the install location, what the skill will be able to do, and a reminder that it takes effect in new sessions. For a **Not audited** or **Flagged** skill you must first tick "I have read SKILL.md and the security report" before you can confirm. **Sessions already open won't pick it up** — start a new one. Installing only downloads files; it never runs the skill's scripts.
Uninstall lives in the same place and deletes the local files under that skill's directory. Uninstall lives in the same place and deletes the local files under that skill's directory.
## Installed skills ## Installed skills
**Settings → Skills** lists everything available on this machine, grouped by source: **My skills** at the top right of the Skills page, or **Settings → Skills**, lists everything available on this machine, grouped by source:
- **User** — installed by you, in `~/.claude/skills/`. - **User** — installed by you, in `~/.claude/skills/`.
- **Project** — shipped with the repo. - **Project** — shipped with the repo.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bun
/**
* Regenerate the Skills Market curated catalog snapshot.
*
* bun run scripts/market-catalog-refresh.ts # rewrite skills.json
* bun run scripts/market-catalog-refresh.ts --check # report only, write nothing
*
* Reads `src/server/services/market/catalog/curation.json` (the editorial
* source) and re-reads what upstream owns for every entry. Upstream bases
* follow `HAHA_MARKET_BASE_CLAWHUB` / `HAHA_MARKET_BASE_SKILLHUB`.
*
* This talks to the live registries, so it is a maintainer tool only: no test
* or CI lane runs it, and it refuses to run when `CI` is set.
*/
import { readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import {
buildCatalogSnapshot,
clawhubLatestVersion,
curationEntriesToRead,
readClawhubEntry,
readSkillhubEntry,
type CatalogRead,
type CurationEntry,
type CurationFile,
} from '../src/server/services/market/catalog/catalogSnapshot.js'
import { getProviderBase } from '../src/server/services/market/providerFetch.js'
if (process.env.CI) {
console.log('market-catalog-refresh: skipped (CI is set; this script reads live registries)')
process.exit(0)
}
const CATALOG_DIR = join(import.meta.dir, '..', 'src', 'server', 'services', 'market', 'catalog')
const CURATION = join(CATALOG_DIR, 'curation.json')
const SNAPSHOT = join(CATALOG_DIR, 'skills.json')
const CONCURRENCY = 6
const checkOnly = process.argv.includes('--check')
type JsonResult = Record<string, any>
async function getJson(url: string): Promise<JsonResult> {
for (let attempt = 0; attempt < 4; attempt++) {
try {
const response = await fetch(url, { headers: { 'user-agent': 'cc-haha-market-catalog' } })
if (response.status === 429 || response.status >= 500) throw new Error(`HTTP ${response.status}`)
if (!response.ok) return { _status: response.status }
return (await response.json()) as JsonResult
} catch (error) {
if (attempt === 3) return { _error: String(error) }
await new Promise((resolve) => setTimeout(resolve, 1500 * (attempt + 1)))
}
}
return {}
}
async function readClawhub(entry: CurationEntry): Promise<CatalogRead> {
const base = getProviderBase('clawhub')
const owner = `?owner=${encodeURIComponent(entry.owner)}`
const detail = await getJson(`${base}/api/v1/skills/${encodeURIComponent(entry.slug)}${owner}`)
const version = clawhubLatestVersion(detail)
const versionDetail = detail.skill && version
? await getJson(`${base}/api/v1/skills/${encodeURIComponent(entry.slug)}/versions/${encodeURIComponent(version)}${owner}`)
: {}
return readClawhubEntry(entry, detail, versionDetail)
}
async function readSkillhub(entry: CurationEntry): Promise<CatalogRead> {
const base = getProviderBase('skillhub')
return readSkillhubEntry(entry, await getJson(`${base}/api/v1/skills/${encodeURIComponent(entry.slug)}`))
}
async function pool<T, R>(items: T[], worker: (item: T) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length)
let next = 0
await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
while (next < items.length) {
const index = next++
results[index] = await worker(items[index]!)
}
}))
return results
}
const curation = JSON.parse(await readFile(CURATION, 'utf8')) as CurationFile
const toRead = curationEntriesToRead(curation)
const results = await pool(toRead, (entry) => entry.source === 'clawhub' ? readClawhub(entry) : readSkillhub(entry))
const reads = new Map<string, CatalogRead>(toRead.map((entry, index) => [`${entry.source}:${entry.slug}`, results[index]!]))
const { snapshot, problems } = buildCatalogSnapshot(curation, reads, Date.now())
console.log(`catalog: ${snapshot.skills.length}/${curation.skills.length} entries kept`)
for (const problem of problems) console.log(` dropped ${problem}`)
if (!checkOnly) {
await writeFile(SNAPSHOT, `${JSON.stringify(snapshot, null, 1)}\n`)
console.log(`wrote ${SNAPSHOT}`)
}
+139 -3
View File
@@ -60,19 +60,72 @@ describe('GET /api/market/skills', () => {
const skillhubBody = await fixture('skillhub-list.json') const skillhubBody = await fixture('skillhub-list.json')
stubUpstreams((url) => (url.includes('clawhub.ai') ? { body: clawhubBody } : { body: skillhubBody })) stubUpstreams((url) => (url.includes('clawhub.ai') ? { body: clawhubBody } : { body: skillhubBody }))
const { status, body } = await call('/api/market/skills?limit=3') const { status, body } = await call('/api/market/skills?scope=market&limit=3')
expect(status).toBe(200) expect(status).toBe(200)
expect(body.scope).toBe('market')
expect(body.items.length).toBeGreaterThan(0) expect(body.items.length).toBeGreaterThan(0)
expect(body.sources.clawhub.status).toBe('ok') expect(body.sources.clawhub.status).toBe('ok')
expect(body.sources.skillhub.status).toBe('ok') expect(body.sources.skillhub.status).toBe('ok')
expect(typeof body.nextCursor === 'string' || body.nextCursor === null).toBe(true) expect(typeof body.nextCursor === 'string' || body.nextCursor === null).toBe(true)
expect(body.categories).toBeUndefined()
}) })
it('rejects invalid filters with 400', async () => { it('rejects invalid filters with 400', async () => {
expect((await call('/api/market/skills?source=evil')).status).toBe(400) 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?security=hacked')).status).toBe(400)
expect((await call('/api/market/skills?installed=nope')).status).toBe(400) expect((await call('/api/market/skills?installed=nope')).status).toBe(400)
expect((await call('/api/market/skills?scope=everything')).status).toBe(400)
})
it('defaults to the curated catalog without touching upstream', async () => {
const upstreamCalls: string[] = []
stubUpstreams((url) => {
upstreamCalls.push(url)
return { status: 500, body: 'catalog must not fetch' }
})
const { status, body } = await call('/api/market/skills')
expect(status).toBe(200)
expect(upstreamCalls).toEqual([])
expect(body.scope).toBe('catalog')
expect(body.items.length).toBe(24)
expect(body.nextCursor).toBe('catalog:24')
expect(body.total).toBeGreaterThan(24)
expect(typeof body.catalogGeneratedAt).toBe('number')
expect(body.sources.clawhub).toEqual({ status: 'ok', fetchedAt: body.catalogGeneratedAt, fromCache: true })
expect(body.categories.length).toBeGreaterThan(0)
expect(Object.keys(body.categories[0]).sort()).toEqual(['count', 'key', 'name', 'nameEn'])
expect(body.items.every((item: any) => item.curated === true)).toBe(true)
const next = await call(`/api/market/skills?cursor=${body.nextCursor}`)
expect(next.body.items[0].id).not.toBe(body.items[0].id)
})
it('filters the catalog by category; an unknown or "all" category is not an error', async () => {
const all = await call('/api/market/skills?scope=catalog&limit=100')
const category = all.body.categories[0]
const filtered = await call(`/api/market/skills?category=${category.key}&limit=100`)
expect(filtered.status).toBe(200)
expect(filtered.body.total).toBe(category.count)
expect(filtered.body.items.every((item: any) => item.category === category.key)).toBe(true)
const unknown = await call('/api/market/skills?category=no-such-category')
expect(unknown.status).toBe(200)
expect(unknown.body.items).toEqual([])
expect(unknown.body.total).toBe(0)
expect((await call('/api/market/skills?category=all')).body.total).toBe(all.body.total)
})
it('searches the catalog locally with q', async () => {
const { status, body } = await call(`/api/market/skills?q=${encodeURIComponent('GIT')}`)
expect(status).toBe(200)
expect(body.scope).toBe('catalog')
expect(body.items.some((item: any) => item.id === 'clawhub:' + 'g' + 'it')).toBe(true)
}) })
it('reports failed status when a provider is disabled via env', async () => { it('reports failed status when a provider is disabled via env', async () => {
@@ -80,7 +133,7 @@ describe('GET /api/market/skills', () => {
const clawhubBody = await fixture('clawhub-list.json') const clawhubBody = await fixture('clawhub-list.json')
stubUpstreams((url) => (url.includes('clawhub.ai') ? { body: clawhubBody } : undefined)) stubUpstreams((url) => (url.includes('clawhub.ai') ? { body: clawhubBody } : undefined))
const { status, body } = await call('/api/market/skills?limit=3') const { status, body } = await call('/api/market/skills?scope=market&limit=3')
expect(status).toBe(200) expect(status).toBe(200)
expect(body.items.length).toBeGreaterThan(0) expect(body.items.length).toBeGreaterThan(0)
@@ -92,7 +145,11 @@ describe('GET /api/market/skills/{source}/{slug}', () => {
it('returns the detail payload', async () => { it('returns the detail payload', async () => {
const detailBody = await fixture('clawhub-detail.json') const detailBody = await fixture('clawhub-detail.json')
const versionBody = await fixture('clawhub-version-detail.json') const versionBody = await fixture('clawhub-version-detail.json')
stubUpstreams((url) => (url.includes('/versions/') ? { body: versionBody } : { body: detailBody })) const upstreamCalls: string[] = []
stubUpstreams((url) => {
upstreamCalls.push(url)
return url.includes('/versions/') ? { body: versionBody } : { body: detailBody }
})
const { status, body } = await call('/api/market/skills/clawhub/git') const { status, body } = await call('/api/market/skills/clawhub/git')
@@ -101,6 +158,70 @@ describe('GET /api/market/skills/{source}/{slug}', () => {
expect(body.skill.files.length).toBeGreaterThan(0) expect(body.skill.files.length).toBeGreaterThan(0)
expect(body.skill.installState).toBe('installable') expect(body.skill.installState).toBe('installable')
expect(body.sourceStatus.status).toBe('ok') expect(body.sourceStatus.status).toBe('ok')
// The fixture skill is in the curated catalog: reads are pinned to its owner
// and the detail carries the editorial overlay.
expect(upstreamCalls.length).toBeGreaterThan(0)
expect(upstreamCalls.every((url) => new URL(url).searchParams.get('owner') === 'ivangdavila')).toBe(true)
expect(body.skill.curated).toBe(true)
expect(body.skill.category).toBe('dev')
expect(body.skill.pageUrl).toBe('https://clawhub.ai/ivangdavila/git')
expect(body.skill.changelog.version).toBe('1.0.8')
})
it('pins a catalog slug to the requested owner and skips the catalog overlay', async () => {
const detailBody = await fixture('clawhub-detail.json')
const versionBody = await fixture('clawhub-version-detail.json')
const upstreamCalls: string[] = []
stubUpstreams((url) => {
upstreamCalls.push(url)
if (url.includes('/versions/')) return { body: versionBody }
const detail = JSON.parse(detailBody)
detail.owner.handle = new URL(url).searchParams.get('owner')
return { body: JSON.stringify(detail) }
})
const copy = await call('/api/market/skills/clawhub/git?owner=other')
expect(copy.status).toBe(200)
expect(upstreamCalls.length).toBeGreaterThan(0)
expect(upstreamCalls.every((url) => new URL(url).searchParams.get('owner') === 'other')).toBe(true)
expect(copy.body.skill.author.handle).toBe('other')
expect(copy.body.skill.curated).toBeUndefined()
expect(copy.body.skill.category).toBeUndefined()
expect(copy.body.skill.pageUrl).toBe('https://clawhub.ai/other/git')
// The owner-pinned detail is cached apart from the catalog owner's one.
upstreamCalls.length = 0
const curated = await call('/api/market/skills/clawhub/git')
expect(upstreamCalls.length).toBeGreaterThan(0)
expect(upstreamCalls.every((url) => new URL(url).searchParams.get('owner') === 'ivangdavila')).toBe(true)
expect(curated.body.skill.curated).toBe(true)
})
it('ignores owner for SkillHub', async () => {
const detailBody = await fixture('skillhub-detail.json')
const upstreamCalls: string[] = []
stubUpstreams((url) => {
upstreamCalls.push(url)
return url.includes('/files') ? { body: '{"count":0,"files":[]}' } : { body: detailBody }
})
const { status } = await call('/api/market/skills/skillhub/pe-compliance-expert-pro?owner=someone')
expect(status).toBe(200)
expect(upstreamCalls.some((url) => new URL(url).searchParams.has('owner'))).toBe(false)
})
it('rejects an invalid owner with 400', async () => {
stubUpstreams(() => ({ status: 500, body: 'must not be called' }))
expect((await call('/api/market/skills/clawhub/git?owner=a%2Fb')).status).toBe(400)
expect((await call(`/api/market/skills/clawhub/git?owner=${'x'.repeat(65)}`)).status).toBe(400)
expect((await call('/api/market/skills/clawhub/git/file?path=SKILL.md&owner=bad%20owner')).status).toBe(400)
const install = await call('/api/market/install', {
method: 'POST',
body: JSON.stringify({ id: 'clawhub:git', owner: 42 }),
})
expect(install.status).toBe(400)
}) })
it('rejects an unknown source with 400', async () => { it('rejects an unknown source with 400', async () => {
@@ -124,6 +245,21 @@ describe('GET /api/market/skills/{source}/{slug}/file', () => {
expect(body.file.truncated).toBe(false) expect(body.file.truncated).toBe(false)
}) })
it('fetches a file for the requested owner and caches it per owner', async () => {
const upstreamCalls: string[] = []
stubUpstreams((url) => {
upstreamCalls.push(url)
return { body: `# by ${new URL(url).searchParams.get('owner')}` }
})
const copy = await call('/api/market/skills/clawhub/git/file?path=SKILL.md&owner=other')
const curated = await call('/api/market/skills/clawhub/git/file?path=SKILL.md')
expect(copy.body.file.content).toBe('# by other')
expect(curated.body.file.content).toBe('# by ivangdavila')
expect(upstreamCalls.length).toBe(2)
})
it('rejects unsafe paths', async () => { 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?path=/etc/passwd')).status).toBe(400) expect((await call('/api/market/skills/clawhub/git/file?path=/etc/passwd')).status).toBe(400)
+195
View File
@@ -0,0 +1,195 @@
import { describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import {
catalogCategories,
catalogClawhubOwners,
catalogEntryFor,
catalogEntryToSkill,
filterCatalog,
getCatalog,
type CatalogEntry,
} from '../services/market/catalog/catalog.js'
import type { CurationFile } from '../services/market/catalog/catalogSnapshot.js'
import { sanitizeDirName, skillId } from '../services/market/types.js'
const catalog = getCatalog()
const CATALOG_DIR = path.join(import.meta.dir, '..', 'services', 'market', 'catalog')
function fold(text: string): string {
return text.normalize('NFKC').toLowerCase()
}
function nameHit(entry: CatalogEntry, term: string): boolean {
return fold(`${entry.name} ${entry.slug}`).includes(fold(term))
}
describe('catalog snapshot invariants', () => {
it('has a generation time and the editorial categories', () => {
expect(catalog.version).toBe(1)
expect(Number.isInteger(catalog.generatedAt)).toBe(true)
expect(catalog.categories.length).toBeGreaterThan(0)
expect(new Set(catalog.categories.map((category) => category.key)).size).toBe(catalog.categories.length)
for (const category of catalog.categories) {
expect(category.name.length).toBeGreaterThan(0)
expect(category.nameEn.length).toBeGreaterThan(0)
}
})
it('lists every source:slug once, each in a known category with an owner and a zh summary', () => {
const keys = new Set(catalog.categories.map((category) => category.key))
const ids = catalog.skills.map((entry) => skillId(entry.source, entry.slug))
expect(new Set(ids).size).toBe(ids.length)
for (const entry of catalog.skills) {
expect(['clawhub', 'skillhub']).toContain(entry.source)
expect(keys.has(entry.category)).toBe(true)
expect(entry.owner.length).toBeGreaterThan(0)
expect(entry.summary.length).toBeGreaterThan(0)
expect(entry.tags.length).toBeGreaterThan(0)
expect(typeof entry.stats.downloads).toBe('number')
}
})
it('ships no malicious entry, and every flagged entry explains itself', () => {
for (const entry of catalog.skills) {
expect(['verified', 'benign', 'unknown', 'flagged']).toContain(entry.security)
if (entry.security === 'flagged') expect(entry.securityNote?.trim().length ?? 0).toBeGreaterThan(0)
else expect(entry.securityNote).toBeUndefined()
}
})
it('installs every entry into a distinct, valid directory name', () => {
const dirNames = catalog.skills.map((entry) => sanitizeDirName(entry.slug))
expect(dirNames.every((name) => name !== null)).toBe(true)
expect(new Set(dirNames).size).toBe(dirNames.length)
})
it('matches its editorial source: same selection, owners, categories, summaries, tags and picks', async () => {
const curation = JSON.parse(await fs.readFile(path.join(CATALOG_DIR, 'curation.json'), 'utf-8')) as CurationFile
expect(typeof curation._source).toBe('string')
expect(curation.categories).toEqual(catalog.categories)
const curated = new Map(curation.skills.map((entry) => [skillId(entry.source, entry.slug), entry]))
for (const entry of catalog.skills) {
const source = curated.get(skillId(entry.source, entry.slug))
expect(source).toBeDefined()
expect(entry.owner).toBe(source!.owner)
expect(entry.category).toBe(source!.category)
expect(entry.summary).toBe(source!.summary)
expect(entry.tags).toEqual(source!.tags ?? [])
expect(entry.featured === true).toBe(source!.featured === true)
// Only an explicit editorial reason lets a suspicious verdict ship.
if (entry.security === 'flagged') expect(source!.allowSuspicious).toBeTruthy()
}
})
it('leads with the editor\'s picks', () => {
const featured = catalog.skills.filter((entry) => entry.featured).length
expect(featured).toBeGreaterThan(0)
expect(catalog.skills.slice(0, featured).every((entry) => entry.featured)).toBe(true)
})
})
describe('catalog accessors', () => {
it('looks entries up by source and slug', () => {
const entry = catalog.skills[0]!
expect(catalogEntryFor(entry.source, entry.slug)).toBe(entry)
expect(catalogEntryFor(entry.source === 'clawhub' ? 'skillhub' : 'clawhub', `${entry.slug}-nope`)).toBeUndefined()
})
it('counts categories over the whole catalog, in editorial order', () => {
const categories = catalogCategories()
expect(categories.map((category) => category.key)).toEqual(catalog.categories.map((category) => category.key))
for (const category of categories) {
expect(category.count).toBe(catalog.skills.filter((entry) => entry.category === category.key).length)
}
})
it('pins an owner for every ClawHub entry', () => {
const owners = new Map(catalogClawhubOwners())
const clawhub = catalog.skills.filter((entry) => entry.source === 'clawhub')
expect(owners.size).toBe(clawhub.length)
for (const entry of clawhub) expect(owners.get(entry.slug)).toBe(entry.owner)
})
it('turns an entry into a curated card', () => {
const flagged = catalog.skills.find((entry) => entry.security === 'flagged')!
const skill = catalogEntryToSkill(flagged)
expect(skill).toMatchObject({
id: skillId(flagged.source, flagged.slug),
name: flagged.name,
summary: flagged.summary,
summaryEn: flagged.summaryEn,
author: { handle: flagged.owner },
category: flagged.category,
securityStatus: 'flagged',
securityNote: flagged.securityNote,
curated: true,
installState: 'installable',
})
// The card owns copies: annotating it never edits the shared snapshot.
skill.tags.push('mutated')
skill.stats.downloads = -1
expect(flagged.tags).not.toContain('mutated')
expect(flagged.stats.downloads).not.toBe(-1)
expect(catalogEntryToSkill(catalog.skills.find((entry) => entry.featured)!).featured).toBe(true)
expect(catalogEntryToSkill(catalog.skills.find((entry) => !entry.featured)!).featured).toBeUndefined()
})
})
describe('filterCatalog', () => {
it('returns the whole catalog in order without filters', () => {
expect(filterCatalog({})).toEqual(catalog.skills)
expect(filterCatalog({ q: ' ', source: 'all' })).toEqual(catalog.skills)
})
it('ranks name/slug hits above summary, tag or owner hits', () => {
const term = 'mcp'
const result = filterCatalog({ q: term })
const firstWeak = result.findIndex((entry) => !nameHit(entry, term))
expect(firstWeak).toBeGreaterThan(0)
expect(result.slice(0, firstWeak).every((entry) => nameHit(entry, term))).toBe(true)
expect(result.slice(firstWeak).some((entry) => nameHit(entry, term))).toBe(false)
// Within each band the catalog order is kept.
const order = new Map(catalog.skills.map((entry, index) => [entry, index]))
const strongIdx = result.slice(0, firstWeak).map((entry) => order.get(entry)!)
expect([...strongIdx].sort((a, b) => a - b)).toEqual(strongIdx)
})
it('folds case and full-width input', () => {
const plain = filterCatalog({ q: 'pdf' })
expect(plain.length).toBeGreaterThan(0)
expect(filterCatalog({ q: 'PDF' })).toEqual(plain)
expect(filterCatalog({ q: 'PdF' })).toEqual(plain)
})
it('requires every term to match', () => {
const both = filterCatalog({ q: 'pdf word' })
expect(both.length).toBeGreaterThan(0)
expect(both.length).toBeLessThan(filterCatalog({ q: 'pdf' }).length)
for (const entry of both) {
const text = fold(`${entry.name} ${entry.slug} ${entry.summary} ${entry.summaryEn ?? ''} ${entry.tags.join(' ')} ${entry.owner}`)
expect(text.includes('pdf') && text.includes('word')).toBe(true)
}
})
it('matches owners and Chinese summaries', () => {
const owner = catalog.skills.find((entry) => entry.source === 'clawhub')!.owner
expect(filterCatalog({ q: owner }).some((entry) => entry.owner === owner)).toBe(true)
expect(filterCatalog({ q: '浏览器' }).length).toBeGreaterThan(0)
})
it('filters by category and source', () => {
const category = catalog.categories[0]!.key
const inCategory = filterCatalog({ category })
expect(inCategory.length).toBeGreaterThan(0)
expect(inCategory.every((entry) => entry.category === category)).toBe(true)
const skillhub = filterCatalog({ source: 'skillhub' })
expect(skillhub.length).toBeGreaterThan(0)
expect(skillhub.every((entry) => entry.source === 'skillhub')).toBe(true)
expect(skillhub.length + filterCatalog({ source: 'clawhub' }).length).toBe(catalog.skills.length)
expect(filterCatalog({ category: 'no-such-category' })).toEqual([])
})
})
+38 -2
View File
@@ -34,9 +34,12 @@ type FileSpec = {
* Stubs the ClawHub API surface used by install: * Stubs the ClawHub API surface used by install:
* detail → versions/{v} (file list) → file?path= for each file. * detail → versions/{v} (file list) → file?path= for each file.
*/ */
let requestedUrls: string[] = []
function stubClawhub(files: FileSpec[], opts: { corruptPath?: string } = {}) { function stubClawhub(files: FileSpec[], opts: { corruptPath?: string } = {}) {
globalThis.fetch = (async (input: string | URL | Request) => { globalThis.fetch = (async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
requestedUrls.push(url)
const parsed = new URL(url) const parsed = new URL(url)
if (parsed.pathname.includes('/file')) { if (parsed.pathname.includes('/file')) {
const filePath = parsed.searchParams.get('path') const filePath = parsed.searchParams.get('path')
@@ -59,15 +62,17 @@ function stubClawhub(files: FileSpec[], opts: { corruptPath?: string } = {}) {
}) })
} }
// detail // detail
const slug = decodeURIComponent(parsed.pathname.split('/').pop() || 'demo')
return Response.json({ return Response.json({
skill: { slug: 'demo', displayName: 'Demo', summary: 'demo', description: SKILL_MD }, skill: { slug, displayName: 'Demo', summary: 'demo', description: SKILL_MD },
latestVersion: { version: '1.0.0' }, latestVersion: { version: '1.0.0' },
owner: { handle: 'alice' }, owner: { handle: parsed.searchParams.get('owner') || 'alice' },
}) })
}) as typeof fetch }) as typeof fetch
} }
beforeEach(async () => { beforeEach(async () => {
requestedUrls = []
resetMarketCacheForTests() resetMarketCacheForTests()
resetInstallLocksForTests() resetInstallLocksForTests()
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'market-install-test-')) tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'market-install-test-'))
@@ -102,6 +107,37 @@ describe('installMarketSkill', () => {
expect(meta.fileCount).toBe(2) expect(meta.fileCount).toBe(2)
}) })
it('pins detail, file list and every download to the requested owner, over the catalog hint', async () => {
// The fixture slug is in the curated catalog, which pins it to another owner.
const catalogSlug = 'g' + 'it'
stubClawhub([
{ path: 'SKILL.md', content: SKILL_MD },
{ path: 'scripts/helper.py', content: HELPER_PY },
])
const result = await installMarketSkill('clawhub', catalogSlug, 'bob')
expect(result.skill.installState).toBe('installed')
const owners = requestedUrls.map((url) => new URL(url).searchParams.get('owner'))
const paths = requestedUrls.map((url) => new URL(url).pathname)
expect(paths.some((p) => p.includes('/versions/'))).toBe(true)
expect(paths.filter((p) => p.endsWith('/file')).length).toBe(2)
expect(owners.every((owner) => owner === 'bob')).toBe(true)
// `.market-meta.json` keeps its shape: no owner field.
const meta = JSON.parse(await fs.readFile(path.join(tmpHome, '.claude', 'skills', catalogSlug, '.market-meta.json'), 'utf-8'))
expect(Object.keys(meta).sort()).toEqual(['fileCount', 'id', 'installedAt', 'slug', 'source', 'version'])
})
it('without an owner a catalog slug installs the catalog owner\'s copy', async () => {
const catalogSlug = 'g' + 'it'
stubClawhub([{ path: 'SKILL.md', content: SKILL_MD }])
await installMarketSkill('clawhub', catalogSlug)
expect(requestedUrls.length).toBeGreaterThan(0)
expect(requestedUrls.every((url) => new URL(url).searchParams.get('owner') === 'ivangdavila')).toBe(true)
})
it('aborts on checksum mismatch and leaves no residue', async () => { it('aborts on checksum mismatch and leaves no residue', async () => {
stubClawhub( stubClawhub(
[ [
+166 -2
View File
@@ -1,10 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises' import * as fs from 'node:fs/promises'
import * as path from 'node:path' import * as path from 'node:path'
import { clawhubProvider, resetClawhubOwnerCacheForTests } from '../services/market/clawhubProvider.js' import {
clawhubOwnerFor,
clawhubProvider,
getClawhubOwnerHints,
resetClawhubOwnerCacheForTests,
setClawhubOwnerHints,
withClawhubOwner,
} from '../services/market/clawhubProvider.js'
import { skillhubProvider } from '../services/market/skillhubProvider.js' import { skillhubProvider } from '../services/market/skillhubProvider.js'
import { resetMarketCacheForTests } from '../services/market/cache.js' import { resetMarketCacheForTests } from '../services/market/cache.js'
import { MarketUpstreamError } from '../services/market/types.js' import { MarketUpstreamError, meaningfulChangelog } from '../services/market/types.js'
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'market') const FIXTURES = path.join(import.meta.dir, 'fixtures', 'market')
@@ -16,6 +23,7 @@ type FetchStub = (url: string) => { status?: number; body: string; contentType?:
let requestedUrls: string[] = [] let requestedUrls: string[] = []
let originalDisableProvidersEnv: string | undefined let originalDisableProvidersEnv: string | undefined
let originalOwnerHints: Array<[string, string]> = []
const originalFetch = globalThis.fetch const originalFetch = globalThis.fetch
function stubFetch(handler: FetchStub) { function stubFetch(handler: FetchStub) {
@@ -35,12 +43,17 @@ beforeEach(() => {
requestedUrls = [] requestedUrls = []
resetMarketCacheForTests() resetMarketCacheForTests()
resetClawhubOwnerCacheForTests() resetClawhubOwnerCacheForTests()
// Importing marketService elsewhere in the same run injects catalog hints;
// provider tests start unpinned and restore whatever was there.
originalOwnerHints = getClawhubOwnerHints()
setClawhubOwnerHints([])
originalDisableProvidersEnv = process.env.HAHA_MARKET_DISABLE_PROVIDERS originalDisableProvidersEnv = process.env.HAHA_MARKET_DISABLE_PROVIDERS
delete process.env.HAHA_MARKET_DISABLE_PROVIDERS delete process.env.HAHA_MARKET_DISABLE_PROVIDERS
}) })
afterEach(() => { afterEach(() => {
globalThis.fetch = originalFetch globalThis.fetch = originalFetch
setClawhubOwnerHints(originalOwnerHints)
// Restore rather than delete: these are the developer's variables, not ours. // Restore rather than delete: these are the developer's variables, not ours.
if (originalDisableProvidersEnv === undefined) { if (originalDisableProvidersEnv === undefined) {
delete process.env.HAHA_MARKET_DISABLE_PROVIDERS delete process.env.HAHA_MARKET_DISABLE_PROVIDERS
@@ -200,6 +213,137 @@ describe('clawhubProvider', () => {
}) })
}) })
describe('clawhubProvider owner pinning', () => {
const ambiguous = JSON.stringify({
code: 'AMBIGUOUS_SKILL_SLUG',
slug: 'git',
matches: [{ ownerHandle: 'early-copy' }, { ownerHandle: 'ivangdavila' }],
})
function ownerAwareUpstream(detailBody: string) {
stubFetch((url) => {
const parsed = new URL(url)
const owner = parsed.searchParams.get('owner')
if (owner !== 'ivangdavila' && owner !== 'early-copy') return { status: 409, body: ambiguous }
if (parsed.pathname.endsWith('/file')) return { body: `# by ${owner}`, contentType: 'text/markdown' }
if (parsed.pathname.includes('/versions/')) return { body: '{"version":{"files":[]}}' }
const detail = JSON.parse(detailBody)
detail.owner.handle = owner
return { body: JSON.stringify(detail) }
})
}
it('sends a catalog owner hint up front instead of taking the 409 first match', async () => {
ownerAwareUpstream(await fixture('clawhub-detail.json'))
setClawhubOwnerHints([['git', 'ivangdavila']])
const detail = await clawhubProvider.detail('git')
expect(detail.author.handle).toBe('ivangdavila')
expect(requestedUrls.length).toBeGreaterThan(0)
expect(requestedUrls.every((url) => new URL(url).searchParams.get('owner') === 'ivangdavila')).toBe(true)
expect(clawhubOwnerFor('git')).toBe('ivangdavila')
})
it('without a hint keeps resolving 409 to the first match', async () => {
ownerAwareUpstream(await fixture('clawhub-detail.json'))
const detail = await clawhubProvider.detail('git')
expect(detail.author.handle).toBe('early-copy')
expect(new URL(requestedUrls[0]!).searchParams.has('owner')).toBe(false)
})
it('throws instead of widening when a pinned owner still answers 409', async () => {
stubFetch(() => ({ status: 409, body: ambiguous }))
setClawhubOwnerHints([['git', 'gone-owner']])
const error = await clawhubProvider.fetchFile('git', 'SKILL.md').catch((caught) => caught)
expect(error).toBeInstanceOf(MarketUpstreamError)
expect(String(error.message)).toContain('409')
// One request with the pinned owner, no retry with a guessed one.
expect(requestedUrls.length).toBe(1)
expect(new URL(requestedUrls[0]!).searchParams.get('owner')).toBe('gone-owner')
})
it('withClawhubOwner pins one operation over the catalog hint, for that slug only', async () => {
ownerAwareUpstream(await fixture('clawhub-detail.json'))
setClawhubOwnerHints([['git', 'ivangdavila']])
const file = await withClawhubOwner('git', 'early-copy', async () => {
expect(clawhubOwnerFor('git')).toBe('early-copy')
expect(clawhubOwnerFor('other-slug')).toBeUndefined()
return clawhubProvider.fetchFile('git', 'SKILL.md')
})
expect(file.content).toBe('# by early-copy')
// Outside the scope the hint applies again.
expect(clawhubOwnerFor('git')).toBe('ivangdavila')
expect((await clawhubProvider.fetchFile('git', 'SKILL.md')).content).toBe('# by ivangdavila')
})
})
describe('clawhubProvider detail extras', () => {
async function detailWith(mutate: (detail: any, version: any) => void) {
const detail = JSON.parse(await fixture('clawhub-detail.json'))
const version = JSON.parse(await fixture('clawhub-version-detail.json'))
mutate(detail, version)
stubFetch((url) => (url.includes('/versions/') ? { body: JSON.stringify(version) } : { body: JSON.stringify(detail) }))
return clawhubProvider.detail('git')
}
it('splits the scan into one report per scanner, each with its own verdict and summary', async () => {
const detail = await detailWith(() => {})
const [overall, ...scanners] = detail.securityReports!
expect(overall!.vendor).toBe('clawhub-scan')
expect(overall!.statusText).toBe('Clean (with warnings)')
expect(scanners.map((report) => [report.vendor, report.status])).toEqual([
['VirusTotal', 'clean'],
['skillspector', 'suspicious'],
['LLM review', 'clean'],
])
expect(scanners[0]!.reportUrl).toContain('virustotal.com')
expect(scanners[1]!.statusText).toBe('suspicious · CAUTION')
expect(scanners[1]!.reportUrl).toBeUndefined()
expect(scanners[2]!.summary).toMatch(/Git reference skill/)
// The overall scan status still decides the badge.
expect(detail.securityStatus).toBe('benign')
})
it('maps the latest version changelog and links the owner-qualified page', async () => {
const detail = await detailWith(() => {})
expect(detail.changelog).toEqual({
version: '1.0.8',
text: 'Simplified the skill name and kept the stateless activation guidance',
publishedAt: 1773255795217,
})
expect(detail.pageUrl).toBe('https://clawhub.ai/ivangdavila/git')
})
it('drops placeholder changelogs stamped by sync pipelines', async () => {
const detail = await detailWith((raw) => {
raw.latestVersion.changelog = 'Synced by the skillhub pipeline'
})
expect(detail.changelog).toBeUndefined()
expect(meaningfulChangelog(' synced by pipeline ')).toBeUndefined()
expect(meaningfulChangelog(' ')).toBeUndefined()
expect(meaningfulChangelog(42)).toBeUndefined()
expect(meaningfulChangelog(' Fixed the pipeline docs ')).toBe('Fixed the pipeline docs')
})
it('omits pageUrl when the detail has no owner', async () => {
const detail = await detailWith((raw) => {
raw.owner = null
})
expect(detail.pageUrl).toBeUndefined()
})
})
describe('skillhubProvider', () => { describe('skillhubProvider', () => {
it('uses pageSize (not limit) and keyword (not q) — upstream silently ignores the wrong names', async () => { it('uses pageSize (not limit) and keyword (not q) — upstream silently ignores the wrong names', async () => {
const body = await fixture('skillhub-search.json') const body = await fixture('skillhub-search.json')
@@ -307,6 +451,26 @@ describe('skillhubProvider', () => {
expect(result.securityStatus).toBe('flagged') expect(result.securityStatus).toBe('flagged')
}) })
it('maps a meaningful latest-version changelog in detail', async () => {
const detailBody = await fixture('skillhub-detail.json')
stubFetch((url) => (url.includes('/files') ? { body: '{"count":0,"files":[]}' } : { body: detailBody }))
const detail = await skillhubProvider.detail('pe-compliance-expert-pro')
expect(detail.changelog).toEqual({ version: '1.0.2', text: '根据最新的监管要求进行skill的思考炼化', publishedAt: 1777381898516 })
expect(detail.pageUrl).toBeUndefined()
})
it('drops a pipeline placeholder changelog in detail', async () => {
const detail = JSON.parse(await fixture('skillhub-detail.json'))
detail.latestVersion.changelog = 'synced by clawhub pipeline'
stubFetch((url) => (url.includes('/files') ? { body: '{"count":0,"files":[]}' } : { body: JSON.stringify(detail) }))
const result = await skillhubProvider.detail('pe-compliance-expert-pro')
expect(result.changelog).toBeUndefined()
})
it('marks list items verified only via the verified field', async () => { it('marks list items verified only via the verified field', async () => {
const envelope = { const envelope = {
code: 0, code: 0,
+310 -8
View File
@@ -3,8 +3,10 @@ import * as fs from 'node:fs/promises'
import * as os from 'node:os' import * as os from 'node:os'
import * as path from 'node:path' import * as path from 'node:path'
import { resetMarketCacheForTests } from '../services/market/cache.js' import { resetMarketCacheForTests } from '../services/market/cache.js'
import { getCatalog, type CatalogEntry } from '../services/market/catalog/catalog.js'
import { import {
annotateInstallState, annotateInstallState,
annotateInstallStates,
applyFileLimits, applyFileLimits,
decodeCursor, decodeCursor,
dedupeSkills, dedupeSkills,
@@ -215,7 +217,7 @@ describe('listMarketSkills', () => {
return { body: skillhubBody } return { body: skillhubBody }
}) })
const result = await listMarketSkills({ source: 'all', limit: 3 }) const result = await listMarketSkills({ scope: 'market', source: 'all', limit: 3 })
expect(result.items.length).toBeGreaterThan(3) expect(result.items.length).toBeGreaterThan(3)
expect(result.sources.clawhub.status).toBe('ok') expect(result.sources.clawhub.status).toBe('ok')
@@ -233,7 +235,7 @@ describe('listMarketSkills', () => {
return { status: 500, body: 'oops' } return { status: 500, body: 'oops' }
}) })
const result = await listMarketSkills({ source: 'all', limit: 3 }) const result = await listMarketSkills({ scope: 'market', source: 'all', limit: 3 })
expect(result.items.length).toBeGreaterThan(0) expect(result.items.length).toBeGreaterThan(0)
expect(result.sources.clawhub.status).toBe('ok') expect(result.sources.clawhub.status).toBe('ok')
@@ -248,11 +250,11 @@ describe('listMarketSkills', () => {
if (url.includes('clawhub.ai')) return { body: clawhubBody } if (url.includes('clawhub.ai')) return { body: clawhubBody }
return { body: skillhubBody } return { body: skillhubBody }
}) })
await listMarketSkills({ source: 'all', limit: 3 }) await listMarketSkills({ scope: 'market', source: 'all', limit: 3 })
// Now both upstreams fail — but entries are cached (fresh) so still ok/fromCache. // Now both upstreams fail — but entries are cached (fresh) so still ok/fromCache.
stubFetch(() => ({ status: 500, body: 'down' })) stubFetch(() => ({ status: 500, body: 'down' }))
const result = await listMarketSkills({ source: 'all', limit: 3 }) const result = await listMarketSkills({ scope: 'market', source: 'all', limit: 3 })
expect(result.items.length).toBeGreaterThan(0) expect(result.items.length).toBeGreaterThan(0)
expect(result.sources.clawhub.fromCache).toBe(true) expect(result.sources.clawhub.fromCache).toBe(true)
@@ -265,7 +267,7 @@ describe('listMarketSkills', () => {
return { status: 500, body: 'should not be called' } return { status: 500, body: 'should not be called' }
}) })
const result = await listMarketSkills({ source: 'clawhub', limit: 3 }) const result = await listMarketSkills({ scope: 'market', source: 'clawhub', limit: 3 })
expect(result.items.every((i) => i.source === 'clawhub')).toBe(true) expect(result.items.every((i) => i.source === 'clawhub')).toBe(true)
expect(requested.every((u) => u.includes('clawhub.ai'))).toBe(true) expect(requested.every((u) => u.includes('clawhub.ai'))).toBe(true)
@@ -285,12 +287,12 @@ describe('listMarketSkills', () => {
JSON.stringify({ id: `clawhub:${slug}`, source: 'clawhub', slug, installedAt: 'x', fileCount: 1 }), JSON.stringify({ id: `clawhub:${slug}`, source: 'clawhub', slug, installedAt: 'x', fileCount: 1 }),
) )
const installed = await listMarketSkills({ source: 'clawhub', limit: 3, installed: 'installed' }) const installed = await listMarketSkills({ scope: 'market', source: 'clawhub', limit: 3, installed: 'installed' })
expect(installed.items.length).toBe(1) expect(installed.items.length).toBe(1)
expect(installed.items[0]!.slug).toBe(slug) expect(installed.items[0]!.slug).toBe(slug)
resetMarketCacheForTests() resetMarketCacheForTests()
const notInstalled = await listMarketSkills({ source: 'clawhub', limit: 3, installed: 'installable' }) const notInstalled = await listMarketSkills({ scope: 'market', source: 'clawhub', limit: 3, installed: 'installable' })
expect(notInstalled.items.every((i) => i.slug !== slug)).toBe(true) expect(notInstalled.items.every((i) => i.slug !== slug)).toBe(true)
}) })
@@ -301,7 +303,7 @@ describe('listMarketSkills', () => {
} }
stubFetch((url) => (url.includes('skillhub') ? { body: JSON.stringify(envelope) } : { body: '{"items":[]}' })) stubFetch((url) => (url.includes('skillhub') ? { body: JSON.stringify(envelope) } : { body: '{"items":[]}' }))
const result = await listMarketSkills({ source: 'skillhub', limit: 24, security: 'verified' }) const result = await listMarketSkills({ scope: 'market', source: 'skillhub', limit: 24, security: 'verified' })
expect(result.items.length).toBe(1) expect(result.items.length).toBe(1)
expect(result.items[0]!.slug).toBe('a') expect(result.items[0]!.slug).toBe('a')
@@ -323,3 +325,303 @@ describe('getMarketSkillDetail', () => {
expect(second.skill.files.length).toBeGreaterThan(0) expect(second.skill.files.length).toBeGreaterThan(0)
}) })
}) })
// ─── Curated catalog scope ───────────────────────────────────────────────────
const catalog = getCatalog()
function catalogEntry(predicate: (entry: CatalogEntry) => boolean): CatalogEntry {
const entry = catalog.skills.find(predicate)
if (!entry) throw new Error('fixture assumption: no matching catalog entry')
return entry
}
async function writeMeta(slug: string, source: 'clawhub' | 'skillhub') {
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: `${source}:${slug}`, source, slug, version: '9.9.9', installedAt: 'x', fileCount: 1 }),
)
}
function failOnAnyFetch() {
globalThis.fetch = (async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
requested.push(url)
throw new Error(`catalog scope must not fetch ${url}`)
}) as typeof fetch
}
describe('listMarketSkills (catalog scope)', () => {
it('is the default scope and pages through the whole snapshot without any upstream request', async () => {
failOnAnyFetch()
const seen: string[] = []
let cursor: string | undefined
let pages = 0
do {
const page = await listMarketSkills({ source: 'all', limit: 24, cursor })
expect(page.scope).toBe('catalog')
expect(page.total).toBe(catalog.skills.length)
expect(page.catalogGeneratedAt).toBe(catalog.generatedAt)
expect(page.sources.clawhub).toEqual({ status: 'ok', fetchedAt: catalog.generatedAt, fromCache: true })
expect(page.sources.skillhub.fetchedAt).toBe(catalog.generatedAt)
if (page.nextCursor) {
expect(page.items.length).toBe(24)
expect(page.nextCursor).toBe(`catalog:${seen.length + 24}`)
}
seen.push(...page.items.map((item) => item.id))
cursor = page.nextCursor ?? undefined
pages++
} while (cursor && pages < 100)
expect(seen).toEqual(catalog.skills.map((entry) => `${entry.source}:${entry.slug}`))
expect(requested).toEqual([])
})
it('returns static category counts and curated card fields', async () => {
failOnAnyFetch()
const flagged = catalogEntry((entry) => entry.security === 'flagged')
const result = await listMarketSkills({ source: 'all', limit: 500 })
expect(result.categories!.map((category) => category.key)).toEqual(catalog.categories.map((category) => category.key))
expect(result.categories!.reduce((sum, category) => sum + category.count, 0)).toBe(catalog.skills.length)
const card = result.items.find((item) => item.slug === flagged.slug)!
expect(card.curated).toBe(true)
expect(card.category).toBe(flagged.category)
expect(card.summary).toBe(flagged.summary)
expect(card.summaryEn).toBe(flagged.summaryEn)
expect(card.securityStatus).toBe('flagged')
expect(card.securityNote).toBe(flagged.securityNote)
expect(result.items.filter((item) => item.featured).length).toBe(catalog.skills.filter((entry) => entry.featured).length)
})
it('filters by security before paginating so every page is full', async () => {
failOnAnyFetch()
const benignCount = catalog.skills.filter((entry) => entry.security === 'benign').length
const first = await listMarketSkills({ source: 'all', limit: 24, security: 'benign' })
const second = await listMarketSkills({ source: 'all', limit: 24, security: 'benign', cursor: first.nextCursor! })
expect(first.total).toBe(benignCount)
expect(first.items.length).toBe(24)
expect(second.items.length).toBe(24)
expect([...first.items, ...second.items].every((item) => item.securityStatus === 'benign')).toBe(true)
expect(new Set([...first.items, ...second.items].map((item) => item.id)).size).toBe(48)
const flagged = await listMarketSkills({ source: 'all', limit: 3, security: 'flagged' })
expect(flagged.items.length).toBe(3)
expect(flagged.total).toBe(catalog.skills.filter((entry) => entry.security === 'flagged').length)
})
it('annotates install state from the skills directory and filters by it before paginating', async () => {
failOnAnyFetch()
const [a, b, c] = catalog.skills.slice(-3) as [CatalogEntry, CatalogEntry, CatalogEntry]
await writeMeta(a.slug, a.source)
await writeMeta(b.slug, b.source)
// A hand-made directory with the same name is a conflict, not an install.
await fs.mkdir(path.join(tmpHome, '.claude', 'skills', c.slug), { recursive: true })
const installed = await listMarketSkills({ source: 'all', limit: 24, installed: 'installed' })
expect(installed.items.map((item) => item.slug).sort()).toEqual([a.slug, b.slug].sort())
expect(installed.total).toBe(2)
expect(installed.items[0]!.installedInfo?.version).toBe('9.9.9')
const installable = await listMarketSkills({ source: 'all', limit: 500, installed: 'installable' })
expect(installable.total).toBe(catalog.skills.length - 2)
const conflict = installable.items.find((item) => item.slug === c.slug)!
expect(conflict.installState).toBe('not-installable')
expect(conflict.notInstallableReason).toBe('name-conflict')
})
it('filters by category and source; an unknown category is an empty page', async () => {
failOnAnyFetch()
const category = catalog.categories[2]!.key
const inCategory = await listMarketSkills({ source: 'all', limit: 500, category })
expect(inCategory.items.length).toBeGreaterThan(0)
expect(inCategory.items.every((item) => item.category === category)).toBe(true)
expect(inCategory.total).toBe(inCategory.categories!.find((entry) => entry.key === category)!.count)
const skillhubOnly = await listMarketSkills({ source: 'skillhub', limit: 500 })
expect(skillhubOnly.total).toBe(catalog.skills.filter((entry) => entry.source === 'skillhub').length)
expect(skillhubOnly.items.every((item) => item.source === 'skillhub')).toBe(true)
const unknown = await listMarketSkills({ source: 'all', limit: 24, category: 'no-such-category' })
expect(unknown.items).toEqual([])
expect(unknown.total).toBe(0)
expect(unknown.nextCursor).toBeNull()
expect(unknown.categories!.length).toBe(catalog.categories.length)
})
})
describe('annotateInstallStates', () => {
it('matches the per-skill annotation for installed, conflicting, invalid and clean skills', async () => {
await writeMeta('demo', 'clawhub')
await fs.mkdir(path.join(tmpHome, '.claude', 'skills', 'taken'), { recursive: true })
const skills = [
makeSkill(),
makeSkill({ id: 'clawhub:taken', slug: 'taken' }),
makeSkill({ id: 'clawhub:../x', slug: '../x' }),
makeSkill({ id: 'clawhub:fresh', slug: 'fresh' }),
]
const batched = await annotateInstallStates(skills)
const single = await Promise.all(skills.map((skill) => annotateInstallState(skill)))
expect(batched).toEqual(single)
expect(batched.map((skill) => skill.installState)).toEqual(['installed', 'not-installable', 'not-installable', 'installable'])
})
it('treats a missing skills directory as nothing installed', async () => {
const [result] = await annotateInstallStates([makeSkill()])
expect(result!.installState).toBe('installable')
})
})
describe('listMarketSkills (market scope) curated marking', () => {
it('marks catalog matches, requiring the pinned owner for ClawHub results', async () => {
const skillhubPick = catalogEntry((entry) => entry.source === 'skillhub' && entry.featured === true)
const clawhubEntry = catalogEntry((entry) => entry.source === 'clawhub' && entry.featured === true)
const otherClawhub = catalogEntry((entry) => entry.source === 'clawhub' && entry.slug !== clawhubEntry.slug)
stubFetch((url) => {
if (url.includes('clawhub.ai')) {
return { body: JSON.stringify({ results: [
{ slug: clawhubEntry.slug, downloads: 30, ownerHandle: clawhubEntry.owner },
{ slug: otherClawhub.slug, downloads: 20, ownerHandle: `${otherClawhub.owner}-copycat` },
] }) }
}
return { body: JSON.stringify({ code: 0, data: { skills: [
{ slug: skillhubPick.slug, name: 'pick', downloads: 10, category: 'raw-upstream-category' },
{ slug: 'not-in-catalog-xyz', name: 'other', downloads: 5, category: 'raw-upstream-category' },
], total: 2 } }) }
})
const result = await listMarketSkills({ scope: 'market', source: 'all', q: 'anything', limit: 24 })
expect(result.scope).toBe('market')
expect(result.total).toBeUndefined()
const byId = new Map(result.items.map((item) => [item.id, item]))
const curatedClawhub = byId.get(`clawhub:${clawhubEntry.slug}`)!
expect(curatedClawhub.curated).toBe(true)
expect(curatedClawhub.featured).toBe(true)
expect(curatedClawhub.category).toBe(clawhubEntry.category)
expect(byId.get(`clawhub:${otherClawhub.slug}`)!.curated).toBeUndefined()
const pick = byId.get(`skillhub:${skillhubPick.slug}`)!
expect(pick.curated).toBe(true)
expect(pick.featured).toBe(true)
expect(pick.category).toBe(skillhubPick.category)
const other = byId.get('skillhub:not-in-catalog-xyz')!
expect(other.curated).toBeUndefined()
expect(other.category).toBe('raw-upstream-category')
})
})
describe('getMarketSkillDetail catalog overlay', () => {
async function stubClawhubDetail(mutate: (detail: any, version: any) => void = () => {}) {
const detail = JSON.parse(await fixture('clawhub-detail.json'))
const version = JSON.parse(await fixture('clawhub-version-detail.json'))
mutate(detail, version)
stubFetch((url) => (url.includes('/versions/') ? { body: JSON.stringify(version) } : { body: JSON.stringify(detail) }))
return detail
}
const fixtureSlug = 'g' + 'it'
it('overlays editorial fields on a curated ClawHub detail and pins its owner', async () => {
const entry = catalogEntry((candidate) => candidate.source === 'clawhub' && candidate.slug === fixtureSlug)
const raw = await stubClawhubDetail()
const { skill } = await getMarketSkillDetail('clawhub', fixtureSlug)
expect(requested.length).toBeGreaterThan(0)
expect(requested.every((url) => new URL(url).searchParams.get('owner') === entry.owner)).toBe(true)
expect(skill.curated).toBe(true)
expect(skill.category).toBe(entry.category)
expect(skill.summary).toBe(entry.summary)
expect(skill.summaryEn).toBe(raw.skill.summary)
// Upstream topics win; upstream stays authoritative for version and files.
expect(skill.tags).toEqual(raw.skill.topics)
expect(skill.version).toBe('1.0.8')
expect(skill.files.length).toBeGreaterThan(0)
expect(skill.securityNote).toBeUndefined()
expect(skill.changelog?.text).toContain('Simplified')
})
it('fills empty upstream tags from the catalog and explains a still-flagged verdict', async () => {
const entry = catalogEntry((candidate) => candidate.source === 'clawhub' && candidate.security === 'flagged')
await stubClawhubDetail((detail, version) => {
detail.skill.slug = entry.slug
detail.skill.topics = []
detail.owner.handle = entry.owner
version.version.security.status = 'suspicious'
})
const { skill } = await getMarketSkillDetail('clawhub', entry.slug)
expect(skill.securityStatus).toBe('flagged')
expect(skill.securityNote).toBe(entry.securityNote)
expect(skill.tags).toEqual(entry.tags)
})
it('drops the security note once upstream clears the skill', async () => {
const entry = catalogEntry((candidate) => candidate.source === 'clawhub' && candidate.security === 'flagged')
await stubClawhubDetail((detail) => {
detail.skill.slug = entry.slug
detail.owner.handle = entry.owner
})
const { skill } = await getMarketSkillDetail('clawhub', entry.slug)
expect(skill.securityStatus).toBe('benign')
expect(skill.curated).toBe(true)
expect(skill.securityNote).toBeUndefined()
})
it('does not overlay a ClawHub detail served for a different owner', async () => {
await stubClawhubDetail((detail) => {
detail.owner.handle = 'someone-else'
})
const { skill } = await getMarketSkillDetail('clawhub', fixtureSlug)
expect(skill.curated).toBeUndefined()
expect(skill.summary).toStartWith('Git commits')
})
it('a requested owner wins over the catalog hint, even when upstream omits the owner', async () => {
await stubClawhubDetail((detail) => {
detail.owner = null
})
const { skill } = await getMarketSkillDetail('clawhub', fixtureSlug, { owner: 'other' })
expect(requested.length).toBeGreaterThan(0)
expect(requested.every((url) => new URL(url).searchParams.get('owner') === 'other')).toBe(true)
expect(skill.curated).toBeUndefined()
expect(skill.summary).toStartWith('Git commits')
})
it('ignores an owner for SkillHub details', async () => {
const detailBody = await fixture('skillhub-detail.json')
stubFetch((url) => (url.includes('/files') ? { body: '{"count":0,"files":[]}' } : { body: detailBody }))
await getMarketSkillDetail('skillhub', 'pe-compliance-expert-pro', { owner: 'someone' })
expect(requested.some((url) => new URL(url).searchParams.has('owner'))).toBe(false)
})
it('leaves skills outside the catalog untouched', async () => {
await stubClawhubDetail((detail) => {
detail.skill.slug = 'not-in-catalog-xyz'
})
const { skill } = await getMarketSkillDetail('clawhub', 'not-in-catalog-xyz')
expect(skill.curated).toBeUndefined()
expect(skill.category).toBeUndefined()
expect(skill.summaryEn).toBeUndefined()
})
})
+36 -9
View File
@@ -1,11 +1,14 @@
/** /**
* Skills Market REST API * Skills Market REST API
* *
* GET /api/market/skills — aggregated list/search across sources * GET /api/market/skills — curated catalog (default) or live list/search
* ?q=&source=all|clawhub|skillhub&security=&installed=&cursor=&limit= * ?scope=catalog|market&category=&q=&source=all|clawhub|skillhub&security=&installed=&cursor=&limit=
* GET /api/market/skills/{source}/{slug} — full detail (description, files, security) * `category` applies to the catalog scope only; an unknown key yields an empty page.
* GET /api/market/skills/{source}/{slug}/file — file content ?path=SKILL.md * GET /api/market/skills/{source}/{slug} — full detail (description, files, security) ?owner=
* POST /api/market/install — body {id: "source:slug"} * GET /api/market/skills/{source}/{slug}/file — file content ?path=SKILL.md&owner=
* POST /api/market/install — body {id: "source:slug", owner?}
* `owner` pins a ClawHub slug (not unique across owners) to the copy the
* reader picked; it is ignored for SkillHub.
* POST /api/market/uninstall — body {id: "source:slug"} * POST /api/market/uninstall — body {id: "source:slug"}
* GET /api/market/status — per-source health * GET /api/market/status — per-source health
*/ */
@@ -21,9 +24,12 @@ import {
} from '../services/market/marketService.js' } from '../services/market/marketService.js'
import { import {
MARKET_ERROR_CODES, MARKET_ERROR_CODES,
MARKET_OWNER_PATTERN,
MARKET_SCOPES,
MARKET_SOURCES, MARKET_SOURCES,
MarketUpstreamError, MarketUpstreamError,
parseSkillId, parseSkillId,
type MarketScope,
type MarketSource, type MarketSource,
} from '../services/market/types.js' } from '../services/market/types.js'
@@ -36,6 +42,12 @@ function parseSource(raw: string | null): 'all' | MarketSource {
throw ApiError.badRequest(`Invalid source: ${raw}`) throw ApiError.badRequest(`Invalid source: ${raw}`)
} }
function parseScope(raw: string | null): MarketScope {
if (!raw) return 'catalog'
if (MARKET_SCOPES.includes(raw as MarketScope)) return raw as MarketScope
throw ApiError.badRequest(`Invalid scope: ${raw}`)
}
function parsePathSource(raw: string | undefined): MarketSource { function parsePathSource(raw: string | undefined): MarketSource {
if (raw && MARKET_SOURCES.includes(raw as MarketSource)) return raw as MarketSource if (raw && MARKET_SOURCES.includes(raw as MarketSource)) return raw as MarketSource
throw ApiError.badRequest(`Invalid market source: ${raw ?? ''}`) throw ApiError.badRequest(`Invalid market source: ${raw ?? ''}`)
@@ -50,6 +62,14 @@ function parseSlug(raw: string | undefined): string {
return slug return slug
} }
function parseOwner(raw: unknown, source: MarketSource): string | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
if (typeof raw !== 'string' || !MARKET_OWNER_PATTERN.test(raw)) {
throw ApiError.badRequest(`Invalid owner: ${String(raw)}`)
}
return source === 'clawhub' ? raw : undefined
}
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> { async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
try { try {
const body = (await req.json()) as Record<string, unknown> const body = (await req.json()) as Record<string, unknown>
@@ -83,9 +103,13 @@ export async function handleMarketApi(
const installed = url.searchParams.get('installed') || 'all' const installed = url.searchParams.get('installed') || 'all'
if (!VALID_SECURITY.has(security)) throw ApiError.badRequest(`Invalid security filter: ${security}`) if (!VALID_SECURITY.has(security)) throw ApiError.badRequest(`Invalid security filter: ${security}`)
if (!VALID_INSTALLED.has(installed)) throw ApiError.badRequest(`Invalid installed filter: ${installed}`) if (!VALID_INSTALLED.has(installed)) throw ApiError.badRequest(`Invalid installed filter: ${installed}`)
const scope = parseScope(url.searchParams.get('scope'))
const category = url.searchParams.get('category')?.trim()
const result = await listMarketSkills({ const result = await listMarketSkills({
q: url.searchParams.get('q')?.trim() || undefined, q: url.searchParams.get('q')?.trim() || undefined,
scope,
category: category && category !== 'all' ? category : undefined,
source: parseSource(url.searchParams.get('source')), source: parseSource(url.searchParams.get('source')),
security, security,
installed: installed as 'all' | 'installed' | 'installable', installed: installed as 'all' | 'installed' | 'installable',
@@ -98,7 +122,8 @@ export async function handleMarketApi(
if (method === 'GET' && sub === 'skills' && segments[3] && segments[4] && !segments[5]) { if (method === 'GET' && sub === 'skills' && segments[3] && segments[4] && !segments[5]) {
const source = parsePathSource(segments[3]) const source = parsePathSource(segments[3])
const slug = parseSlug(segments[4]) const slug = parseSlug(segments[4])
const { skill, sourceStatus } = await getMarketSkillDetail(source, slug) const owner = parseOwner(url.searchParams.get('owner'), source)
const { skill, sourceStatus } = await getMarketSkillDetail(source, slug, { owner })
return Response.json({ skill, sourceStatus }) return Response.json({ skill, sourceStatus })
} }
@@ -109,7 +134,8 @@ export async function handleMarketApi(
if (!isValidMarketFilePath(filePath)) { if (!isValidMarketFilePath(filePath)) {
throw ApiError.badRequest(`Invalid file path: ${filePath}`) throw ApiError.badRequest(`Invalid file path: ${filePath}`)
} }
const file = await getMarketFileContent(source, slug, filePath) const owner = parseOwner(url.searchParams.get('owner'), source)
const file = await getMarketFileContent(source, slug, filePath, owner)
return Response.json({ file }) return Response.json({ file })
} }
@@ -118,8 +144,9 @@ export async function handleMarketApi(
} }
if (method === 'POST' && sub === 'install') { if (method === 'POST' && sub === 'install') {
const { source, slug } = parseIdFromBody(await parseJsonBody(req)) const body = await parseJsonBody(req)
const result = await installMarketSkill(source, slug) const { source, slug } = parseIdFromBody(body)
const result = await installMarketSkill(source, slug, parseOwner(body.owner, source))
return Response.json({ ok: true, installedPath: result.installedPath, skill: result.skill }) return Response.json({ ok: true, installedPath: result.installedPath, skill: result.skill })
} }
@@ -0,0 +1,151 @@
/**
* Skills Market — curated catalog, the market's home list.
*
* The upstream registries hold ~80k ClawHub and ~110k SkillHub skills; nobody
* chooses from that. The home list is instead a few hundred reviewed skills,
* shipped with the app as `skills.json` so the home page needs no network.
*
* Maintenance:
* - `curation.json` is the editorial source (which skills, which category, the
* zh-CN summary and tags, editor's picks). It is ported from dsh-skills-hub
* and synced by hand; it is never imported at runtime.
* - `bun run scripts/market-catalog-refresh.ts` re-reads what upstream owns
* (name, stats, version, icon, security verdict) and rewrites `skills.json`;
* `--check` only reports. It hits the live registries, so no test or CI lane
* runs it. A refresh never changes the selection.
* - Selection/security policy (see `catalogSnapshot.ts`): an entry upstream no
* longer serves, whose owner changed, or that upstream calls malicious is
* dropped; a suspicious verdict ships (as `flagged`, with a `securityNote`)
* only when the curation entry carries an `allowSuspicious` reason.
*
* Only the list comes from the snapshot. Detail, file preview and install still
* read the owning registry, so nobody installs the snapshot's version of a skill.
*/
import data from './skills.json'
import {
skillId,
type MarketCategory,
type MarketSource,
type NormalizedSkill,
type SecurityStatus,
} from '../types.js'
export type CatalogEntry = {
source: MarketSource
slug: string
/** Registry owner; disambiguates ClawHub slugs shared by several authors. */
owner: string
name: string
/** Reader-facing summary (zh-CN). */
summary: string
/** Original upstream summary, kept for English readers and search. */
summaryEn?: string
/** Key into `categories`. */
category: string
tags: string[]
/** Editor's pick: listed first and marked on the card. */
featured?: boolean
stats: { downloads: number; installs?: number; stars?: number }
version?: string
updatedAt?: number
iconUrl?: string
security: SecurityStatus
/** Why the security verdict is what it is, when it is not clean. */
securityNote?: string
requiresApiKey?: boolean
license?: string
}
export type CatalogCategoryDef = { key: string; name: string; nameEn: string }
export type CatalogFile = {
version: 1
/** Epoch millis of the upstream reads behind this snapshot. */
generatedAt: number
categories: CatalogCategoryDef[]
skills: CatalogEntry[]
}
const catalog = data as CatalogFile
export function getCatalog(): CatalogFile {
return catalog
}
const bySkillId = new Map(catalog.skills.map((entry) => [skillId(entry.source, entry.slug), entry]))
export function catalogEntryFor(source: MarketSource, slug: string): CatalogEntry | undefined {
return bySkillId.get(skillId(source, slug))
}
/** Category bar entries in editorial order, counted over the whole catalog. */
export function catalogCategories(): MarketCategory[] {
const counts = new Map<string, number>()
for (const entry of catalog.skills) counts.set(entry.category, (counts.get(entry.category) ?? 0) + 1)
return catalog.categories
.map((category) => ({ key: category.key, name: category.name, nameEn: category.nameEn, count: counts.get(category.key) ?? 0 }))
.filter((category) => category.count > 0)
}
/** ClawHub owners the catalog pins, for the provider's slug disambiguation. */
export function catalogClawhubOwners(): Array<readonly [string, string]> {
return catalog.skills
.filter((entry) => entry.source === 'clawhub')
.map((entry) => [entry.slug, entry.owner] as const)
}
export function catalogEntryToSkill(entry: CatalogEntry): NormalizedSkill {
return {
id: skillId(entry.source, entry.slug),
source: entry.source,
slug: entry.slug,
name: entry.name,
summary: entry.summary,
summaryEn: entry.summaryEn,
author: { handle: entry.owner },
stats: { ...entry.stats },
tags: [...entry.tags],
category: entry.category,
version: entry.version,
updatedAt: entry.updatedAt,
iconUrl: entry.iconUrl,
securityStatus: entry.security,
securityNote: entry.securityNote,
requiresApiKey: entry.requiresApiKey,
featured: entry.featured === true ? true : undefined,
curated: true,
installState: 'installable',
}
}
/** Fold case and width so `GitHub`, `github` and full-width input all match. */
function fold(text: string): string {
return text.normalize('NFKC').toLowerCase()
}
/**
* Entries matching every whitespace-separated term, in catalog order.
*
* A name/slug hit ranks above a summary, tag or owner hit: typing a skill's
* name should put that skill first, not the one that mentions it most.
*/
export function filterCatalog(params: { q?: string; category?: string; source?: 'all' | MarketSource }): CatalogEntry[] {
const terms = fold(params.q ?? '').split(/\s+/).filter(Boolean)
const strong: CatalogEntry[] = []
const weak: CatalogEntry[] = []
for (const entry of catalog.skills) {
if (params.category && entry.category !== params.category) continue
if (params.source && params.source !== 'all' && entry.source !== params.source) continue
if (terms.length === 0) {
strong.push(entry)
continue
}
const head = fold(`${entry.name} ${entry.slug}`)
const body = fold(`${entry.summary} ${entry.summaryEn ?? ''} ${entry.tags.join(' ')} ${entry.owner}`)
if (!terms.every((term) => head.includes(term) || body.includes(term))) continue
if (terms.every((term) => head.includes(term))) strong.push(entry)
else weak.push(entry)
}
return [...strong, ...weak]
}
@@ -0,0 +1,276 @@
import { describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import type { CatalogEntry, CatalogFile } from './catalog.js'
import {
buildCatalogSnapshot,
clawhubVerdict,
curationEntriesToRead,
orderForHome,
readClawhubEntry,
readSkillhubEntry,
skillhubVerdict,
type CatalogRead,
type CurationEntry,
type CurationFile,
type LiveRead,
type UpstreamVerdict,
} from './catalogSnapshot.js'
const FIXTURES = path.join(import.meta.dir, '..', '..', '..', '__tests__', 'fixtures', 'market')
async function readJson<T>(file: string): Promise<T> {
return JSON.parse(await fs.readFile(file, 'utf-8')) as T
}
const categories = [
{ key: 'a', name: '甲', nameEn: 'A' },
{ key: 'b', name: '乙', nameEn: 'B' },
]
function curationEntry(overrides: Partial<CurationEntry> = {}): CurationEntry {
return { source: 'clawhub', slug: 'demo', owner: 'alice', category: 'a', summary: '演示', tags: ['标签'], ...overrides }
}
function liveRead(overrides: Partial<LiveRead> = {}): LiveRead {
return { name: 'Demo', stats: { downloads: 1 }, verdict: 'clean', reasons: [], ...overrides }
}
function entry(overrides: Partial<CatalogEntry>): CatalogEntry {
return {
source: 'clawhub', slug: 'x', owner: 'o', name: 'x', summary: 's', category: 'a', tags: [],
stats: { downloads: 0 }, security: 'benign', ...overrides,
}
}
describe('buildCatalogSnapshot', () => {
it('reproduces the shipped skills.json from curation.json and the reads behind it', async () => {
const curation = await readJson<CurationFile>(path.join(import.meta.dir, 'curation.json'))
const shipped = await readJson<CatalogFile>(path.join(import.meta.dir, 'skills.json'))
const curated = new Map(curation.skills.map((item) => [`${item.source}:${item.slug}`, item]))
const verdicts: Record<string, UpstreamVerdict> = { benign: 'clean', flagged: 'suspicious', verified: 'verified', unknown: 'unknown' }
const reads = new Map<string, CatalogRead>()
for (const item of shipped.skills) {
const id = `${item.source}:${item.slug}`
const allow = curated.get(id)?.allowSuspicious
const note = item.securityNote ?? ''
const reasons = !note || note === allow ? [] : [allow && note.startsWith(`${allow}; `) ? note.slice(allow.length + 2) : note]
reads.set(id, {
name: item.name,
summaryEn: item.summaryEn,
stats: item.stats,
version: item.version,
updatedAt: item.updatedAt,
license: item.license,
iconUrl: item.iconUrl,
requiresApiKey: item.requiresApiKey,
verdict: verdicts[item.security]!,
reasons,
})
}
const { snapshot, problems } = buildCatalogSnapshot(curation, reads, shipped.generatedAt)
expect(problems).toEqual([])
// Byte-for-byte: the refresh script writes exactly this serialization.
expect(`${JSON.stringify(snapshot, null, 1)}\n`).toBe(await fs.readFile(path.join(import.meta.dir, 'skills.json'), 'utf-8'))
})
it('drops duplicates, unknown categories, missing reads, malicious and unexplained suspicious entries', () => {
const curation: CurationFile = {
categories,
skills: [
curationEntry({ slug: 'keep' }),
curationEntry({ slug: 'keep', summary: 'second copy' }),
curationEntry({ slug: 'lost-category', category: 'zzz' }),
curationEntry({ slug: 'moved' }),
curationEntry({ slug: 'evil' }),
curationEntry({ slug: 'shady' }),
curationEntry({ slug: 'unread' }),
],
}
const reads = new Map<string, CatalogRead>([
['clawhub:keep', liveRead()],
['clawhub:moved', { missing: 'owner is now mallory' }],
['clawhub:evil', liveRead({ verdict: 'malicious' })],
['clawhub:shady', liveRead({ verdict: 'suspicious', reasons: ['skillspector: DO_NOT_INSTALL'] })],
])
const { snapshot, problems } = buildCatalogSnapshot(curation, reads, 123)
expect(snapshot.skills.map((item) => item.slug)).toEqual(['keep'])
expect(snapshot.skills[0]!.summary).toBe('演示')
expect(snapshot.generatedAt).toBe(123)
expect(problems).toEqual([
'clawhub:keep: duplicate entry',
'clawhub:lost-category: unknown category zzz',
'clawhub:moved: owner is now mallory',
'clawhub:evil: upstream verdict: malicious',
'clawhub:shady: suspicious (skillspector: DO_NOT_INSTALL)',
'clawhub:unread: not read',
])
})
it('ships an allowed suspicious entry as flagged with the editorial reason first', () => {
const curation: CurationFile = {
categories,
skills: [curationEntry({ slug: 'shady', allowSuspicious: '审核意见' })],
}
const reads = new Map<string, CatalogRead>([
['clawhub:shady', liveRead({ verdict: 'suspicious', reasons: ['skillspector: CAUTION', 'llm review: suspicious'] })],
])
const [shipped] = buildCatalogSnapshot(curation, reads, 1).snapshot.skills
expect(shipped!.security).toBe('flagged')
expect(shipped!.securityNote).toBe('审核意见; skillspector: CAUTION; llm review: suspicious')
})
it('maps verdicts to card security and omits empty fields', () => {
const curation: CurationFile = {
categories,
skills: [
curationEntry({ slug: 'v', source: 'skillhub' }),
curationEntry({ slug: 'c' }),
curationEntry({ slug: 'u', tags: undefined }),
],
}
const reads = new Map<string, CatalogRead>([
['skillhub:v', liveRead({ verdict: 'verified' })],
['clawhub:c', liveRead({ verdict: 'clean', reasons: ['skillspector: CAUTION'] })],
['clawhub:u', liveRead({ verdict: 'unknown' })],
])
const skills = buildCatalogSnapshot(curation, reads, 1).snapshot.skills
const bySlug = new Map(skills.map((item) => [item.slug, item]))
expect(bySlug.get('v')!.security).toBe('verified')
expect(bySlug.get('c')!.security).toBe('benign')
expect(bySlug.get('c')!.securityNote).toBeUndefined()
expect(bySlug.get('u')!.security).toBe('unknown')
expect(bySlug.get('u')!.tags).toEqual([])
expect('featured' in bySlug.get('u')!).toBe(false)
expect('version' in bySlug.get('u')!).toBe(false)
})
})
describe('orderForHome', () => {
it('ranks by download percentile within each source, not raw downloads', () => {
const skills = [
entry({ slug: 'ch-low', source: 'clawhub', stats: { downloads: 10 } }),
entry({ slug: 'sh-mid', source: 'skillhub', stats: { downloads: 500_000 } }),
entry({ slug: 'ch-top', source: 'clawhub', stats: { downloads: 100 } }),
entry({ slug: 'sh-top', source: 'skillhub', stats: { downloads: 1_000_000 } }),
entry({ slug: 'sh-low', source: 'skillhub', stats: { downloads: 1 } }),
]
expect(orderForHome(categories, skills).map((item) => item.slug)).toEqual(['ch-top', 'sh-top', 'sh-mid', 'ch-low', 'sh-low'])
})
it('deals editor\'s picks round-robin across categories ahead of everything else', () => {
const skills = [
entry({ slug: 'plain-hot', stats: { downloads: 1_000 } }),
entry({ slug: 'a-pick-2', category: 'a', featured: true, stats: { downloads: 50 } }),
entry({ slug: 'a-pick-1', category: 'a', featured: true, stats: { downloads: 90 } }),
entry({ slug: 'a-pick-3', category: 'a', featured: true, stats: { downloads: 20 } }),
entry({ slug: 'b-pick-1', category: 'b', featured: true, stats: { downloads: 5 } }),
]
expect(orderForHome(categories, skills).map((item) => item.slug)).toEqual([
'a-pick-1', 'b-pick-1', 'a-pick-2', 'a-pick-3', 'plain-hot',
])
})
})
describe('upstream verdicts', () => {
it('reads ClawHub moderation before the version scan', () => {
expect(clawhubVerdict({ moderation: { isMalwareBlocked: true } }, {}).verdict).toBe('malicious')
expect(clawhubVerdict({ moderation: { verdict: 'malicious' } }, { version: { security: { status: 'clean' } } }).verdict).toBe('malicious')
expect(clawhubVerdict({}, { version: { security: { status: 'malicious' } } }).verdict).toBe('malicious')
expect(clawhubVerdict({ moderation: { isSuspicious: true } }, { version: { security: { status: 'clean' } } }).verdict).toBe('suspicious')
expect(clawhubVerdict({}, { version: { security: { status: 'suspicious' } } }).verdict).toBe('suspicious')
expect(clawhubVerdict({ moderation: { verdict: 'clean' } }, {}).verdict).toBe('clean')
expect(clawhubVerdict({ moderation: null }, {}).verdict).toBe('unknown')
})
it('collects ClawHub scanner objections as reasons', async () => {
const version = await readJson<any>(path.join(FIXTURES, 'clawhub-version-detail.json'))
version.version.security.scanners.vt.normalizedStatus = 'suspicious'
version.version.security.scanners.llm.normalizedStatus = 'suspicious'
expect(clawhubVerdict({}, version)).toEqual({
verdict: 'clean',
reasons: ['skillspector: CAUTION', 'llm review: suspicious', 'VirusTotal: suspicious'],
})
})
it('ignores SkillHub scans that have no verdict yet', () => {
expect(skillhubVerdict({ securityReports: { a: { status: 'queued' }, b: { status: 'pending' } } })).toEqual({ verdict: 'unknown', reasons: [] })
expect(skillhubVerdict({ securityReports: { a: { status: 'Benign' }, b: { status: 'pending' } } })).toEqual({ verdict: 'clean', reasons: ['reports: benign'] })
expect(skillhubVerdict({ skill: { verified: true }, securityReports: { a: { status: 'safe' } } }).verdict).toBe('verified')
expect(skillhubVerdict({ securityReports: { a: { status: 'benign' }, b: { status: 'risky' } } }).verdict).toBe('suspicious')
expect(skillhubVerdict({ securityReports: { a: { status: 'risky' }, b: { status: 'malicious' } } }).verdict).toBe('malicious')
expect(skillhubVerdict({}).verdict).toBe('unknown')
})
})
describe('upstream reads', () => {
it('reads a ClawHub entry from its detail and version fixtures', async () => {
const detail = await readJson<any>(path.join(FIXTURES, 'clawhub-detail.json'))
const version = await readJson<any>(path.join(FIXTURES, 'clawhub-version-detail.json'))
const read = readClawhubEntry(curationEntry({ slug: detail.skill.slug, owner: 'ivangdavila' }), detail, version)
expect(read).toEqual({
name: 'Git',
summaryEn: detail.skill.summary,
stats: { downloads: 16142, installs: 510, stars: 31 },
version: '1.0.8',
updatedAt: detail.skill.updatedAt,
license: 'MIT-0',
verdict: 'clean',
reasons: ['skillspector: CAUTION'],
})
})
it('reports a ClawHub entry whose owner changed or that no longer resolves', async () => {
const detail = await readJson<any>(path.join(FIXTURES, 'clawhub-detail.json'))
expect(readClawhubEntry(curationEntry({ owner: 'someone' }), detail, {})).toEqual({ missing: 'owner is now ivangdavila' })
expect(readClawhubEntry(curationEntry(), { _status: 404 }, {})).toEqual({ missing: 'clawhub detail 404' })
})
it('reads a SkillHub entry and checks it still resolves to the curated owner', async () => {
const detail = await readJson<any>(path.join(FIXTURES, 'skillhub-detail.json'))
const curated = curationEntry({ source: 'skillhub', slug: detail.skill.slug, owner: 'user_378eb7ca' })
const read = readSkillhubEntry(curated, detail)
expect(read).toMatchObject({
name: '私募合规',
stats: { downloads: 1978, installs: 40, stars: 1 },
version: '1.0.2',
verdict: 'clean',
iconUrl: detail.skill.iconUrl,
})
expect((read as LiveRead).requiresApiKey).toBeUndefined()
expect(readSkillhubEntry({ ...curated, owner: 'other' }, detail)).toEqual({ missing: 'slug now resolves to user_378eb7ca' })
expect(readSkillhubEntry(curated, { _error: 'timeout' })).toEqual({ missing: 'skillhub detail timeout' })
})
})
describe('curationEntriesToRead', () => {
it('reads each id once and skips entries in unknown categories', () => {
const toRead = curationEntriesToRead({
categories,
skills: [
curationEntry({ slug: 'one' }),
curationEntry({ slug: 'one', summary: 'dup' }),
curationEntry({ slug: 'one', source: 'skillhub' }),
curationEntry({ slug: 'bad', category: 'zzz' }),
],
})
expect(toRead.map((item) => `${item.source}:${item.slug}`)).toEqual(['clawhub:one', 'skillhub:one'])
})
})
@@ -0,0 +1,247 @@
/**
* Skills Market — pure catalog snapshot builder.
*
* `scripts/market-catalog-refresh.ts` fetches the upstream reads; everything
* that decides what ships (drops, security mapping, home-list order) lives here
* so it is testable without the network.
*/
import { skillId, type MarketSource, type SecurityStatus } from '../types.js'
import type { CatalogCategoryDef, CatalogEntry, CatalogFile } from './catalog.js'
export type CurationEntry = {
source: MarketSource
slug: string
owner: string
category: string
/** Reader-facing summary (zh-CN). */
summary: string
tags?: string[]
featured?: boolean
/** Reason a `suspicious` upstream verdict is accepted; without it the entry is dropped. */
allowSuspicious?: string
iconUrl?: string
}
export type CurationFile = {
_source?: string
categories: CatalogCategoryDef[]
skills: CurationEntry[]
}
export type UpstreamVerdict = 'malicious' | 'suspicious' | 'clean' | 'verified' | 'unknown'
export type VerdictResult = { verdict: UpstreamVerdict; reasons: string[] }
/** What one upstream read contributes to a snapshot entry. */
export type LiveRead = {
name: string
summaryEn?: string
stats: { downloads: number; installs?: number; stars?: number }
version?: string
updatedAt?: number
license?: string
iconUrl?: string
requiresApiKey?: boolean
verdict: UpstreamVerdict
reasons: string[]
}
/** The entry cannot ship: upstream no longer serves it as curated. */
export type MissingRead = { missing: string }
export type CatalogRead = LiveRead | MissingRead
type Json = Record<string, any> | undefined | null
const num = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isFinite(value) ? value : undefined
const str = (value: unknown): string | undefined =>
typeof value === 'string' && value !== '' ? value : undefined
/** ClawHub: moderation on the skill detail plus the latest version's scan. */
export function clawhubVerdict(detail: Json, versionDetail: Json): VerdictResult {
const scan = versionDetail?.version?.security ?? {}
const moderation = detail?.moderation ?? {}
let verdict: UpstreamVerdict = 'unknown'
const reasons: string[] = []
if (moderation.isMalwareBlocked || moderation.verdict === 'malicious' || scan.status === 'malicious') verdict = 'malicious'
else if (moderation.isSuspicious || scan.status === 'suspicious') verdict = 'suspicious'
else if (moderation.verdict === 'clean' || scan.status === 'clean') verdict = 'clean'
if (scan.scanners?.skillspector?.recommendation) reasons.push(`skillspector: ${scan.scanners.skillspector.recommendation}`)
if (scan.scanners?.llm?.normalizedStatus && scan.scanners.llm.normalizedStatus !== 'clean') reasons.push(`llm review: ${scan.scanners.llm.normalizedStatus}`)
if (scan.scanners?.vt?.normalizedStatus && scan.scanners.vt.normalizedStatus !== 'clean') reasons.push(`VirusTotal: ${scan.scanners.vt.normalizedStatus}`)
return { verdict, reasons }
}
/** SkillHub: `queued`/`pending` scans have no verdict yet and leave the skill unknown. */
export function skillhubVerdict(detail: Json): VerdictResult {
const reports = Object.values((detail?.securityReports ?? {}) as Record<string, any>)
.map((report) => String(report?.status ?? '').toLowerCase())
.filter((status) => status !== '' && status !== 'queued' && status !== 'pending')
let verdict: UpstreamVerdict = 'unknown'
if (reports.some((status) => status === 'malicious')) verdict = 'malicious'
else if (reports.some((status) => !['benign', 'safe', 'clean'].includes(status))) verdict = 'suspicious'
else if (reports.length > 0) verdict = detail?.skill?.verified ? 'verified' : 'clean'
return { verdict, reasons: reports.length ? [`reports: ${reports.join(', ')}`] : [] }
}
/**
* ClawHub read from an owner-qualified detail and its latest version.
* `detail` may be an error marker (`{_status}`/`{_error}`) from the fetcher.
*/
export function readClawhubEntry(entry: CurationEntry, detail: Json, versionDetail: Json): CatalogRead {
const skill = detail?.skill
if (!skill) return { missing: `clawhub detail ${detail?._status ?? detail?._error ?? 'empty'}` }
if (detail?.owner?.handle && detail.owner.handle !== entry.owner) {
return { missing: `owner is now ${detail.owner.handle}` }
}
return {
name: str(skill.displayName) ?? entry.slug,
summaryEn: str(skill.summary),
stats: { downloads: num(skill.stats?.downloads) ?? 0, installs: num(skill.stats?.installs), stars: num(skill.stats?.stars) },
version: clawhubLatestVersion(detail),
updatedAt: num(skill.updatedAt),
license: str(detail?.latestVersion?.license) ?? str(versionDetail?.version?.license),
...clawhubVerdict(detail, versionDetail),
}
}
/** The version a ClawHub detail points at (its version detail carries the scan). */
export function clawhubLatestVersion(detail: Json): string | undefined {
return str(detail?.latestVersion?.version) ?? str(detail?.skill?.tags?.latest)
}
/** SkillHub read: the slug must still resolve to the curated owner's skill. */
export function readSkillhubEntry(entry: CurationEntry, detail: Json): CatalogRead {
const skill = detail?.skill
if (!skill) return { missing: `skillhub detail ${detail?._status ?? detail?._error ?? 'empty'}` }
const owner = str(detail?.owner?.handle) ?? str(skill.ownerName)
if (owner && owner !== entry.owner) return { missing: `slug now resolves to ${owner}` }
return {
name: str(skill.displayName) ?? str(skill.name) ?? entry.slug,
summaryEn: str(skill.summary),
stats: {
downloads: num(skill.stats?.downloads) ?? 0,
installs: num(skill.stats?.installs),
stars: num(skill.stats?.stars),
},
version: str(detail?.latestVersion?.version),
updatedAt: num(skill.updatedAt),
iconUrl: str(skill.iconUrl),
requiresApiKey: skill.labels?.requires_api_key === 'true' ? true : undefined,
...skillhubVerdict(detail),
}
}
/** Curation entries worth reading upstream: first occurrence of each id, known category. */
export function curationEntriesToRead(curation: CurationFile): CurationEntry[] {
const categoryKeys = new Set(curation.categories.map((category) => category.key))
const seen = new Set<string>()
return curation.skills.filter((entry) => {
const id = skillId(entry.source, entry.slug)
if (seen.has(id) || !categoryKeys.has(entry.category)) return false
seen.add(id)
return true
})
}
function securityFor(verdict: UpstreamVerdict): SecurityStatus {
if (verdict === 'suspicious') return 'flagged'
if (verdict === 'verified') return 'verified'
if (verdict === 'clean') return 'benign'
return 'unknown'
}
/**
* Build the shipped snapshot from the editorial source and the upstream reads
* (keyed by `source:slug`). Drops are reported, never shipped as dead cards.
*/
export function buildCatalogSnapshot(
curation: CurationFile,
reads: ReadonlyMap<string, CatalogRead>,
now: number,
): { snapshot: CatalogFile; problems: string[] } {
const categoryKeys = new Set(curation.categories.map((category) => category.key))
const problems: string[] = []
const seen = new Set<string>()
const skills: CatalogEntry[] = []
for (const entry of curation.skills) {
const id = skillId(entry.source, entry.slug)
if (seen.has(id)) {
problems.push(`${id}: duplicate entry`)
continue
}
seen.add(id)
if (!categoryKeys.has(entry.category)) {
problems.push(`${id}: unknown category ${entry.category}`)
continue
}
const live = reads.get(id) ?? { missing: 'not read' }
if ('missing' in live) {
problems.push(`${id}: ${live.missing}`)
continue
}
if (live.verdict === 'malicious') {
problems.push(`${id}: upstream verdict: malicious`)
continue
}
if (live.verdict === 'suspicious' && !entry.allowSuspicious) {
problems.push(`${id}: suspicious (${live.reasons.join('; ')})`)
continue
}
const security = securityFor(live.verdict)
// Key order is the file's order; JSON round trip drops undefined fields.
const skill = {
source: entry.source,
slug: entry.slug,
owner: entry.owner,
name: live.name,
summary: entry.summary,
summaryEn: live.summaryEn,
category: entry.category,
tags: entry.tags ?? [],
featured: entry.featured === true ? true : undefined,
stats: live.stats,
version: live.version,
updatedAt: live.updatedAt,
iconUrl: live.iconUrl ?? entry.iconUrl,
security,
securityNote: security === 'flagged' ? [entry.allowSuspicious, ...live.reasons].filter(Boolean).join('; ') : undefined,
requiresApiKey: live.requiresApiKey,
license: live.license,
}
skills.push(JSON.parse(JSON.stringify(skill)) as CatalogEntry)
}
return {
snapshot: { version: 1, generatedAt: now, categories: curation.categories, skills: orderForHome(curation.categories, skills) },
problems,
}
}
/**
* Home-list order. Download counts are not comparable across registries
* (SkillHub's run far higher with near-zero installs), so popularity is the
* percentile within each source. Editor's picks lead, dealt round-robin across
* categories so the first screen shows the breadth of the catalog.
*/
export function orderForHome(categories: CatalogCategoryDef[], skills: CatalogEntry[]): CatalogEntry[] {
const percentile = new Map<CatalogEntry, number>()
for (const source of ['clawhub', 'skillhub'] as const) {
const ranked = skills.filter((skill) => skill.source === source).sort((a, b) => b.stats.downloads - a.stats.downloads)
ranked.forEach((skill, index) => percentile.set(skill, 1 - index / Math.max(1, ranked.length)))
}
const byPopularity = (a: CatalogEntry, b: CatalogEntry) => percentile.get(b)! - percentile.get(a)!
const picks = new Map<string, CatalogEntry[]>(categories.map((category) => [category.key, []]))
const featured = skills.filter((entry) => entry.featured).sort(byPopularity)
for (const skill of featured) picks.get(skill.category)?.push(skill)
const lead: CatalogEntry[] = []
const rounds = Math.max(0, ...[...picks.values()].map((queue) => queue.length))
for (let round = 0; round < rounds; round++) {
for (const queue of picks.values()) if (queue[round]) lead.push(queue[round]!)
}
const rest = skills.filter((entry) => !entry.featured).sort(byPopularity)
return [...lead, ...rest]
}
@@ -0,0 +1,404 @@
{
"_source": "Editorial source, synced by hand from dsh-skills-hub catalog/curation.json on 2026-10-02. Edit it here, then run scripts/market-catalog-refresh.ts to regenerate skills.json.",
"categories": [{"key": "agent", "name": "智能体增强", "nameEn": "Agent Boost"}, {"key": "research", "name": "搜索调研", "nameEn": "Search & Research"}, {"key": "dev", "name": "开发编程", "nameEn": "Development"}, {"key": "office", "name": "办公文档", "nameEn": "Office & Docs"}, {"key": "productivity", "name": "效率协作", "nameEn": "Productivity"}, {"key": "content", "name": "内容创作", "nameEn": "Writing & Content"}, {"key": "media", "name": "设计多媒体", "nameEn": "Design & Media"}, {"key": "data", "name": "数据分析", "nameEn": "Data & Analytics"}, {"key": "marketing", "name": "营销电商", "nameEn": "Marketing & E-commerce"}, {"key": "finance", "name": "金融投资", "nameEn": "Finance"}, {"key": "professional", "name": "行业专业", "nameEn": "Industry & Professional"}, {"key": "life", "name": "生活服务", "nameEn": "Lifestyle"}, {"key": "security", "name": "安全运维", "nameEn": "Security & Ops"}],
"skills": [
{"source": "clawhub", "slug": "agent-memory", "owner": "dennis-da-menace", "category": "agent", "summary": "为智能体提供跨会话的事实存储、回忆与实体追踪", "tags": ["长期记忆"], "featured": true},
{"source": "clawhub", "slug": "planning-with-files", "owner": "othmanadi", "category": "agent", "summary": "用任务计划、发现和进度三个文件持久化跟踪多步任务", "tags": ["任务规划", "文件记忆"], "featured": true},
{"source": "clawhub", "slug": "qmd", "owner": "steipete", "category": "agent", "summary": "本地文档检索索引工具,结合关键词、向量与重排序,支持 MCP", "tags": ["本地检索", "MCP"], "featured": true},
{"source": "clawhub", "slug": "agent-team-orchestration", "owner": "arminnaimi", "category": "agent", "summary": "编排多智能体团队,定义角色、任务流转、交接与审查流程", "tags": ["多智能体", "任务编排"]},
{"source": "clawhub", "slug": "api-gateway", "owner": "byungkyu", "category": "agent", "summary": "通过 Maton 网关调用已授权的第三方应用 API", "tags": ["API网关", "第三方集成"]},
{"source": "clawhub", "slug": "capability-evolver-pro", "owner": "kennyzir", "category": "agent", "summary": "分析运行日志中的错误模式,提出能力改进方案", "tags": ["自我改进", "日志分析"]},
{"source": "clawhub", "slug": "deep-thinking", "owner": "amankr-novo", "category": "agent", "summary": "为复杂、模糊的多步骤任务提供系统化深度推理流程", "tags": ["深度推理"]},
{"source": "clawhub", "slug": "first-principles-decomposer", "owner": "artyomx33", "category": "agent", "summary": "将问题拆解到基本事实,再从底层重建方案", "tags": ["第一性原理", "思考框架"]},
{"source": "clawhub", "slug": "gemini", "owner": "steipete", "category": "agent", "summary": "调用 Gemini CLI 做一次性问答、摘要和内容生成", "tags": ["Gemini", "多模型"]},
{"source": "clawhub", "slug": "mcp-skill", "owner": "simlocker", "category": "agent", "summary": "通过 MCP 使用网页搜索、代码上下文、深度研究和爬取工具", "tags": ["MCP", "搜索工具"]},
{"source": "clawhub", "slug": "mcporter", "owner": "steipete", "category": "agent", "summary": "通过 mcporter CLI 列出、配置和调用 MCP 服务器与工具", "tags": ["MCP", "工具接入"], "allowSuspicious": "ClawHub 审核指出它赋予智能体较宽的 MCP、认证与本地命令能力,缺少范围限制说明"},
{"source": "clawhub", "slug": "memory", "owner": "ivangdavila", "category": "agent", "summary": "以分类 Markdown 文件保存和整理长期事实,补充内置记忆", "tags": ["长期记忆", "Markdown"]},
{"source": "clawhub", "slug": "oracle", "owner": "steipete", "category": "agent", "summary": "打包提示词和相关文件,交给第二个模型做审查或交叉验证", "tags": ["多模型", "代码审查"]},
{"source": "clawhub", "slug": "proactive-agent", "owner": "halthelobster", "category": "agent", "summary": "让智能体主动跟进任务、维护长期记忆并在空闲时整理工作,减少反复提醒", "tags": ["主动执行", "长期记忆"], "allowSuspicious": "ClawHub 审核认为其赋予智能体较大的自动记忆与自我修改权限,用户控制不足"},
{"source": "clawhub", "slug": "proactive-agent-lite", "owner": "bestrocky", "category": "agent", "summary": "通过记忆结构和自检模式让智能体更主动地推进任务", "tags": ["主动性", "自我修复"]},
{"source": "clawhub", "slug": "prompt-engineering-expert", "owner": "tomstools11", "category": "agent", "summary": "设计和优化提示词、自定义指令及智能体提示", "tags": ["提示词工程"]},
{"source": "clawhub", "slug": "qveris-official", "owner": "linfangw", "category": "agent", "summary": "按需发现并调用实时数据、报表等专用 API 工具", "tags": ["工具发现", "API"]},
{"source": "clawhub", "slug": "reasoning-personas", "owner": "artyomx33", "category": "agent", "summary": "切换多种思考角色用于头脑风暴和方案评审", "tags": ["思考模式", "头脑风暴"]},
{"source": "clawhub", "slug": "self-improving-agent", "owner": "pskoett", "category": "agent", "summary": "把命令失败、用户纠正和新认知记录到本地文件,供后续会话复用以持续改进", "tags": ["自我改进", "经验沉淀"], "allowSuspicious": "ClawHub 审核认为其可选 Hook 会读取会话记录且脱敏不足;核心功能只读写本地 .learnings/,安装前请阅读安全报告"},
{"source": "clawhub", "slug": "skill-builder", "owner": "ivangdavila", "category": "agent", "summary": "以模块化结构和渐进披露编写省 Token 的技能", "tags": ["技能创作"]},
{"source": "clawhub", "slug": "skill-creator-2", "owner": "yixinli867", "category": "agent", "summary": "指导创建或更新结构规范、高效的技能", "tags": ["技能创作"]},
{"source": "clawhub", "slug": "skill-finder-cn", "owner": "guohongbin-git", "category": "agent", "summary": "根据需求查找并安装 ClawHub 上的技能", "tags": ["技能发现", "ClawHub"]},
{"source": "clawhub", "slug": "skills-search", "owner": "thesethrose", "category": "agent", "summary": "在命令行搜索 skills.sh 技能注册表", "tags": ["技能发现", "skills.sh"]},
{"source": "clawhub", "slug": "subagent-driven-development", "owner": "zlc000190", "category": "agent", "summary": "把实施计划拆成独立任务,交给子代理逐个执行并审查", "tags": ["子代理", "任务拆分"]},
{"source": "clawhub", "slug": "thinking-partner", "owner": "itsflow", "category": "agent", "summary": "通过追问帮助梳理和探索复杂问题", "tags": ["思考伙伴", "决策"]},
{"source": "clawhub", "slug": "xiucheng-self-improving-agent", "owner": "xiucheng", "category": "agent", "summary": "分析对话质量,记录改进点并持续优化回复策略", "tags": ["自我改进"]},
{"source": "skillhub", "slug": "find-skill-skillhub", "owner": "user_290ac21c", "category": "agent", "summary": "在 SkillHub 按关键词和分类查找可安装的技能", "tags": ["技能发现", "SkillHub"]},
{"source": "skillhub", "slug": "luban-skill-pro", "owner": "user_741dc82b", "category": "agent", "summary": "对已有技能进行评分、质量检查和自动优化", "tags": ["技能优化", "评分"]},
{"source": "skillhub", "slug": "tiangong-skill", "owner": "user_741dc82b", "category": "agent", "summary": "设计和创建智能体或人物蒸馏型技能", "tags": ["智能体设计", "人物蒸馏"]},
{"source": "skillhub", "slug": "zy930511", "owner": "user_19b9fbd9", "category": "agent", "summary": "把一本书的方法论和思维模型蒸馏成可用的新技能", "tags": ["读书蒸馏", "技能创作"]},
{"source": "clawhub", "slug": "ddg-web-search", "owner": "jakelin", "category": "research", "summary": "通过 DuckDuckGo Lite 免密钥搜索网页,可作备用搜索", "tags": ["网页搜索", "免Key"], "featured": true},
{"source": "clawhub", "slug": "openclaw-tavily-search", "owner": "jacky1n7", "category": "research", "summary": "通过 Tavily 搜索公开网页,返回带链接和摘要的结构化结果", "tags": ["网页搜索", "Tavily"], "featured": true},
{"source": "clawhub", "slug": "summarize-pro", "owner": "mkpareek0315", "category": "research", "summary": "总结文章、文档、会议、邮件、书籍等长文本", "tags": ["内容总结"], "featured": true},
{"source": "clawhub", "slug": "academic-research-hub", "owner": "anisafifi", "category": "research", "summary": "检索学术论文、下载文献并提取引文", "tags": ["文献检索", "学术"]},
{"source": "clawhub", "slug": "agent-browser-clawdbot", "owner": "matrixy", "category": "research", "summary": "通过 agent-browser CLI 打开网页、点击、填表和截图,完成浏览器自动化", "tags": ["浏览器", "自动化"], "allowSuspicious": "ClawHub 审核指出它对保存的登录态、Cookie 与全局安装依赖的风险披露不足"},
{"source": "clawhub", "slug": "agent-browser-core", "owner": "codedao12", "category": "research", "summary": "agent-browser CLI 的使用指南,用于网页导航、元素操作和页面快照", "tags": ["浏览器", "网页操作"]},
{"source": "clawhub", "slug": "ai-news-collectors", "owner": "kenxcomp", "category": "research", "summary": "聚合 AI 领域新产品、论文和讨论,并按热度排序", "tags": ["AI资讯"]},
{"source": "clawhub", "slug": "answeroverflow", "owner": "rhyssullivan", "category": "research", "summary": "搜索已索引的 Discord 社区讨论,查找技术问题答案", "tags": ["社区问答", "Discord"]},
{"source": "clawhub", "slug": "arxiv", "owner": "ractorrr", "category": "research", "summary": "搜索、下载并总结 arXiv 论文", "tags": ["学术论文", "arXiv"]},
{"source": "clawhub", "slug": "baidu-baike-data", "owner": "ide-rea", "category": "research", "summary": "查询百度百科的词条解释", "tags": ["百科", "百度"]},
{"source": "clawhub", "slug": "bailian-web-search", "owner": "krisyejh", "category": "research", "summary": "通过阿里云百炼 API 获取多源精简的网页搜索结果", "tags": ["网页搜索", "阿里云"]},
{"source": "clawhub", "slug": "blogwatcher", "owner": "steipete", "category": "research", "summary": "用 blogwatcher CLI 监控博客和 RSS 订阅更新", "tags": ["RSS", "博客订阅"]},
{"source": "clawhub", "slug": "brave-search", "owner": "steipete", "category": "research", "summary": "用 Brave Search API 搜索网页并提取页面正文", "tags": ["网页搜索", "Brave"], "allowSuspicious": "ClawHub 审核指出它可从运行环境抓取任意 URL,且文档对 Brave 的使用描述不准确"},
{"source": "clawhub", "slug": "cn-web-search", "owner": "joansongjr", "category": "research", "summary": "聚合多个免费中文搜索源,无需 API Key,覆盖公众号、财经、学术", "tags": ["中文搜索", "免Key"]},
{"source": "clawhub", "slug": "crawl4ai-skill", "owner": "lancelin111", "category": "research", "summary": "用 Crawl4AI 爬取网页并输出适合大模型的文本", "tags": ["网页爬取"]},
{"source": "clawhub", "slug": "daily-trending", "owner": "enchograph", "category": "research", "summary": "从今日热榜抓取微博等各平台热搜榜单", "tags": ["热搜", "热榜"]},
{"source": "clawhub", "slug": "exa", "owner": "fardeenxyz", "category": "research", "summary": "通过 Exa API 做语义网页搜索,查找文档和代码示例", "tags": ["网页搜索", "Exa"]},
{"source": "clawhub", "slug": "firecrawl-search", "owner": "ashwingupy", "category": "research", "summary": "用 Firecrawl 搜索网页并抓取含 JS 渲染的页面内容", "tags": ["网页抓取", "Firecrawl"]},
{"source": "clawhub", "slug": "gemini-deep-research", "owner": "arun-8687", "category": "research", "summary": "调用 Gemini 深度研究代理完成长时间调研任务", "tags": ["深度调研", "Gemini"]},
{"source": "clawhub", "slug": "in-depth-research", "owner": "ivangdavila", "category": "research", "summary": "多来源深入调研,记录方法并评估信源可靠性", "tags": ["深度调研", "信源评估"]},
{"source": "clawhub", "slug": "jina-reader", "owner": "ericsantos", "category": "research", "summary": "用 Jina Reader 把网页转为 Markdown,也可搜索并抓全文", "tags": ["网页转文本", "Jina"]},
{"source": "clawhub", "slug": "literature-review", "owner": "weird-aftertaste", "category": "research", "summary": "检索 Semantic Scholar、PubMed 等学术库,辅助写文献综述", "tags": ["文献综述", "学术检索"]},
{"source": "clawhub", "slug": "market-research", "owner": "ivangdavila", "category": "research", "summary": "做市场规模估算、细分、竞品梳理和需求验证", "tags": ["市场调研"]},
{"source": "clawhub", "slug": "market-research-agent", "owner": "1kalin", "category": "research", "summary": "按成熟框架做市场规模、趋势、竞品与客群调研", "tags": ["市场调研", "竞品"]},
{"source": "clawhub", "slug": "news-aggregator", "owner": "httencn", "category": "research", "summary": "自动搜索、筛选并整理国内外社会、科技、军事新闻", "tags": ["新闻汇总", "中文"]},
{"source": "clawhub", "slug": "news-summary", "owner": "joargp", "category": "research", "summary": "获取最新新闻并生成每日简报", "tags": ["新闻简报"]},
{"source": "clawhub", "slug": "playwright", "owner": "ivangdavila", "category": "research", "summary": "用 Playwright 自动化操作、测试和调试浏览器", "tags": ["浏览器自动化"]},
{"source": "clawhub", "slug": "reddit-insights", "owner": "dowands", "category": "research", "summary": "语义搜索并分析 Reddit 讨论内容", "tags": ["Reddit", "用户洞察"]},
{"source": "clawhub", "slug": "reddit-readonly", "owner": "buksan1950", "category": "research", "summary": "只读浏览和搜索Reddit帖子与评论", "tags": ["Reddit", "社区调研"]},
{"source": "clawhub", "slug": "the-news", "owner": "sfkislev", "category": "research", "summary": "查看 20 个国家媒体的实时和历史头版头条", "tags": ["国际新闻", "媒体对比"]},
{"source": "clawhub", "slug": "trend-watcher", "owner": "guogang1024", "category": "research", "summary": "追踪 GitHub Trending 等社区的新兴开发工具", "tags": ["GitHub趋势", "技术动态"]},
{"source": "clawhub", "slug": "web-scraping", "owner": "zhangqixin9527", "category": "research", "summary": "静态页直接提取,动态页用浏览器处理分页和滚动", "tags": ["网页抓取", "结构化"]},
{"source": "clawhub", "slug": "xiaohongshu-deep-research", "owner": "palmpalm7", "category": "research", "summary": "围绕小红书话题分析热门笔记并生成调研报告", "tags": ["小红书", "话题调研"]},
{"source": "clawhub", "slug": "youtube-watcher", "owner": "michaelgathara", "category": "research", "summary": "获取 YouTube 视频字幕,用于总结和问答", "tags": ["YouTube", "字幕"]},
{"source": "skillhub", "slug": "tencent-news", "owner": "u_8c6b225d", "category": "research", "summary": "搜索国内外新闻、热榜和早晚报,也可查天气", "tags": ["新闻资讯", "腾讯"]},
{"source": "clawhub", "slug": "docker-essentials", "owner": "arnarsson", "category": "dev", "summary": "Docker 常用命令与工作流,涵盖容器管理、镜像操作和调试", "tags": ["Docker", "容器"], "featured": true},
{"source": "clawhub", "slug": "github", "owner": "steipete", "category": "dev", "summary": "通过 gh 命令行管理 GitHub 议题、PR、CI 运行,并支持 API 高级查询", "tags": ["GitHub", "代码协作"], "featured": true},
{"source": "clawhub", "slug": "superpowers", "owner": "wlshlad85", "category": "dev", "summary": "规格先行、测试驱动、子代理协作的软件开发方法论", "tags": ["开发方法", "TDD"], "featured": true},
{"source": "clawhub", "slug": "api-tester", "owner": "wanng-ide", "category": "dev", "summary": "发送结构化 HTTP 请求测试接口,支持多种方法与请求头", "tags": ["接口测试", "HTTP"]},
{"source": "clawhub", "slug": "backend-patterns", "owner": "charmmm718", "category": "dev", "summary": "Node.js 后端架构、API 设计和数据库优化的最佳实践", "tags": ["后端", "API设计"]},
{"source": "clawhub", "slug": "code", "owner": "ivangdavila", "category": "dev", "summary": "按规划、实现、验证、测试的流程完成编码任务", "tags": ["编码流程", "测试"]},
{"source": "clawhub", "slug": "code-mentor", "owner": "samuelkahessay", "category": "dev", "summary": "面向各水平学习者的编程辅导与代码讲解", "tags": ["编程学习", "辅导"]},
{"source": "clawhub", "slug": "context7", "owner": "thesethrose", "category": "dev", "summary": "按需检索各类开发库的最新文档和代码示例", "tags": ["开发文档", "MCP"]},
{"source": "clawhub", "slug": "conventional-commits", "owner": "bastos", "category": "dev", "summary": "按 Conventional Commits 规范生成提交信息", "tags": ["Git", "提交规范"]},
{"source": "clawhub", "slug": "critical-code-reviewer", "owner": "ziad-hsn", "category": "dev", "summary": "以严格对抗的视角审查代码,指出质量与设计问题", "tags": ["代码审查"]},
{"source": "clawhub", "slug": "debug-pro", "owner": "cmanfre7", "category": "dev", "summary": "七步调试流程加各语言调试命令,系统化定位并修复缺陷", "tags": ["调试", "Bug修复"]},
{"source": "clawhub", "slug": "deepwiki", "owner": "arun-8687", "category": "dev", "summary": "查询 DeepWiki 获取 GitHub 仓库文档、结构说明和问答", "tags": ["仓库文档", "GitHub"]},
{"source": "clawhub", "slug": "docker-compose", "owner": "ivangdavila", "category": "dev", "summary": "用 Docker Compose 定义多容器应用,管理依赖、网络和数据卷", "tags": ["Docker", "容器编排"]},
{"source": "clawhub", "slug": "e2e-testing-patterns", "owner": "wpank", "category": "dev", "summary": "用 Playwright 和 Cypress 构建稳定的端到端测试套件", "tags": ["E2E测试", "Playwright"]},
{"source": "clawhub", "slug": "explain-code", "owner": "edwininau", "category": "dev", "summary": "用图表和类比解释代码逻辑", "tags": ["代码讲解", "可视化"]},
{"source": "clawhub", "slug": "file-search", "owner": "xejrax", "category": "dev", "summary": "使用 fd 和 ripgrep 快速搜索文件名与文件内容", "tags": ["文件搜索", "命令行"]},
{"source": "clawhub", "slug": "git", "owner": "ivangdavila", "category": "dev", "summary": "处理 Git 提交、分支、变基、合并冲突和历史恢复等日常操作", "tags": ["Git", "冲突解决"]},
{"source": "clawhub", "slug": "git-essentials", "owner": "arnarsson", "category": "dev", "summary": "Git 核心命令与工作流,涵盖版本控制、分支和协作", "tags": ["Git", "版本控制"]},
{"source": "clawhub", "slug": "gitlab-cli-skills", "owner": "vince-winkintel", "category": "dev", "summary": "GitLab glab 命令行参考与工作流,覆盖合并请求、流水线等操作", "tags": ["GitLab"]},
{"source": "clawhub", "slug": "jq", "owner": "gumadeiras", "category": "dev", "summary": "用 jq 在命令行提取、过滤和转换 JSON 数据", "tags": ["JSON", "命令行"]},
{"source": "clawhub", "slug": "mermaid-diagrams", "owner": "wpank", "category": "dev", "summary": "用 Mermaid 语法绘制流程图、时序图、类图等软件图表", "tags": ["Mermaid", "图表"]},
{"source": "clawhub", "slug": "n8n-workflow-automation", "owner": "kowl64", "category": "dev", "summary": "设计带重试、日志和人工审核的 n8n 工作流 JSON", "tags": ["n8n", "工作流"]},
{"source": "clawhub", "slug": "python", "owner": "adarshdigievo", "category": "dev", "summary": "Python 编码规范与最佳实践,用于编写、审查和重构代码", "tags": ["Python", "编码规范"]},
{"source": "clawhub", "slug": "react", "owner": "ivangdavila", "category": "dev", "summary": "React 19 工程实践,涵盖组件、Hooks、状态管理和服务端组件", "tags": ["React", "前端"]},
{"source": "clawhub", "slug": "shadcn-ui", "owner": "jgarrison929", "category": "dev", "summary": "用 shadcn/ui、Tailwind CSS 和表单校验库构建界面", "tags": ["UI组件", "Tailwind"]},
{"source": "clawhub", "slug": "shell", "owner": "xueyetianya", "category": "dev", "summary": "Bash 脚本参考,涵盖语法、重定向、信号处理和调试技巧", "tags": ["Shell", "Bash"]},
{"source": "clawhub", "slug": "sqlite", "owner": "ivangdavila", "category": "dev", "summary": "正确使用 SQLite,处理并发、PRAGMA 设置和类型问题", "tags": ["SQLite", "数据库"]},
{"source": "clawhub", "slug": "superdesign", "owner": "mpociot", "category": "dev", "summary": "前端设计指南,帮助生成美观现代的落地页和界面", "tags": ["前端设计", "UI"]},
{"source": "clawhub", "slug": "swiftui-ui-patterns", "owner": "dimillian", "category": "dev", "summary": "构建 SwiftUI 视图与组件的最佳实践和示例", "tags": ["SwiftUI", "iOS"]},
{"source": "clawhub", "slug": "test-runner", "owner": "cmanfre7", "category": "dev", "summary": "为 TypeScript、Python、Swift 编写和运行单元、集成及端到端测试", "tags": ["测试"]},
{"source": "clawhub", "slug": "ui-skills", "owner": "correctroadh", "category": "dev", "summary": "为智能体构建界面提供明确的设计约束", "tags": ["前端界面", "设计规范"]},
{"source": "clawhub", "slug": "vercel", "owner": "thesethrose", "category": "dev", "summary": "使用 Vercel 命令行部署应用并管理项目", "tags": ["部署", "Vercel"]},
{"source": "clawhub", "slug": "web", "owner": "ivangdavila", "category": "dev", "summary": "按生产规范构建、调试和部署网站", "tags": ["网站开发", "前端"]},
{"source": "skillhub", "slug": "code-review93", "owner": "user_19b9fbd9", "category": "dev", "summary": "从七个维度审查 AI 生成的代码,用白话说明影响并给出修复优先级", "tags": ["代码审查", "AI代码"]},
{"source": "skillhub", "slug": "dev-expert", "owner": "user_741dc82b", "category": "dev", "summary": "全栈编程助手,覆盖 API 设计、代码生成、审查、重构和测试用例", "tags": ["全栈", "编程助手"]},
{"source": "skillhub", "slug": "diagram-builder", "owner": "user_04c33885", "category": "dev", "summary": "绘制专业的软件架构图与流程图", "tags": ["架构图", "流程图"]},
{"source": "skillhub", "slug": "frontend-spec", "owner": "user_27ccabd4", "category": "dev", "summary": "企业级前端开发规范,覆盖命名、目录、Vue/React、Git、构建与安全", "tags": ["前端", "开发规范"]},
{"source": "skillhub", "slug": "gh-cli-readonly-agent", "owner": "user_64e5b706", "category": "dev", "summary": "只读方式查询 GitHub 仓库、议题和 PR,带缓存与字段裁剪以节省 Token", "tags": ["GitHub", "只读查询"]},
{"source": "skillhub", "slug": "paperless-business-system-from-files", "owner": "user_84b8c7d7", "category": "dev", "summary": "根据 Excel、Word 等业务附件生成可本地部署的 Python Web 业务系统", "tags": ["系统生成", "Python"]},
{"source": "skillhub", "slug": "project-explanation", "owner": "u_5c028db6", "category": "dev", "summary": "扫描本地代码库或仓库,输出架构、模块依赖和能力清单等项目讲解", "tags": ["读代码", "项目讲解"]},
{"source": "clawhub", "slug": "markdown-converter", "owner": "steipete", "category": "office", "summary": "用 markitdown 把 PDF、Word、PPT 等文件转为 Markdown", "tags": ["格式转换", "Markdown"], "featured": true},
{"source": "clawhub", "slug": "pdf", "owner": "awspace", "category": "office", "summary": "提取 PDF 文本表格,创建、合并、拆分和填写表单", "tags": ["PDF"], "featured": true},
{"source": "skillhub", "slug": "wps-office-suite", "owner": "user_1a470ba8", "category": "office", "summary": "自动识别本机 WPS/Office/LibreOffice,处理文字、表格、演示文档", "tags": ["WPS", "文档处理"], "featured": true},
{"source": "clawhub", "slug": "ai-ppt-generator", "owner": "ide-rea", "category": "office", "summary": "调用百度文库 AI 按内容自动选模板生成 PPT(需 API Key)", "tags": ["PPT", "百度文库"]},
{"source": "clawhub", "slug": "chinese-official-writing", "owner": "gongyu0918-debug", "category": "office", "summary": "起草、改写和审校中文公文、事务材料与新闻稿,并处理 Word 格式", "tags": ["公文写作", "审校"]},
{"source": "clawhub", "slug": "excel-xlsx", "owner": "ivangdavila", "category": "office", "summary": "创建和编辑 Excel 工作簿,保证公式、日期、格式和模板可靠", "tags": ["Excel", "表格"]},
{"source": "clawhub", "slug": "feishu-doc-manager", "owner": "shuai-daidai", "category": "office", "summary": "把 Markdown 内容发布到飞书文档,自动转换表格等格式", "tags": ["飞书", "文档"]},
{"source": "clawhub", "slug": "google-sheets", "owner": "byungkyu", "category": "office", "summary": "通过 OAuth 连接 Google Sheets,读写表格数据和设置格式(需 Maton 网关)", "tags": ["Google", "表格"]},
{"source": "clawhub", "slug": "mineru-document-extractor", "owner": "mineru-extract", "category": "office", "summary": "用 MinerU 把 PDF、Word、PPT、Excel 和图片转成 Markdown", "tags": ["文档解析", "Markdown"]},
{"source": "clawhub", "slug": "nano-pdf", "owner": "steipete", "category": "office", "summary": "用自然语言指令编辑 PDF 文件,基于 nano-pdf 命令行工具", "tags": ["PDF", "编辑"]},
{"source": "clawhub", "slug": "ocr-local", "owner": "shaw555", "category": "office", "summary": "用 Tesseract.js 在本地识别图片中的文字,无需 API Key", "tags": ["OCR", "本地"]},
{"source": "clawhub", "slug": "paddleocr-doc-parsing", "owner": "bobholamovic", "category": "office", "summary": "用 PaddleOCR 把 PDF 和文档图片解析成 Markdown,表格公式精确", "tags": ["OCR", "文档解析"]},
{"source": "clawhub", "slug": "pdf-form-filler", "owner": "raulsimpetru", "category": "office", "summary": "以编程方式填写 PDF 表单中的文本字段和复选框", "tags": ["PDF", "表单"]},
{"source": "clawhub", "slug": "pdf-generator", "owner": "ivangdavila", "category": "office", "summary": "从 Markdown、HTML 或数据生成报告、发票、合同等 PDF", "tags": ["PDF", "生成"]},
{"source": "clawhub", "slug": "pdf-tools", "owner": "cmpdchtr", "category": "office", "summary": "查看、提取、编辑和处理 PDF 文件中的文字与页面", "tags": ["PDF", "工具"]},
{"source": "clawhub", "slug": "powerpoint-pptx", "owner": "ivangdavila", "category": "office", "summary": "创建和编辑 PowerPoint 演示文稿,支持模板、占位符、备注和图表", "tags": ["PPT", "演示文稿"]},
{"source": "clawhub", "slug": "resume-assistant", "owner": "wscats", "category": "office", "summary": "润色、定制并评分简历,支持多种格式导出", "tags": ["简历", "求职"]},
{"source": "clawhub", "slug": "tesseract-ocr", "owner": "whalefell", "category": "office", "summary": "用 Tesseract 从图片中识别提取文字,支持中英文", "tags": ["OCR", "文字识别"]},
{"source": "clawhub", "slug": "word-docx", "owner": "ivangdavila", "category": "office", "summary": "创建、检查和编辑 Word 文档,处理样式、编号、修订和表格", "tags": ["Word", "文档"]},
{"source": "skillhub", "slug": "adaptive-presentation-studio", "owner": "user_84b8c7d7", "category": "office", "summary": "把 Word、Excel、PDF 等资料自动整理成演示文稿", "tags": ["PPT", "资料整理"]},
{"source": "skillhub", "slug": "dknowc-official-doc-writer-skillhub", "owner": "user_c9faff27", "category": "office", "summary": "把会议记录、调研素材整理成格式规范、依据可查的公文和汇报材料", "tags": ["公文写作", "材料"]},
{"source": "skillhub", "slug": "doc-consistency-checker", "owner": "user_7180264c", "category": "office", "summary": "校对文档中的实体矛盾、术语不统一和数据冲突,并可一键修正", "tags": ["校对", "一致性"]},
{"source": "skillhub", "slug": "doc-snapshot", "owner": "user_cbeb91bb", "category": "office", "summary": "为文档目录保存版本快照,支持查看历史、回退和对比差异", "tags": ["版本管理", "文档"]},
{"source": "skillhub", "slug": "excel-auto-zh", "owner": "user_7871dce1", "category": "office", "summary": "用 Python 处理 Excel/WPS 表格:生成报表、解析宏文件、合并多表", "tags": ["Excel", "报表"]},
{"source": "skillhub", "slug": "excel-formula-generator", "owner": "user_70c2f807", "category": "office", "summary": "根据自然语言描述生成 Excel/WPS 公式,并逐层解释用法", "tags": ["Excel", "公式"]},
{"source": "skillhub", "slug": "excel-sync-bitable", "owner": "user_af28adda", "category": "office", "summary": "在 Excel/CSV 与飞书多维表格之间双向导入导出数据", "tags": ["飞书", "Excel"]},
{"source": "skillhub", "slug": "gongwenformat-pro", "owner": "user_2ecc1bb2", "category": "office", "summary": "按 GB/T 9704-2012 国标把文本排版成标准党政机关公文 Word", "tags": ["公文排版", "Word"]},
{"source": "skillhub", "slug": "html-ppt-skill", "owner": "user_e9af5021", "category": "office", "summary": "生成多主题的 HTML 幻灯片,可按需转为可编辑 PPTX", "tags": ["PPT", "HTML"]},
{"source": "skillhub", "slug": "image-to-editable-pptx", "owner": "user_84b8c7d7", "category": "office", "summary": "把 PPT 截图、海报或图表页还原成可编辑的 PowerPoint 文件", "tags": ["PPT", "图片转换"]},
{"source": "skillhub", "slug": "kdocs-skill", "owner": "user_5ea84866", "category": "office", "summary": "金山文档官方技能,在云端新建、读取、编辑、搜索和分享在线文档", "tags": ["金山文档", "云文档"]},
{"source": "skillhub", "slug": "moways-doc", "owner": "user_6113fd9f", "category": "office", "summary": "一键规范 Word 报告的页面、字体、标题层级、目录、页码和表格", "tags": ["Word排版", "报告"]},
{"source": "skillhub", "slug": "office-doc-suite", "owner": "user_69794d19", "category": "office", "summary": "本地处理 Word、Excel、PDF:替换、统计、合并拆分、加密和转换", "tags": ["文档处理", "PDF"]},
{"source": "skillhub", "slug": "pdf-image-text-extractor", "owner": "user_5f9c21aa", "category": "office", "summary": "从图片或 PDF 中识别文字和表格,扫描件也能提取并保留格式", "tags": ["OCR", "PDF"]},
{"source": "skillhub", "slug": "pdf-ocr-md", "owner": "user_779b44c7", "category": "office", "summary": "用 PP-OCR 本地识别 PDF、照片和扫描件,适合中文合同发票", "tags": ["OCR", "扫描件"]},
{"source": "skillhub", "slug": "ppt-craft-master", "owner": "u_63451245", "category": "office", "summary": "按内容策略和视觉设计生成结构清晰、排版不溢出的 PPT 演示文稿", "tags": ["PPT", "演示文稿"]},
{"source": "skillhub", "slug": "processon-diagram-generator", "owner": "user_697d94c5", "category": "office", "summary": "生成可编辑的流程图、泳道图、时序图、架构图和组织结构图", "tags": ["流程图", "ProcessOn"]},
{"source": "clawhub", "slug": "gog", "owner": "steipete", "category": "productivity", "summary": "命令行操作 Gmail、Google 日历、云端硬盘、通讯录、表格和文档", "tags": ["Google", "邮件日历"], "featured": true},
{"source": "clawhub", "slug": "obsidian", "owner": "steipete", "category": "productivity", "summary": "读写 Obsidian 笔记库,并通过 obsidian-cli 自动化", "tags": ["Obsidian", "笔记"], "featured": true},
{"source": "skillhub", "slug": "tencent-meeting-mcp", "owner": "u_d126ecee", "category": "productivity", "summary": "预约、修改、取消腾讯会议,查询参会成员并导出参会统计", "tags": ["腾讯会议", "会议"], "featured": true},
{"source": "clawhub", "slug": "accli", "owner": "joargp", "category": "productivity", "summary": "在 macOS 上查看、创建、修改苹果日历事件并检查忙闲", "tags": ["日历", "macOS"]},
{"source": "clawhub", "slug": "ai-notes-of-video", "owner": "baiduqianfangroup", "category": "productivity", "summary": "输入视频链接,用百度接口生成文档、大纲和图文笔记", "tags": ["视频笔记", "百度"]},
{"source": "clawhub", "slug": "apple-reminders", "owner": "steipete", "category": "productivity", "summary": "在macOS上管理Apple提醒事项", "tags": ["提醒事项", "苹果"]},
{"source": "clawhub", "slug": "caldav-calendar", "owner": "asleep123", "category": "productivity", "summary": "同步并查询iCloud、Google等CalDAV日历", "tags": ["日历", "同步"]},
{"source": "clawhub", "slug": "email-gmail-outlook", "owner": "porteden", "category": "productivity", "summary": "多账号管理 Gmail、Outlook 和 Exchange 邮件", "tags": ["邮件", "多账号"]},
{"source": "clawhub", "slug": "email-send", "owner": "xejrax", "category": "productivity", "summary": "用 msmtp 通过 SMTP 快速发送邮件", "tags": ["邮件", "SMTP"]},
{"source": "clawhub", "slug": "feishu", "owner": "agisearch", "category": "productivity", "summary": "整合飞书消息、审批、会议、文档、多维表格、日程和邮箱", "tags": ["飞书", "协作"]},
{"source": "clawhub", "slug": "getnote", "owner": "iswalle", "category": "productivity", "summary": "连接得到 Get 笔记,保存、搜索和整理个人知识库", "tags": ["笔记", "知识库"]},
{"source": "clawhub", "slug": "gmail", "owner": "byungkyu", "category": "productivity", "summary": "通过 Gmail API 读取、发送邮件,管理会话、标签和草稿(需 Maton 网关)", "tags": ["Gmail", "邮件"]},
{"source": "clawhub", "slug": "gws-calendar", "owner": "googleworkspace-bot", "category": "productivity", "summary": "管理 Google 日历和日程事件", "tags": ["日历", "Google"]},
{"source": "clawhub", "slug": "himalaya", "owner": "lamelas", "category": "productivity", "summary": "通过 IMAP/SMTP 在命令行收发、回复和管理邮件", "tags": ["邮件", "命令行"]},
{"source": "clawhub", "slug": "jira", "owner": "jdrhyne", "category": "productivity", "summary": "查询、创建和更新Jira问题,查看冲刺状态", "tags": ["Jira", "项目管理"]},
{"source": "clawhub", "slug": "meeting-to-action", "owner": "codedao12", "category": "productivity", "summary": "把会议记录提炼为摘要、决策和带负责人与截止日期的行动项", "tags": ["会议纪要", "待办"]},
{"source": "clawhub", "slug": "memos", "owner": "fty4", "category": "productivity", "summary": "通过 Memos API 创建、查询和删除备忘录", "tags": ["备忘录", "笔记"]},
{"source": "clawhub", "slug": "morning-email-rollup", "owner": "am-will", "category": "productivity", "summary": "每天汇总重要邮件和日历事件并生成摘要", "tags": ["邮件摘要", "日程"]},
{"source": "clawhub", "slug": "notion", "owner": "steipete", "category": "productivity", "summary": "通过 Notion API 创建和管理页面、数据库和块", "tags": ["Notion", "笔记"]},
{"source": "clawhub", "slug": "outlook-api", "owner": "byungkyu", "category": "productivity", "summary": "通过 Microsoft Graph 读取、发送 Outlook 邮件并管理日历(需 Maton 网关)", "tags": ["Outlook", "邮件"]},
{"source": "clawhub", "slug": "personal-assistant", "owner": "gustavoziaugra", "category": "productivity", "summary": "生成包含优先事项和习惯提醒的每日晨间简报", "tags": ["每日简报", "个人助理"]},
{"source": "clawhub", "slug": "smart-weekly-report", "owner": "wscats", "category": "productivity", "summary": "根据岗位和本周工作内容生成结构化周报及下周计划", "tags": ["周报", "总结"]},
{"source": "clawhub", "slug": "todo-management", "owner": "lstpsche", "category": "productivity", "summary": "基于本地 SQLite 的待办管理,支持分组和任务状态", "tags": ["待办", "任务管理"]},
{"source": "clawhub", "slug": "trello", "owner": "steipete", "category": "productivity", "summary": "通过Trello REST API管理看板、列表和卡片", "tags": ["Trello", "看板"]},
{"source": "clawhub", "slug": "wecom", "owner": "qidu", "category": "productivity", "summary": "通过 Webhook 向企业微信群发送消息", "tags": ["企业微信", "消息通知"]},
{"source": "skillhub", "slug": "agently-mail", "owner": "u_d95b6787", "category": "productivity", "summary": "QQ 邮箱团队的智能体专属邮箱,与个人邮箱隔离,可收发邮件", "tags": ["邮箱", "QQ邮箱"]},
{"source": "skillhub", "slug": "excerpo", "owner": "user_a10ab3ad", "category": "productivity", "summary": "把学习资料、网页或文本整理成结构化笔记并标注置信度", "tags": ["笔记整理", "学习"]},
{"source": "skillhub", "slug": "file-organizer", "owner": "user_e4ae361c", "category": "productivity", "summary": "按类型、日期或规则自动整理文件夹中的文件", "tags": ["文件整理"]},
{"source": "skillhub", "slug": "meeting-and-brief", "owner": "user_6113fd9f", "category": "productivity", "summary": "把会议文字稿整理成正式纪要 docx,并汇总生成月度工作简报", "tags": ["会议纪要", "工作简报"]},
{"source": "skillhub", "slug": "meeting-notes-assistant", "owner": "user_cffec473", "category": "productivity", "summary": "整理会议内容,生成会议纪要", "tags": ["会议纪要"]},
{"source": "skillhub", "slug": "processon-mindmap-generator", "owner": "user_697d94c5", "category": "productivity", "summary": "把文本、文档、网页内容生成可编辑的 ProcessOn 思维导图", "tags": ["思维导图", "ProcessOn"]},
{"source": "skillhub", "slug": "wecom-unified", "owner": "u_25e1d415", "category": "productivity", "summary": "企业微信通讯录、消息、文档、日程、会议和待办的命令行工具", "tags": ["企业微信", "协作"]},
{"source": "skillhub", "slug": "libai", "owner": "user_741dc82b", "category": "content", "summary": "检测并改写中文文本中的AI腔,使表达更自然", "tags": ["去AI味", "润色"], "featured": true},
{"source": "skillhub", "slug": "official-document-skill", "owner": "user_a601cd18", "category": "content", "summary": "起草、修改和润色中文公文与政务应用文,并做质量检查", "tags": ["公文", "写作"], "featured": true},
{"source": "skillhub", "slug": "wechat-ai-publisher", "owner": "user_57af6244", "category": "content", "summary": "公众号选题、撰写、去 AI 味、配图排版到存入草稿箱的全流程", "tags": ["公众号", "自动化"], "featured": true},
{"source": "clawhub", "slug": "cinematic-script-writer", "owner": "praveenspeaks", "category": "content", "summary": "为 AI 视频生成编写镜头化、角色一致的剧本", "tags": ["视频脚本", "AI视频"]},
{"source": "clawhub", "slug": "content-writer", "owner": "alexandrali1989-cell", "category": "content", "summary": "为小红书、知乎、公众号、抖音生成符合平台风格的内容", "tags": ["多平台", "文案"]},
{"source": "clawhub", "slug": "creative-thought-partner", "owner": "vincentchan", "category": "content", "summary": "对话式创意伙伴,通过提问和观察帮你发掘想法中的亮点", "tags": ["创意", "头脑风暴"]},
{"source": "clawhub", "slug": "humanize", "owner": "artur-zhdan", "category": "content", "summary": "识别并去除英文文本中的 AI 写作模式,改写得更自然", "tags": ["英文写作", "去AI味"]},
{"source": "clawhub", "slug": "humanizer", "owner": "biostartechnology", "category": "content", "summary": "识别并改写文本中常见的 AI 写作痕迹,让表达更自然", "tags": ["去AI味", "润色"], "allowSuspicious": "ClawHub 审核指出其用途是隐藏 AI 写作痕迹且缺少披露约束;无可执行代码"},
{"source": "clawhub", "slug": "poetry", "owner": "wscats", "category": "content", "summary": "基于开源诗词库查询和创作唐诗宋词", "tags": ["诗词", "古典文学"]},
{"source": "clawhub", "slug": "translation", "owner": "kostja94", "category": "content", "summary": "内容翻译、术语管理与本地化流程", "tags": ["翻译", "本地化"]},
{"source": "clawhub", "slug": "xhs-content-creator", "owner": "372768498", "category": "content", "summary": "按小红书平台规律撰写笔记标题和正文", "tags": ["小红书", "笔记写作"]},
{"source": "skillhub", "slug": "douyin-copy-extract", "owner": "user_a41ae6c8", "category": "content", "summary": "从短视频分享链接提取标题与口播文案,并做敏感词检查", "tags": ["文案提取", "短视频"]},
{"source": "skillhub", "slug": "douyin-extract-copywriter", "owner": "user_8eb1b5b0", "category": "content", "summary": "采集抖音视频并提取口播文案", "tags": ["抖音", "文案提取"]},
{"source": "skillhub", "slug": "fanqiexiaoshuoxiezuo", "owner": "u_a029fe8a", "category": "content", "summary": "针对番茄小说平台的分章节网文创作,强化节奏、爆点和钩子", "tags": ["网文", "番茄小说"]},
{"source": "skillhub", "slug": "hongguozhoubao", "owner": "u_a524ee38", "category": "content", "summary": "基于红果短剧周榜数据生成市场周报,含热门题材和标签趋势", "tags": ["短剧", "周报"]},
{"source": "skillhub", "slug": "humanize-ai-text", "owner": "user_481397b5", "category": "content", "summary": "中英双语学术写作风格自查与自然化改写,降低论文 AI 痕迹", "tags": ["论文", "去AI味"]},
{"source": "skillhub", "slug": "jinjiangxiaoshuoxiezuo", "owner": "u_be0aa813", "category": "content", "summary": "针对晋江文学城的分章节网文创作,按言情、纯爱等分类设定", "tags": ["网文", "晋江"]},
{"source": "skillhub", "slug": "jubenfuke", "owner": "u_be0aa813", "category": "content", "summary": "逆向拆解参考作品的结构和风格,按需重建成新剧本", "tags": ["剧本", "复刻"]},
{"source": "skillhub", "slug": "lingyi-wx-video-decomposer-exp", "owner": "u_c19e970c", "category": "content", "summary": "在本地拆解视频号视频的结构、脚本类型和爆款原因并评分", "tags": ["视频号", "拆解"]},
{"source": "skillhub", "slug": "manjuduanjushengcheng", "owner": "u_be0aa813", "category": "content", "summary": "AI 漫剧和短剧从立项、剧本、角色设定到分镜的全流程辅助", "tags": ["漫剧", "短剧"]},
{"source": "skillhub", "slug": "moways00001", "owner": "user_6113fd9f", "category": "content", "summary": "润色报告、方案和总结,减少套话,保留原文事实与数字", "tags": ["润色", "报告"]},
{"source": "skillhub", "slug": "novel-deai-tomato-pass", "owner": "user_170e8f72", "category": "content", "summary": "去除网文的 AI 痕迹,并对照平台审核红线修改章节", "tags": ["网文", "去AI味"]},
{"source": "skillhub", "slug": "perfectly-replicate-writing-skills", "owner": "user_fb9c3efc", "category": "content", "summary": "分析某位作者的多篇文章,提炼写作风格并生成可复用的提示词", "tags": ["文风", "写作"]},
{"source": "skillhub", "slug": "qimaoxiaoshuoxiezuo", "owner": "u_be0aa813", "category": "content", "summary": "针对七猫免费小说平台的分章节网文创作,单章爽点闭环", "tags": ["网文", "七猫"]},
{"source": "skillhub", "slug": "resume-interview-3party-optimizer", "owner": "user_5d00e27d", "category": "content", "summary": "脱敏后优化简历,从多方视角评估并准备面试问题", "tags": ["简历", "面试"]},
{"source": "skillhub", "slug": "unclecheng-reduce-ai-perception-v2", "owner": "user_ab5ae6ee", "category": "content", "summary": "检测并修改文本中的 AI 写作痕迹,让文字读起来更自然", "tags": ["去AI味", "润色"]},
{"source": "skillhub", "slug": "wechat-official-account-typesetting-master", "owner": "u_e50a4177", "category": "content", "summary": "把 Markdown 或文章整理成可直接粘贴到公众号的排版 HTML", "tags": ["公众号", "排版"]},
{"source": "skillhub", "slug": "weixingongzhonghaochuangzuo", "owner": "u_be0aa813", "category": "content", "summary": "公众号创作工作流:选题、核验、写作、排版与质检", "tags": ["公众号", "写作"]},
{"source": "skillhub", "slug": "xiaoshuojubenzhuanfenjing", "owner": "u_be0aa813", "category": "content", "summary": "把小说改成剧本、再拆成分镜,并生成角色卡和绘图提示词", "tags": ["剧本", "分镜"]},
{"source": "skillhub", "slug": "xiaoshuozhuantuiwenfenjing", "owner": "u_be0aa813", "category": "content", "summary": "把小说原文改写成短视频推文的口播文案和分镜脚本", "tags": ["小说推文", "短视频"]},
{"source": "clawhub", "slug": "openai-whisper", "owner": "steipete", "category": "media", "summary": "用本地 Whisper 把音频转成文字,无需 API Key", "tags": ["语音转文字", "本地"], "featured": true},
{"source": "skillhub", "slug": "contextweave-interactive-architecture", "owner": "user_bddf3fe6", "category": "media", "summary": "生成和修改架构图、流程图、思维导图,可导出可编辑 PPTX", "tags": ["架构图", "流程图"], "featured": true},
{"source": "skillhub", "slug": "poster-design-studio", "owner": "user_87b8e34f", "category": "media", "summary": "根据活动描述或产品图生成海报、宣传 banner 和社媒配图", "tags": ["海报", "设计"], "featured": true},
{"source": "clawhub", "slug": "canva-designs", "owner": "hith3sh", "category": "media", "summary": "通过 Canva Connect API 创建设计、上传素材和导出作品", "tags": ["Canva", "设计"]},
{"source": "clawhub", "slug": "demo-video", "owner": "0xs4m1337", "category": "media", "summary": "自动操作浏览器并录制画面,生成产品演示视频", "tags": ["演示视频", "录屏"]},
{"source": "clawhub", "slug": "diagram-generator", "owner": "matthewyin", "category": "media", "summary": "生成 Mermaid 流程图、架构图、思维导图和时序图", "tags": ["图表", "Mermaid"], "allowSuspicious": "ClawHub 审核指出其安装步骤使用未锁定版本的 npx 命令"},
{"source": "clawhub", "slug": "excalidraw-diagram-generator", "owner": "elihuvillaraus", "category": "media", "summary": "根据自然语言描述生成 Excalidraw 手绘风格图表", "tags": ["Excalidraw", "图表"]},
{"source": "clawhub", "slug": "ffmpeg", "owner": "ivangdavila", "category": "media", "summary": "用 FFmpeg 处理音视频,选择合适的编码、滤镜和参数", "tags": ["FFmpeg", "音视频"]},
{"source": "clawhub", "slug": "image", "owner": "ivangdavila", "category": "media", "summary": "处理和优化图片:格式选择、缩放、压缩、色彩配置和元数据", "tags": ["图片处理", "压缩"]},
{"source": "clawhub", "slug": "image-edit", "owner": "ivangdavila", "category": "media", "summary": "AI 修图:局部重绘、扩图、去背景、放大和老照片修复", "tags": ["修图", "抠图"]},
{"source": "clawhub", "slug": "image-generation", "owner": "ivangdavila", "category": "media", "summary": "统一调用 GPT Image、Nano Banana、FLUX 等模型生成图片", "tags": ["AI绘图", "多模型"]},
{"source": "clawhub", "slug": "klingai", "owner": "klingai-dev", "category": "media", "summary": "可灵 AI 官方技能,调用可灵生成视频和图片", "tags": ["可灵", "AI视频"]},
{"source": "clawhub", "slug": "kokoro-tts", "owner": "edkief", "category": "media", "summary": "用本地 Kokoro 引擎把文字转成语音,离线可用", "tags": ["TTS", "本地"]},
{"source": "clawhub", "slug": "nano-banana-pro", "owner": "steipete", "category": "media", "summary": "调用 Gemini 图像模型生成和编辑图片,支持文生图与图生图", "tags": ["图片生成", "Gemini"], "allowSuspicious": "ClawHub 审核指出 Gemini API Key 的处理方式可能泄露密钥,且未提醒内容会发送给 Google"},
{"source": "clawhub", "slug": "nano-banana-pro-2", "owner": "dycathecorde", "category": "media", "summary": "通过 Gemini 3 Pro Image(Nano Banana Pro)生成或编辑图片", "tags": ["AI绘图", "Gemini"]},
{"source": "clawhub", "slug": "qr-code", "owner": "omar-khaleel", "category": "media", "summary": "生成和识别二维码,支持文本和网址", "tags": ["二维码"]},
{"source": "clawhub", "slug": "remotion-best-practices", "owner": "am-will", "category": "media", "summary": "用 Remotion 以 React 代码制作视频的最佳实践", "tags": ["Remotion", "视频制作"]},
{"source": "clawhub", "slug": "sag", "owner": "steipete", "category": "media", "summary": "用 ElevenLabs 合成语音,命令用法类似 Mac 的 say", "tags": ["TTS", "ElevenLabs"]},
{"source": "clawhub", "slug": "svg-draw", "owner": "lijy2015", "category": "media", "summary": "直接编写 SVG 绘制插图、图标或 Logo,并可转为 PNG", "tags": ["SVG", "插画"]},
{"source": "clawhub", "slug": "veo", "owner": "buddyh", "category": "media", "summary": "调用 Google Veo 3.1/3.0 生成视频", "tags": ["Veo", "AI视频"]},
{"source": "clawhub", "slug": "video-frames", "owner": "steipete", "category": "media", "summary": "用 ffmpeg 从视频中截取画面帧或短片段", "tags": ["视频", "截帧"]},
{"source": "clawhub", "slug": "volcengine-ai-image-generation", "owner": "cinience", "category": "media", "summary": "调用火山引擎方舟的图像生成模型按提示词出图", "tags": ["火山引擎", "AI绘图"]},
{"source": "clawhub", "slug": "wan-image-video-generation-editting", "owner": "krisyejh", "category": "media", "summary": "用通义万相模型做文生图、图片编辑、文生视频和图生视频", "tags": ["通义万相", "AI视频"]},
{"source": "skillhub", "slug": "ai-image-realism", "owner": "user_87b8e34f", "category": "media", "summary": "修复 AI 图片中的塑料皮肤、畸形手等问题,提升真实感", "tags": ["图片精修", "去AI味"]},
{"source": "skillhub", "slug": "ai-logo-maker", "owner": "user_87b8e34f", "category": "media", "summary": "根据品牌名称和行业生成 Logo、品牌标识和应用图标", "tags": ["Logo", "品牌"]},
{"source": "skillhub", "slug": "ai-podcast-voiceover", "owner": "u_319b171b", "category": "media", "summary": "把文章整理成播客脚本并用 AI 配音生成 MP3 单集", "tags": ["播客", "配音"]},
{"source": "skillhub", "slug": "beatra", "owner": "u_319b171b", "category": "media", "summary": "在一个工具里完成 AI 图片、视频、音乐和配音创作并管理素材", "tags": ["AI创作", "多媒体"]},
{"source": "skillhub", "slug": "beatra-ai-video-studio", "owner": "u_319b171b", "category": "media", "summary": "文生视频、图生视频、首尾帧和视频延长,制作各类短视频", "tags": ["AI视频", "图生视频"]},
{"source": "skillhub", "slug": "gpt-image-2-5-studio", "owner": "user_87b8e34f", "category": "media", "summary": "用 GPT Image 2.5 文生图或参考图生图,适合海报和商品图", "tags": ["AI绘图", "GPT"]},
{"source": "skillhub", "slug": "handwritten-newspaper-template-generator-showapi", "owner": "u_b7ff7d1b", "category": "media", "summary": "按主题和年级生成可 A4 打印的手抄报黑白线稿模板", "tags": ["手抄报", "线稿"]},
{"source": "skillhub", "slug": "image-prompt-skill", "owner": "user_5d00e27d", "category": "media", "summary": "为图片和视频生成统一的提示词", "tags": ["提示词", "AI绘画"]},
{"source": "skillhub", "slug": "jimeng-cli-text2image", "owner": "user_c1d16043", "category": "media", "summary": "通过即梦 Dreamina 命令行提交和查询文生图任务", "tags": ["即梦", "AI绘图"]},
{"source": "skillhub", "slug": "perler-beads-pattern-generation-showapi", "owner": "u_b7ff7d1b", "category": "media", "summary": "根据实物图片生成拼豆图纸,方便手工 DIY", "tags": ["拼豆", "手工"]},
{"source": "skillhub", "slug": "seedance-prompt-expert", "owner": "user_b26ab084", "category": "media", "summary": "编写 Seedance 2.0 视频生成提示词,掌握镜头与结构写法", "tags": ["Seedance", "提示词"]},
{"source": "skillhub", "slug": "talking-avatar-video", "owner": "u_319b171b", "category": "media", "summary": "用一张人像和脚本或录音生成数字人口播视频", "tags": ["数字人", "口播"]},
{"source": "skillhub", "slug": "tencent-mps", "owner": "u_9bf8f197", "category": "media", "summary": "调用腾讯云 MPS 做音视频转码、画质增强和内容理解", "tags": ["腾讯云", "转码"]},
{"source": "skillhub", "slug": "wechat-cover-maker", "owner": "user_87b8e34f", "category": "media", "summary": "根据文章标题和摘要生成公众号封面和文章配图", "tags": ["公众号", "封面"]},
{"source": "skillhub", "slug": "whiteboard-animation-maker", "owner": "user_87b8e34f", "category": "media", "summary": "把概念或口播稿做成竖屏白板手绘讲解视频", "tags": ["白板动画", "短视频"]},
{"source": "clawhub", "slug": "data-analysis", "owner": "ivangdavila", "category": "data", "summary": "查询数据库、生成报告、自动处理表格并做可视化分析", "tags": ["数据分析", "可视化"], "featured": true},
{"source": "skillhub", "slug": "academic-figures", "owner": "user_481397b5", "category": "data", "summary": "从 JSON、CSV、Excel 一条命令生成 22 种论文级统计图表", "tags": ["论文配图", "可视化"], "featured": true},
{"source": "skillhub", "slug": "data-visualization-master", "owner": "u_e50a4177", "category": "data", "summary": "从数据体检、指标口径到图表选择,生成单图或分析仪表板", "tags": ["可视化", "仪表板"], "featured": true},
{"source": "clawhub", "slug": "ai-data-analysis", "owner": "arthasking123", "category": "data", "summary": "对 CSV、Excel 做清洗、统计、趋势与异常检测并生成报告", "tags": ["数据清洗", "统计分析"]},
{"source": "clawhub", "slug": "csv-pipeline", "owner": "gitgoodordietrying", "category": "data", "summary": "对 CSV、JSON 数据做筛选、合并、聚合、去重和格式转换", "tags": ["CSV", "数据处理"]},
{"source": "clawhub", "slug": "data-analyst-cn", "owner": "yang1002378395-cmyk", "category": "data", "summary": "中文数据分析助手,提供数据清洗、统计分析和可视化建议", "tags": ["数据分析", "中文"]},
{"source": "clawhub", "slug": "data-analyst-pro", "owner": "realroc", "category": "data", "summary": "执行用户委托的数据分析任务,必要时编写并运行代码", "tags": ["数据分析"]},
{"source": "clawhub", "slug": "duckdb-cli-ai-skills", "owner": "camelsprout", "category": "data", "summary": "用 DuckDB 命令行对本地文件做 SQL 分析和格式转换", "tags": ["DuckDB", "SQL"]},
{"source": "clawhub", "slug": "python-dataviz", "owner": "matthew-a-gordon", "category": "data", "summary": "用 matplotlib、seaborn、plotly 绘制出版级统计图表", "tags": ["Python", "可视化"]},
{"source": "clawhub", "slug": "social-sentiment", "owner": "atyachin", "category": "data", "summary": "分析品牌和产品在 Twitter、Reddit、Instagram 上的舆情倾向", "tags": ["舆情", "情感分析"]},
{"source": "skillhub", "slug": "data-analysis-plus", "owner": "user_d75f625d", "category": "data", "summary": "处理市场调研数据、舆情监控和营销 ROI 分析并生成报告", "tags": ["市场数据", "ROI分析"]},
{"source": "skillhub", "slug": "excel-chart", "owner": "user_889ced6f", "category": "data", "summary": "读取 Excel 数据生成柱状、折线、饼图、热力图等统计图表", "tags": ["Excel", "图表"]},
{"source": "skillhub", "slug": "generate-chart", "owner": "user_af28adda", "category": "data", "summary": "根据数据自动生成可视化图表", "tags": ["图表", "可视化"]},
{"source": "skillhub", "slug": "lanshan-competitor-bid-analysis", "owner": "u_7990b9ad", "category": "data", "summary": "分析竞争对手的招投标记录与中标情况", "tags": ["招投标", "竞品"]},
{"source": "skillhub", "slug": "qclaw-data-analyst-pro", "owner": "user_8d26dabd", "category": "data", "summary": "通用表格数据分析与可视化,按场景选择分析方法并评估质量", "tags": ["表格分析", "可视化"]},
{"source": "skillhub", "slug": "sql-dataviz", "owner": "user_64e5b706", "category": "data", "summary": "把 SQL 查询结果转换为可视化图表", "tags": ["SQL", "可视化"]},
{"source": "skillhub", "slug": "sql-master", "owner": "user_64e5b706", "category": "data", "summary": "用自然语言生成 SQL 查询并获取数据", "tags": ["SQL", "取数"]},
{"source": "skillhub", "slug": "sql-report-generator", "owner": "user_64e5b706", "category": "data", "summary": "基于 SQL 结果生成表格与图表报告,支持多格式导出和自动洞察", "tags": ["SQL", "报表"]},
{"source": "skillhub", "slug": "zlbx-bidding", "owner": "user_98035bfb", "category": "data", "summary": "查询全网招标、中标公告,分析采购单位、竞争对手和商机", "tags": ["招投标", "商机"]},
{"source": "clawhub", "slug": "seo", "owner": "ivangdavila", "category": "marketing", "summary": "提供网站SEO审计、关键词研究、技术修复与内容优化建议", "tags": ["SEO", "网站优化"], "featured": true},
{"source": "skillhub", "slug": "lingyi-wx-viral-script-generator", "owner": "u_c19e970c", "category": "marketing", "summary": "按目的、行业、受众和选题生成视频号口播脚本,本地运行无需Key", "tags": ["视频号", "口播脚本"], "featured": true},
{"source": "skillhub", "slug": "live-commerce-script-studio", "owner": "u_319b171b", "category": "marketing", "summary": "把排品清单变成整场直播带货脚本、话术库与合规检查", "tags": ["直播带货", "话术"], "featured": true},
{"source": "clawhub", "slug": "cold-outreach", "owner": "staybased", "category": "marketing", "summary": "用成熟框架撰写个性化多轮冷启动外联消息,提高回复率", "tags": ["销售外联", "B2B"]},
{"source": "clawhub", "slug": "competitor-analysis", "owner": "aaron-he-zhu", "category": "marketing", "summary": "梳理竞争对手的产品、定价与定位并做对比分析", "tags": ["竞品分析"]},
{"source": "clawhub", "slug": "content-strategy", "owner": "jk-0001", "category": "marketing", "summary": "为个人创业者规划内容营销策略与执行节奏", "tags": ["内容营销", "策略"]},
{"source": "clawhub", "slug": "copywriting", "owner": "jk-0001", "category": "marketing", "summary": "为落地页、邮件、广告和销售页撰写营销文案", "tags": ["营销文案", "落地页"]},
{"source": "clawhub", "slug": "email-marketing", "owner": "ivangdavila", "category": "marketing", "summary": "优化邮件送达率、列表管理、分群与营销序列", "tags": ["邮件营销", "分群"]},
{"source": "clawhub", "slug": "google-ads", "owner": "jdrhyne", "category": "marketing", "summary": "查询、审计并优化Google Ads广告系列", "tags": ["广告投放", "谷歌"]},
{"source": "clawhub", "slug": "gsc", "owner": "jdrhyne", "category": "marketing", "summary": "查询Google Search Console的搜索词、页面点击与收录数据", "tags": ["SEO", "谷歌"]},
{"source": "clawhub", "slug": "lead-enrichment-skill", "owner": "jernejcicorbin-hub", "category": "marketing", "summary": "外联前利用公开信息补全公司或个人线索资料", "tags": ["线索补全", "销售"]},
{"source": "clawhub", "slug": "on-page-seo-auditor", "owner": "aaron-he-zhu", "category": "marketing", "summary": "诊断单个页面的标题、结构、内容等站内SEO问题", "tags": ["SEO", "页面审计"]},
{"source": "clawhub", "slug": "picsee-short-link", "owner": "picseeinc", "category": "marketing", "summary": "生成短链接与二维码,查看点击数据并管理链接", "tags": ["短链接", "数据追踪"]},
{"source": "clawhub", "slug": "product-marketing-context", "owner": "michaelhoughtondebox", "category": "marketing", "summary": "整理产品定位、用户和竞品,形成营销背景文档", "tags": ["产品营销"]},
{"source": "clawhub", "slug": "sales-pipeline-tracker", "owner": "1kalin", "category": "marketing", "summary": "跟踪从线索到成交的销售管线,预测收入并发现瓶颈", "tags": ["销售管线", "CRM"]},
{"source": "clawhub", "slug": "seo-competitor-analysis", "owner": "qqyule", "category": "marketing", "summary": "分析竞品网站的关键词、反向链接与内容策略", "tags": ["SEO", "竞品分析"]},
{"source": "clawhub", "slug": "social-media-marketing", "owner": "jk-0001", "category": "marketing", "summary": "为个人创业者制定社媒平台选择、发布计划与涨粉策略", "tags": ["社媒营销", "增长"]},
{"source": "clawhub", "slug": "social-media-scheduler", "owner": "1kalin", "category": "marketing", "summary": "跨平台策划、起草并整理社交媒体发布内容", "tags": ["社媒运营", "排期"]},
{"source": "skillhub", "slug": "brandgeo", "owner": "u_a524ee38", "category": "marketing", "summary": "查询品牌在主流 AI 模型中的表现并给出改进建议", "tags": ["GEO", "品牌诊断"]},
{"source": "skillhub", "slug": "cross-border-copywriter", "owner": "user_29dc410e", "category": "marketing", "summary": "为亚马逊、Shopee、Temu等平台撰写多语种商品文案并优化关键词", "tags": ["跨境电商", "商品文案"]},
{"source": "skillhub", "slug": "cross-border-ecommerce-product-analysis", "owner": "user_a41ae6c8", "category": "marketing", "summary": "跨境电商选品分析:榜单抓取、利润测算、侵权排查与竞品分析", "tags": ["跨境电商", "选品"]},
{"source": "skillhub", "slug": "douyindashi", "owner": "user_ef64daef", "category": "marketing", "summary": "抖音运营工具集,覆盖文案、选题、视频创作、数据分析与直播", "tags": ["抖音", "运营"]},
{"source": "skillhub", "slug": "ecommerce-listing-image-set", "owner": "u_319b171b", "category": "marketing", "summary": "基于真实商品照片生成主图、卖点图、详情图等电商套图", "tags": ["电商", "商品图"]},
{"source": "skillhub", "slug": "ecommerce-product-selector", "owner": "user_c2b88a14", "category": "marketing", "summary": "基于数据分析淘宝、京东、拼多多、抖音、亚马逊等平台的潜力选品", "tags": ["电商选品", "数据分析"]},
{"source": "skillhub", "slug": "gzh-explosive-content-detector", "owner": "user_5f9c21aa", "category": "marketing", "summary": "按关键词搜索公众号热门文章,辅助选题与趋势判断", "tags": ["公众号", "选题"]},
{"source": "skillhub", "slug": "lingyi-member-system-setup", "owner": "u_c19e970c", "category": "marketing", "summary": "为品牌设计会员等级、权益与积分规则,输出会员体系方案", "tags": ["会员体系", "私域运营"]},
{"source": "skillhub", "slug": "lingyi-private-domain-sales-repurchase-strategy", "owner": "u_c19e970c", "category": "marketing", "summary": "制定私域销售转化与老客复购策略", "tags": ["私域运营", "复购"]},
{"source": "skillhub", "slug": "lingyi-xhs-seeding-campaign", "owner": "u_c19e970c", "category": "marketing", "summary": "为产品制定小红书种草投放与内容方案", "tags": ["小红书", "种草"]},
{"source": "skillhub", "slug": "product-photo-studio", "owner": "user_87b8e34f", "category": "marketing", "summary": "把手机随拍的产品照做成影棚级电商主图和场景图", "tags": ["电商", "商品图"]},
{"source": "skillhub", "slug": "tencent-ads-assistant", "owner": "u_316fedc7", "category": "marketing", "summary": "腾讯广告官方助手,解答微信、朋友圈、视频号广告投放问题", "tags": ["广告投放", "腾讯广告"]},
{"source": "skillhub", "slug": "video-script-dissect", "owner": "user_cbbcbcca", "category": "marketing", "summary": "拆解爆款短视频的钩子与结构,生成同类可拍脚本", "tags": ["短视频", "爆款拆解"]},
{"source": "clawhub", "slug": "akshare-stock", "owner": "mbpz", "category": "finance", "summary": "基于 AkShare 获取 A 股行情、财务数据和板块信息", "tags": ["A股", "AkShare"], "featured": true},
{"source": "clawhub", "slug": "mx-finance-search", "owner": "financial-ai-analyst", "category": "finance", "summary": "基于东方财富数据用自然语言搜索公告、研报、财经新闻和政策", "tags": ["研报公告", "东方财富"], "featured": true},
{"source": "skillhub", "slug": "mysteel-datasearch", "owner": "u_9ed3401d", "category": "finance", "summary": "查询大宗商品期现货价格、宏观指标和产业链数据", "tags": ["大宗商品", "宏观数据"], "featured": true},
{"source": "clawhub", "slug": "a-share-real-time-data", "owner": "wangdinglu", "category": "finance", "summary": "通过通达信协议获取 A 股 K 线、实时报价和逐笔成交", "tags": ["A股", "实时行情"]},
{"source": "clawhub", "slug": "china-stock-analysis", "owner": "paulshe", "category": "finance", "summary": "分析 A 股和港股的股价走势与基本面", "tags": ["A股", "港股"]},
{"source": "clawhub", "slug": "eastmoney-fin-search", "owner": "qqk000", "category": "finance", "summary": "基于东方财富检索金融新闻、公告、研报和政策", "tags": ["财经搜索", "东方财富"]},
{"source": "clawhub", "slug": "expense-tracker-pro", "owner": "jhillin8", "category": "finance", "summary": "用自然语言记录开支,查看支出汇总并设置预算", "tags": ["记账", "预算"]},
{"source": "clawhub", "slug": "financial-analyst", "owner": "aniebyl", "category": "finance", "summary": "市场研究、可比公司分析和 DCF 估值等财务分析流程", "tags": ["DCF估值", "财务分析"]},
{"source": "clawhub", "slug": "finnhub", "owner": "matthewxfz3", "category": "finance", "summary": "通过 Finnhub 获取美股行情、公司新闻和财报", "tags": ["美股", "财经数据"]},
{"source": "clawhub", "slug": "finnhub-pro", "owner": "lsj210001", "category": "finance", "summary": "查询美股实时报价、公司资料、新闻、分析师评级和财报日历", "tags": ["美股", "Finnhub"]},
{"source": "clawhub", "slug": "fundamental-stock-analysis", "owner": "nickfiorani", "category": "finance", "summary": "用结构化评分模型做股票基本面分析和同业排名", "tags": ["基本面", "选股"]},
{"source": "clawhub", "slug": "mx-macro-data", "owner": "financial-ai-analyst", "category": "finance", "summary": "基于东方财富数据查询全球宏观经济指标,如 GDP、CPI、货币金融", "tags": ["宏观经济", "东方财富"]},
{"source": "clawhub", "slug": "stock-earnings-review", "owner": "financial-ai-analyst", "category": "finance", "summary": "基于东方财富数据生成沪深港美上市公司业绩点评", "tags": ["业绩点评", "财报"]},
{"source": "clawhub", "slug": "stock-info-explorer", "owner": "kys42", "category": "finance", "summary": "基于 Yahoo Finance 获取实时行情、历史数据和技术指标", "tags": ["美股", "yfinance"]},
{"source": "clawhub", "slug": "stock-watcher", "owner": "robin797860", "category": "finance", "summary": "管理个人自选股列表,基于同花顺数据汇总近期表现", "tags": ["自选股"]},
{"source": "clawhub", "slug": "tushare", "owner": "manifoldor", "category": "finance", "summary": "通过 Tushare 获取 A 股、期货行情和公司基本面", "tags": ["A股", "Tushare"]},
{"source": "clawhub", "slug": "tushare-data", "owner": "lidayan", "category": "finance", "summary": "用中文提问调用 Tushare 查询个股、财报、板块和资金流向", "tags": ["Tushare", "A股"]},
{"source": "skillhub", "slug": "earnings-review", "owner": "user_3c6cb52e", "category": "finance", "summary": "基于财报等一手资料做深度解读", "tags": ["财报解读"]},
{"source": "skillhub", "slug": "financial-report-analysis", "owner": "user_c2b88a14", "category": "finance", "summary": "解析资产负债表、利润表、现金流量表并生成财务分析报告", "tags": ["财报分析"]},
{"source": "skillhub", "slug": "fundadvisor", "owner": "user_9d5a2a39", "category": "finance", "summary": "基金分析助手,查询基金、基金经理、重仓股和相关资讯", "tags": ["基金", "基金经理"]},
{"source": "skillhub", "slug": "hithink-finance", "owner": "u_fff30bcb", "category": "finance", "summary": "通过同花顺数据服务查询和导出 A 股、指数、基金、期货数据", "tags": ["同花顺", "金融数据"]},
{"source": "skillhub", "slug": "investoday-finance-data-v2", "owner": "user_d196d58d", "category": "finance", "summary": "提供 A 股、港股、基金、宏观等 200 多个金融数据接口", "tags": ["金融数据", "投研"]},
{"source": "skillhub", "slug": "stock-theme-radar", "owner": "u_a524ee38", "category": "finance", "summary": "把题材或新闻事件映射到相关 A 股个股,并标注依据与来源", "tags": ["题材追踪", "A股"]},
{"source": "skillhub", "slug": "tdx-pro", "owner": "user_8c7f0f65", "category": "finance", "summary": "通达信 pytdx 接口参考,获取 A 股与期货行情、K 线和财务数据", "tags": ["通达信", "行情"]},
{"source": "skillhub", "slug": "valuation-analysis", "owner": "user_a38fd8a2", "category": "finance", "summary": "对 A 股和港股做价值投资分析与估值", "tags": ["估值", "价值投资"]},
{"source": "skillhub", "slug": "westock-data", "owner": "u_88c62808", "category": "finance", "summary": "查询 A 股、港股、美股、ETF、期货等行情、财报、研报和公告数据", "tags": ["行情数据", "A股"]},
{"source": "skillhub", "slug": "baozheng", "owner": "user_741dc82b", "category": "professional", "summary": "法律咨询、起诉状起草、刑事材料辅助与法条检索,法条来源分级标注", "tags": ["法律咨询", "起诉状"], "featured": true},
{"source": "skillhub", "slug": "luhe-paper-free-pro", "owner": "u_a3c224a2", "category": "professional", "summary": "按GB/T 7714插入可溯源的真实文献,生成学术论文初稿并导出Word", "tags": ["论文写作", "学术"], "featured": true},
{"source": "skillhub", "slug": "patent-drafting-cn", "owner": "user_9c63fdb4", "category": "professional", "summary": "根据技术交底书生成中国专利申请文件初稿", "tags": ["专利撰写", "知识产权"], "featured": true},
{"source": "clawhub", "slug": "academic-writing", "owner": "teamolab", "category": "professional", "summary": "撰写论文、文献综述与研究方法,遵循学术规范与引用标准", "tags": ["学术写作", "文献综述"]},
{"source": "clawhub", "slug": "academic-writing-refiner", "owner": "zihan-zhu", "category": "professional", "summary": "按NeurIPS、ACL、CVPR等顶会标准润色英文论文", "tags": ["论文润色", "英文写作"]},
{"source": "clawhub", "slug": "business-plan", "owner": "jk-0001", "category": "professional", "summary": "为个体创业者撰写和迭代商业计划书", "tags": ["商业计划", "创业"]},
{"source": "clawhub", "slug": "business-writing", "owner": "teamolab", "category": "professional", "summary": "撰写行业研究、商业洞察与公司研究报告", "tags": ["行业研究", "咨询"]},
{"source": "clawhub", "slug": "career-planner-china", "owner": "mnetfairy", "category": "professional", "summary": "面向AI时代职场变化的职业规划与转型建议", "tags": ["职业规划", "求职"]},
{"source": "clawhub", "slug": "interview-simulator", "owner": "wscats", "category": "professional", "summary": "按职位与资历模拟面试提问,并给出反馈与评分", "tags": ["面试", "求职"]},
{"source": "clawhub", "slug": "japanese-translation-and-tutor", "owner": "itsjaydesu", "category": "professional", "summary": "日英翻译并讲解语法与表达", "tags": ["日语", "翻译"]},
{"source": "clawhub", "slug": "language-learning", "owner": "chipagosfinest", "category": "professional", "summary": "通过对话、词汇、语法与抽认卡练习学习任意语言", "tags": ["语言学习", "外语"]},
{"source": "skillhub", "slug": "academic-pre-review-committee", "owner": "user_5933f5b3", "category": "professional", "summary": "模拟五位审稿人对学术论文做投稿前预审", "tags": ["论文审稿", "学术"]},
{"source": "skillhub", "slug": "biaoshu-bailian", "owner": "u_62be253f", "category": "professional", "summary": "解读招标文件、生成投标文件、审查合规风险并查重", "tags": ["标书", "招投标"]},
{"source": "skillhub", "slug": "biaoshuzhizuoshengchengshenchachachong", "owner": "u_be0aa813", "category": "professional", "summary": "按货物、服务、工程三类生成投标文件,并做废标与雷同自查", "tags": ["标书", "招投标"]},
{"source": "skillhub", "slug": "bidding-data-free", "owner": "user_98035bfb", "category": "professional", "summary": "免费查询全国招标、中标公告,无需Key与注册", "tags": ["标讯查询", "招投标"]},
{"source": "skillhub", "slug": "classical-chinese-learning-showapi", "owner": "u_b7ff7d1b", "category": "professional", "summary": "面向文言文教学的讲解与练习工作流", "tags": ["文言文", "语文教学"]},
{"source": "skillhub", "slug": "drawing-extractor", "owner": "user_2d1faf6c", "category": "professional", "summary": "提取并解读CAD/PDF工程图纸中的信息", "tags": ["工程图纸", "建筑"]},
{"source": "skillhub", "slug": "gongwenxiezuo", "owner": "u_be0aa813", "category": "professional", "summary": "为机关、企事业单位撰写规范的公文与正式材料", "tags": ["公文写作", "机关"]},
{"source": "skillhub", "slug": "hetongfengxianshencha", "owner": "u_be0aa813", "category": "professional", "summary": "本地识别合同风险条款、提取关键信息并给出修改建议", "tags": ["合同审查", "法务"]},
{"source": "skillhub", "slug": "k12-smart-teacher", "owner": "user_6d79eef2", "category": "professional", "summary": "中小学作业批改、错题分析与同类练习生成", "tags": ["K12教育", "作业批改"]},
{"source": "skillhub", "slug": "kunlun-fanyi", "owner": "u_441b0ae9", "category": "professional", "summary": "面向译者与出海团队的翻译策略、术语管理与译文质量评分", "tags": ["翻译", "本地化"]},
{"source": "skillhub", "slug": "litigation-docs-generator", "owner": "user_b8af0005", "category": "professional", "summary": "成套生成民商事诉讼文书文本", "tags": ["诉讼文书", "法律"]},
{"source": "skillhub", "slug": "novel-evaluator", "owner": "user_a75e6679", "category": "professional", "summary": "从情节、人物、文笔等六个维度对小说量化评分", "tags": ["小说评审", "网文"]},
{"source": "skillhub", "slug": "patent-oa-response-cn", "owner": "user_9c63fdb4", "category": "professional", "summary": "辅助撰写中国发明专利审查意见通知书的答复", "tags": ["专利答复", "知识产权"]},
{"source": "skillhub", "slug": "patseek-patent-search", "owner": "user_0b65fce7", "category": "professional", "summary": "基于patseek接口进行专利检索与技术情报分析", "tags": ["专利检索", "技术情报"]},
{"source": "skillhub", "slug": "pmaster", "owner": "user_dec48685", "category": "professional", "summary": "需求分析与PRD、BRD、用户故事等产品文档撰写", "tags": ["产品经理", "PRD"]},
{"source": "skillhub", "slug": "prc-labor-law-resolution", "owner": "user_84b8c7d7", "category": "professional", "summary": "处理劳动争议:核验法条、梳理证据、测算金额并规划维权路径", "tags": ["劳动法", "维权"]},
{"source": "skillhub", "slug": "pubmed-verifier", "owner": "user_481397b5", "category": "professional", "summary": "批量核验PMID文献引用的真实性,检测AI幻觉引用与撤稿", "tags": ["文献核验", "医学科研"]},
{"source": "skillhub", "slug": "qilinbashe", "owner": "user_cd0fcd93", "category": "professional", "summary": "律师办案工作台,覆盖案件分析、文书初稿、质证与庭审演练", "tags": ["律师", "办案"]},
{"source": "skillhub", "slug": "showapi-error-question-intelligent-analysis", "owner": "u_fc561cd6", "category": "professional", "summary": "解析学生错题并给出专项辅导", "tags": ["错题解析", "辅导"]},
{"source": "skillhub", "slug": "tax-policy-knowledge", "owner": "user_11064e10", "category": "professional", "summary": "覆盖各税费种的财税政策问答与合规风险提示", "tags": ["财税", "税务合规"]},
{"source": "skillhub", "slug": "teacher-assistant", "owner": "user_00200da2", "category": "professional", "summary": "面向教师的备课、教案、出题等教学辅助工具集", "tags": ["教师", "备课"]},
{"source": "skillhub", "slug": "telehot-bid-compliance-check", "owner": "u_6142d31f", "category": "professional", "summary": "比对招标与投标文件,排查废标风险并输出符合性检查报告", "tags": ["标书", "合规检查"]},
{"source": "skillhub", "slug": "tender-document-smart-parser", "owner": "u_84294987", "category": "professional", "summary": "从招标文件中抽取项目名称、预算、资格条件和评分标准,需 API Key", "tags": ["招标文件", "解析"]},
{"source": "skillhub", "slug": "thesis-tutor", "owner": "user_e62481f0", "category": "professional", "summary": "论文写作全流程辅导,从选题到成稿", "tags": ["毕业论文", "学术"]},
{"source": "skillhub", "slug": "xiaobaogongai-law", "owner": "user_89f3090c", "category": "professional", "summary": "通过小包公API进行法律问答、法规查询与类案检索", "tags": ["类案检索", "法律"]},
{"source": "skillhub", "slug": "xique-ai", "owner": "u_84923c62", "category": "professional", "summary": "上传招标文件,规划技术标大纲、生成正文并导出 Word", "tags": ["标书", "投标"]},
{"source": "skillhub", "slug": "xueshulunwenyushenchaloudongsaomiao", "owner": "u_be0aa813", "category": "professional", "summary": "论文投稿前审查:规则扫描评分加多视角审稿意见", "tags": ["论文审查", "学术"]},
{"source": "skillhub", "slug": "ydxc0511", "owner": "user_0a48355b", "category": "professional", "summary": "起草法定公文、领导讲话稿与工作汇报,并可润色审校", "tags": ["讲话稿", "公文"]},
{"source": "skillhub", "slug": "zhongguozhuanli-zhuanxiecailiaojiedushenchazhengce", "owner": "u_be0aa813", "category": "professional", "summary": "交底书撰写、软著材料、专利解读与审查答复的专利工作链", "tags": ["专利", "软著"]},
{"source": "clawhub", "slug": "weather", "owner": "steipete", "category": "life", "summary": "查询当前天气和预报,无需API密钥", "tags": ["天气", "出行"], "featured": true},
{"source": "skillhub", "slug": "anti-fraud", "owner": "user_741dc82b", "category": "life", "summary": "识别可疑短信、链接、来电话术,提示诈骗风险并科普防骗", "tags": ["防诈骗", "安全"], "featured": true},
{"source": "skillhub", "slug": "parenting-expert", "owner": "user_741dc82b", "category": "life", "summary": "为家长解答0至18岁孩子的养育、情绪、学习与亲子问题", "tags": ["育儿", "亲子"], "featured": true},
{"source": "clawhub", "slug": "adhd-daily-planner", "owner": "mikecourt", "category": "life", "summary": "为注意力困难人群设计的日常计划与执行支持", "tags": ["日程规划", "ADHD"]},
{"source": "clawhub", "slug": "baidu-ai-map", "owner": "baidu-maps", "category": "life", "summary": "百度地图地点检索、路线规划、地理编码和天气查询", "tags": ["地图", "百度"]},
{"source": "clawhub", "slug": "daily-review-ritual", "owner": "itsflow", "category": "life", "summary": "每日收尾复盘:记录进展与心得并规划明天", "tags": ["每日复盘", "计划"]},
{"source": "clawhub", "slug": "google-weather", "owner": "shaharsha", "category": "life", "summary": "调用Google天气API获取实时天气与预报", "tags": ["天气", "预报"]},
{"source": "clawhub", "slug": "habit-tracker", "owner": "jhillin8", "category": "life", "summary": "通过打卡、提醒与进度展示帮助养成习惯", "tags": ["习惯养成", "打卡"]},
{"source": "clawhub", "slug": "openhue", "owner": "steipete", "category": "life", "summary": "通过命令行控制飞利浦Hue灯光与场景", "tags": ["智能家居", "灯光"]},
{"source": "clawhub", "slug": "pc-builder-assistant", "owner": "gongyu0918-debug", "category": "life", "summary": "按预算规划台式电脑装机和升级配置", "tags": ["装机", "电脑硬件"]},
{"source": "clawhub", "slug": "personal-finance", "owner": "aka-anoop", "category": "life", "summary": "记录分类支出、设定预算并提醒周期性费用", "tags": ["记账", "预算"]},
{"source": "clawhub", "slug": "seoul-subway", "owner": "dukbong", "category": "life", "summary": "首尔地铁实时到站、路线规划与运营提醒", "tags": ["首尔", "地铁"]},
{"source": "clawhub", "slug": "sonoscli", "owner": "steipete", "category": "life", "summary": "控制Sonos音箱的播放、音量与分组", "tags": ["智能家居", "音乐"]},
{"source": "clawhub", "slug": "spotify", "owner": "2mawi2", "category": "life", "summary": "在macOS上控制Spotify播放、切歌与音量", "tags": ["音乐", "Spotify"]},
{"source": "clawhub", "slug": "travel-manager", "owner": "alvarobcmed", "category": "life", "summary": "综合旅行规划、预订信息整理与行程管理", "tags": ["旅行", "行程管理"]},
{"source": "clawhub", "slug": "travel-planner-notion-ai-obsidian-kontour-integration", "owner": "skylinehk", "category": "life", "summary": "用结构化问答逐步规划旅行,可导出到Notion或Obsidian", "tags": ["旅行规划", "攻略"]},
{"source": "skillhub", "slug": "generate-science-illustration-for-kids-showapi", "owner": "u_38e1d485", "category": "life", "summary": "把孩子的十万个为什么生成适合3至8岁的科普图文", "tags": ["儿童科普", "亲子"]},
{"source": "skillhub", "slug": "qingxi-skill-01", "owner": "u_17e88978", "category": "life", "summary": "通过36道题测试金钱人格与消费观,并给出理财建议", "tags": ["消费观", "性格测试"]},
{"source": "skillhub", "slug": "shanghai-ocean-university-hengsha-birdwatching", "owner": "user_a730098d", "category": "life", "summary": "横沙岛与长江口湿地观鸟助手,含观鸟日历与鸟种识别辅助", "tags": ["观鸟", "自然"]},
{"source": "skillhub", "slug": "travelassistant", "owner": "user_3d638e23", "category": "life", "summary": "根据需求规划旅行行程", "tags": ["旅行规划", "行程"]},
{"source": "skillhub", "slug": "zhuangji-assistant-skill", "owner": "user_f3d82da7", "category": "life", "summary": "按预算推荐台式机配置,支持旧机升级与兼容性检查", "tags": ["装机", "数码"]},
{"source": "clawhub", "slug": "linux-service-triage", "owner": "kowl64", "category": "security", "summary": "结合日志、systemd、Nginx 和 DNS 排查 Linux 服务故障", "tags": ["Linux", "故障排查"], "featured": true},
{"source": "clawhub", "slug": "skill-vetter", "owner": "spclaudehome", "category": "security", "summary": "安装技能前检查危险信号、权限范围和可疑代码", "tags": ["技能审查", "安全"], "featured": true},
{"source": "skillhub", "slug": "zhongkui-skill", "owner": "user_741dc82b", "category": "security", "summary": "对技能做静态审计、行为模拟和供应链溯源的安全审查", "tags": ["技能审查", "供应链"], "featured": true},
{"source": "clawhub", "slug": "agent-bom-registry", "owner": "msaad00", "category": "security", "summary": "查询 MCP 服务器安全注册表,安装前评估可信度", "tags": ["MCP安全", "信任评估"]},
{"source": "clawhub", "slug": "aws-infra", "owner": "bmdhodl", "category": "security", "summary": "借助 AWS CLI 查询、审计和管理 AWS 基础设施", "tags": ["AWS", "云运维"]},
{"source": "clawhub", "slug": "cicd-pipeline", "owner": "gitgoodordietrying", "category": "security", "summary": "用 GitHub Actions 创建和调试 CI/CD 流水线", "tags": ["CI/CD", "GitHub"]},
{"source": "clawhub", "slug": "gcloud", "owner": "jortega0033", "category": "security", "summary": "用 gcloud CLI 管理 Google Cloud 资源", "tags": ["GCP", "云运维"]},
{"source": "clawhub", "slug": "homebrew", "owner": "thesethrose", "category": "security", "summary": "用 Homebrew 搜索、安装和排查 macOS 软件包", "tags": ["macOS", "包管理"]},
{"source": "clawhub", "slug": "kubectl", "owner": "ddevaal", "category": "security", "summary": "用 kubectl 查询、部署和排查 Kubernetes 集群", "tags": ["Kubernetes", "运维"]},
{"source": "clawhub", "slug": "log-analyzer", "owner": "gitgoodordietrying", "category": "security", "summary": "解析、搜索和分析多种格式的应用日志", "tags": ["日志分析"]},
{"source": "clawhub", "slug": "prompt-injection-guard", "owner": "maorun", "category": "security", "summary": "检测和拦截提示注入,保护系统指令", "tags": ["提示注入", "防护"]},
{"source": "clawhub", "slug": "security-scanner", "owner": "dmx64", "category": "security", "summary": "对 Web 应用、API 和基础设施进行自动化安全扫描", "tags": ["漏洞扫描"]},
{"source": "clawhub", "slug": "system-info", "owner": "xejrax", "category": "security", "summary": "快速查看 CPU、内存、磁盘和运行时间", "tags": ["系统诊断"]},
{"source": "clawhub", "slug": "system-resource-monitor", "owner": "passersss", "category": "security", "summary": "查看 CPU 负载、内存、交换分区和磁盘使用情况", "tags": ["系统监控", "资源"]},
{"source": "clawhub", "slug": "vercel-deployment", "owner": "brennerspear", "category": "security", "summary": "部署和管理 Vercel 项目、环境变量与域名", "tags": ["Vercel", "部署"]},
{"source": "clawhub", "slug": "vmware-monitor", "owner": "zw008", "category": "security", "summary": "只读查询 VMware 虚拟化基础设施状态", "tags": ["VMware", "监控"]},
{"source": "clawhub", "slug": "wp-multi-tool", "owner": "marcindudekdev", "category": "security", "summary": "WordPress 站点健康审计、性能优化和数据库清理", "tags": ["WordPress", "站点运维"]},
{"source": "skillhub", "slug": "cybersec-learning", "owner": "user_d2cc4baf", "category": "security", "summary": "网络安全入门学习路径与 SRC 漏洞挖掘实战指导", "tags": ["网络安全", "SRC"]},
{"source": "skillhub", "slug": "safe-c-drive-cleanup", "owner": "user_c1ee5550", "category": "security", "summary": "安全清理 Windows C 盘垃圾文件、释放空间", "tags": ["C盘清理", "Windows"]},
{"source": "skillhub", "slug": "zhuangqiancha", "owner": "user_34343d1f", "category": "security", "summary": "查询中文技能、MCP、插件的安全风险记录", "tags": ["工具风险", "安全查询"]}
]
}
File diff suppressed because it is too large Load Diff
+116 -23
View File
@@ -7,8 +7,12 @@
* - GET /api/v1/skills/{slug} → {skill, latestVersion, owner, metadata, moderation} * - 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}/versions/{v} → {version:{license, files[], security}}
* - GET /api/v1/skills/{slug}/file?path= → raw file text * - GET /api/v1/skills/{slug}/file?path= → raw file text
*
* Slugs are not unique: every per-skill endpoint accepts `?owner=`, and without
* it a shared slug answers 409 AMBIGUOUS_SKILL_SLUG. See `clawhubOwnerFor`.
*/ */
import { AsyncLocalStorage } from 'node:async_hooks'
import { parseFrontmatter } from '../../../utils/frontmatterParser.js' import { parseFrontmatter } from '../../../utils/frontmatterParser.js'
import { import {
getProviderBase, getProviderBase,
@@ -21,6 +25,7 @@ import {
MARKET_ERROR_CODES, MARKET_ERROR_CODES,
MARKET_LIMITS, MARKET_LIMITS,
MarketUpstreamError, MarketUpstreamError,
meaningfulChangelog,
skillId, skillId,
type MarketProvider, type MarketProvider,
type NormalizedSkill, type NormalizedSkill,
@@ -57,7 +62,7 @@ type ClawhubSearchResult = {
type ClawhubDetail = { type ClawhubDetail = {
skill: ClawhubListItem skill: ClawhubListItem
latestVersion?: { version?: string; license?: string } latestVersion?: { version?: string; license?: string; changelog?: string; createdAt?: number }
owner?: { handle?: string; displayName?: string; image?: string } owner?: { handle?: string; displayName?: string; image?: string }
moderation?: unknown moderation?: unknown
} }
@@ -67,19 +72,74 @@ type ClawhubVersionDetail = {
version?: string version?: string
license?: string license?: string
files?: Array<{ path: string; size: number; sha256?: string; contentType?: string }> files?: Array<{ path: string; size: number; sha256?: string; contentType?: string }>
security?: { status?: string; hasWarnings?: boolean; virustotalUrl?: string } security?: ClawhubSecurity
} }
} }
// ClawHub slugs are not unique across owners. Ambiguous slugs return type ClawhubScanner = {
// 409 AMBIGUOUS_SKILL_SLUG with candidate owners; disambiguate via ?owner= status?: string
// (first match = primary listing) and remember the resolution. normalizedStatus?: string
recommendation?: string
summary?: string
}
type ClawhubSecurity = {
status?: string
hasWarnings?: boolean
virustotalUrl?: string
scanners?: { vt?: ClawhubScanner; skillspector?: ClawhubScanner; llm?: ClawhubScanner }
}
/** Public skill pages; links shown to readers, independent of the API base override. */
const CLAWHUB_SITE = 'https://clawhub.ai'
// ClawHub slugs are not unique across owners. Every per-skill read is made for
// one owner, chosen in this order:
// 1. the owner one operation was explicitly run with (`withClawhubOwner`);
// 2. the owner the curated catalog pins for that slug (`setClawhubOwnerHints`);
// 3. an owner resolved earlier for the slug (remembered below).
// A pinned owner (1 or 2) is never widened: a 409 for it is an error. Without
// one, 409 AMBIGUOUS_SKILL_SLUG resolves to the first match (the primary
// listing) and that resolution is remembered.
const ownerCache = new Map<string, string>() const ownerCache = new Map<string, string>()
/** Owners the curated catalog pins: a catalog card names the exact skill it recommends. */
const ownerHints = new Map<string, string>()
/** The owner one detail/file/install operation was asked for. */
const requestedOwner = new AsyncLocalStorage<{ slug: string; owner: string }>()
export function setClawhubOwnerHints(hints: Iterable<readonly [string, string]>): void {
ownerHints.clear()
for (const [slug, owner] of hints) ownerHints.set(slug, owner)
}
export function getClawhubOwnerHints(): Array<[string, string]> {
return [...ownerHints]
}
/** Run one operation pinned to `owner` for `slug`; without an owner it runs unpinned. */
export function withClawhubOwner<T>(slug: string, owner: string | undefined, operation: () => Promise<T>): Promise<T> {
if (!owner) return operation()
return requestedOwner.run({ slug, owner }, operation)
}
function pinnedClawhubOwner(slug: string): string | undefined {
const requested = requestedOwner.getStore()
if (requested?.slug === slug) return requested.owner
return ownerHints.get(slug)
}
/** The owner a read of `slug` will use right now, if one is known before asking upstream. */
export function clawhubOwnerFor(slug: string): string | undefined {
return pinnedClawhubOwner(slug) ?? ownerCache.get(slug)
}
async function clawhubFetch(url: URL, slug: string): Promise<Response> { async function clawhubFetch(url: URL, slug: string): Promise<Response> {
const cachedOwner = ownerCache.get(slug) const pinnedOwner = pinnedClawhubOwner(slug)
if (cachedOwner && !url.searchParams.has('owner')) { const knownOwner = pinnedOwner ?? ownerCache.get(slug)
url.searchParams.set('owner', cachedOwner) if (knownOwner && !url.searchParams.has('owner')) {
url.searchParams.set('owner', knownOwner)
} }
const res = await providerFetch('clawhub', url.toString()) const res = await providerFetch('clawhub', url.toString())
if (res.status !== 409) return res if (res.status !== 409) return res
@@ -87,7 +147,10 @@ async function clawhubFetch(url: URL, slug: string): Promise<Response> {
const body = (await res.json().catch(() => null)) as const body = (await res.json().catch(() => null)) as
| { code?: string; matches?: Array<{ ownerHandle?: string }> } | { code?: string; matches?: Array<{ ownerHandle?: string }> }
| null | null
const resolvedOwner = body?.code === 'AMBIGUOUS_SKILL_SLUG' ? body.matches?.[0]?.ownerHandle : undefined // A pinned owner that still answers 409 is not a guess we may widen.
const resolvedOwner = !pinnedOwner && body?.code === 'AMBIGUOUS_SKILL_SLUG'
? body.matches?.[0]?.ownerHandle
: undefined
if (!resolvedOwner) { if (!resolvedOwner) {
throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamError, `clawhub responded 409 for ${url.pathname}`) throw new MarketUpstreamError('clawhub', MARKET_ERROR_CODES.upstreamError, `clawhub responded 409 for ${url.pathname}`)
} }
@@ -113,24 +176,47 @@ async function clawhubFetchJson<T>(url: URL, slug: string): Promise<T> {
} }
} }
function mapSecurity(security?: { status?: string; hasWarnings?: boolean; virustotalUrl?: string }): { /**
* ClawHub's scan of one version: an overall status plus per-scanner verdicts
* (VirusTotal, skillspector, an LLM review). The overall status decides the
* badge; each scanner becomes its own report so the reader sees which one
* objected and why.
*/
function mapSecurity(security?: ClawhubSecurity): {
status: SecurityStatus status: SecurityStatus
reports: SecurityReport[] reports: SecurityReport[]
} { if (!security?.status) return { status: 'unknown', reports: [] } } {
if (!security?.status) return { status: 'unknown', reports: [] }
const clean = security.status === 'clean' const clean = security.status === 'clean'
return { const reports: SecurityReport[] = [
status: clean ? 'benign' : 'flagged', {
reports: [ vendor: 'clawhub-scan',
{ status: security.status,
vendor: 'clawhub-scan', statusText: clean
status: security.status, ? security.hasWarnings ? 'Clean (with warnings)' : 'Clean'
statusText: clean : `Scan status: ${security.status}`,
? security.hasWarnings ? 'Clean (with warnings)' : 'Clean' reportUrl: security.virustotalUrl,
: `Scan status: ${security.status}`, },
reportUrl: security.virustotalUrl, ]
}, const scanners: Array<[string, ClawhubScanner | undefined]> = [
], ['VirusTotal', security.scanners?.vt],
['skillspector', security.scanners?.skillspector],
['LLM review', security.scanners?.llm],
]
for (const [vendor, scanner] of scanners) {
const status = scanner?.normalizedStatus || scanner?.status
if (typeof status !== 'string' || !status) continue
const recommendation = typeof scanner?.recommendation === 'string' ? scanner.recommendation : ''
const summary = typeof scanner?.summary === 'string' && scanner.summary ? scanner.summary : undefined
reports.push({
vendor,
status,
statusText: recommendation ? `${status} · ${recommendation}` : status,
...(summary ? { summary } : {}),
...(vendor === 'VirusTotal' && security.virustotalUrl ? { reportUrl: security.virustotalUrl } : {}),
})
} }
return { status: clean ? 'benign' : 'flagged', reports }
} }
function normalizeListItem(item: ClawhubListItem): NormalizedSkill { function normalizeListItem(item: ClawhubListItem): NormalizedSkill {
@@ -260,9 +346,16 @@ export const clawhubProvider: MarketProvider = {
} }
const item = normalizeListItem(data.skill) const item = normalizeListItem(data.skill)
const owner = data.owner?.handle
const changelog = meaningfulChangelog(data.latestVersion?.changelog)
const publishedAt = data.latestVersion?.createdAt
return { return {
...item, ...item,
version, version,
...(changelog
? { changelog: { version, text: changelog, publishedAt: typeof publishedAt === 'number' ? publishedAt : undefined } }
: {}),
...(owner ? { pageUrl: `${CLAWHUB_SITE}/${encodeURIComponent(owner)}/${encodeURIComponent(item.slug)}` } : {}),
author: { author: {
handle: data.owner?.handle || '', handle: data.owner?.handle || '',
displayName: data.owner?.displayName, displayName: data.owner?.displayName,
+9 -4
View File
@@ -20,6 +20,7 @@ import {
MARKET_META_FILENAME, MARKET_META_FILENAME,
readMarketMeta, readMarketMeta,
resolveMarketSkill, resolveMarketSkill,
withMarketOwner,
type MarketMeta, type MarketMeta,
} from './marketService.js' } from './marketService.js'
import { marketCache } from './cache.js' import { marketCache } from './cache.js'
@@ -74,12 +75,16 @@ export type InstallResult = {
skill: NormalizedSkill skill: NormalizedSkill
} }
export async function installMarketSkill(source: MarketSource, slug: string): Promise<InstallResult> { /**
* `owner` pins a ClawHub skill to the copy the reader picked (slugs are shared
* across owners); detail, file list and every file download use that owner.
*/
export async function installMarketSkill(source: MarketSource, slug: string, owner?: string): Promise<InstallResult> {
const existing = inFlight.get(slug) const existing = inFlight.get(slug)
if (existing) { if (existing) {
throw new ApiError(409, `Install already in progress for ${slug}`, MARKET_ERROR_CODES.installInProgress) throw new ApiError(409, `Install already in progress for ${slug}`, MARKET_ERROR_CODES.installInProgress)
} }
const task = performInstall(source, slug) const task = withMarketOwner(source, slug, owner, () => performInstall(source, slug, owner))
inFlight.set(slug, task.catch(() => {})) inFlight.set(slug, task.catch(() => {}))
try { try {
return await task return await task
@@ -88,7 +93,7 @@ export async function installMarketSkill(source: MarketSource, slug: string): Pr
} }
} }
async function performInstall(source: MarketSource, slug: string): Promise<InstallResult> { async function performInstall(source: MarketSource, slug: string, owner: string | undefined): Promise<InstallResult> {
const dirName = sanitizeDirName(slug) const dirName = sanitizeDirName(slug)
if (!dirName) { if (!dirName) {
throw new ApiError(422, `Skill slug cannot be used as a directory name: ${slug}`, MARKET_ERROR_CODES.notInstallable) throw new ApiError(422, `Skill slug cannot be used as a directory name: ${slug}`, MARKET_ERROR_CODES.notInstallable)
@@ -97,7 +102,7 @@ async function performInstall(source: MarketSource, slug: string): Promise<Insta
// Resolve detail (includes file list + limits + install state). // Resolve detail (includes file list + limits + install state).
let detail let detail
try { try {
detail = await resolveMarketSkill(source, slug) detail = await resolveMarketSkill(source, slug, owner)
} catch (error) { } catch (error) {
throw toUpstreamApiError(error) throw toUpstreamApiError(error)
} }
+169 -13
View File
@@ -1,16 +1,26 @@
/** /**
* Skills Market — aggregation service. * Skills Market — aggregation service.
* *
* Merges the two upstream providers into a single paginated feed with * The default `catalog` scope pages through the curated snapshot shipped with
* cross-source dedupe, per-source health/degradation reporting, TTL caching * the app (no network). The `market` scope merges the two upstream providers
* (stale-while-error), and locally-computed install state. * 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 fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import { getClaudeConfigHomeDir } from '../../../utils/envUtils.js' import { getClaudeConfigHomeDir } from '../../../utils/envUtils.js'
import { marketCache, getSourceHealth, MARKET_TTL } from './cache.js' import { marketCache, getSourceHealth, MARKET_TTL } from './cache.js'
import { clawhubProvider } from './clawhubProvider.js' import {
catalogCategories,
catalogClawhubOwners,
catalogEntryFor,
catalogEntryToSkill,
filterCatalog,
getCatalog,
} from './catalog/catalog.js'
import { clawhubProvider, setClawhubOwnerHints, withClawhubOwner } from './clawhubProvider.js'
import { skillhubProvider } from './skillhubProvider.js' import { skillhubProvider } from './skillhubProvider.js'
import { import {
MARKET_LIMITS, MARKET_LIMITS,
@@ -21,6 +31,7 @@ import {
type MarketFileContent, type MarketFileContent,
type MarketListResult, type MarketListResult,
type MarketProvider, type MarketProvider,
type MarketScope,
type MarketSource, type MarketSource,
type NormalizedSkill, type NormalizedSkill,
type NormalizedSkillDetail, type NormalizedSkillDetail,
@@ -35,6 +46,10 @@ const providers: Record<MarketSource, MarketProvider> = {
skillhub: skillhubProvider, skillhub: skillhubProvider,
} }
// Catalog entries name an exact ClawHub owner; detail, files and install must
// resolve the same skill the card showed.
setClawhubOwnerHints(catalogClawhubOwners())
// ─── Cursor (opaque, merges both providers' pagination) ───────────────────── // ─── Cursor (opaque, merges both providers' pagination) ─────────────────────
type MergedCursor = Partial<Record<MarketSource, string>> type MergedCursor = Partial<Record<MarketSource, string>>
@@ -154,6 +169,27 @@ export async function annotateInstallState<T extends NormalizedSkill>(skill: T):
return { ...skill, installState: 'not-installable', notInstallableReason: 'name-conflict' } return { ...skill, installState: 'not-installable', notInstallableReason: 'name-conflict' }
} }
/**
* Install state for a whole page: one `readdir` of the skills directory, then
* the per-skill check only for slugs that already have a directory (which is
* where installed vs. name-conflict is decided).
*/
export async function annotateInstallStates<T extends NormalizedSkill>(items: T[]): Promise<T[]> {
let existing: Set<string>
try {
existing = new Set(await fs.readdir(getMarketSkillsDir()))
} catch {
existing = new Set()
}
return Promise.all(items.map((item) => {
const dirName = sanitizeDirName(item.slug)
if (dirName && !existing.has(dirName)) {
return { ...item, installState: 'installable' as const, notInstallableReason: undefined, installedInfo: undefined }
}
return annotateInstallState(item)
}))
}
/** File-level installability checks — only possible once the file list is known. */ /** File-level installability checks — only possible once the file list is known. */
export function applyFileLimits(detail: NormalizedSkillDetail): NormalizedSkillDetail { export function applyFileLimits(detail: NormalizedSkillDetail): NormalizedSkillDetail {
const files = detail.files.map((f) => ({ ...f, tooBig: f.size > MARKET_LIMITS.maxFileSize })) const files = detail.files.map((f) => ({ ...f, tooBig: f.size > MARKET_LIMITS.maxFileSize }))
@@ -207,6 +243,10 @@ export function dedupeSkills(items: NormalizedSkill[]): NormalizedSkill[] {
export type MarketListParams = { export type MarketListParams = {
q?: string q?: string
/** Defaults to `catalog`. */
scope?: MarketScope
/** Catalog category key; ignored by the `market` scope. */
category?: string
source: 'all' | MarketSource source: 'all' | MarketSource
security?: string security?: string
installed?: 'all' | 'installed' | 'installable' installed?: 'all' | 'installed' | 'installable'
@@ -255,6 +295,11 @@ async function fetchProviderPage(
} }
export async function listMarketSkills(params: MarketListParams): Promise<MarketListResult> { export async function listMarketSkills(params: MarketListParams): Promise<MarketListResult> {
if (params.scope !== 'market') return listCatalogSkills(params)
return listLiveMarketSkills(params)
}
async function listLiveMarketSkills(params: MarketListParams): Promise<MarketListResult> {
const cursor = decodeCursor(params.cursor) const cursor = decodeCursor(params.cursor)
const isFirstPage = !params.cursor const isFirstPage = !params.cursor
const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source] const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source]
@@ -292,7 +337,7 @@ export async function listMarketSkills(params: MarketListParams): Promise<Market
merged = dedupeSkills(merged) merged = dedupeSkills(merged)
merged.sort((a, b) => b.stats.downloads - a.stats.downloads) merged.sort((a, b) => b.stats.downloads - a.stats.downloads)
merged = await Promise.all(merged.map((item) => annotateInstallState(item))) merged = await annotateInstallStates(merged.map(markCurated))
if (params.security && params.security !== 'all') { if (params.security && params.security !== 'all') {
merged = merged.filter((item) => item.securityStatus === params.security) merged = merged.filter((item) => item.securityStatus === params.security)
@@ -305,22 +350,128 @@ export async function listMarketSkills(params: MarketListParams): Promise<Market
) )
} }
return { items: merged, nextCursor: encodeCursor(nextCursor), sources } return { scope: 'market', items: merged, nextCursor: encodeCursor(nextCursor), sources }
}
/**
* Mark a live result that is a curated catalog skill. ClawHub slugs are shared
* across owners, so a ClawHub result only counts when its owner is the one the
* catalog pins (list payloads carry no owner and are never marked).
*/
function markCurated(item: NormalizedSkill): NormalizedSkill {
const entry = catalogEntryFor(item.source, item.slug)
if (!entry) return item
if (item.source === 'clawhub' && item.author.handle !== entry.owner) return item
return { ...item, curated: true, featured: entry.featured === true ? true : undefined, category: entry.category }
}
// ─── Curated catalog ─────────────────────────────────────────────────────────
const CATALOG_CURSOR_PREFIX = 'catalog:'
/**
* One page of the curated catalog.
*
* Everything is local, so every filter — security and installed included —
* runs before pagination: a page is always full until the list is exhausted,
* which keeps infinite scroll from stalling on a filtered-out page.
*/
export async function listCatalogSkills(params: MarketListParams): Promise<MarketListResult> {
const catalog = getCatalog()
let items = await annotateInstallStates(
filterCatalog({ q: params.q, category: params.category, source: params.source }).map(catalogEntryToSkill),
)
if (params.security && params.security !== 'all') {
items = items.filter((item) => item.securityStatus === params.security)
}
if (params.installed && params.installed !== 'all') {
items = items.filter((item) =>
params.installed === 'installed' ? item.installState === 'installed' : item.installState !== 'installed',
)
}
const raw = params.cursor?.startsWith(CATALOG_CURSOR_PREFIX) ? params.cursor.slice(CATALOG_CURSOR_PREFIX.length) : ''
const offset = Math.max(0, Number.parseInt(raw, 10) || 0)
const end = offset + params.limit
// Provenance of every catalog answer: one snapshot, read when it was generated.
const status: SourceStatusInfo = { status: 'ok', fetchedAt: catalog.generatedAt, fromCache: true }
return {
scope: 'catalog',
items: items.slice(offset, end),
nextCursor: end < items.length ? `${CATALOG_CURSOR_PREFIX}${end}` : null,
sources: { clawhub: status, skillhub: { ...status } },
total: items.length,
categories: catalogCategories(),
catalogGeneratedAt: catalog.generatedAt,
}
}
/**
* Overlay the catalog's editorial fields on a live detail.
*
* Upstream stays authoritative for everything it owns (version, files,
* security, stats); the catalog only adds what upstream does not have — the
* category, the editor's pick, the reader-facing zh-CN summary, fallback tags
* and the note explaining a flagged verdict the catalog accepted.
*/
export function withCatalogMetadata(detail: NormalizedSkillDetail, requestedOwner?: string): NormalizedSkillDetail {
const entry = catalogEntryFor(detail.source, detail.slug)
if (!entry) return detail
if (detail.source === 'clawhub') {
// A different owner's copy of a catalog slug is not the curated skill.
const owner = requestedOwner || detail.author.handle
if (owner && owner !== entry.owner) return detail
}
const summaryEn = detail.summary || entry.summaryEn
return {
...detail,
category: entry.category,
featured: entry.featured === true ? true : undefined,
curated: true,
summary: entry.summary || detail.summary,
...(summaryEn ? { summaryEn } : {}),
tags: detail.tags.length > 0 ? detail.tags : [...entry.tags],
...(detail.securityStatus === 'flagged' && entry.securityNote ? { securityNote: entry.securityNote } : {}),
}
} }
// ─── Detail / file content ─────────────────────────────────────────────────── // ─── Detail / file content ───────────────────────────────────────────────────
/**
* The ClawHub owner a request names (the card it came from). SkillHub slugs
* are unique, so an owner there means nothing and is dropped.
*/
function requestOwner(source: MarketSource, owner: string | undefined): string | undefined {
return source === 'clawhub' && owner ? owner : undefined
}
/** Run a whole detail/file/install operation pinned to the requested owner. */
export function withMarketOwner<T>(
source: MarketSource,
slug: string,
owner: string | undefined,
operation: () => Promise<T>,
): Promise<T> {
return withClawhubOwner(slug, requestOwner(source, owner), operation)
}
function ownerCacheSuffix(source: MarketSource, owner: string | undefined): string {
const pinned = requestOwner(source, owner)
return pinned ? `@${pinned}` : ''
}
export async function getMarketSkillDetail( export async function getMarketSkillDetail(
source: MarketSource, source: MarketSource,
slug: string, slug: string,
options: { owner?: string } = {},
): Promise<{ skill: NormalizedSkillDetail; sourceStatus: SourceStatusInfo }> { ): Promise<{ skill: NormalizedSkillDetail; sourceStatus: SourceStatusInfo }> {
const cacheKey = `detail:${source}:${slug}` const owner = requestOwner(source, options.owner)
const cacheKey = `detail:${source}:${slug}${ownerCacheSuffix(source, owner)}`
let detail = marketCache.get<NormalizedSkillDetail>(cacheKey) let detail = marketCache.get<NormalizedSkillDetail>(cacheKey)
let sourceStatus: SourceStatusInfo = { status: 'ok', fetchedAt: Date.now(), fromCache: true } let sourceStatus: SourceStatusInfo = { status: 'ok', fetchedAt: Date.now(), fromCache: true }
if (!detail) { if (!detail) {
try { try {
detail = await providers[source].detail(slug) detail = await withMarketOwner(source, slug, owner, () => providers[source].detail(slug))
marketCache.set(cacheKey, detail, MARKET_TTL.detail) marketCache.set(cacheKey, detail, MARKET_TTL.detail)
sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: false } sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: false }
} catch (error) { } catch (error) {
@@ -336,7 +487,7 @@ export async function getMarketSkillDetail(
} }
} }
const annotated = applyFileLimits(await annotateInstallState(detail)) const annotated = applyFileLimits(await annotateInstallState(withCatalogMetadata(detail, owner)))
return { skill: annotated, sourceStatus } return { skill: annotated, sourceStatus }
} }
@@ -351,12 +502,13 @@ export async function getMarketFileContent(
source: MarketSource, source: MarketSource,
slug: string, slug: string,
filePath: string, filePath: string,
owner?: string,
): Promise<MarketFileContent> { ): Promise<MarketFileContent> {
const cacheKey = `file:${source}:${slug}:${filePath}` const cacheKey = `file:${source}:${slug}${ownerCacheSuffix(source, owner)}:${filePath}`
const cached = marketCache.get<MarketFileContent>(cacheKey) const cached = marketCache.get<MarketFileContent>(cacheKey)
if (cached) return cached if (cached) return cached
const fetched = await providers[source].fetchFile(slug, filePath) const fetched = await withMarketOwner(source, slug, owner, () => providers[source].fetchFile(slug, filePath))
let content = fetched.content let content = fetched.content
let truncated = false let truncated = false
if (Buffer.byteLength(content, 'utf-8') > MARKET_LIMITS.previewTruncateBytes) { if (Buffer.byteLength(content, 'utf-8') > MARKET_LIMITS.previewTruncateBytes) {
@@ -384,8 +536,12 @@ export function getMarketStatus(): Record<MarketSource, SourceStatusInfo> {
} }
/** Look up a single skill (used by install) — detail path, bypassing list. */ /** Look up a single skill (used by install) — detail path, bypassing list. */
export async function resolveMarketSkill(source: MarketSource, slug: string): Promise<NormalizedSkillDetail> { export async function resolveMarketSkill(
const { skill } = await getMarketSkillDetail(source, slug) source: MarketSource,
slug: string,
owner?: string,
): Promise<NormalizedSkillDetail> {
const { skill } = await getMarketSkillDetail(source, slug, { owner })
return skill return skill
} }
@@ -21,6 +21,7 @@ import {
MARKET_ERROR_CODES, MARKET_ERROR_CODES,
MARKET_LIMITS, MARKET_LIMITS,
MarketUpstreamError, MarketUpstreamError,
meaningfulChangelog,
skillId, skillId,
type MarketProvider, type MarketProvider,
type NormalizedSkill, type NormalizedSkill,
@@ -65,7 +66,7 @@ type SkillhubSecurityReports = Record<
type SkillhubDetail = { type SkillhubDetail = {
skill?: SkillhubListItem & { stats?: { downloads?: number; installs?: number; stars?: number } } skill?: SkillhubListItem & { stats?: { downloads?: number; installs?: number; stars?: number } }
owner?: { handle?: string; displayName?: string; image?: string } owner?: { handle?: string; displayName?: string; image?: string }
latestVersion?: { version?: string; changelog?: string } latestVersion?: { version?: string; changelog?: string; createdAt?: number }
securityReports?: SkillhubSecurityReports securityReports?: SkillhubSecurityReports
} }
@@ -197,6 +198,8 @@ export const skillhubProvider: MarketProvider = {
} }
const security = mapSecurity(data.securityReports, data.skill.verified) const security = mapSecurity(data.securityReports, data.skill.verified)
const version = data.latestVersion?.version || item.version const version = data.latestVersion?.version || item.version
const changelog = meaningfulChangelog(data.latestVersion?.changelog)
const publishedAt = data.latestVersion?.createdAt
let files: ProviderFileEntry[] = [] let files: ProviderFileEntry[] = []
try { try {
@@ -222,6 +225,9 @@ export const skillhubProvider: MarketProvider = {
return { return {
...item, ...item,
version, version,
...(changelog
? { changelog: { version, text: changelog, publishedAt: typeof publishedAt === 'number' ? publishedAt : undefined } }
: {}),
author: { author: {
handle: data.owner?.handle || item.author.handle, handle: data.owner?.handle || item.author.handle,
displayName: data.owner?.displayName, displayName: data.owner?.displayName,
+52
View File
@@ -28,6 +28,8 @@ export type SecurityReport = {
vendor: string vendor: string
status: string status: string
statusText: string statusText: string
/** The scanner's own explanation of its verdict, when it gives one. */
summary?: string
reportUrl?: string reportUrl?: string
} }
@@ -56,6 +58,17 @@ export type NormalizedSkill = {
installState: InstallState installState: InstallState
notInstallableReason?: NotInstallableReason notInstallableReason?: NotInstallableReason
installedInfo?: { version?: string; installedAt?: string; dirName: string } installedInfo?: { version?: string; installedAt?: string; dirName: string }
/** Editor's pick in the curated catalog. */
featured?: boolean
/**
* The entry is (or matches) a curated catalog skill. Only then is `category`
* a catalog category key; otherwise it is SkillHub's raw category string.
*/
curated?: boolean
/** Original upstream summary, for non-Chinese readers (`summary` is zh-CN for curated skills). */
summaryEn?: string
/** Why a curated skill ships despite a flagged verdict. */
securityNote?: string
} }
export type MarketFileMeta = { export type MarketFileMeta = {
@@ -76,6 +89,10 @@ export type NormalizedSkillDetail = NormalizedSkill & {
license?: string license?: string
files: MarketFileMeta[] files: MarketFileMeta[]
totalSize: number totalSize: number
/** Release note of the latest version, when upstream has a meaningful one. */
changelog?: { version?: string; text: string; publishedAt?: number }
/** The skill's page on its registry website. */
pageUrl?: string
} }
export type MarketFileContent = { export type MarketFileContent = {
@@ -93,10 +110,34 @@ export type SourceStatusInfo = {
error?: string error?: string
} }
/**
* `catalog`: the curated snapshot shipped with the app (no network).
* `market`: live list/search across both upstream registries.
*/
export type MarketScope = 'catalog' | 'market'
export const MARKET_SCOPES: MarketScope[] = ['catalog', 'market']
export type MarketCategory = {
key: string
/** zh-CN display name */
name: string
nameEn: string
/** Skills in this category across the whole catalog (static, not filtered). */
count: number
}
export type MarketListResult = { export type MarketListResult = {
scope: MarketScope
items: NormalizedSkill[] items: NormalizedSkill[]
nextCursor: string | null nextCursor: string | null
sources: Record<MarketSource, SourceStatusInfo> sources: Record<MarketSource, SourceStatusInfo>
/** Catalog scope: matching skills across all pages. */
total?: number
/** Catalog scope: category bar entries. */
categories?: MarketCategory[]
/** Catalog scope: epoch millis of the upstream reads behind the snapshot. */
catalogGeneratedAt?: number
} }
// ─── Provider layer ────────────────────────────────────────────────────────── // ─── Provider layer ──────────────────────────────────────────────────────────
@@ -165,6 +206,17 @@ export const MARKET_LIMITS = {
searchResultCap: 50, searchResultCap: 50,
} as const } as const
/** Shape of a registry owner handle accepted from clients (`?owner=` / install body). */
export const MARKET_OWNER_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/
/** A release note worth showing: registries stamp placeholders on synced versions. */
export function meaningfulChangelog(text: unknown): string | undefined {
const value = typeof text === 'string' ? text.trim() : ''
if (!value) return undefined
if (/^synced by .*pipeline$/i.test(value)) return undefined
return value
}
export function skillId(source: MarketSource, slug: string): string { export function skillId(source: MarketSource, slug: string): string {
return `${source}:${slug}` return `${source}:${slug}`
} }