feat: add skill market desktop store

Tested: cd desktop && bun run test -- skillMarketStore.test.ts
Tested: cd desktop && bun run lint
Tested: git diff --check
Scope-risk: narrow
Confidence: high
This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 18:53:23 +08:00
parent f5f4ff9858
commit df00c65fb2
4 changed files with 407 additions and 0 deletions

View File

@ -0,0 +1,54 @@
import { api } from './client'
import type {
SkillMarketDetail,
SkillMarketInstallResult,
SkillMarketListParams,
SkillMarketListResponse,
SkillMarketSource,
} from '../types/skillMarket'
export const skillMarketApi = {
list: (params: SkillMarketListParams = {}) => {
const query = new URLSearchParams()
setStringParam(query, 'source', params.source)
setStringParam(query, 'sort', params.sort)
setStringParam(query, 'q', params.q)
if (!query.has('q')) {
setStringParam(query, 'query', params.query)
}
setStringParam(query, 'cursor', params.cursor)
if (params.limit !== undefined) {
query.set('limit', String(params.limit))
}
const suffix = query.toString() ? `?${query.toString()}` : ''
return api.get<SkillMarketListResponse>(`/api/skill-market${suffix}`, {
timeout: 30_000,
})
},
detail: (source: SkillMarketSource, slug: string) => {
return api.get<{ detail: SkillMarketDetail }>(
`/api/skill-market/${source}/${encodeURIComponent(slug)}`,
{ timeout: 30_000 },
)
},
install: (source: SkillMarketSource, slug: string, version?: string) => {
const body: { source: SkillMarketSource; slug: string; version?: string } = { source, slug }
if (version !== undefined) {
body.version = version
}
return api.post<SkillMarketInstallResult>('/api/skill-market/install', body, {
timeout: 120_000,
})
},
}
function setStringParam(query: URLSearchParams, name: string, value: string | undefined): void {
const trimmed = value?.trim()
if (trimmed) {
query.set(name, trimmed)
}
}

View File

@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { skillMarketApi } from '../api/skillMarket'
import type { SkillMarketDetail, SkillMarketItem } from '../types/skillMarket'
import { useSkillMarketStore } from './skillMarketStore'
vi.mock('../api/skillMarket', () => ({
skillMarketApi: {
list: vi.fn(),
detail: vi.fn(),
install: vi.fn(),
},
}))
const mockedSkillMarketApi = vi.mocked(skillMarketApi)
function makeItem(overrides: Partial<SkillMarketItem> = {}): SkillMarketItem {
return {
source: 'clawhub',
sourceMode: 'primary',
slug: 'skill-vetter',
displayName: 'Skill Vetter',
summary: 'Security-first skill vetting.',
canonicalUrl: 'https://clawhub.ai/skill-vetter',
trustState: 'clean',
installed: false,
...overrides,
}
}
function makeDetail(overrides: Partial<SkillMarketDetail> = {}): SkillMarketDetail {
return {
...makeItem(),
files: [],
riskLabels: [],
installEligibility: { status: 'installable' },
...overrides,
}
}
describe('skillMarketStore', () => {
beforeEach(() => {
vi.clearAllMocks()
useSkillMarketStore.setState({
items: [],
selectedDetail: null,
source: 'auto',
sort: 'downloads',
query: '',
isLoading: false,
isDetailLoading: false,
isInstalling: false,
error: null,
})
})
it('loads marketplace items with trimmed query parameters', async () => {
const item = makeItem()
mockedSkillMarketApi.list.mockResolvedValue({
items: [item],
nextCursor: null,
source: 'clawhub',
sourceStatus: 'ok',
})
useSkillMarketStore.setState({
source: 'skillhub',
sort: 'trending',
query: ' vetter ',
})
await useSkillMarketStore.getState().fetchItems()
expect(mockedSkillMarketApi.list).toHaveBeenCalledWith({
source: 'skillhub',
sort: 'trending',
q: 'vetter',
})
expect(useSkillMarketStore.getState().items).toEqual([item])
expect(useSkillMarketStore.getState().isLoading).toBe(false)
expect(useSkillMarketStore.getState().error).toBeNull()
})
it('sets an error when loading marketplace items fails', async () => {
mockedSkillMarketApi.list.mockRejectedValue(new Error('market unavailable'))
await useSkillMarketStore.getState().fetchItems()
expect(useSkillMarketStore.getState().items).toEqual([])
expect(useSkillMarketStore.getState().isLoading).toBe(false)
expect(useSkillMarketStore.getState().error).toBe('market unavailable')
})
it('updates source, sort, and query filters', () => {
useSkillMarketStore.getState().setSource('clawhub')
useSkillMarketStore.getState().setSort('updated')
useSkillMarketStore.getState().setQuery('security')
expect(useSkillMarketStore.getState().source).toBe('clawhub')
expect(useSkillMarketStore.getState().sort).toBe('updated')
expect(useSkillMarketStore.getState().query).toBe('security')
})
it('loads selected marketplace detail', async () => {
const detail = makeDetail()
mockedSkillMarketApi.detail.mockResolvedValue({ detail })
await useSkillMarketStore.getState().fetchDetail('clawhub', 'skill-vetter')
expect(mockedSkillMarketApi.detail).toHaveBeenCalledWith('clawhub', 'skill-vetter')
expect(useSkillMarketStore.getState().selectedDetail).toEqual(detail)
expect(useSkillMarketStore.getState().isDetailLoading).toBe(false)
expect(useSkillMarketStore.getState().error).toBeNull()
})
it('installs selected detail and refreshes the list and detail', async () => {
const selected = makeDetail({ version: '1.0.0' })
const refreshed = makeDetail({
installed: true,
installEligibility: { status: 'installed', installedSkillName: 'skill-vetter' },
})
mockedSkillMarketApi.install.mockResolvedValue({
installed: true,
skillName: 'skill-vetter',
targetPath: '/Users/nanmi/.claude/skills/skill-vetter',
})
mockedSkillMarketApi.list.mockResolvedValue({
items: [makeItem({ installed: true })],
nextCursor: null,
source: 'clawhub',
sourceStatus: 'ok',
})
mockedSkillMarketApi.detail.mockResolvedValue({ detail: refreshed })
useSkillMarketStore.setState({ selectedDetail: selected })
await useSkillMarketStore.getState().installSelected()
expect(mockedSkillMarketApi.install).toHaveBeenCalledWith('clawhub', 'skill-vetter', '1.0.0')
expect(mockedSkillMarketApi.list).toHaveBeenCalledWith({
source: 'auto',
sort: 'downloads',
q: undefined,
})
expect(mockedSkillMarketApi.detail).toHaveBeenCalledWith('clawhub', 'skill-vetter')
expect(useSkillMarketStore.getState().selectedDetail).toEqual(refreshed)
expect(useSkillMarketStore.getState().isInstalling).toBe(false)
})
it('does not call install APIs when no detail is selected', async () => {
await useSkillMarketStore.getState().installSelected()
expect(mockedSkillMarketApi.install).not.toHaveBeenCalled()
expect(mockedSkillMarketApi.list).not.toHaveBeenCalled()
expect(mockedSkillMarketApi.detail).not.toHaveBeenCalled()
expect(useSkillMarketStore.getState().isInstalling).toBe(false)
})
it('sets an error when installing selected detail fails', async () => {
useSkillMarketStore.setState({ selectedDetail: makeDetail() })
mockedSkillMarketApi.install.mockRejectedValue(new Error('install failed'))
await useSkillMarketStore.getState().installSelected()
expect(mockedSkillMarketApi.install).toHaveBeenCalledWith('clawhub', 'skill-vetter', undefined)
expect(mockedSkillMarketApi.list).not.toHaveBeenCalled()
expect(mockedSkillMarketApi.detail).not.toHaveBeenCalled()
expect(useSkillMarketStore.getState().isInstalling).toBe(false)
expect(useSkillMarketStore.getState().error).toBe('install failed')
})
})

View File

@ -0,0 +1,99 @@
import { create } from 'zustand'
import { skillMarketApi } from '../api/skillMarket'
import type {
SkillMarketDetail,
SkillMarketItem,
SkillMarketListSource,
SkillMarketSort,
SkillMarketSource,
} from '../types/skillMarket'
type SkillMarketStore = {
items: SkillMarketItem[]
selectedDetail: SkillMarketDetail | null
source: SkillMarketListSource
sort: SkillMarketSort
query: string
isLoading: boolean
isDetailLoading: boolean
isInstalling: boolean
error: string | null
setSource: (source: SkillMarketListSource) => void
setSort: (sort: SkillMarketSort) => void
setQuery: (query: string) => void
fetchItems: () => Promise<void>
fetchDetail: (source: SkillMarketSource, slug: string) => Promise<void>
installSelected: () => Promise<void>
clearDetail: () => void
}
export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
items: [],
selectedDetail: null,
source: 'auto',
sort: 'downloads',
query: '',
isLoading: false,
isDetailLoading: false,
isInstalling: false,
error: null,
setSource: (source) => set({ source }),
setSort: (sort) => set({ sort }),
setQuery: (query) => set({ query }),
fetchItems: async () => {
const { source, sort, query } = get()
set({ isLoading: true, error: null })
try {
const result = await skillMarketApi.list({
source,
sort,
q: query.trim() || undefined,
})
set({ items: result.items, isLoading: false })
} catch (err) {
set({
isLoading: false,
error: getErrorMessage(err),
})
}
},
fetchDetail: async (source, slug) => {
set({ isDetailLoading: true, error: null })
try {
const { detail } = await skillMarketApi.detail(source, slug)
set({ selectedDetail: detail, isDetailLoading: false })
} catch (err) {
set({
isDetailLoading: false,
error: getErrorMessage(err),
})
}
},
installSelected: async () => {
const detail = get().selectedDetail
if (!detail) return
set({ isInstalling: true, error: null })
try {
await skillMarketApi.install(detail.source, detail.slug, detail.version)
await get().fetchItems()
await get().fetchDetail(detail.source, detail.slug)
set({ isInstalling: false })
} catch (err) {
set({
isInstalling: false,
error: getErrorMessage(err),
})
}
},
clearDetail: () => set({ selectedDetail: null }),
}))
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}

View File

@ -0,0 +1,86 @@
export type SkillMarketSource = 'clawhub' | 'skillhub'
export type SkillMarketListSource = 'auto' | SkillMarketSource
export type SkillMarketSourceMode = 'primary' | 'fallback' | 'enhanced'
export type SkillMarketTrustState =
| 'clean'
| 'benign'
| 'signed'
| 'official'
| 'warning'
| 'unknown'
| 'blocked'
export type SkillMarketRiskLabel =
| 'allowed-tools'
| 'hooks'
| 'scripts'
| 'executables'
| 'external-network'
| 'requires-api-key'
export type SkillMarketSort = 'downloads' | 'installs' | 'stars' | 'updated' | 'trending'
export type SkillMarketItem = {
source: SkillMarketSource
sourceMode: SkillMarketSourceMode
slug: string
displayName: string
summary: string
summaryZh?: string
owner?: string
canonicalUrl: string
upstreamUrl?: string
license?: string | null
version?: string
downloads?: number
installs?: number
stars?: number
category?: string
tags?: string[]
requiresApiKey?: boolean
trustState: SkillMarketTrustState
trustSummary?: string
installed: boolean
}
export type SkillMarketFile = {
path: string
size?: number
sha256?: string
}
export type SkillMarketInstallEligibility =
| { status: 'installable' }
| { status: 'installed'; installedSkillName: string }
| { status: 'conflict'; targetPath: string }
| { status: 'blocked'; reason: string }
export type SkillMarketDetail = SkillMarketItem & {
files: SkillMarketFile[]
entryPreview?: string
riskLabels: SkillMarketRiskLabel[]
installEligibility: SkillMarketInstallEligibility
}
export type SkillMarketListResponse = {
items: SkillMarketItem[]
nextCursor: string | null
source: SkillMarketSource
sourceStatus: 'ok' | 'fallback' | 'cached'
message?: string
}
export type SkillMarketInstallResult = {
installed: true
skillName: string
targetPath: string
}
export type SkillMarketListParams = {
source?: SkillMarketListSource
sort?: SkillMarketSort
q?: string
query?: string
cursor?: string
limit?: number
}