mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
f5f4ff9858
commit
df00c65fb2
54
desktop/src/api/skillMarket.ts
Normal file
54
desktop/src/api/skillMarket.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
168
desktop/src/stores/skillMarketStore.test.ts
Normal file
168
desktop/src/stores/skillMarketStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
99
desktop/src/stores/skillMarketStore.ts
Normal file
99
desktop/src/stores/skillMarketStore.ts
Normal 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)
|
||||
}
|
||||
86
desktop/src/types/skillMarket.ts
Normal file
86
desktop/src/types/skillMarket.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user