import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { AlertTriangle, BadgeCheck, Check, ChevronRight, Clock3, Download, ExternalLink, FileText, Globe2, ListFilter, Lock, Package, RefreshCw, Search, ShieldCheck, Sparkles, Star, X, } from 'lucide-react' import { SkillList } from '../components/skills/SkillList' import { SkillDetail } from '../components/skills/SkillDetail' import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' import { CodeViewer } from '../components/chat/CodeViewer' import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { useTranslation } from '../i18n' import { formatBytes } from '../lib/formatBytes' import { useSessionStore } from '../stores/sessionStore' import { skillMarketDetailKey, useSkillMarketStore } from '../stores/skillMarketStore' import { useSkillStore } from '../stores/skillStore' import type { SkillMarketDetail, SkillMarketFilePreview, SkillMarketInstallEligibility, SkillMarketItem, SkillMarketListSource, SkillMarketSort, SkillMarketSource, SkillMarketTrustState, } from '../types/skillMarket' type SkillCenterTab = 'marketplace' | 'mine' type MarketFilter = 'all' | 'safe' | 'installed' | 'apiKey' | 'popular' type TFunction = ReturnType const SOURCE_OPTIONS: SkillMarketListSource[] = ['auto', 'clawhub', 'skillhub'] const SORT_OPTIONS: SkillMarketSort[] = ['downloads', 'installs', 'stars', 'updated', 'trending'] const FILTER_OPTIONS: MarketFilter[] = ['all', 'safe', 'popular', 'installed', 'apiKey'] const TRUST_SAFE: SkillMarketTrustState[] = ['clean', 'benign', 'signed', 'official'] const VISIBLE_DETAIL_PREFETCH_LIMIT = 3 export function SkillCenter() { const t = useTranslation() const selectedInstalledSkill = useSkillStore((s) => s.selectedSkill) const installedSkills = useSkillStore((s) => s.skills) const fetchInstalledSkillDetail = useSkillStore((s) => s.fetchSkillDetail) const sessions = useSessionStore((s) => s.sessions) const activeSessionId = useSessionStore((s) => s.activeSessionId) const activeSession = sessions.find((session) => session.id === activeSessionId) const currentWorkDir = activeSession?.workDir || undefined const [activeTab, setActiveTab] = useState(() => selectedInstalledSkill ? 'mine' : 'marketplace' ) const [marketFilter, setMarketFilter] = useState('all') const [pendingInstallDetail, setPendingInstallDetail] = useState(null) const { items, nextCursor, selectedDetail, loadingDetailKey, source, resolvedSource, sourceStatus, statusMessage, sort, query, isLoading, isLoadingMore, isDetailLoading, isInstalling, error, setSource, setSort, setQuery, fetchItems, fetchMore, fetchDetail, prefetchDetail, installSelected, clearDetail, } = useSkillMarketStore() useEffect(() => { if (activeTab !== 'marketplace') return void fetchItems() }, [activeTab, fetchItems, source, sort]) useEffect(() => { if (selectedInstalledSkill) { setActiveTab('mine') } }, [selectedInstalledSkill]) const installedCount = useMemo( () => items.filter((item) => item.installed).length, [items], ) const safeCount = useMemo( () => items.filter((item) => TRUST_SAFE.includes(item.trustState)).length, [items], ) const filteredItems = useMemo( () => items.filter((item) => matchesMarketFilter(item, marketFilter)), [items, marketFilter], ) const activeMarketDetailKey = selectedDetail ? skillMarketDetailKey(selectedDetail.source, selectedDetail.slug) : loadingDetailKey useEffect(() => { if (activeTab !== 'marketplace' || isLoading || filteredItems.length === 0) return for (const item of filteredItems.slice(0, VISIBLE_DETAIL_PREFETCH_LIMIT)) { void prefetchDetail(item.source, item.slug) } }, [activeTab, filteredItems, isLoading, prefetchDetail]) const handleSearch = (event?: FormEvent) => { event?.preventDefault() void fetchItems() } const handleClearSearch = () => { setQuery('') void fetchItems({ query: '' }) } const handleOpenInstalled = (detail: SkillMarketDetail) => { const eligibility = detail.installEligibility const skillName = eligibility.status === 'installed' ? eligibility.installedSkillName : detail.slug setActiveTab('mine') void fetchInstalledSkillDetail('user', skillName, currentWorkDir, 'skills') } return (

{t('skillCenter.title')}

{activeTab === 'marketplace' ? ( ) : null}
{activeTab === 'marketplace' ? (
{FILTER_OPTIONS.map((filter) => ( ))}
setSource(value)} /> setSort(value as SkillMarketSort)} options={SORT_OPTIONS.map((value) => ({ value, label: t(`skillCenter.marketplace.sort.${value}`), }))} compact />
{error ? (
) : null}
{isLoading ? ( ) : items.length === 0 ? (
{selectedDetail || isDetailLoading ? ( selectedDetail && setPendingInstallDetail(selectedDetail)} onOpenInstalled={handleOpenInstalled} onClose={clearDetail} /> ) : null} setPendingInstallDetail(null)} title={t('skillCenter.marketplace.confirmTitle')} body={pendingInstallDetail ? : null} confirmLabel={t('skillCenter.marketplace.confirmInstall')} cancelLabel={t('common.cancel')} confirmVariant="primary" loading={isInstalling} onConfirm={async () => { await installSelected() setPendingInstallDetail(null) }} />
) : (
{selectedInstalledSkill ? : }
)}
) } function MarketplaceStatusLine({ t, source, resolvedSource, sourceStatus, statusMessage, total, safeCount, installedCount, loading, }: { t: TFunction source: SkillMarketListSource resolvedSource: SkillMarketSource | null sourceStatus: 'ok' | 'fallback' | 'cached' | null statusMessage: string | null total: number safeCount: number installedCount: number loading: boolean }) { const sourceName = resolvedSource ? t(`skillCenter.marketplace.source.${resolvedSource}`) : t(`skillCenter.marketplace.source.${source}`) const statusLabel = sourceStatus ? t(`skillCenter.marketplace.sourceStatus.${sourceStatus}`) : t('skillCenter.marketplace.sourceStatus.pending') return (
{loading ? ( {t('skillCenter.marketplace.loading')} ) : null} {statusMessage ? ( {statusMessage} ) : ( {t('skillCenter.marketplace.sourcePolicy')} ยท {t('skillCenter.marketplace.safetyPolicy')} )}
) } function StatusDot({ label }: { label: string }) { return ( ) } function StateBadge({ tone, icon, label, }: { tone: 'neutral' | 'info' | 'success' | 'warning' icon: ReactNode label: string }) { const className = tone === 'success' ? 'bg-[var(--color-success-container)] text-[var(--color-success)]' : tone === 'warning' ? 'bg-[var(--color-warning-container)] text-[var(--color-warning)]' : tone === 'info' ? 'bg-[var(--color-info-container)] text-[var(--color-info)]' : 'border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)]' return ( {icon} {label} ) } function SourceSwitch({ value, onChange, }: { value: SkillMarketListSource onChange: (value: SkillMarketListSource) => void }) { const t = useTranslation() return (
{SOURCE_OPTIONS.map((sourceOption) => ( ))}
) } function InstalledLibraryHeader({ t, selected, installedCount, }: { t: TFunction selected: boolean installedCount: number }) { return (
{selected ? t('skillCenter.mine.detailTitle') : t('skillCenter.mine.libraryTitle')}
{t('skillCenter.mine.libraryMeta', { count: String(installedCount) })}
{t('skillCenter.mine.localDirectory')} {t('skillCenter.mine.sources')}
) } function InstallConfirmBody({ detail }: { detail: SkillMarketDetail }) { const t = useTranslation() const target = `~/.claude/skills/${detail.slug}` const riskLabel = detail.riskLabels.length > 0 ? detail.riskLabels.map((label) => t(`skillCenter.marketplace.risk.${label}`)).join(', ') : t('skillCenter.marketplace.noRiskLabels') return (

{t('skillCenter.marketplace.confirmBody')}

) } function ConfirmRow({ label, value }: { label: string; value: string }) { return (
{label}
{value}
) } function SkillMarketCard({ item, active, onSelect, onPrefetch, }: { item: SkillMarketItem active: boolean onSelect: () => void onPrefetch: () => void }) { const t = useTranslation() const stats = formatStats(item, t) const sourceLabel = t(`skillCenter.marketplace.source.${item.source}`) const summary = item.summaryZh || item.summary return ( ) } function SkillMarketDetailPanel({ detail, loading, installing, onInstall, onOpenInstalled, onClose, }: { detail: SkillMarketDetail | null loading: boolean installing: boolean onInstall: () => void onOpenInstalled: (detail: SkillMarketDetail) => void onClose: () => void }) { const t = useTranslation() if (loading && !detail) { return createPortal(
, document.body, ) } if (!detail) { return createPortal(
, document.body, ) } const eligibility = detail.installEligibility const installable = eligibility.status === 'installable' const canOpenInstalled = eligibility.status === 'installed' const summary = detail.summaryZh || detail.summary return createPortal(

{t('skillCenter.marketplace.drawer.decision')}

{eligibility.status === 'blocked' || eligibility.status === 'conflict' ? (
{eligibilityMessage(t, eligibility)}
) : null}

{summary}

{detail.trustSummary ? (
{detail.trustSummary}
) : null} {detail.riskLabels.length > 0 ? (
{t('skillCenter.marketplace.riskLabels')}
{detail.riskLabels.map((label) => ( {t(`skillCenter.marketplace.risk.${label}`)} ))}
) : (
)} {detail.requiresApiKey ? (
) : null}
, document.body, ) } function FilePreviewSection({ detail }: { detail: SkillMarketDetail }) { const t = useTranslation() const [selectedPath, setSelectedPath] = useState(null) const previews = detail.filePreviews?.length ? detail.filePreviews : detail.entryPreview ? [{ path: 'SKILL.md', content: detail.entryPreview, language: 'markdown', }] : [] const activePreview = previews.find((preview) => preview.path === selectedPath) ?? previews[0] if (previews.length === 0) { if (!detail.previewUnavailableReason) { return null } return (

{formatPreviewUnavailable(t, detail.previewUnavailableReason)}

) } if (!activePreview) { return null } return (
{previews.map((preview) => { const active = preview.path === activePreview.path return ( ) })}
{activePreview.path} {activePreview.language ? ( {normalizePreviewLanguage(activePreview.language)} ) : null} {typeof activePreview.size === 'number' ? ( {formatBytes(activePreview.size)} ) : null} {activePreview.truncated ? ( {t('skillCenter.marketplace.previewTruncated')} ) : null}
{isMarkdownPreview(activePreview) ? ( ) : (
)}
) } function isMarkdownPreview(preview: SkillMarketFilePreview): boolean { const language = normalizePreviewLanguage(preview.language) const path = preview.path.toLowerCase() return language === 'markdown' || path.endsWith('.md') || path.endsWith('.mdx') } function normalizePreviewLanguage(language: string | undefined): string | undefined { if (language === 'shell') return 'bash' return language } function DrawerSection({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
) } function DecisionHint({ icon, title, body, }: { icon: ReactNode title: string body: string }) { return (
{icon} {title}

{body}

) } function SelectField({ label, value, options, onChange, compact = false, }: { label: string value: string options: Array<{ value: string; label: string }> onChange: (value: string) => void compact?: boolean }) { return ( ) } function MetaItem({ icon, label, value, }: { icon: ReactNode label: string value: string }) { return (
{icon} {label}
{value}
) } function TrustBadge({ state }: { state: SkillMarketTrustState }) { const t = useTranslation() const safe = TRUST_SAFE.includes(state) const blocked = state === 'blocked' || state === 'unknown' const className = safe ? 'bg-[var(--color-success-container)] text-[var(--color-success)]' : blocked ? 'bg-[var(--color-error-container)] text-[var(--color-error)]' : 'bg-[var(--color-warning-container)] text-[var(--color-warning)]' const Icon = safe ? ShieldCheck : AlertTriangle return ( ) } function EligibilityBadge({ eligibility }: { eligibility: SkillMarketInstallEligibility }) { const t = useTranslation() const status = eligibility.status const className = status === 'installable' ? 'bg-[var(--color-success-container)] text-[var(--color-success)]' : status === 'installed' ? 'bg-[var(--color-info-container)] text-[var(--color-info)]' : 'bg-[var(--color-warning-container)] text-[var(--color-warning)]' return ( {status === 'installable' ? ) } function MarketplaceSkeletonGrid() { const t = useTranslation() return (
{Array.from({ length: 8 }).map((_, index) => (
))}
) } function DetailDrawerSkeleton() { const t = useTranslation() return (
{t('skillCenter.marketplace.loadingDetail')}
) } function EmptyState({ icon, label, hint, }: { icon: ReactNode label: string hint: string }) { return (
{icon}

{label}

{hint}

) } function tabClass(active: boolean) { return [ 'inline-flex min-w-[6rem] items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]', active ? 'bg-[var(--color-surface)] text-[var(--color-text-primary)] shadow-sm' : 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]', ].join(' ') } function marketFilterClass(active: boolean) { return [ 'h-9 rounded-md px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]', active ? 'bg-[var(--color-text-primary)] text-[var(--color-surface)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]', ].join(' ') } function sourceIconClass(source: SkillMarketSource, trustState: SkillMarketTrustState) { const safe = TRUST_SAFE.includes(trustState) return [ 'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md', safe ? 'bg-[var(--color-success-container)] text-[var(--color-success)]' : source === 'clawhub' ? 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]' : 'bg-[var(--color-info-container)] text-[var(--color-info)]', ].join(' ') } function matchesMarketFilter(item: SkillMarketItem, filter: MarketFilter) { if (filter === 'safe') return TRUST_SAFE.includes(item.trustState) if (filter === 'installed') return item.installed if (filter === 'apiKey') return !!item.requiresApiKey if (filter === 'popular') { return (item.downloads ?? item.installs ?? item.stars ?? 0) >= 1000 } return true } function formatStats(item: SkillMarketItem, t: TFunction): string { if (typeof item.downloads === 'number') { return t('skillCenter.marketplace.cardMeta.downloads', { count: formatNumber(item.downloads) }) } if (typeof item.installs === 'number') { return `${compactNumber(item.installs)} ${t('skillCenter.marketplace.sort.installs')}` } if (typeof item.stars === 'number') { return t('skillCenter.marketplace.cardMeta.stars', { count: formatNumber(item.stars) }) } return '' } function formatNumber(value: number | undefined): string { if (typeof value !== 'number' || !Number.isFinite(value)) return '-' return new Intl.NumberFormat().format(value) } function compactNumber(value: number | undefined): string { if (typeof value !== 'number' || !Number.isFinite(value)) return '-' return new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1, }).format(value) } function formatMarketError(t: TFunction, error: string): string { if (/failed to fetch/i.test(error) || /network/i.test(error)) { return t('skillCenter.marketplace.networkError') } return error } function formatPreviewUnavailable(t: TFunction, reason: string): string { if (/SkillHub does not expose/i.test(reason)) { return t('skillCenter.marketplace.previewUnavailable.skillhub') } if (/file list/i.test(reason)) { return t('skillCenter.marketplace.previewUnavailable.fileList') } if (/package metadata|full detail/i.test(reason)) { return t('skillCenter.marketplace.previewUnavailable.metadata') } if (/No small text files/i.test(reason)) { return t('skillCenter.marketplace.previewUnavailable.noText') } return reason } function installLabel( t: TFunction, eligibility: SkillMarketInstallEligibility, ): string { if (eligibility.status === 'installable') return t('skillCenter.marketplace.install') if (eligibility.status === 'installed') return t('skillCenter.marketplace.installedAction') if (eligibility.status === 'conflict') return t('skillCenter.marketplace.conflictAction') return t('skillCenter.marketplace.blockedAction') } function eligibilityMessage( t: TFunction, eligibility: SkillMarketInstallEligibility, ): string { if (eligibility.status === 'conflict') { return t('skillCenter.marketplace.conflictReason', { path: eligibility.targetPath }) } if (eligibility.status === 'blocked') { if (eligibility.reason === 'Full package safety scan is required before install.') { return t('skillCenter.marketplace.blockedReason.scanRequired') } return eligibility.reason } return '' }