From 4097fc6506e910122a6bc51bba3a69abc6458494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 2 Oct 2026 02:25:23 +0800 Subject: [PATCH] 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. --- desktop/src/api/market.ts | 26 +- .../market/CapabilityPanel.test.tsx | 49 + .../src/components/market/CapabilityPanel.tsx | 114 + .../components/market/CategoryBar.test.tsx | 50 + desktop/src/components/market/CategoryBar.tsx | 55 + .../components/market/ChangelogPanel.test.tsx | 44 + .../src/components/market/ChangelogPanel.tsx | 66 + .../market/InstallConfirmDialog.test.tsx | 91 +- .../market/InstallConfirmDialog.tsx | 68 +- .../components/market/MarketDisclaimer.tsx | 2 +- .../src/components/market/MarketHome.test.tsx | 138 +- desktop/src/components/market/MarketHome.tsx | 246 +- .../market/MarketSkillDetail.test.tsx | 236 +- .../components/market/MarketSkillDetail.tsx | 228 +- .../src/components/market/SecurityBadge.tsx | 23 +- .../market/SecurityReportPanel.test.tsx | 64 + .../components/market/SecurityReportPanel.tsx | 98 + .../src/components/market/SkillCard.test.tsx | 50 +- desktop/src/components/market/SkillCard.tsx | 140 +- .../market/SkillDetailView.test.tsx | 123 + .../src/components/market/SkillDetailView.tsx | 311 +- .../components/market/catalogLocale.test.ts | 68 + .../src/components/market/catalogLocale.ts | 53 + desktop/src/components/market/marketFormat.ts | 21 + .../components/market/useMarketGridFill.ts | 4 +- desktop/src/components/skills/SkillDetail.tsx | 6 +- .../components/ui/SegmentedControl.test.tsx | 41 +- .../src/components/ui/SegmentedControl.tsx | 36 +- desktop/src/dev/ComponentGallery.tsx | 17 + desktop/src/i18n/locales/en.ts | 54 +- desktop/src/i18n/locales/jp.ts | 54 +- desktop/src/i18n/locales/kr.ts | 54 +- desktop/src/i18n/locales/zh-TW.ts | 54 +- desktop/src/i18n/locales/zh.ts | 54 +- desktop/src/lib/skillInsights.test.ts | 83 + desktop/src/lib/skillInsights.ts | 166 + desktop/src/pages/Market.tsx | 24 +- desktop/src/stores/marketStore.test.ts | 245 +- desktop/src/stores/marketStore.ts | 250 +- desktop/src/types/market.ts | 39 + docs/desktop/skills.md | 45 +- docs/en/desktop/skills.md | 37 +- docs/images/app/en/skill-market.webp | Bin 168386 -> 98232 bytes docs/images/app/zh-CN/skill-market.webp | Bin 168386 -> 109964 bytes scripts/market-catalog-refresh.ts | 97 + src/server/__tests__/market-api.test.ts | 142 +- src/server/__tests__/market-catalog.test.ts | 195 + src/server/__tests__/market-install.test.ts | 40 +- src/server/__tests__/market-providers.test.ts | 168 +- src/server/__tests__/market-service.test.ts | 318 +- src/server/api/market.ts | 45 +- src/server/services/market/catalog/catalog.ts | 151 + .../market/catalog/catalogSnapshot.test.ts | 276 + .../market/catalog/catalogSnapshot.ts | 247 + .../services/market/catalog/curation.json | 404 + .../services/market/catalog/skills.json | 8673 +++++++++++++++++ src/server/services/market/clawhubProvider.ts | 139 +- src/server/services/market/installService.ts | 13 +- src/server/services/market/marketService.ts | 182 +- .../services/market/skillhubProvider.ts | 8 +- src/server/services/market/types.ts | 52 + 61 files changed, 14296 insertions(+), 481 deletions(-) create mode 100644 desktop/src/components/market/CapabilityPanel.test.tsx create mode 100644 desktop/src/components/market/CapabilityPanel.tsx create mode 100644 desktop/src/components/market/CategoryBar.test.tsx create mode 100644 desktop/src/components/market/CategoryBar.tsx create mode 100644 desktop/src/components/market/ChangelogPanel.test.tsx create mode 100644 desktop/src/components/market/ChangelogPanel.tsx create mode 100644 desktop/src/components/market/SecurityReportPanel.test.tsx create mode 100644 desktop/src/components/market/SecurityReportPanel.tsx create mode 100644 desktop/src/components/market/catalogLocale.test.ts create mode 100644 desktop/src/components/market/catalogLocale.ts create mode 100644 desktop/src/components/market/marketFormat.ts create mode 100644 desktop/src/lib/skillInsights.test.ts create mode 100644 desktop/src/lib/skillInsights.ts create mode 100644 scripts/market-catalog-refresh.ts create mode 100644 src/server/__tests__/market-catalog.test.ts create mode 100644 src/server/services/market/catalog/catalog.ts create mode 100644 src/server/services/market/catalog/catalogSnapshot.test.ts create mode 100644 src/server/services/market/catalog/catalogSnapshot.ts create mode 100644 src/server/services/market/catalog/curation.json create mode 100644 src/server/services/market/catalog/skills.json diff --git a/desktop/src/api/market.ts b/desktop/src/api/market.ts index 0ee47703..40c68d43 100644 --- a/desktop/src/api/market.ts +++ b/desktop/src/api/market.ts @@ -3,6 +3,7 @@ import type { MarketFileContent, MarketInstalledFilter, MarketListResponse, + MarketScope, MarketSecurityFilter, MarketSource, MarketSourceFilter, @@ -12,6 +13,9 @@ import type { } from '../types/market' export type MarketListParams = { + scope?: MarketScope + /** Catalog category key; ignored by the server in `market` scope. */ + category?: string q?: string source?: MarketSourceFilter security?: MarketSecurityFilter @@ -23,6 +27,8 @@ export type MarketListParams = { export const marketApi = { list: (params: MarketListParams = {}) => { 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.source && params.source !== 'all') search.set('source', params.source) if (params.security && params.security !== 'all') search.set('security', params.security) @@ -33,22 +39,30 @@ export const marketApi = { return api.get(`/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/market/skills/${source}/${encodeURIComponent(slug)}`, + `/api/market/skills/${source}/${encodeURIComponent(slug)}${ + options.owner ? `?owner=${encodeURIComponent(options.owner)}` : '' + }`, { timeout: 30_000 }, ), - fileContent: (source: MarketSource, slug: string, path: string) => + fileContent: (source: MarketSource, slug: string, path: string, owner?: string) => 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 }, ), - install: (id: string) => + install: (id: string, owner?: string) => api.post<{ ok: boolean; installedPath: string; skill: NormalizedSkill }>( '/api/market/install', - { id }, + owner ? { id, owner } : { id }, { timeout: 120_000 }, ), diff --git a/desktop/src/components/market/CapabilityPanel.test.tsx b/desktop/src/components/market/CapabilityPanel.test.tsx new file mode 100644 index 00000000..9d68d1b8 --- /dev/null +++ b/desktop/src/components/market/CapabilityPanel.test.tsx @@ -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() + + 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() + + fireEvent.click(screen.getByRole('button', { name: 'Full report →' })) + expect(onViewReport).toHaveBeenCalledTimes(1) + + rerender() + expect(screen.queryByRole('button', { name: 'Full report →' })).not.toBeInTheDocument() + }) + + it('renders nothing when no rule fired', () => { + render() + + expect(screen.queryByTestId('market-capability-panel')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/market/CapabilityPanel.tsx b/desktop/src/components/market/CapabilityPanel.tsx new file mode 100644 index 00000000..33cf2248 --- /dev/null +++ b/desktop/src/components/market/CapabilityPanel.tsx @@ -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 + +export const CAPABILITY_LEVEL_TONES: Record = { + 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 ( +
+
+

+

+ {onViewReport && ( + + )} +
+
    + {capabilities.map((capability) => { + const text = capabilityText(t, capability) + return ( +
  • + + {text.title} + + {t(`market.cap.level.${capability.level}`)} + + + + {text.detail} + +
  • + ) + })} +
+

{t('market.cap.note')}

+
+ ) +} diff --git a/desktop/src/components/market/CategoryBar.test.tsx b/desktop/src/components/market/CategoryBar.test.tsx new file mode 100644 index 00000000..c01f7004 --- /dev/null +++ b/desktop/src/components/market/CategoryBar.test.tsx @@ -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() + + 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() + + 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() + + fireEvent.click(screen.getByRole('radio', { name: 'Office 36' })) + expect(onChange).toHaveBeenCalledWith('office') + }) + + it('renders nothing before the server sent any categories', () => { + render() + + expect(screen.queryByTestId('market-category-bar')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/market/CategoryBar.tsx b/desktop/src/components/market/CategoryBar.tsx new file mode 100644 index 00000000..bef0146f --- /dev/null +++ b/desktop/src/components/market/CategoryBar.tsx @@ -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)} + {category.count} + + ), + })), + ] + + return ( +
+ +
+ ) +} diff --git a/desktop/src/components/market/ChangelogPanel.test.tsx b/desktop/src/components/market/ChangelogPanel.test.tsx new file mode 100644 index 00000000..443a8c27 --- /dev/null +++ b/desktop/src/components/market/ChangelogPanel.test.tsx @@ -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( + , + ) + + 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() + expect(screen.getByTestId('market-changelog-panel')).toHaveTextContent('v1.2.0') + unmount() + + render() + 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() + + expect(screen.getByText('Upstream has no release note for this version.')).toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/market/ChangelogPanel.tsx b/desktop/src/components/market/ChangelogPanel.tsx new file mode 100644 index 00000000..6f7158b5 --- /dev/null +++ b/desktop/src/components/market/ChangelogPanel.tsx @@ -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 ( + +

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

+ {changelog ? ( +
+
+ {releaseVersion && ( + + {releaseVersion.startsWith('v') ? releaseVersion : `v${releaseVersion}`} + + )} + {published && {published}} +
+

+ {changelog.text} +

+
+ ) : ( +

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

+ )} + {href && ( + + {t('market.detail.sourcePage')} + + )} +
+ ) +} diff --git a/desktop/src/components/market/InstallConfirmDialog.test.tsx b/desktop/src/components/market/InstallConfirmDialog.test.tsx index a8187060..f7ead7cc 100644 --- a/desktop/src/components/market/InstallConfirmDialog.test.tsx +++ b/desktop/src/components/market/InstallConfirmDialog.test.tsx @@ -4,7 +4,7 @@ import '@testing-library/jest-dom' import { InstallConfirmDialog } from './InstallConfirmDialog' import { useSettingsStore } from '../../stores/settingsStore' -import type { NormalizedSkill } from '../../types/market' +import type { NormalizedSkill, NormalizedSkillDetail } from '../../types/market' function makeSkill(overrides: Partial = {}): NormalizedSkill { return { @@ -88,3 +88,92 @@ describe('InstallConfirmDialog', () => { 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( + , + ) + 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( + , + ) + + 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() + + fireEvent.click(screen.getByRole('checkbox')) + expect(screen.getByTestId('market-install-confirm-button')).toBeEnabled() + + rerender() + 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() + + fireEvent.click(screen.getByRole('checkbox')) + rerender() + rerender() + + 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() + + 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() + + expect(screen.queryByTestId('market-install-capabilities')).not.toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/market/InstallConfirmDialog.tsx b/desktop/src/components/market/InstallConfirmDialog.tsx index deeb363d..65c5cb94 100644 --- a/desktop/src/components/market/InstallConfirmDialog.tsx +++ b/desktop/src/components/market/InstallConfirmDialog.tsx @@ -1,8 +1,12 @@ +import { useEffect, useState } from 'react' import { ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react' 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 { Checkbox } from '@/components/ui/Checkbox' import { Modal } from '@/components/ui/Modal' +import { CAPABILITY_LEVEL_TONES, capabilityText, useSkillInsights } from './CapabilityPanel' import { SecurityBadge } from './SecurityBadge' const RISK_KEYS = { @@ -12,23 +16,48 @@ const RISK_KEYS = { flagged: 'market.installConfirm.riskFlagged', } 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({ skill, + detail, open, installing, onConfirm, onClose, }: { skill: NormalizedSkill | null + /** The loaded detail for this skill, when there is one. */ + detail?: NormalizedSkillDetail | null open: boolean installing: boolean onConfirm: () => void onClose: () => void }) { 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 const risky = skill.securityStatus === 'flagged' || skill.securityStatus === 'unknown' + const armed = !risky || acknowledged const RiskIcon = skill.securityStatus === 'flagged' ? ShieldAlert : risky ? ShieldQuestion : ShieldCheck @@ -78,8 +107,44 @@ export function InstallConfirmDialog({ {t(RISK_KEYS[skill.securityStatus])} + {capabilities.length > 0 && ( +
+

+ {t('market.installConfirm.willGet')} +

+
    + {capabilities.map((capability) => { + const text = capabilityText(t, capability) + return ( +
  • + + {text.title} + + {text.detail} + + + + {t(`market.cap.level.${capability.level}`)} + +
  • + ) + })} +
+
+ )} +

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

+ {risky && ( + setAcknowledged(event.currentTarget.checked)} + /> + )} +
-
- + - + -
- {/* Kept hand-rolled rather than moved onto `SearchField`: the command - 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 - className is the class fight components/AGENTS.md §3.6 warns about. */} -
-
+
+ +
+
+

+ {/* No count while the first page is in flight: "0 results" would be a claim. */} + {!isLoading && !error && ( + + {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) })} + + )} + {catalogUpdated && ( + + {t('market.catalog.updated', { date: catalogUpdated })} + + )} + {!catalog && ( + + {t('market.scope.notCurated')} + + )} +

+ {/* Source health only describes live reads; the catalog is a shipped snapshot. */} + {!catalog && }
- {featured} - - {!isLoading && items.length > 0 && ( -

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

+ {catalog ? t('market.scope.catalogHint') : t('market.scope.marketHint')} +

)} + {featured} + {isLoading && ( )} @@ -200,7 +308,7 @@ export function MarketHome({ onRequestInstall, featured }: { onRequestInstall: (