feat: add skill marketplace preview flow

Tested: bun test src/server/__tests__/skill-market.test.ts
Tested: cd desktop && bun run test -- SkillCenter.test.tsx skillMarketStore.test.ts generalSettings.test.ts ComputerUseSettings.test.ts --run
Tested: cd desktop && bun run test -- persistenceMigrations.test.ts --run
Tested: bun run check:desktop
Tested: real browser smoke for Skill Vetter across white, light, and dark themes
Not-tested: live install of an uninstalled marketplace skill to disk
Confidence: medium
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 23:31:34 +08:00
parent b305e798e7
commit b6aeaf0109
27 changed files with 2110 additions and 425 deletions

View File

@ -4,7 +4,6 @@ import '@testing-library/jest-dom'
import { Settings } from '../pages/Settings'
import { useAgentStore } from '../stores/agentStore'
import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
@ -41,10 +40,6 @@ vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div>Adapter Settings Mock</div>,
}))
vi.mock('../components/chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <pre data-testid="code-viewer">{code}</pre>,
}))
const MOCK_AGENTS = [
{
agentType: 'code-reviewer',
@ -97,50 +92,10 @@ const MOCK_AGENTS = [
},
]
const MOCK_SKILL_DETAIL = {
meta: {
name: 'skill-docs',
displayName: 'Skill Docs',
description: 'A rich skill readme',
source: 'user' as const,
userInvocable: true,
contentLength: 200,
hasDirectory: true,
},
tree: [
{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' as const },
{ name: 'helper.ts', path: 'helper.ts', type: 'file' as const },
],
files: [
{
path: 'SKILL.md',
language: 'markdown',
content: '# Heading\n\nParagraph with `inline code`.\n\n## Section\n\n- First item\n- Second item\n\n> Helpful quote',
body: '# Heading\n\nParagraph with `inline code`.\n\n## Section\n\n- First item\n- Second item\n\n> Helpful quote',
isEntry: true,
frontmatter: {
description: 'A rich skill readme',
model: 'sonnet',
},
},
{
path: 'helper.ts',
language: 'typescript',
content: 'export const helper = true',
isEntry: false,
},
],
skillRoot: '/tmp/skill-docs',
}
function switchToAgentsTab() {
fireEvent.click(screen.getByText('Agents'))
}
function switchToSkillsTab() {
fireEvent.click(screen.getByText('Skills'))
}
describe('Settings > Agents tab', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
@ -182,16 +137,6 @@ describe('Settings > Agents tab', () => {
fetchAgents: noopFetch,
selectAgent: (agent) => useAgentStore.setState({ selectedAgent: agent }),
})
useSkillStore.setState({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: noopFetch,
fetchSkillDetail: noopFetch,
clearSelection: () => useSkillStore.setState({ selectedSkill: null }),
})
})
it('renders the Agents tab button in sidebar', () => {
@ -340,53 +285,3 @@ describe('Settings > Agents tab', () => {
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
})
})
describe('Settings > Skills tab', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useSkillStore.setState({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: noopFetch,
fetchSkillDetail: noopFetch,
clearSelection: () => useSkillStore.setState({ selectedSkill: null }),
})
})
it('renders markdown skills with document styling in detail view', () => {
useSkillStore.setState({
selectedSkill: MOCK_SKILL_DETAIL,
clearSelection: () => useSkillStore.setState({ selectedSkill: null }),
})
render(<Settings />)
switchToSkillsTab()
expect(screen.getByText('Skill metadata')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Heading' })).toBeInTheDocument()
const rendererRoot = screen.getByRole('heading', { name: 'Heading' }).closest('div[class*="prose"]')
expect(rendererRoot?.className).toContain('max-w-[72ch]')
expect(rendererRoot?.className).toContain('prose-h2:border-b')
expect(rendererRoot?.className).toContain('prose-p:text-[15px]')
expect(screen.getByText('Helpful quote')).toBeInTheDocument()
})
it('keeps code files rendered in CodeViewer instead of markdown prose', () => {
useSkillStore.setState({
selectedSkill: MOCK_SKILL_DETAIL,
clearSelection: () => useSkillStore.setState({ selectedSkill: null }),
})
render(<Settings />)
switchToSkillsTab()
fireEvent.click(screen.getAllByText('helper.ts')[0]!)
expect(screen.getByTestId('code-viewer')).toHaveTextContent('export const helper = true')
expect(screen.queryByRole('heading', { name: 'Heading' })).not.toBeInTheDocument()
})
})

View File

@ -504,7 +504,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
className={`
rounded-lg py-2 text-center text-xs font-semibold transition-colors
${isSelected
? 'bg-[var(--color-brand)] text-white'
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}
`}

View File

@ -998,7 +998,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
selectedSessionIds.has(session.id)
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white'
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-[var(--color-on-primary)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}
aria-hidden="true"

View File

@ -294,7 +294,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-3 py-2">
<span className="truncate font-[var(--font-mono)] text-[10px] text-[var(--color-text-tertiary)]">{browsePath}</span>
<button onClick={() => handleSelect(browsePath)} className="rounded-lg bg-[var(--color-brand)] px-3 py-1.5 text-xs font-semibold text-white hover:opacity-90">
<button onClick={() => handleSelect(browsePath)} className="rounded-lg bg-[var(--color-brand)] px-3 py-1.5 text-xs font-semibold text-[var(--color-on-primary)] hover:opacity-90">
{t('dirPicker.useThisFolder')}
</button>
</div>

View File

@ -2007,21 +2007,46 @@ export const en = {
'skillCenter.tab.mine': 'Mine',
'skillCenter.marketplace.loading': 'Loading skill marketplace...',
'skillCenter.marketplace.refresh': 'Refresh',
'skillCenter.marketplace.loadMore': 'Load more',
'skillCenter.marketplace.loadingMore': 'Loading more...',
'skillCenter.marketplace.sourcePolicy': 'ClawHub first, SkillHub fallback',
'skillCenter.marketplace.safetyPolicy': 'Install only after local safety checks',
'skillCenter.marketplace.marketTitle': 'Skill marketplace',
'skillCenter.marketplace.marketSummary': 'Browse high-signal skills with source, popularity, safety, and local install status in one place.',
'skillCenter.marketplace.searchLabel': 'Search',
'skillCenter.marketplace.searchPlaceholder': 'Search name, author, or use case...',
'skillCenter.marketplace.clearSearch': 'Clear search',
'skillCenter.marketplace.runSearch': 'Run search',
'skillCenter.marketplace.sourceLabel': 'Source',
'skillCenter.marketplace.sortLabel': 'Sort',
'skillCenter.marketplace.filterLabel': 'Filters',
'skillCenter.marketplace.filter.all': 'All',
'skillCenter.marketplace.filter.safe': 'Safe',
'skillCenter.marketplace.filter.popular': 'Popular',
'skillCenter.marketplace.filter.installed': 'Installed',
'skillCenter.marketplace.filter.apiKey': 'API key',
'skillCenter.marketplace.total': 'Total',
'skillCenter.marketplace.loadedCount': '{count} loaded',
'skillCenter.marketplace.totalHint': 'Current result set',
'skillCenter.marketplace.safe': 'Verified',
'skillCenter.marketplace.safeHint': 'Clean or trusted signals',
'skillCenter.marketplace.installed': 'Installed',
'skillCenter.marketplace.installedHint': '{count} already installed',
'skillCenter.marketplace.empty': 'No marketplace skills found',
'skillCenter.marketplace.emptyHint': 'Try refreshing, changing source, or searching a broader keyword.',
'skillCenter.marketplace.filterEmpty': 'No skills match this filter',
'skillCenter.marketplace.filterEmptyHint': 'Clear the filter or choose another trust/source signal.',
'skillCenter.marketplace.detailLoading': 'Loading skill details...',
'skillCenter.marketplace.noSelection': 'Select a skill to inspect safety and install status.',
'skillCenter.marketplace.selectionTitle': 'Install decision',
'skillCenter.marketplace.closeDetail': 'Close skill details',
'skillCenter.marketplace.source.auto': 'Auto',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sourceStatus.ok': 'Online',
'skillCenter.marketplace.sourceStatus.fallback': 'Fallback active',
'skillCenter.marketplace.sourceStatus.cached': 'Cached',
'skillCenter.marketplace.sourceStatus.pending': 'Waiting',
'skillCenter.marketplace.sort.downloads': 'Downloads',
'skillCenter.marketplace.sort.installs': 'Installs',
'skillCenter.marketplace.sort.stars': 'Stars',
@ -2048,11 +2073,21 @@ export const en = {
'skillCenter.marketplace.risk.external-network': 'Network',
'skillCenter.marketplace.risk.requires-api-key': 'API key',
'skillCenter.marketplace.entryPreview': 'Entry preview',
'skillCenter.marketplace.filePreview': 'File preview',
'skillCenter.marketplace.previewTruncated': 'Truncated',
'skillCenter.marketplace.previewUnavailable': 'Preview unavailable',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub does not expose a safe raw file preview endpoint yet.',
'skillCenter.marketplace.previewUnavailable.fileList': 'This marketplace detail did not include a file list for preview.',
'skillCenter.marketplace.previewUnavailable.metadata': 'This marketplace detail did not include enough package metadata for preview.',
'skillCenter.marketplace.previewUnavailable.noText': 'No small text files were available for safe preview.',
'skillCenter.marketplace.install': 'Install',
'skillCenter.marketplace.installing': 'Installing...',
'skillCenter.marketplace.installedAction': 'Installed',
'skillCenter.marketplace.viewInstalled': 'View installed',
'skillCenter.marketplace.conflictAction': 'Conflict',
'skillCenter.marketplace.blockedAction': 'Blocked',
'skillCenter.marketplace.conflictReason': 'A local skill directory already exists: {path}',
'skillCenter.marketplace.blockedReason.scanRequired': 'A full package safety scan is required before install. This skill only has list-level trust data right now.',
'skillCenter.marketplace.openSource': 'Open source',
'skillCenter.marketplace.openUpstream': 'Open upstream',
'skillCenter.marketplace.confirmTitle': 'Confirm skill install',
@ -2061,6 +2096,19 @@ export const en = {
'skillCenter.marketplace.confirmTarget': 'Target',
'skillCenter.marketplace.confirmInstall': 'Install skill',
'skillCenter.marketplace.noRiskLabels': 'No risk signals reported',
'skillCenter.marketplace.errorTitle': 'Marketplace unavailable',
'skillCenter.marketplace.networkError': 'The skill marketplace could not be reached. Check the current network or proxy, then refresh.',
'skillCenter.marketplace.hint.sourceTitle': 'Source',
'skillCenter.marketplace.hint.sourceBody': 'Auto mode uses ClawHub first and falls back when needed.',
'skillCenter.marketplace.hint.safetyTitle': 'Safety',
'skillCenter.marketplace.hint.safetyBody': 'Clean, signed, and official signals are promoted before install.',
'skillCenter.marketplace.hint.previewTitle': 'Preview',
'skillCenter.marketplace.hint.previewBody': 'Open a skill to inspect files, risk labels, and upstream links.',
'skillCenter.mine.libraryTitle': 'Installed skills',
'skillCenter.mine.detailTitle': 'Installed skill detail',
'skillCenter.mine.libraryMeta': '{count} local skills',
'skillCenter.mine.localDirectory': '~/.claude/skills',
'skillCenter.mine.sources': 'User, project, plugin, MCP',
// ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close',

View File

@ -2009,21 +2009,46 @@ export const jp: Record<TranslationKey, string> = {
'skillCenter.tab.mine': '自分のスキル',
'skillCenter.marketplace.loading': 'スキルマーケットを読み込み中...',
'skillCenter.marketplace.refresh': '更新',
'skillCenter.marketplace.loadMore': 'さらに読み込む',
'skillCenter.marketplace.loadingMore': 'さらに読み込み中...',
'skillCenter.marketplace.sourcePolicy': 'ClawHub 優先、失敗時は SkillHub',
'skillCenter.marketplace.safetyPolicy': 'インストール前にローカル安全確認',
'skillCenter.marketplace.marketTitle': 'スキルマーケット',
'skillCenter.marketplace.marketSummary': 'ソース、人気度、安全状態、ローカルのインストール状態を同じ画面で確認できます。',
'skillCenter.marketplace.searchLabel': '検索',
'skillCenter.marketplace.searchPlaceholder': '名前、作者、用途で検索...',
'skillCenter.marketplace.clearSearch': '検索をクリア',
'skillCenter.marketplace.runSearch': '検索を実行',
'skillCenter.marketplace.sourceLabel': 'ソース',
'skillCenter.marketplace.sortLabel': '並び替え',
'skillCenter.marketplace.filterLabel': 'フィルター',
'skillCenter.marketplace.filter.all': 'すべて',
'skillCenter.marketplace.filter.safe': '安全',
'skillCenter.marketplace.filter.popular': '人気',
'skillCenter.marketplace.filter.installed': 'インストール済み',
'skillCenter.marketplace.filter.apiKey': 'API キー',
'skillCenter.marketplace.total': '合計',
'skillCenter.marketplace.loadedCount': '{count} 件読み込み済み',
'skillCenter.marketplace.totalHint': '現在の結果',
'skillCenter.marketplace.safe': '信頼済み',
'skillCenter.marketplace.safeHint': '安全または信頼シグナル',
'skillCenter.marketplace.installed': 'インストール済み',
'skillCenter.marketplace.installedHint': '{count} 件インストール済み',
'skillCenter.marketplace.empty': 'マーケットスキルが見つかりません',
'skillCenter.marketplace.emptyHint': '更新、ソース変更、または広めのキーワードで検索してください。',
'skillCenter.marketplace.filterEmpty': 'このフィルターに一致するスキルはありません',
'skillCenter.marketplace.filterEmptyHint': 'フィルターを解除するか別のシグナルを選択してください。',
'skillCenter.marketplace.detailLoading': 'スキル詳細を読み込み中...',
'skillCenter.marketplace.noSelection': 'スキルを選択して安全性とインストール状態を確認します。',
'skillCenter.marketplace.selectionTitle': 'インストール判断',
'skillCenter.marketplace.closeDetail': 'スキル詳細を閉じる',
'skillCenter.marketplace.source.auto': '自動',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sourceStatus.ok': 'オンライン',
'skillCenter.marketplace.sourceStatus.fallback': 'フォールバック中',
'skillCenter.marketplace.sourceStatus.cached': 'キャッシュ',
'skillCenter.marketplace.sourceStatus.pending': '待機中',
'skillCenter.marketplace.sort.downloads': 'ダウンロード',
'skillCenter.marketplace.sort.installs': 'インストール',
'skillCenter.marketplace.sort.stars': 'スター',
@ -2050,11 +2075,21 @@ export const jp: Record<TranslationKey, string> = {
'skillCenter.marketplace.risk.external-network': '外部ネットワーク',
'skillCenter.marketplace.risk.requires-api-key': 'API キー',
'skillCenter.marketplace.entryPreview': 'エントリプレビュー',
'skillCenter.marketplace.filePreview': 'ファイルプレビュー',
'skillCenter.marketplace.previewTruncated': '切り詰め済み',
'skillCenter.marketplace.previewUnavailable': 'プレビュー不可',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub はまだ安全な raw ファイルプレビュー API を提供していません。',
'skillCenter.marketplace.previewUnavailable.fileList': 'このマーケット詳細にはプレビュー用のファイル一覧が含まれていません。',
'skillCenter.marketplace.previewUnavailable.metadata': 'このマーケット詳細には安全にプレビューするためのパッケージメタデータが不足しています。',
'skillCenter.marketplace.previewUnavailable.noText': '安全にプレビューできる小さなテキストファイルがありません。',
'skillCenter.marketplace.install': 'インストール',
'skillCenter.marketplace.installing': 'インストール中...',
'skillCenter.marketplace.installedAction': 'インストール済み',
'skillCenter.marketplace.viewInstalled': 'インストール済みを表示',
'skillCenter.marketplace.conflictAction': '競合',
'skillCenter.marketplace.blockedAction': 'インストール不可',
'skillCenter.marketplace.conflictReason': 'ローカルのスキルディレクトリが既に存在します: {path}',
'skillCenter.marketplace.blockedReason.scanRequired': 'インストール前に完全なパッケージ安全スキャンが必要です。このスキルは現在、一覧レベルの信頼情報のみです。',
'skillCenter.marketplace.openSource': 'ソースを開く',
'skillCenter.marketplace.openUpstream': '上流を開く',
'skillCenter.marketplace.confirmTitle': 'スキルのインストールを確認',
@ -2063,6 +2098,19 @@ export const jp: Record<TranslationKey, string> = {
'skillCenter.marketplace.confirmTarget': '対象',
'skillCenter.marketplace.confirmInstall': 'スキルをインストール',
'skillCenter.marketplace.noRiskLabels': 'リスクシグナルなし',
'skillCenter.marketplace.errorTitle': 'マーケットを利用できません',
'skillCenter.marketplace.networkError': 'スキルマーケットに接続できません。ネットワークまたはシステムプロキシを確認して更新してください。',
'skillCenter.marketplace.hint.sourceTitle': 'ソース',
'skillCenter.marketplace.hint.sourceBody': '自動モードは ClawHub を優先し、必要時に予備ソースへ切り替えます。',
'skillCenter.marketplace.hint.safetyTitle': '安全性',
'skillCenter.marketplace.hint.safetyBody': '安全、署名済み、公式などの信頼シグナルを優先表示します。',
'skillCenter.marketplace.hint.previewTitle': 'プレビュー',
'skillCenter.marketplace.hint.previewBody': 'スキルを開くとファイル、リスクラベル、上流リンクを確認できます。',
'skillCenter.mine.libraryTitle': 'インストール済みスキル',
'skillCenter.mine.detailTitle': 'インストール済みスキル詳細',
'skillCenter.mine.libraryMeta': 'ローカル {count} 件のスキル',
'skillCenter.mine.localDirectory': '~/.claude/skills',
'skillCenter.mine.sources': 'User、project、plugin、MCP',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '閉じる',

View File

@ -2009,21 +2009,46 @@ export const kr: Record<TranslationKey, string> = {
'skillCenter.tab.mine': '내 스킬',
'skillCenter.marketplace.loading': '스킬 마켓을 불러오는 중...',
'skillCenter.marketplace.refresh': '새로고침',
'skillCenter.marketplace.loadMore': '더 불러오기',
'skillCenter.marketplace.loadingMore': '더 불러오는 중...',
'skillCenter.marketplace.sourcePolicy': 'ClawHub 우선, 실패 시 SkillHub',
'skillCenter.marketplace.safetyPolicy': '설치 전 로컬 안전 확인',
'skillCenter.marketplace.marketTitle': '스킬 마켓',
'skillCenter.marketplace.marketSummary': '소스, 인기, 안전 상태, 로컬 설치 상태를 한 화면에서 확인합니다.',
'skillCenter.marketplace.searchLabel': '검색',
'skillCenter.marketplace.searchPlaceholder': '이름, 작성자, 용도로 검색...',
'skillCenter.marketplace.clearSearch': '검색 지우기',
'skillCenter.marketplace.runSearch': '검색 실행',
'skillCenter.marketplace.sourceLabel': '소스',
'skillCenter.marketplace.sortLabel': '정렬',
'skillCenter.marketplace.filterLabel': '필터',
'skillCenter.marketplace.filter.all': '전체',
'skillCenter.marketplace.filter.safe': '안전',
'skillCenter.marketplace.filter.popular': '인기',
'skillCenter.marketplace.filter.installed': '설치됨',
'skillCenter.marketplace.filter.apiKey': 'API 키',
'skillCenter.marketplace.total': '전체',
'skillCenter.marketplace.loadedCount': '{count}개 로드됨',
'skillCenter.marketplace.totalHint': '현재 결과',
'skillCenter.marketplace.safe': '신뢰됨',
'skillCenter.marketplace.safeHint': '안전 또는 신뢰 신호',
'skillCenter.marketplace.installed': '설치됨',
'skillCenter.marketplace.installedHint': '{count}개 설치됨',
'skillCenter.marketplace.empty': '마켓 스킬을 찾을 수 없습니다',
'skillCenter.marketplace.emptyHint': '새로고침하거나 소스를 바꾸거나 더 넓은 키워드로 검색하세요.',
'skillCenter.marketplace.filterEmpty': '이 필터와 일치하는 스킬이 없습니다',
'skillCenter.marketplace.filterEmptyHint': '필터를 해제하거나 다른 신뢰/소스 신호를 선택하세요.',
'skillCenter.marketplace.detailLoading': '스킬 세부 정보를 불러오는 중...',
'skillCenter.marketplace.noSelection': '스킬을 선택해 안전성과 설치 상태를 확인합니다.',
'skillCenter.marketplace.selectionTitle': '설치 판단',
'skillCenter.marketplace.closeDetail': '스킬 세부 정보 닫기',
'skillCenter.marketplace.source.auto': '자동',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sourceStatus.ok': '온라인',
'skillCenter.marketplace.sourceStatus.fallback': '대체 소스 사용 중',
'skillCenter.marketplace.sourceStatus.cached': '캐시됨',
'skillCenter.marketplace.sourceStatus.pending': '대기 중',
'skillCenter.marketplace.sort.downloads': '다운로드',
'skillCenter.marketplace.sort.installs': '설치',
'skillCenter.marketplace.sort.stars': '스타',
@ -2050,11 +2075,21 @@ export const kr: Record<TranslationKey, string> = {
'skillCenter.marketplace.risk.external-network': '외부 네트워크',
'skillCenter.marketplace.risk.requires-api-key': 'API 키',
'skillCenter.marketplace.entryPreview': '엔트리 미리보기',
'skillCenter.marketplace.filePreview': '파일 미리보기',
'skillCenter.marketplace.previewTruncated': '잘림',
'skillCenter.marketplace.previewUnavailable': '미리보기 불가',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub는 아직 안전한 원본 파일 미리보기 API를 제공하지 않습니다.',
'skillCenter.marketplace.previewUnavailable.fileList': '이 마켓 상세 정보에는 미리보기에 사용할 파일 목록이 없습니다.',
'skillCenter.marketplace.previewUnavailable.metadata': '이 마켓 상세 정보에는 안전한 미리보기에 필요한 패키지 메타데이터가 부족합니다.',
'skillCenter.marketplace.previewUnavailable.noText': '안전하게 미리볼 수 있는 작은 텍스트 파일이 없습니다.',
'skillCenter.marketplace.install': '설치',
'skillCenter.marketplace.installing': '설치 중...',
'skillCenter.marketplace.installedAction': '설치됨',
'skillCenter.marketplace.viewInstalled': '설치된 항목 보기',
'skillCenter.marketplace.conflictAction': '충돌',
'skillCenter.marketplace.blockedAction': '설치 불가',
'skillCenter.marketplace.conflictReason': '로컬 스킬 디렉터리가 이미 존재합니다: {path}',
'skillCenter.marketplace.blockedReason.scanRequired': '설치 전에 전체 패키지 안전 검사가 필요합니다. 이 스킬은 현재 목록 수준의 신뢰 정보만 있습니다.',
'skillCenter.marketplace.openSource': '소스 열기',
'skillCenter.marketplace.openUpstream': '업스트림 열기',
'skillCenter.marketplace.confirmTitle': '스킬 설치 확인',
@ -2063,6 +2098,19 @@ export const kr: Record<TranslationKey, string> = {
'skillCenter.marketplace.confirmTarget': '대상',
'skillCenter.marketplace.confirmInstall': '스킬 설치',
'skillCenter.marketplace.noRiskLabels': '보고된 위험 신호 없음',
'skillCenter.marketplace.errorTitle': '마켓을 사용할 수 없음',
'skillCenter.marketplace.networkError': '스킬 마켓에 연결할 수 없습니다. 네트워크 또는 시스템 프록시를 확인한 뒤 새로고침하세요.',
'skillCenter.marketplace.hint.sourceTitle': '소스',
'skillCenter.marketplace.hint.sourceBody': '자동 모드는 ClawHub를 우선 요청하고 필요하면 대체 소스로 전환합니다.',
'skillCenter.marketplace.hint.safetyTitle': '안전',
'skillCenter.marketplace.hint.safetyBody': '안전, 서명됨, 공식 등의 신뢰 신호를 우선 표시합니다.',
'skillCenter.marketplace.hint.previewTitle': '미리보기',
'skillCenter.marketplace.hint.previewBody': '스킬을 열어 파일, 위험 라벨, 업스트림 링크를 확인합니다.',
'skillCenter.mine.libraryTitle': '설치된 스킬',
'skillCenter.mine.detailTitle': '설치된 스킬 세부 정보',
'skillCenter.mine.libraryMeta': '로컬 스킬 {count}개',
'skillCenter.mine.localDirectory': '~/.claude/skills',
'skillCenter.mine.sources': 'User, project, plugin, MCP',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '닫기',

View File

@ -2009,21 +2009,46 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.tab.mine': '我的',
'skillCenter.marketplace.loading': '正在載入技能市場...',
'skillCenter.marketplace.refresh': '重新整理',
'skillCenter.marketplace.loadMore': '載入更多',
'skillCenter.marketplace.loadingMore': '正在載入更多...',
'skillCenter.marketplace.sourcePolicy': 'ClawHub 優先,失敗切 SkillHub',
'skillCenter.marketplace.safetyPolicy': '安裝前做本機安全校驗',
'skillCenter.marketplace.marketTitle': '技能市場',
'skillCenter.marketplace.marketSummary': '把來源、熱度、安全狀態和本機安裝狀態放在同一個視圖裡,方便判斷是否值得安裝。',
'skillCenter.marketplace.searchLabel': '搜尋',
'skillCenter.marketplace.searchPlaceholder': '搜尋名稱、作者或用途...',
'skillCenter.marketplace.clearSearch': '清空搜尋',
'skillCenter.marketplace.runSearch': '執行搜尋',
'skillCenter.marketplace.sourceLabel': '來源',
'skillCenter.marketplace.sortLabel': '排序',
'skillCenter.marketplace.filterLabel': '篩選',
'skillCenter.marketplace.filter.all': '全部',
'skillCenter.marketplace.filter.safe': '安全',
'skillCenter.marketplace.filter.popular': '熱門',
'skillCenter.marketplace.filter.installed': '已安裝',
'skillCenter.marketplace.filter.apiKey': '需 Key',
'skillCenter.marketplace.total': '總數',
'skillCenter.marketplace.loadedCount': '已載入 {count} 個',
'skillCenter.marketplace.totalHint': '目前結果集',
'skillCenter.marketplace.safe': '可信',
'skillCenter.marketplace.safeHint': '安全或可信訊號',
'skillCenter.marketplace.installed': '已安裝',
'skillCenter.marketplace.installedHint': '已安裝 {count} 個',
'skillCenter.marketplace.empty': '沒有找到市場技能',
'skillCenter.marketplace.emptyHint': '可以重新整理、切換來源,或換一個更寬泛的關鍵詞。',
'skillCenter.marketplace.filterEmpty': '沒有符合這個篩選的技能',
'skillCenter.marketplace.filterEmptyHint': '清空篩選,或換一個安全/來源訊號。',
'skillCenter.marketplace.detailLoading': '正在載入技能詳情...',
'skillCenter.marketplace.noSelection': '選擇一個技能檢視安全與安裝狀態。',
'skillCenter.marketplace.selectionTitle': '安裝決策',
'skillCenter.marketplace.closeDetail': '關閉技能詳情',
'skillCenter.marketplace.source.auto': '自動',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sourceStatus.ok': '線上',
'skillCenter.marketplace.sourceStatus.fallback': '已切換備用源',
'skillCenter.marketplace.sourceStatus.cached': '快取',
'skillCenter.marketplace.sourceStatus.pending': '等待中',
'skillCenter.marketplace.sort.downloads': '下載量',
'skillCenter.marketplace.sort.installs': '安裝量',
'skillCenter.marketplace.sort.stars': '星標',
@ -2050,11 +2075,21 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.risk.external-network': '外部網路',
'skillCenter.marketplace.risk.requires-api-key': 'API Key',
'skillCenter.marketplace.entryPreview': '入口預覽',
'skillCenter.marketplace.filePreview': '檔案預覽',
'skillCenter.marketplace.previewTruncated': '已截斷',
'skillCenter.marketplace.previewUnavailable': '暫時無法預覽',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub 目前尚未提供安全的原始檔案預覽介面。',
'skillCenter.marketplace.previewUnavailable.fileList': '這個市場詳情沒有返回可用於預覽的檔案清單。',
'skillCenter.marketplace.previewUnavailable.metadata': '這個市場詳情缺少可安全預覽的套件中繼資料。',
'skillCenter.marketplace.previewUnavailable.noText': '沒有找到適合安全預覽的小型文字檔案。',
'skillCenter.marketplace.install': '安裝',
'skillCenter.marketplace.installing': '正在安裝...',
'skillCenter.marketplace.installedAction': '已安裝',
'skillCenter.marketplace.viewInstalled': '檢視已安裝',
'skillCenter.marketplace.conflictAction': '存在衝突',
'skillCenter.marketplace.blockedAction': '不可安裝',
'skillCenter.marketplace.conflictReason': '本機已存在衝突目錄:{path}',
'skillCenter.marketplace.blockedReason.scanRequired': '需要完整的套件安全掃描後才能安裝。這個技能目前只有列表級信任資訊,暫不允許直接安裝。',
'skillCenter.marketplace.openSource': '開啟來源',
'skillCenter.marketplace.openUpstream': '開啟上游',
'skillCenter.marketplace.confirmTitle': '確認安裝技能',
@ -2063,6 +2098,19 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.confirmTarget': '目標',
'skillCenter.marketplace.confirmInstall': '安裝技能',
'skillCenter.marketplace.noRiskLabels': '未回報風險訊號',
'skillCenter.marketplace.errorTitle': '市場暫時不可用',
'skillCenter.marketplace.networkError': '暫時無法連接技能市場。請檢查目前網路或系統代理後重新整理。',
'skillCenter.marketplace.hint.sourceTitle': '來源',
'skillCenter.marketplace.hint.sourceBody': '自動模式優先請求 ClawHub需要時再切換備用源。',
'skillCenter.marketplace.hint.safetyTitle': '安全',
'skillCenter.marketplace.hint.safetyBody': '優先展示安全、簽名、官方等可信訊號。',
'skillCenter.marketplace.hint.previewTitle': '預覽',
'skillCenter.marketplace.hint.previewBody': '開啟技能後可檢視檔案、風險標籤和上游連結。',
'skillCenter.mine.libraryTitle': '已安裝技能',
'skillCenter.mine.detailTitle': '已安裝技能詳情',
'skillCenter.mine.libraryMeta': '本機 {count} 個技能',
'skillCenter.mine.localDirectory': '~/.claude/skills',
'skillCenter.mine.sources': '使用者、專案、外掛、MCP',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '關閉',

View File

@ -2009,21 +2009,46 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.tab.mine': '我的',
'skillCenter.marketplace.loading': '正在加载技能市场...',
'skillCenter.marketplace.refresh': '刷新',
'skillCenter.marketplace.loadMore': '加载更多',
'skillCenter.marketplace.loadingMore': '正在加载更多...',
'skillCenter.marketplace.sourcePolicy': 'ClawHub 优先,失败切 SkillHub',
'skillCenter.marketplace.safetyPolicy': '安装前做本地安全校验',
'skillCenter.marketplace.marketTitle': '技能市场',
'skillCenter.marketplace.marketSummary': '把来源、热度、安全状态和本机安装状态放在同一个视图里,方便判断是否值得安装。',
'skillCenter.marketplace.searchLabel': '搜索',
'skillCenter.marketplace.searchPlaceholder': '搜索名称、作者或用途...',
'skillCenter.marketplace.clearSearch': '清空搜索',
'skillCenter.marketplace.runSearch': '执行搜索',
'skillCenter.marketplace.sourceLabel': '来源',
'skillCenter.marketplace.sortLabel': '排序',
'skillCenter.marketplace.filterLabel': '筛选',
'skillCenter.marketplace.filter.all': '全部',
'skillCenter.marketplace.filter.safe': '安全',
'skillCenter.marketplace.filter.popular': '热门',
'skillCenter.marketplace.filter.installed': '已安装',
'skillCenter.marketplace.filter.apiKey': '需 Key',
'skillCenter.marketplace.total': '总数',
'skillCenter.marketplace.loadedCount': '已加载 {count} 个',
'skillCenter.marketplace.totalHint': '当前结果集',
'skillCenter.marketplace.safe': '可信',
'skillCenter.marketplace.safeHint': '安全或可信信号',
'skillCenter.marketplace.installed': '已安装',
'skillCenter.marketplace.installedHint': '已安装 {count} 个',
'skillCenter.marketplace.empty': '没有找到市场技能',
'skillCenter.marketplace.emptyHint': '可以刷新、切换来源,或换一个更宽泛的关键词。',
'skillCenter.marketplace.filterEmpty': '没有匹配这个筛选的技能',
'skillCenter.marketplace.filterEmptyHint': '清空筛选,或换一个安全/来源信号。',
'skillCenter.marketplace.detailLoading': '正在加载技能详情...',
'skillCenter.marketplace.noSelection': '选择一个技能查看安全与安装状态。',
'skillCenter.marketplace.selectionTitle': '安装决策',
'skillCenter.marketplace.closeDetail': '关闭技能详情',
'skillCenter.marketplace.source.auto': '自动',
'skillCenter.marketplace.source.clawhub': 'ClawHub',
'skillCenter.marketplace.source.skillhub': 'SkillHub',
'skillCenter.marketplace.sourceStatus.ok': '在线',
'skillCenter.marketplace.sourceStatus.fallback': '已切换备用源',
'skillCenter.marketplace.sourceStatus.cached': '缓存',
'skillCenter.marketplace.sourceStatus.pending': '等待中',
'skillCenter.marketplace.sort.downloads': '下载量',
'skillCenter.marketplace.sort.installs': '安装量',
'skillCenter.marketplace.sort.stars': '星标',
@ -2050,11 +2075,21 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.risk.external-network': '外部网络',
'skillCenter.marketplace.risk.requires-api-key': 'API Key',
'skillCenter.marketplace.entryPreview': '入口预览',
'skillCenter.marketplace.filePreview': '文件预览',
'skillCenter.marketplace.previewTruncated': '已截断',
'skillCenter.marketplace.previewUnavailable': '暂时无法预览',
'skillCenter.marketplace.previewUnavailable.skillhub': 'SkillHub 目前还没有提供安全的原始文件预览接口。',
'skillCenter.marketplace.previewUnavailable.fileList': '这个市场详情没有返回可用于预览的文件清单。',
'skillCenter.marketplace.previewUnavailable.metadata': '这个市场详情缺少可安全预览的软件包元数据。',
'skillCenter.marketplace.previewUnavailable.noText': '没有找到适合安全预览的小型文本文件。',
'skillCenter.marketplace.install': '安装',
'skillCenter.marketplace.installing': '正在安装...',
'skillCenter.marketplace.installedAction': '已安装',
'skillCenter.marketplace.viewInstalled': '查看已安装',
'skillCenter.marketplace.conflictAction': '存在冲突',
'skillCenter.marketplace.blockedAction': '不可安装',
'skillCenter.marketplace.conflictReason': '本地已存在冲突目录:{path}',
'skillCenter.marketplace.blockedReason.scanRequired': '需要完整的软件包安全扫描后才能安装。这个技能目前只有列表级信任信息,暂不允许直接安装。',
'skillCenter.marketplace.openSource': '打开来源',
'skillCenter.marketplace.openUpstream': '打开上游',
'skillCenter.marketplace.confirmTitle': '确认安装技能',
@ -2063,6 +2098,19 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.confirmTarget': '目标',
'skillCenter.marketplace.confirmInstall': '安装技能',
'skillCenter.marketplace.noRiskLabels': '未报告风险信号',
'skillCenter.marketplace.errorTitle': '市场暂时不可用',
'skillCenter.marketplace.networkError': '暂时无法连接技能市场。请检查当前网络或系统代理后刷新。',
'skillCenter.marketplace.hint.sourceTitle': '来源',
'skillCenter.marketplace.hint.sourceBody': '自动模式优先请求 ClawHub需要时再切换备用源。',
'skillCenter.marketplace.hint.safetyTitle': '安全',
'skillCenter.marketplace.hint.safetyBody': '优先展示安全、签名、官方等可信信号。',
'skillCenter.marketplace.hint.previewTitle': '预览',
'skillCenter.marketplace.hint.previewBody': '打开技能后可查看文件、风险标签和上游链接。',
'skillCenter.mine.libraryTitle': '已安装技能',
'skillCenter.mine.detailTitle': '已安装技能详情',
'skillCenter.mine.libraryMeta': '本机 {count} 个技能',
'skillCenter.mine.localDirectory': '~/.claude/skills',
'skillCenter.mine.sources': '用户、项目、插件、MCP',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',

View File

@ -27,6 +27,27 @@ describe('desktop persistence migrations', () => {
expect(window.localStorage.getItem(DESKTOP_PERSISTENCE_VERSION_KEY)).toBe(String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
})
test('preserves persisted skill center tabs during startup migration', () => {
window.localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [
{ sessionId: '__skill_center__', title: 'Skills', type: 'skill-center' },
{ sessionId: '__traces__', title: 'Traces', type: 'traces' },
],
activeTabId: '__skill_center__',
}))
const report = runDesktopPersistenceMigrations()
expect(report.migratedKeys).toContain('cc-haha-open-tabs')
expect(JSON.parse(window.localStorage.getItem('cc-haha-open-tabs') || '{}')).toEqual({
openTabs: [
{ sessionId: '__skill_center__', title: 'Skills', type: 'skill-center' },
{ sessionId: '__traces__', title: 'Traces', type: 'traces' },
],
activeTabId: '__skill_center__',
})
})
test('filters stale session runtime selections without clearing unrelated keys', () => {
window.localStorage.setItem('unrelated-user-key', 'keep')
window.localStorage.setItem('cc-haha-session-runtime', JSON.stringify({
@ -65,6 +86,17 @@ describe('desktop persistence migrations', () => {
expect(window.localStorage.getItem('cc-haha-theme')).toBe('white')
})
test('preserves every supported locale during startup migration', () => {
for (const locale of ['en', 'zh', 'zh-TW', 'jp', 'kr']) {
window.localStorage.setItem('cc-haha-locale', locale)
const report = runDesktopPersistenceMigrations()
expect(report.migratedKeys).not.toContain('cc-haha-locale')
expect(window.localStorage.getItem('cc-haha-locale')).toBe(locale)
}
})
test('preserves valid app zoom and removes invalid app zoom values', () => {
window.localStorage.setItem('cc-haha-app-zoom', '1.2')

View File

@ -20,6 +20,8 @@ const SESSION_RUNTIME_STORAGE_KEY = 'cc-haha-session-runtime'
const THEME_STORAGE_KEY = 'cc-haha-theme'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
const EFFORT_LEVELS = ['low', 'medium', 'high', 'max']
const PERSISTED_SPECIAL_TAB_TYPES = ['settings', 'scheduled', 'skill-center', 'traces'] as const
const SUPPORTED_LOCALES = ['en', 'zh', 'zh-TW', 'jp', 'kr']
function readJson(storage: StorageLike, key: string): unknown {
const raw = storage.getItem(key)
@ -31,6 +33,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function isPersistedSpecialTabType(value: unknown): value is (typeof PERSISTED_SPECIAL_TAB_TYPES)[number] {
return typeof value === 'string' && (PERSISTED_SPECIAL_TAB_TYPES as readonly string[]).includes(value)
}
function writeJson(storage: StorageLike, key: string, value: unknown): void {
storage.setItem(key, JSON.stringify(value))
}
@ -53,7 +59,9 @@ function migrateTabs(storage: StorageLike, report: DesktopMigrationReport): void
.map((tab) => ({
sessionId: tab.sessionId as string,
title: tab.title as string,
type: tab.type === 'settings' || tab.type === 'scheduled' ? tab.type : 'session',
type: isPersistedSpecialTabType(tab.type)
? tab.type
: 'session',
}))
const activeTabId =
isRecord(parsed) &&
@ -174,7 +182,7 @@ export function runDesktopPersistenceMigrations(storage: StorageLike | null = ge
runMigrationStep(report, TAB_STORAGE_KEY, () => migrateTabs(storage, report))
runMigrationStep(report, SESSION_RUNTIME_STORAGE_KEY, () => migrateSessionRuntime(storage, report))
runMigrationStep(report, THEME_STORAGE_KEY, () => normalizeEnumKey(storage, THEME_STORAGE_KEY, [...THEME_MODES], report))
runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report))
runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, SUPPORTED_LOCALES, report))
runMigrationStep(report, APP_ZOOM_STORAGE_KEY, () => normalizeAppZoomKey(storage, report))
try {
storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))

View File

@ -367,7 +367,7 @@ export function ComputerUseSettings() {
<button
onClick={() => savePythonPath()}
disabled={pythonPathSaving || !pythonPathDirty}
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-brand)] px-3 py-2 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50"
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-brand)] px-3 py-2 text-xs font-semibold text-[var(--color-on-primary)] hover:opacity-90 disabled:opacity-50"
>
<span className="material-symbols-outlined text-[16px]">{pythonPathSaving ? 'hourglass_empty' : 'save'}</span>
{t('settings.computerUse.pythonPathSave')}
@ -463,7 +463,7 @@ export function ComputerUseSettings() {
{!status.python.installed && (
<button
onClick={() => openExternalUrl(pythonDownloadUrl)}
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--color-brand)] text-white text-sm font-semibold rounded-lg hover:opacity-90 transition-opacity"
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--color-brand)] text-[var(--color-on-primary)] text-sm font-semibold rounded-lg hover:opacity-90 transition-opacity"
>
<span className="material-symbols-outlined text-[18px]">open_in_new</span>
{t('settings.computerUse.downloadPython')}
@ -473,7 +473,7 @@ export function ComputerUseSettings() {
<button
onClick={handleSetup}
disabled={setupRunning}
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--color-brand)] text-white text-sm font-semibold rounded-lg hover:opacity-90 disabled:opacity-50 transition-opacity"
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--color-brand)] text-[var(--color-on-primary)] text-sm font-semibold rounded-lg hover:opacity-90 disabled:opacity-50 transition-opacity"
>
<span className="material-symbols-outlined text-[18px]">{setupRunning ? 'hourglass_empty' : 'download'}</span>
{setupRunning ? t('settings.computerUse.setupRunning') : t('settings.computerUse.setupBtn')}

View File

@ -99,7 +99,7 @@ export function ScheduledTasksEmpty() {
<p className="text-[var(--color-text-secondary)] font-[Inter,sans-serif] text-lg max-w-sm mx-auto leading-relaxed mb-12">
{t('tasks.emptyTitle')} {t('tasks.emptyDesc')}
</p>
<button className="group relative px-8 py-4 bg-[var(--color-brand)] text-white rounded-xl font-[Manrope,sans-serif] font-bold text-sm tracking-wide shadow-lg hover:shadow-[var(--color-brand)]/20 transition-all flex items-center gap-3 overflow-hidden">
<button className="group relative px-8 py-4 bg-[var(--color-brand)] text-[var(--color-on-primary)] rounded-xl font-[Manrope,sans-serif] font-bold text-sm tracking-wide shadow-lg hover:shadow-[var(--color-brand)]/20 transition-all flex items-center gap-3 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-white/10 to-transparent opacity-30"></div>
<span className="material-symbols-outlined text-lg">add_task</span>
<span>{t('tasks.newTask')}</span>

View File

@ -112,7 +112,7 @@ export function ScheduledTasksList() {
<h1 className="font-[Manrope,sans-serif] text-3xl font-bold tracking-tight text-[var(--color-text-primary)]">{t('scheduledPage.title')}</h1>
<p className="text-[var(--color-text-tertiary)] text-sm">{t('scheduledPage.subtitle')}</p>
</div>
<button className="bg-[var(--color-brand)] hover:bg-[var(--color-primary-container)] text-white px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all shadow-sm font-medium text-sm">
<button className="bg-[var(--color-brand)] hover:bg-[var(--color-primary-container)] text-[var(--color-on-primary)] px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all shadow-sm font-medium text-sm">
<span className="material-symbols-outlined text-[1.1rem]">add_task</span>
{t('tasks.createNew')}
</button>

View File

@ -2607,7 +2607,7 @@ export function GeneralSettings() {
onClick={() => setLocale(value)}
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
locale === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)] border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
@ -3051,7 +3051,7 @@ export function GeneralSettings() {
onClick={() => setWebSearchDraft({ ...webSearchDraft, mode: value })}
className={`h-9 px-2 text-xs font-semibold rounded-lg border transition-all truncate ${
(webSearchDraft.mode ?? 'auto') === value
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)] border-[var(--color-brand)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
title={label}
@ -3831,7 +3831,7 @@ function SettingsCheckboxMark({ checked, disabled = false }: { checked: boolean;
aria-hidden="true"
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-all peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--color-brand)]/40 ${
checked
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-white shadow-[var(--shadow-button-primary)]'
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-[var(--color-on-primary)] shadow-[var(--shadow-button-primary)]'
: 'border-[var(--color-border-focus)] bg-[var(--color-surface)] text-transparent'
} ${disabled ? 'opacity-50' : ''}`}
>
@ -4342,7 +4342,7 @@ function SkillSettings() {
<button
type="button"
onClick={() => useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')}
className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--color-brand)] px-4 text-sm font-semibold text-white transition-colors hover:bg-[var(--color-brand-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--color-brand)] px-4 text-sm font-semibold text-[var(--color-on-primary)] transition-colors hover:bg-[var(--color-brand-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[18px]">auto_awesome</span>
{t('settings.skills.openSkillCenter')}

View File

@ -56,6 +56,14 @@ function makeDetail(overrides: Partial<SkillMarketDetail> = {}): SkillMarketDeta
return {
...makeItem(),
files: [{ path: 'SKILL.md', size: 512 }],
filePreviews: [
{
path: 'SKILL.md',
content: '# PPT Generator',
language: 'markdown',
size: 512,
},
],
entryPreview: '# PPT Generator',
riskLabels: [],
installEligibility: { status: 'installable' },
@ -68,11 +76,16 @@ describe('SkillCenter', () => {
useSettingsStore.setState({ locale: 'en' })
useSkillMarketStore.setState({
items: [],
nextCursor: null,
selectedDetail: null,
source: 'auto',
resolvedSource: null,
sourceStatus: null,
statusMessage: null,
sort: 'downloads',
query: '',
isLoading: false,
isLoadingMore: false,
isDetailLoading: false,
isInstalling: false,
error: null,
@ -108,11 +121,16 @@ describe('SkillCenter', () => {
useSettingsStore.setState({ locale: 'en' })
useSkillMarketStore.setState({
items: [],
nextCursor: null,
selectedDetail: null,
source: 'auto',
resolvedSource: null,
sourceStatus: null,
statusMessage: null,
sort: 'downloads',
query: '',
isLoading: false,
isLoadingMore: false,
isDetailLoading: false,
isInstalling: false,
error: null,
@ -128,13 +146,15 @@ describe('SkillCenter', () => {
})
it('loads marketplace cards and opens a detail confirmation panel', async () => {
render(<SkillCenter />)
const { container } = render(<SkillCenter />)
expect(screen.getByRole('tab', { name: 'Marketplace' })).toHaveAttribute('aria-selected', 'true')
expect(await screen.findByRole('button', { name: 'PPT Generator' })).toBeInTheDocument()
expect(mockedSkillMarketApi.list).toHaveBeenCalledWith({
source: 'auto',
sort: 'downloads',
q: undefined,
limit: 100,
})
fireEvent.click(screen.getByRole('button', { name: 'PPT Generator' }))
@ -144,8 +164,15 @@ describe('SkillCenter', () => {
})
const detail = await screen.findByText('# PPT Generator')
expect(detail).toBeInTheDocument()
const dialog = screen.getByRole('dialog', { name: 'PPT Generator' })
const detailLayer = screen.getByTestId('skill-market-detail-layer')
expect(container.contains(dialog)).toBe(false)
expect(document.body.contains(dialog)).toBe(true)
expect(detailLayer).toHaveClass('fixed', 'inset-0', 'z-50')
expect(screen.getByRole('button', { name: 'Install' })).toBeEnabled()
expect(screen.getByText('Open upstream')).toHaveAttribute('href', 'https://github.com/example/ppt-generator')
expect(screen.getByText('File preview')).toBeInTheDocument()
expect(screen.getByText('SKILL.md')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
expect(mockedSkillMarketApi.install).not.toHaveBeenCalled()
@ -158,6 +185,120 @@ describe('SkillCenter', () => {
})
})
it('renders raw Markdown and Python previews for an uninstalled marketplace skill', async () => {
mockedSkillMarketApi.detail.mockResolvedValue({
detail: makeDetail({
files: [
{ path: 'SKILL.md', size: 256, contentType: 'text/markdown' },
{ path: 'scripts/audit.py', size: 42, contentType: 'text/x-python' },
],
filePreviews: [
{
path: 'SKILL.md',
content: '# Audit Skill\n\nUse this before install.',
language: 'markdown',
size: 256,
},
{
path: 'scripts/audit.py',
content: 'print("audit")\n',
language: 'python',
size: 42,
},
],
entryPreview: '# Audit Skill',
riskLabels: ['scripts', 'executables'],
}),
})
render(<SkillCenter />)
fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' }))
expect(await screen.findByText('File preview')).toBeInTheDocument()
expect(screen.getByText('scripts/audit.py')).toBeInTheDocument()
expect(screen.getByText('python')).toBeInTheDocument()
expect(screen.getByText(/print\("audit"\)/)).toBeInTheDocument()
expect(screen.getByText('Scripts')).toBeInTheDocument()
expect(screen.getByText('Executables')).toBeInTheDocument()
})
it('explains when marketplace file preview is unavailable', async () => {
mockedSkillMarketApi.detail.mockResolvedValue({
detail: makeDetail({
source: 'skillhub',
sourceMode: 'fallback',
filePreviews: [],
entryPreview: undefined,
previewUnavailableReason: 'SkillHub does not expose a safe raw file preview endpoint yet.',
}),
})
render(<SkillCenter />)
fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' }))
expect(await screen.findByText('Preview unavailable')).toBeInTheDocument()
expect(screen.getByText('SkillHub does not expose a safe raw file preview endpoint yet.')).toBeInTheDocument()
})
it('shows marketplace source fallback status without losing the selected source control', async () => {
mockedSkillMarketApi.list.mockResolvedValue({
items: [makeItem({ source: 'skillhub', sourceMode: 'fallback' })],
nextCursor: null,
source: 'skillhub',
sourceStatus: 'fallback',
message: 'ClawHub unavailable, using SkillHub.',
})
render(<SkillCenter />)
expect(await screen.findByText('ClawHub unavailable, using SkillHub.')).toBeInTheDocument()
expect(screen.getByText('Fallback active')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Auto' })).toHaveAttribute('aria-pressed', 'true')
})
it('replaces raw network failures with a user-facing marketplace error', async () => {
mockedSkillMarketApi.list.mockRejectedValue(new Error('Failed to fetch'))
render(<SkillCenter />)
expect(await screen.findByText('Marketplace unavailable')).toBeInTheDocument()
expect(screen.getByText(/could not be reached/)).toBeInTheDocument()
expect(screen.queryByText('Failed to fetch')).not.toBeInTheDocument()
})
it('opens the installed skill detail from an installed marketplace item', async () => {
const fetchSkillDetail = vi.fn()
useSkillStore.setState({
skills: [
{
name: 'ppt-generator',
displayName: 'PPT Generator',
description: 'Installed local skill',
source: 'user',
userInvocable: true,
contentLength: 80,
hasDirectory: true,
},
],
fetchSkillDetail,
})
mockedSkillMarketApi.detail.mockResolvedValue({
detail: makeDetail({
installed: true,
installEligibility: { status: 'installed', installedSkillName: 'ppt-generator' },
}),
})
render(<SkillCenter />)
fireEvent.click(await screen.findByRole('button', { name: 'PPT Generator' }))
const viewInstalledButton = await screen.findByRole('button', { name: 'View installed' })
expect(viewInstalledButton).toHaveClass('bg-[var(--color-success-container)]')
fireEvent.click(viewInstalledButton)
expect(screen.getByRole('tab', { name: 'Mine' })).toHaveAttribute('aria-selected', 'true')
expect(fetchSkillDetail).toHaveBeenCalledWith('user', 'ppt-generator', undefined, 'skills')
})
it('submits market searches without auto-searching every keystroke', async () => {
render(<SkillCenter />)
await screen.findByRole('button', { name: 'PPT Generator' })
@ -175,6 +316,7 @@ describe('SkillCenter', () => {
source: 'auto',
sort: 'downloads',
q: 'ppt',
limit: 100,
})
})
})
@ -204,7 +346,7 @@ describe('SkillCenter', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Mine' }))
expect(screen.getByTestId('installed-skill-list')).toBeInTheDocument()
expect(within(screen.getByTestId('skill-mine-tab')).getByText('Installed skills')).toBeInTheDocument()
expect(within(screen.getByTestId('skill-mine-tab')).getByTestId('installed-skill-list')).toBeInTheDocument()
})
it('opens the mine tab when an installed skill detail is already selected', async () => {

File diff suppressed because it is too large Load Diff

View File

@ -42,11 +42,16 @@ describe('skillMarketStore', () => {
vi.clearAllMocks()
useSkillMarketStore.setState({
items: [],
nextCursor: null,
selectedDetail: null,
source: 'auto',
resolvedSource: null,
sourceStatus: null,
statusMessage: null,
sort: 'downloads',
query: '',
isLoading: false,
isLoadingMore: false,
isDetailLoading: false,
isInstalling: false,
error: null,
@ -73,12 +78,68 @@ describe('skillMarketStore', () => {
source: 'skillhub',
sort: 'trending',
q: 'vetter',
limit: 100,
})
expect(useSkillMarketStore.getState().items).toEqual([item])
expect(useSkillMarketStore.getState().nextCursor).toBeNull()
expect(useSkillMarketStore.getState().resolvedSource).toBe('clawhub')
expect(useSkillMarketStore.getState().sourceStatus).toBe('ok')
expect(useSkillMarketStore.getState().statusMessage).toBeNull()
expect(useSkillMarketStore.getState().isLoading).toBe(false)
expect(useSkillMarketStore.getState().error).toBeNull()
})
it('keeps the resolved fallback source status from the marketplace API', async () => {
const item = makeItem({ source: 'skillhub', sourceMode: 'fallback' })
mockedSkillMarketApi.list.mockResolvedValue({
items: [item],
nextCursor: null,
source: 'skillhub',
sourceStatus: 'fallback',
message: 'ClawHub unavailable, using SkillHub.',
})
await useSkillMarketStore.getState().fetchItems()
expect(useSkillMarketStore.getState().items).toEqual([item])
expect(useSkillMarketStore.getState().resolvedSource).toBe('skillhub')
expect(useSkillMarketStore.getState().sourceStatus).toBe('fallback')
expect(useSkillMarketStore.getState().statusMessage).toBe('ClawHub unavailable, using SkillHub.')
})
it('loads additional marketplace pages without duplicating existing skills', async () => {
const first = makeItem({ slug: 'skill-vetter', displayName: 'Skill Vetter' })
const duplicate = makeItem({ slug: 'skill-vetter', displayName: 'Skill Vetter Updated' })
const second = makeItem({ slug: 'ppt-generator', displayName: 'PPT Generator' })
mockedSkillMarketApi.list
.mockResolvedValueOnce({
items: [first],
nextCursor: 'page-2',
source: 'clawhub',
sourceStatus: 'ok',
})
.mockResolvedValueOnce({
items: [duplicate, second],
nextCursor: null,
source: 'clawhub',
sourceStatus: 'ok',
})
await useSkillMarketStore.getState().fetchItems()
await useSkillMarketStore.getState().fetchMore()
expect(mockedSkillMarketApi.list).toHaveBeenLastCalledWith({
source: 'auto',
sort: 'downloads',
q: undefined,
cursor: 'page-2',
limit: 100,
})
expect(useSkillMarketStore.getState().items).toEqual([first, second])
expect(useSkillMarketStore.getState().nextCursor).toBeNull()
expect(useSkillMarketStore.getState().isLoadingMore).toBe(false)
})
it('sets an error when loading marketplace items fails', async () => {
mockedSkillMarketApi.list.mockRejectedValue(new Error('market unavailable'))
@ -169,6 +230,7 @@ describe('skillMarketStore', () => {
source: 'auto',
sort: 'downloads',
q: undefined,
limit: 100,
})
expect(mockedSkillMarketApi.detail).toHaveBeenCalledWith('clawhub', 'skill-vetter')
expect(useSkillMarketStore.getState().selectedDetail).toEqual(refreshed)

View File

@ -3,6 +3,7 @@ import { skillMarketApi } from '../api/skillMarket'
import type {
SkillMarketDetail,
SkillMarketItem,
SkillMarketListResponse,
SkillMarketListSource,
SkillMarketSort,
SkillMarketSource,
@ -10,11 +11,16 @@ import type {
type SkillMarketStore = {
items: SkillMarketItem[]
nextCursor: string | null
selectedDetail: SkillMarketDetail | null
source: SkillMarketListSource
resolvedSource: SkillMarketSource | null
sourceStatus: SkillMarketListResponse['sourceStatus'] | null
statusMessage: string | null
sort: SkillMarketSort
query: string
isLoading: boolean
isLoadingMore: boolean
isDetailLoading: boolean
isInstalling: boolean
error: string | null
@ -22,48 +28,93 @@ type SkillMarketStore = {
setSort: (sort: SkillMarketSort) => void
setQuery: (query: string) => void
fetchItems: () => Promise<void>
fetchMore: () => Promise<void>
fetchDetail: (source: SkillMarketSource, slug: string) => Promise<void>
installSelected: () => Promise<void>
clearDetail: () => void
}
let detailRequestSeq = 0
const MARKET_PAGE_LIMIT = 100
export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
items: [],
nextCursor: null,
selectedDetail: null,
source: 'auto',
resolvedSource: null,
sourceStatus: null,
statusMessage: null,
sort: 'downloads',
query: '',
isLoading: false,
isLoadingMore: false,
isDetailLoading: false,
isInstalling: false,
error: null,
setSource: (source) => {
detailRequestSeq += 1
set({ source, selectedDetail: null, isDetailLoading: false })
set({ source, selectedDetail: null, nextCursor: null, isDetailLoading: false, statusMessage: null })
},
setSort: (sort) => {
detailRequestSeq += 1
set({ sort, selectedDetail: null, isDetailLoading: false })
set({ sort, selectedDetail: null, nextCursor: null, isDetailLoading: false, statusMessage: null })
},
setQuery: (query) => set({ query }),
fetchItems: async () => {
const { source, sort, query } = get()
detailRequestSeq += 1
set({ isLoading: true, isDetailLoading: false, selectedDetail: null, error: null })
set({ isLoading: true, isLoadingMore: false, isDetailLoading: false, selectedDetail: null, error: null })
try {
const result = await skillMarketApi.list({
source,
sort,
q: query.trim() || undefined,
limit: MARKET_PAGE_LIMIT,
})
set({
items: result.items,
nextCursor: result.nextCursor,
resolvedSource: result.source,
sourceStatus: result.sourceStatus,
statusMessage: result.message ?? null,
isLoading: false,
})
set({ items: result.items, isLoading: false })
} catch (err) {
set({
isLoading: false,
nextCursor: null,
error: getErrorMessage(err),
})
}
},
fetchMore: async () => {
const { source, sort, query, nextCursor, isLoading, isLoadingMore } = get()
if (!nextCursor || isLoading || isLoadingMore) return
set({ isLoadingMore: true, error: null })
try {
const result = await skillMarketApi.list({
source,
sort,
q: query.trim() || undefined,
cursor: nextCursor,
limit: MARKET_PAGE_LIMIT,
})
set((state) => ({
items: mergeMarketItems(state.items, result.items),
nextCursor: result.nextCursor,
resolvedSource: result.source,
sourceStatus: result.sourceStatus,
statusMessage: result.message ?? state.statusMessage,
isLoadingMore: false,
}))
} catch (err) {
set({
isLoadingMore: false,
error: getErrorMessage(err),
})
}
@ -110,6 +161,18 @@ export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
},
}))
function mergeMarketItems(current: SkillMarketItem[], incoming: SkillMarketItem[]): SkillMarketItem[] {
const seen = new Set(current.map((item) => `${item.source}:${item.slug}`))
const merged = [...current]
for (const item of incoming) {
const key = `${item.source}:${item.slug}`
if (seen.has(key)) continue
seen.add(key)
merged.push(item)
}
return merged
}
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}

View File

@ -402,7 +402,7 @@
--color-inverse-on-surface: #F2F1ED;
--color-inverse-primary: #FFB59D;
--color-success: #16A34A;
--color-success: #087E4F;
--color-success-container: #E8F5E2;
--color-info: #2D628F;
--color-info-container: #E4F1FF;
@ -507,6 +507,7 @@
/* Brand & accents */
--color-brand: var(--color-primary);
--color-brand-hover: var(--color-primary-container);
--color-text-accent: var(--color-secondary);
/* Shadows */
@ -526,6 +527,7 @@
/* Glass / overlays */
--color-overlay-scrim: rgba(27, 28, 26, 0.48);
--color-modal-scrim: rgba(18, 18, 18, 0.56);
--color-surface-glass: rgba(255, 255, 255, 0.84);
--color-surface-glass-border: rgba(218, 193, 186, 0.22);
@ -706,6 +708,7 @@
--color-surface-info: #F6F8FA;
--color-brand: var(--color-primary);
--color-brand-hover: var(--color-primary-container);
--color-text-accent: var(--color-secondary);
--shadow-dropdown: 0 8px 24px rgba(15, 23, 42, 0.06), 0 24px 60px rgba(15, 23, 42, 0.08);
@ -721,6 +724,7 @@
--color-surface-user-msg: #F1F4F7;
--color-overlay-scrim: rgba(15, 23, 42, 0.42);
--color-modal-scrim: rgba(18, 18, 18, 0.54);
--color-surface-glass: rgba(255, 255, 255, 0.94);
--color-surface-glass-border: rgba(203, 213, 225, 0.72);
@ -893,6 +897,7 @@
--color-surface-info: rgba(32, 31, 31, 0.88);
--color-brand: var(--color-primary);
--color-brand-hover: var(--color-primary-container);
--color-text-accent: var(--color-tertiary);
--shadow-dropdown: 0 16px 40px rgba(0, 0, 0, 0.42);
@ -907,6 +912,7 @@
--color-surface-user-msg: rgba(32, 31, 31, 0.92);
--color-overlay-scrim: rgba(6, 6, 6, 0.56);
--color-modal-scrim: rgba(6, 6, 6, 0.68);
--color-surface-glass: rgba(32, 31, 31, 0.8);
--color-surface-glass-border: rgba(90, 65, 56, 0.18);
@ -1070,6 +1076,12 @@ button, input, textarea, select, a, [role="button"] {
background: var(--color-overlay-scrim);
}
.skill-market-detail-scrim {
background: var(--color-modal-scrim);
backdrop-filter: blur(1.5px);
-webkit-backdrop-filter: blur(1.5px);
}
.sidebar-shell {
width: var(--sidebar-width);
flex-shrink: 0;

View File

@ -47,6 +47,15 @@ export type SkillMarketFile = {
path: string
size?: number
sha256?: string
contentType?: string | null
}
export type SkillMarketFilePreview = {
path: string
content: string
language?: string
size?: number
truncated?: boolean
}
export type SkillMarketInstallEligibility =
@ -57,7 +66,9 @@ export type SkillMarketInstallEligibility =
export type SkillMarketDetail = SkillMarketItem & {
files: SkillMarketFile[]
filePreviews?: SkillMarketFilePreview[]
entryPreview?: string
previewUnavailableReason?: string
riskLabels: SkillMarketRiskLabel[]
installEligibility: SkillMarketInstallEligibility
}

View File

@ -24,6 +24,74 @@ export const CLAWHUB_SCAN_RESPONSE = {
sha256: 'a'.repeat(64),
}
export const CLAWHUB_DETAIL_RESPONSE = {
skill: {
slug: 'skill-vetter',
displayName: 'Skill Vetter',
summary: 'Security-first skill vetting for AI agents.',
description: '---\nname: skill-vetter\nversion: 1.0.0\n---\n# Skill Vetter',
topics: ['GitHub', 'Permission'],
tags: { latest: '1.0.0' },
stats: { downloads: 260960, installs: 11990, stars: 1248 },
},
latestVersion: { version: '1.0.0', license: 'Apache-2.0' },
owner: { handle: 'spclaudehome', displayName: 'spclaudehome' },
}
export const CLAWHUB_NESTED_SCAN_RESPONSE = {
skill: {
slug: 'skill-vetter',
displayName: 'Skill Vetter',
},
version: {
version: '1.0.0',
},
security: {
status: 'clean',
hasWarnings: true,
hasScanResult: true,
sha256hash: 'b'.repeat(64),
scanners: {
skillspector: { status: 'clean', normalizedStatus: 'clean' },
llm: {
status: 'clean',
normalizedStatus: 'clean',
summary: 'This is a non-executable checklist for reviewing other skills.',
},
},
},
}
export const CLAWHUB_VERSION_RESPONSE = {
skill: {
slug: 'skill-vetter',
displayName: 'Skill Vetter',
},
version: {
version: '1.0.0',
files: [
{
path: 'SKILL.md',
size: 4561,
sha256: 'e8eb7583355c2ae78a79187dca6a1ec448d9c8360e91652871392179f7ffb8bf',
contentType: 'text/markdown',
},
{
path: 'scripts/audit.py',
size: 320,
sha256: 'd'.repeat(64),
contentType: 'text/x-python',
},
{
path: 'assets/logo.png',
size: 1024,
sha256: 'e'.repeat(64),
contentType: 'image/png',
},
],
},
}
export const SKILLHUB_TOP_SKILLS_RESPONSE = {
code: 0,
data: {

View File

@ -8,14 +8,22 @@ import {
resetSkillMarketServiceFactoryForTests,
setSkillMarketServiceFactoryForTests,
} from '../api/skill-market.js'
import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js'
import {
normalizeClawHubDetail,
normalizeClawHubList,
normalizeClawHubScan,
normalizeClawHubVersionFiles,
} from '../services/skillMarket/clawhubAdapter.js'
import { analyzeSkillRisk } from '../services/skillMarket/risk.js'
import { createSkillMarketService } from '../services/skillMarket/service.js'
import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js'
import type { SkillMarketDetail } from '../services/skillMarket/types.js'
import {
CLAWHUB_SCAN_RESPONSE,
CLAWHUB_DETAIL_RESPONSE,
CLAWHUB_NESTED_SCAN_RESPONSE,
CLAWHUB_TOP_SKILLS_RESPONSE,
CLAWHUB_VERSION_RESPONSE,
SKILLHUB_DETAIL_RESPONSE,
SKILLHUB_TOP_SKILLS_RESPONSE,
} from './fixtures/skill-market.js'
@ -65,6 +73,59 @@ describe('skill market source normalization', () => {
})
})
it('normalizes real nested ClawHub scan responses as clean when scanner statuses are clean', () => {
expect(normalizeClawHubScan(CLAWHUB_NESTED_SCAN_RESPONSE)).toEqual({
trustState: 'clean',
trustSummary: 'This is a non-executable checklist for reviewing other skills.',
packageSha256: 'b'.repeat(64),
})
})
it('normalizes ClawHub detail with scan proof into an installable detail', () => {
const detail = normalizeClawHubDetail(CLAWHUB_DETAIL_RESPONSE, CLAWHUB_NESTED_SCAN_RESPONSE)
expect(detail).toMatchObject({
source: 'clawhub',
sourceMode: 'primary',
slug: 'skill-vetter',
displayName: 'Skill Vetter',
owner: 'spclaudehome',
canonicalUrl: 'https://clawhub.ai/spclaudehome/skill-vetter',
version: '1.0.0',
trustState: 'clean',
trustSummary: 'This is a non-executable checklist for reviewing other skills.',
files: [{ path: 'SKILL.md' }],
entryPreview: expect.stringContaining('# Skill Vetter'),
riskLabels: [],
installEligibility: { status: 'installable' },
})
})
it('normalizes ClawHub version file manifests for detail previews', () => {
const files = normalizeClawHubVersionFiles(CLAWHUB_VERSION_RESPONSE)
expect(files).toEqual([
{
path: 'SKILL.md',
size: 4561,
sha256: 'e8eb7583355c2ae78a79187dca6a1ec448d9c8360e91652871392179f7ffb8bf',
contentType: 'text/markdown',
},
{
path: 'scripts/audit.py',
size: 320,
sha256: 'd'.repeat(64),
contentType: 'text/x-python',
},
{
path: 'assets/logo.png',
size: 1024,
sha256: 'e'.repeat(64),
contentType: 'image/png',
},
])
})
it('keeps malicious ClawHub scan responses blocked even with warnings', () => {
expect(normalizeClawHubScan({ status: 'malicious', hasWarnings: true })).toMatchObject({
trustState: 'blocked',
@ -434,6 +495,21 @@ describe('skill market service source selection', () => {
})
})
it('requests a full first marketplace page by default', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE)
},
})
await service.listSkills({ source: 'clawhub' })
expect(fetchCalls).toHaveLength(1)
expect(fetchCalls[0]).toContain('limit=100')
})
it('falls back to SkillHub in auto mode when ClawHub fails and marks installed skills', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
@ -675,28 +751,53 @@ describe('skill market service source selection', () => {
expect(fetchCalls).toHaveLength(0)
})
it('builds detail from the explicit ClawHub list and marks installed eligibility', async () => {
it('builds detail from ClawHub detail and scan endpoints and marks installed eligibility', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async (url) => {
fetchCalls.push(String(url))
return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE)
const urlString = String(url)
fetchCalls.push(urlString)
if (urlString.endsWith('/scan')) {
return Response.json(CLAWHUB_NESTED_SCAN_RESPONSE)
}
if (urlString.endsWith('/versions/1.0.0')) {
return Response.json(CLAWHUB_VERSION_RESPONSE)
}
if (urlString.includes('/file?path=SKILL.md')) {
return new Response('# Skill Vetter\n\nallowed-tools: Bash\n')
}
if (urlString.includes('/file?path=scripts%2Faudit.py') || urlString.includes('/file?path=scripts/audit.py')) {
return new Response('print("audit")\n')
}
return Response.json(CLAWHUB_DETAIL_RESPONSE)
},
installedSkillNames: new Set(['skill-vetter']),
})
const detail = await service.getDetail({ source: 'clawhub', slug: 'skill-vetter' })
expect(fetchCalls).toHaveLength(1)
expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills')
expect(fetchCalls[0]).toContain('query=skill-vetter')
expect(fetchCalls[0]).toContain('limit=100')
expect(fetchCalls).toEqual([
'https://clawhub.ai/api/v1/skills/skill-vetter',
'https://clawhub.ai/api/v1/skills/skill-vetter/scan',
'https://clawhub.ai/api/v1/skills/skill-vetter/versions/1.0.0',
'https://clawhub.ai/api/v1/skills/skill-vetter/file?path=SKILL.md&version=1.0.0',
'https://clawhub.ai/api/v1/skills/skill-vetter/file?path=scripts%2Faudit.py&version=1.0.0',
])
expect(detail).toMatchObject({
source: 'clawhub',
slug: 'skill-vetter',
installed: true,
files: [],
riskLabels: [],
files: [
{ path: 'SKILL.md', contentType: 'text/markdown' },
{ path: 'scripts/audit.py', contentType: 'text/x-python' },
{ path: 'assets/logo.png', contentType: 'image/png' },
],
filePreviews: [
{ path: 'SKILL.md', language: 'markdown', content: expect.stringContaining('# Skill Vetter') },
{ path: 'scripts/audit.py', language: 'python', content: expect.stringContaining('print("audit")') },
],
entryPreview: expect.stringContaining('# Skill Vetter'),
riskLabels: ['allowed-tools', 'scripts', 'executables'],
installEligibility: {
status: 'installed',
installedSkillName: 'skill-vetter',
@ -705,13 +806,22 @@ describe('skill market service source selection', () => {
})
it('blocks uninstalled detail installs when only list-level ClawHub metadata is available', async () => {
const fetchCalls: string[] = []
const service = createSkillMarketService({
fetchImpl: async () => Response.json(CLAWHUB_TOP_SKILLS_RESPONSE),
fetchImpl: async (url) => {
fetchCalls.push(String(url))
if (String(url) === 'https://clawhub.ai/api/v1/skills/skill-vetter') {
return new Response('not found', { status: 404 })
}
return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE)
},
installedSkillNames: new Set(),
})
const detail = await service.getDetail({ source: 'clawhub', slug: 'skill-vetter' })
expect(fetchCalls[0]).toBe('https://clawhub.ai/api/v1/skills/skill-vetter')
expect(fetchCalls[1]).toContain('https://clawhub.ai/api/v1/skills?')
expect(detail).toMatchObject({
source: 'clawhub',
slug: 'skill-vetter',
@ -971,11 +1081,12 @@ describe('skill market API', () => {
})
})
it('does not install from canonical or upstream URLs when package metadata is missing', async () => {
it('does not install SkillHub details from canonical or upstream URLs when package metadata is missing', async () => {
const fetchCalls: string[] = []
setInstallDetail(makeApiDetail({
canonicalUrl: 'https://clawhub.ai/packages/skill-vetter.zip',
upstreamUrl: 'https://clawhub.ai/upstream/skill-vetter.zip',
source: 'skillhub',
canonicalUrl: 'https://skillhub.cn/packages/skill-vetter.zip',
upstreamUrl: 'https://skillhub.cn/upstream/skill-vetter.zip',
}))
globalThis.fetch = async (input) => {
fetchCalls.push(String(input))
@ -984,7 +1095,7 @@ describe('skill market API', () => {
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'clawhub', slug: 'skill-vetter' }),
body: JSON.stringify({ source: 'skillhub', slug: 'skill-vetter' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
@ -994,7 +1105,7 @@ describe('skill market API', () => {
await expect(res.json()).resolves.toMatchObject({ error: 'install_not_available' })
})
it('installs an installable marketplace package into the user skill directory', async () => {
it('installs an installable ClawHub marketplace package from the official download endpoint', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-market-api-install-'))
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const packageZip = Buffer.from(zipSync({
@ -1006,12 +1117,14 @@ describe('skill market API', () => {
process.env.CLAUDE_CONFIG_DIR = path.join(tmpDir, '.claude')
setInstallDetail(makeApiDetail({
version: '1.0.0',
downloadUrl: 'https://clawhub.ai/packages/skill-vetter.zip',
}))
globalThis.fetch = async (input) => {
globalThis.fetch = async (input, init) => {
fetchCalls.push(String(input))
expect(init?.headers).toEqual({
accept: 'application/zip, application/json;q=0.9, */*;q=0.1',
})
return new Response(packageZip, {
headers: { 'content-length': String(packageZip.byteLength) },
headers: { 'content-length': String(packageZip.byteLength), 'content-type': 'application/zip' },
})
}
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
@ -1023,7 +1136,7 @@ describe('skill market API', () => {
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(200)
expect(fetchCalls).toEqual(['https://clawhub.ai/packages/skill-vetter.zip'])
expect(fetchCalls).toEqual(['https://clawhub.ai/api/v1/download?slug=skill-vetter&version=1.0.0'])
await expect(res.json()).resolves.toEqual({
installed: true,
skillName: 'skill-vetter',
@ -1042,6 +1155,29 @@ describe('skill market API', () => {
}
})
it('rejects ClawHub public GitHub handoff responses instead of installing non-zip bytes', async () => {
setInstallDetail(makeApiDetail({
version: '1.0.0',
}))
globalThis.fetch = async () => Response.json({
kind: 'public-github',
repo: 'example/skill-vetter',
})
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'clawhub', slug: 'skill-vetter', version: '1.0.0' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(502)
await expect(res.json()).resolves.toMatchObject({
error: 'install_download_failed',
message: expect.stringContaining('GitHub handoff'),
})
})
it('rejects invalid install JSON', async () => {
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {

View File

@ -213,7 +213,7 @@ async function handleInstall(req: Request): Promise<Response> {
})
}
const packageUrl = resolvePackageDownloadUrl(detail)
const packageUrl = resolvePackageDownloadUrl(detail, installRequest.version)
if (!packageUrl) {
return jsonError(
'install_not_available',
@ -272,7 +272,7 @@ function parseInstallRequest(body: Record<string, unknown>): SkillMarketInstallR
return { source, slug, version: version.trim() }
}
function resolvePackageDownloadUrl(detail: SkillMarketDetail): URL | null {
function resolvePackageDownloadUrl(detail: SkillMarketDetail, requestedVersion?: string): URL | null {
const record = detail as SkillMarketDetail & Record<string, unknown>
const sourceHosts = allowedPackageHosts(detail.source)
@ -283,9 +283,22 @@ function resolvePackageDownloadUrl(detail: SkillMarketDetail): URL | null {
}
}
if (detail.source === 'clawhub') {
return clawHubDownloadUrl(detail.slug, requestedVersion ?? detail.version)
}
return null
}
function clawHubDownloadUrl(slug: string, version?: string): URL {
const url = new URL('https://clawhub.ai/api/v1/download')
url.searchParams.set('slug', slug)
if (version) {
url.searchParams.set('version', version)
}
return url
}
function parseSafePackageUrl(value: unknown, allowedHosts: string[]): URL | null {
if (typeof value !== 'string' || !value.trim()) {
return null
@ -320,7 +333,11 @@ function isAllowedHost(hostname: string, allowedHosts: string[]): boolean {
}
async function downloadPackageZip(url: URL): Promise<Buffer> {
const res = await fetch(url)
const res = await fetch(url, {
headers: {
accept: 'application/zip, application/json;q=0.9, */*;q=0.1',
},
})
if (!res.ok) {
throw new Error(`Package download failed with HTTP ${res.status}`)
}
@ -337,9 +354,40 @@ async function downloadPackageZip(url: URL): Promise<Buffer> {
if (buffer.byteLength > MAX_PACKAGE_BYTES) {
throw new Error('Package download is too large')
}
if (!isLikelyZip(buffer)) {
if (isJsonResponse(res)) {
throw new Error(nonZipJsonDownloadMessage(buffer))
}
throw new Error('Package download did not return a ZIP archive')
}
return buffer
}
function isLikelyZip(buffer: Buffer): boolean {
return buffer.length >= 4
&& buffer[0] === 0x50
&& buffer[1] === 0x4b
&& (buffer[2] === 0x03 || buffer[2] === 0x05 || buffer[2] === 0x07)
&& (buffer[3] === 0x04 || buffer[3] === 0x06 || buffer[3] === 0x08)
}
function isJsonResponse(res: Response): boolean {
return (res.headers.get('content-type') ?? '').toLowerCase().includes('application/json')
}
function nonZipJsonDownloadMessage(buffer: Buffer): string {
try {
const payload = JSON.parse(buffer.toString('utf-8')) as { kind?: unknown; type?: unknown; source?: unknown }
const handoffKind = typeof payload.kind === 'string' ? payload.kind : payload.type
if (handoffKind === 'public-github' || payload.source === 'github') {
return 'ClawHub returned a public GitHub handoff instead of ZIP bytes; direct local install is not supported yet.'
}
} catch {
// Fall through to the generic message below.
}
return 'Package download did not return a ZIP archive'
}
function isAlreadyExistsError(error: unknown): boolean {
return errorMessage(error).includes('already exists')
}

View File

@ -1,4 +1,4 @@
import type { SkillMarketItem, SkillMarketListResult, SkillMarketTrustState } from './types.js'
import type { SkillMarketDetail, SkillMarketFile, SkillMarketItem, SkillMarketListResult, SkillMarketTrustState } from './types.js'
type ClawHubListResponse = {
items?: Array<{
@ -14,13 +14,55 @@ type ClawHubListResponse = {
nextCursor?: string | null
}
type ClawHubDetailResponse = {
skill?: {
slug?: string
displayName?: string
summary?: string
description?: string | null
topics?: string[]
tags?: { latest?: string } | null
stats?: { downloads?: number; installs?: number; stars?: number }
}
latestVersion?: { version?: string; license?: string | null } | null
owner?: { handle?: string; displayName?: string }
}
type ClawHubScanResponse = {
status?: string
hasWarnings?: boolean
sha256?: string
scanners?: Record<string, { status?: string; summary?: string }>
sha256hash?: string
hasScanResult?: boolean
scanners?: Record<string, { status?: string; normalizedStatus?: string; summary?: string; analysis?: string }>
security?: {
status?: string
hasWarnings?: boolean
sha256?: string
sha256hash?: string
hasScanResult?: boolean
scanners?: Record<string, { status?: string; normalizedStatus?: string; summary?: string; analysis?: string }>
}
}
type ClawHubVersionResponse = {
version?: {
files?: Array<{
path?: string
size?: number
sha256?: string
contentType?: string | null
}>
} | null
}
const CLAWHUB_DETAIL_INSTALLABLE_TRUST_STATES = new Set<SkillMarketTrustState>([
'clean',
'benign',
'signed',
'official',
])
export function normalizeClawHubList(payload: ClawHubListResponse): SkillMarketListResult {
const items = (payload.items ?? [])
.filter((item) => item.slug && item.displayName)
@ -51,45 +93,127 @@ export function normalizeClawHubList(payload: ClawHubListResponse): SkillMarketL
}
}
export function normalizeClawHubDetail(
payload: ClawHubDetailResponse,
scanPayload: ClawHubScanResponse,
options: { installed?: boolean } = {},
): SkillMarketDetail | null {
const skill = payload.skill ?? {}
const slug = skill.slug
if (!slug) {
return null
}
const trust = normalizeClawHubScan(scanPayload)
const installed = options.installed ?? false
const entryPreview = skill.description ?? undefined
return {
source: 'clawhub',
sourceMode: 'primary',
slug,
displayName: skill.displayName || slug,
summary: skill.summary || skill.description || '',
owner: payload.owner?.handle || payload.owner?.displayName,
canonicalUrl: clawHubCanonicalUrl(slug, payload.owner?.handle),
license: payload.latestVersion?.license ?? null,
version: payload.latestVersion?.version ?? skill.tags?.latest,
downloads: skill.stats?.downloads,
installs: skill.stats?.installs,
stars: skill.stats?.stars,
tags: skill.topics,
requiresApiKey: false,
trustState: trust.trustState,
trustSummary: trust.trustSummary,
installed,
files: entryPreview ? [{ path: 'SKILL.md' }] : [],
entryPreview,
riskLabels: [],
installEligibility: installed
? { status: 'installed', installedSkillName: slug }
: installEligibilityFromTrustState(trust.trustState),
}
}
export function normalizeClawHubVersionFiles(payload: ClawHubVersionResponse): SkillMarketFile[] {
return (payload.version?.files ?? [])
.filter((file) => typeof file.path === 'string' && file.path.trim())
.map((file) => ({
path: file.path!.trim(),
size: typeof file.size === 'number' && Number.isFinite(file.size) ? file.size : undefined,
sha256: typeof file.sha256 === 'string' ? file.sha256 : undefined,
contentType: typeof file.contentType === 'string' ? file.contentType : file.contentType ?? undefined,
}))
}
export function normalizeClawHubScan(payload: ClawHubScanResponse): {
trustState: SkillMarketTrustState
trustSummary?: string
packageSha256?: string
} {
const scannerEntries = Object.values(payload.scanners ?? {})
const security = payload.security ?? payload
const scannerEntries = Object.values(security.scanners ?? {})
const blockedStatuses = ['malicious', 'blocked']
const warningStatuses = ['suspicious', 'warning']
const scannerStatus = (entry: { status?: string; normalizedStatus?: string }) => entry.normalizedStatus || entry.status
const scannerSummary = scannerEntries.find((entry) => entry.summary)?.summary
const scannerSummaryForStatuses = (statuses: string[]) =>
scannerEntries.find((entry) => entry.summary && entry.status && statuses.includes(entry.status))?.summary
scannerEntries.find((entry) => entry.summary && scannerStatus(entry) && statuses.includes(scannerStatus(entry)!))?.summary
const hasScannerStatus = (statuses: string[]) =>
scannerEntries.some((entry) => entry.status && statuses.includes(entry.status))
scannerEntries.some((entry) => scannerStatus(entry) && statuses.includes(scannerStatus(entry)!))
const packageSha256 = security.sha256 ?? security.sha256hash
if (payload.status === 'malicious' || payload.status === 'blocked' || hasScannerStatus(blockedStatuses)) {
if (security.status === 'malicious' || security.status === 'blocked' || hasScannerStatus(blockedStatuses)) {
return {
trustState: 'blocked',
trustSummary: scannerSummaryForStatuses(blockedStatuses),
packageSha256: payload.sha256,
packageSha256,
}
}
if (
payload.status === 'suspicious'
|| payload.status === 'warning'
|| payload.hasWarnings
security.status === 'suspicious'
|| security.status === 'warning'
|| hasScannerStatus(warningStatuses)
) {
return {
trustState: 'warning',
trustSummary: scannerSummaryForStatuses(warningStatuses),
packageSha256: payload.sha256,
packageSha256,
}
}
if (payload.status === 'clean') {
return { trustState: 'clean', trustSummary: scannerSummary, packageSha256: payload.sha256 }
if (security.status === 'clean') {
return { trustState: 'clean', trustSummary: scannerSummary, packageSha256 }
}
if (security.hasWarnings) {
return {
trustState: 'warning',
trustSummary: scannerSummaryForStatuses(warningStatuses),
packageSha256,
}
}
return {
trustState: 'unknown',
trustSummary: undefined,
packageSha256: payload.sha256,
packageSha256,
}
}
function installEligibilityFromTrustState(trustState: SkillMarketTrustState): SkillMarketDetail['installEligibility'] {
if (CLAWHUB_DETAIL_INSTALLABLE_TRUST_STATES.has(trustState)) {
return { status: 'installable' }
}
if (trustState === 'blocked') {
return { status: 'blocked', reason: 'ClawHub security scan blocked this skill.' }
}
if (trustState === 'warning') {
return { status: 'blocked', reason: 'ClawHub security scan returned warnings.' }
}
return { status: 'blocked', reason: 'ClawHub security scan is missing or inconclusive.' }
}
function clawHubCanonicalUrl(slug: string, ownerHandle?: string): string {
if (ownerHandle) {
return `https://clawhub.ai/${encodeURIComponent(ownerHandle)}/${encodeURIComponent(slug)}`
}
return `https://clawhub.ai/${encodeURIComponent(slug)}`
}

View File

@ -1,7 +1,10 @@
import { normalizeClawHubList } from './clawhubAdapter.js'
import { normalizeClawHubDetail, normalizeClawHubList, normalizeClawHubVersionFiles } from './clawhubAdapter.js'
import { analyzeSkillRisk } from './risk.js'
import { normalizeSkillHubList } from './skillhubAdapter.js'
import type {
SkillMarketDetail,
SkillMarketFile,
SkillMarketFilePreview,
SkillMarketItem,
SkillMarketListResult,
SkillMarketSource,
@ -39,10 +42,41 @@ export type SkillMarketService = {
const CLAWHUB_SKILLS_URL = 'https://clawhub.ai/api/v1/skills'
const SKILLHUB_SKILLS_URL = 'https://api.skillhub.cn/api/skills'
const DEFAULT_LIMIT = 24
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 100
const CATALOG_CACHE_TTL_MS = 5 * 60 * 1_000
const FAILURE_CACHE_TTL_MS = 60 * 1_000
const FILE_PREVIEW_LIMIT = 5
const FILE_PREVIEW_MAX_BYTES = 96 * 1024
const FILE_PREVIEW_MAX_CHARS = 24_000
const TEXT_FILE_EXTENSIONS = new Set([
'.md',
'.mdx',
'.txt',
'.json',
'.jsonl',
'.yaml',
'.yml',
'.toml',
'.py',
'.js',
'.jsx',
'.ts',
'.tsx',
'.sh',
'.bash',
'.zsh',
'.fish',
'.ps1',
'.rb',
'.go',
'.rs',
'.java',
'.kt',
'.swift',
'.css',
'.html',
])
type CatalogCacheEntry = {
expiresAt: number
result: SkillMarketListResult
@ -199,6 +233,17 @@ export function createSkillMarketService(options: SkillMarketServiceOptions = {}
return null
}
if (params.source === 'clawhub') {
const installed = await resolveInstalledSkillNames(installedSkillNames)
try {
return await fetchClawHubDetail(slug, installed.has(slug))
} catch (error) {
if (!(error instanceof SkillMarketRequestError)) {
throw error
}
}
}
const list = await listSkills({
source: params.source,
query: slug,
@ -211,6 +256,72 @@ export function createSkillMarketService(options: SkillMarketServiceOptions = {}
return detailFromListItem(item)
}
async function fetchClawHubDetail(slug: string, installed: boolean): Promise<SkillMarketDetail | null> {
const detailPayload = await requestJson(fetchImpl, clawHubDetailUrl(slug), 'ClawHub detail')
const scanPayload = await requestJson(fetchImpl, clawHubScanUrl(slug), 'ClawHub scan')
const detail = normalizeClawHubDetail(detailPayload, scanPayload, { installed })
if (!detail) {
return null
}
const versionFiles = detail.version
? await fetchClawHubVersionFiles(slug, detail.version)
: []
const files = versionFiles.length > 0 ? versionFiles : detail.files
const filePreviews = await fetchClawHubFilePreviews(slug, detail.version, files)
const entryPreview = filePreviews.find((preview) => preview.path === 'SKILL.md')?.content ?? detail.entryPreview
return {
...detail,
files,
filePreviews,
entryPreview,
previewUnavailableReason: previewUnavailableReason(filePreviews, files, detail.entryPreview),
riskLabels: analyzeSkillRisk({
entryContent: entryPreview,
files,
requiresApiKey: detail.requiresApiKey,
}),
}
}
async function fetchClawHubVersionFiles(slug: string, version: string): Promise<SkillMarketFile[]> {
try {
const payload = await requestJson(fetchImpl, clawHubVersionUrl(slug, version), 'ClawHub version')
return normalizeClawHubVersionFiles(payload)
} catch (error) {
if (error instanceof SkillMarketRequestError) {
return []
}
throw error
}
}
async function fetchClawHubFilePreviews(
slug: string,
version: string | undefined,
files: SkillMarketFile[],
): Promise<SkillMarketFilePreview[]> {
const previews: SkillMarketFilePreview[] = []
for (const file of previewCandidateFiles(files)) {
try {
const content = await requestText(fetchImpl, clawHubFileUrl(slug, file.path, version), 'ClawHub file')
const preview = buildTextPreview(file, content)
if (preview) {
previews.push(preview)
}
} catch (error) {
if (!(error instanceof SkillMarketRequestError)) {
throw error
}
}
if (previews.length >= FILE_PREVIEW_LIMIT) {
break
}
}
return previews
}
return {
listSkills,
list: listSkills,
@ -222,6 +333,9 @@ function detailFromListItem(item: SkillMarketItem): SkillMarketDetail {
return {
...item,
files: [],
previewUnavailableReason: item.source === 'skillhub'
? 'SkillHub does not expose a safe raw file preview endpoint yet.'
: 'Marketplace detail did not include enough package metadata for file preview.',
riskLabels: [],
installEligibility: installEligibilityFromListItem(item),
}
@ -261,6 +375,27 @@ function clawHubUrlFor(params: SkillMarketListParams): URL {
return url
}
function clawHubDetailUrl(slug: string): URL {
return new URL(`${CLAWHUB_SKILLS_URL}/${encodeURIComponent(slug)}`)
}
function clawHubScanUrl(slug: string): URL {
return new URL(`${CLAWHUB_SKILLS_URL}/${encodeURIComponent(slug)}/scan`)
}
function clawHubVersionUrl(slug: string, version: string): URL {
return new URL(`${CLAWHUB_SKILLS_URL}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`)
}
function clawHubFileUrl(slug: string, filePath: string, version?: string): URL {
const url = new URL(`${CLAWHUB_SKILLS_URL}/${encodeURIComponent(slug)}/file`)
url.searchParams.set('path', filePath)
if (version) {
url.searchParams.set('version', version)
}
return url
}
function skillHubUrlFor(params: SkillMarketListParams): URL {
const url = new URL(SKILLHUB_SKILLS_URL)
url.searchParams.set('sortBy', skillHubSort(params.sort))
@ -294,6 +429,143 @@ async function requestJson(fetchImpl: FetchImpl, url: URL, sourceName: string):
return response.json()
}
async function requestText(fetchImpl: FetchImpl, url: URL, sourceName: string): Promise<string> {
let response: Response
try {
response = await fetchImpl(url, {
headers: {
accept: 'text/plain, text/markdown;q=0.9, */*;q=0.1',
},
})
} catch (error) {
throw new SkillMarketRequestError(`${sourceName} request failed: ${errorMessage(error)}`, { cause: error })
}
if (!response.ok) {
throw new SkillMarketRequestError(`${sourceName} request failed with status ${response.status}`)
}
return response.text()
}
function previewCandidateFiles(files: SkillMarketFile[]): SkillMarketFile[] {
return files
.filter(isPreviewableTextFile)
.sort((a, b) => previewPriority(a) - previewPriority(b) || a.path.localeCompare(b.path))
.slice(0, FILE_PREVIEW_LIMIT)
}
function isPreviewableTextFile(file: SkillMarketFile): boolean {
const normalizedPath = file.path.replace(/\\/g, '/')
if (!normalizedPath || normalizedPath.includes('\0') || normalizedPath.endsWith('/')) {
return false
}
if (typeof file.size === 'number' && file.size > FILE_PREVIEW_MAX_BYTES) {
return false
}
const contentType = file.contentType?.toLowerCase()
if (contentType?.startsWith('text/')) {
return true
}
if (contentType === 'application/json' || contentType === 'application/x-sh') {
return true
}
return TEXT_FILE_EXTENSIONS.has(fileExtension(normalizedPath))
}
function previewPriority(file: SkillMarketFile): number {
const normalizedPath = file.path.replace(/\\/g, '/')
const lowerPath = normalizedPath.toLowerCase()
if (lowerPath === 'skill.md') return 0
if (lowerPath.endsWith('/skill.md')) return 1
if (lowerPath.endsWith('.md') || lowerPath.endsWith('.mdx')) return 2
if (lowerPath.startsWith('scripts/')) return 3
if (lowerPath.endsWith('.py') || lowerPath.endsWith('.ts') || lowerPath.endsWith('.js')) return 4
return 5
}
function buildTextPreview(file: SkillMarketFile, rawContent: string): SkillMarketFilePreview | null {
if (!isLikelyText(rawContent)) {
return null
}
const truncated = rawContent.length > FILE_PREVIEW_MAX_CHARS
return {
path: file.path,
content: truncated ? rawContent.slice(0, FILE_PREVIEW_MAX_CHARS) : rawContent,
language: languageForPath(file.path),
size: typeof file.size === 'number' ? file.size : undefined,
truncated,
}
}
function isLikelyText(content: string): boolean {
if (content.includes('\0')) {
return false
}
if (!content.includes('\uFFFD')) {
return true
}
const replacementCount = [...content].filter((char) => char === '\uFFFD').length
return replacementCount / Math.max(content.length, 1) < 0.01
}
function previewUnavailableReason(
previews: SkillMarketFilePreview[],
files: SkillMarketFile[],
fallbackPreview: string | undefined,
): string | undefined {
if (previews.length > 0 || fallbackPreview) {
return undefined
}
if (files.length === 0) {
return 'Marketplace detail did not include a file list for preview.'
}
return 'No small text files were available for safe preview.'
}
function languageForPath(filePath: string): string | undefined {
switch (fileExtension(filePath.replace(/\\/g, '/'))) {
case '.md':
case '.mdx':
return 'markdown'
case '.json':
case '.jsonl':
return 'json'
case '.yaml':
case '.yml':
return 'yaml'
case '.toml':
return 'toml'
case '.py':
return 'python'
case '.js':
case '.jsx':
return 'javascript'
case '.ts':
case '.tsx':
return 'typescript'
case '.sh':
case '.bash':
case '.zsh':
case '.fish':
return 'shell'
case '.ps1':
return 'powershell'
case '.html':
return 'html'
case '.css':
return 'css'
default:
return undefined
}
}
function fileExtension(filePath: string): string {
const basename = filePath.split('/').pop() ?? filePath
const index = basename.lastIndexOf('.')
return index >= 0 ? basename.slice(index).toLowerCase() : ''
}
async function resolveInstalledSkillNames(provider?: InstalledSkillNamesProvider): Promise<Set<string>> {
if (!provider) {
return new Set()

View File

@ -45,11 +45,22 @@ export type SkillMarketFile = {
path: string
size?: number
sha256?: string
contentType?: string | null
}
export type SkillMarketFilePreview = {
path: string
content: string
language?: string
size?: number
truncated?: boolean
}
export type SkillMarketDetail = SkillMarketItem & {
files: SkillMarketFile[]
filePreviews?: SkillMarketFilePreview[]
entryPreview?: string
previewUnavailableReason?: string
riskLabels: SkillMarketRiskLabel[]
installEligibility:
| { status: 'installable' }