diff --git a/desktop/src/api/skillMarket.ts b/desktop/src/api/skillMarket.ts new file mode 100644 index 00000000..e1416c87 --- /dev/null +++ b/desktop/src/api/skillMarket.ts @@ -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(`/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('/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) + } +} diff --git a/desktop/src/stores/skillMarketStore.test.ts b/desktop/src/stores/skillMarketStore.test.ts new file mode 100644 index 00000000..e0fe375a --- /dev/null +++ b/desktop/src/stores/skillMarketStore.test.ts @@ -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 { + 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 { + 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') + }) +}) diff --git a/desktop/src/stores/skillMarketStore.ts b/desktop/src/stores/skillMarketStore.ts new file mode 100644 index 00000000..821b303c --- /dev/null +++ b/desktop/src/stores/skillMarketStore.ts @@ -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 + fetchDetail: (source: SkillMarketSource, slug: string) => Promise + installSelected: () => Promise + clearDetail: () => void +} + +export const useSkillMarketStore = create((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) +} diff --git a/desktop/src/types/skillMarket.ts b/desktop/src/types/skillMarket.ts new file mode 100644 index 00000000..258019cc --- /dev/null +++ b/desktop/src/types/skillMarket.ts @@ -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 +}