mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
b305e798e7
commit
b6aeaf0109
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)]'
|
||||
}
|
||||
`}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '閉じる',
|
||||
|
||||
@ -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': '닫기',
|
||||
|
||||
@ -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': '關閉',
|
||||
|
||||
@ -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': '关闭',
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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)}`
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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' }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user