diff --git a/src/server/__tests__/skill-market.test.ts b/src/server/__tests__/skill-market.test.ts index 131da521..8bdd92c1 100644 --- a/src/server/__tests__/skill-market.test.ts +++ b/src/server/__tests__/skill-market.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test' import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js' +import { createSkillMarketService } from '../services/skillMarket/service.js' import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js' import { CLAWHUB_SCAN_RESPONSE, @@ -386,3 +387,130 @@ describe('skill market source normalization', () => { }) }) }) + +describe('skill market service source selection', () => { + it('uses ClawHub only in auto mode when ClawHub succeeds and marks installed skills', async () => { + const fetchCalls: string[] = [] + const service = createSkillMarketService({ + fetchImpl: async (url) => { + fetchCalls.push(String(url)) + return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE) + }, + installedSkillNames: async () => new Set(['skill-vetter']), + }) + + const result = await service.listSkills({ source: 'auto', limit: 12, query: 'vetter', cursor: 'next-page' }) + + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills') + expect(fetchCalls[0]).toContain('sort=downloads') + expect(fetchCalls[0]).toContain('nonSuspiciousOnly=true') + expect(fetchCalls[0]).toContain('limit=12') + expect(fetchCalls[0]).toContain('query=vetter') + expect(fetchCalls[0]).toContain('cursor=next-page') + expect(result).toMatchObject({ + source: 'clawhub', + sourceStatus: 'ok', + items: [ + { + source: 'clawhub', + slug: 'skill-vetter', + installed: true, + }, + ], + }) + }) + + it('falls back to SkillHub in auto mode when ClawHub fails and marks installed skills', async () => { + const fetchCalls: string[] = [] + const service = createSkillMarketService({ + fetchImpl: async (url) => { + fetchCalls.push(String(url)) + if (String(url).startsWith('https://clawhub.ai/')) { + return new Response('temporarily unavailable', { status: 503 }) + } + return Response.json(SKILLHUB_TOP_SKILLS_RESPONSE) + }, + installedSkillNames: new Set(['skill-vetter']), + }) + + const result = await service.listSkills({ source: 'auto', limit: 10, query: 'vetter', cursor: '3' }) + + expect(fetchCalls).toHaveLength(2) + expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills') + expect(fetchCalls[1]).toStartWith('https://api.skillhub.cn/api/skills') + expect(fetchCalls[1]).toContain('sortBy=downloads') + expect(fetchCalls[1]).toContain('order=desc') + expect(fetchCalls[1]).toContain('limit=10') + expect(fetchCalls[1]).toContain('query=vetter') + expect(fetchCalls[1]).toContain('cursor=3') + expect(result.source).toBe('skillhub') + expect(result.sourceStatus).toBe('fallback') + expect(result.message).toContain('ClawHub unavailable') + expect(result.items[0]).toMatchObject({ + source: 'skillhub', + slug: 'skill-vetter', + installed: true, + }) + }) + + it("does not fallback to SkillHub when source is 'clawhub'", async () => { + const fetchCalls: string[] = [] + const service = createSkillMarketService({ + fetchImpl: async (url) => { + fetchCalls.push(String(url)) + return new Response('temporarily unavailable', { status: 503 }) + }, + installedSkillNames: async () => new Set(), + }) + + await expect(service.listSkills({ source: 'clawhub' })).rejects.toThrow('ClawHub request failed') + + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0]).toStartWith('https://clawhub.ai/api/v1/skills') + }) + + it("uses SkillHub only when source is 'skillhub'", async () => { + const fetchCalls: string[] = [] + const service = createSkillMarketService({ + fetchImpl: async (url) => { + fetchCalls.push(String(url)) + return Response.json(SKILLHUB_TOP_SKILLS_RESPONSE) + }, + installedSkillNames: async () => new Set(), + }) + + const result = await service.listSkills({ source: 'skillhub', limit: 6, query: 'vetter', cursor: '2' }) + + expect(fetchCalls).toHaveLength(1) + expect(fetchCalls[0]).toStartWith('https://api.skillhub.cn/api/skills') + expect(fetchCalls[0]).toContain('sortBy=downloads') + expect(fetchCalls[0]).toContain('order=desc') + expect(fetchCalls[0]).toContain('limit=6') + expect(fetchCalls[0]).toContain('query=vetter') + expect(fetchCalls[0]).toContain('cursor=2') + expect(result.source).toBe('skillhub') + expect(result.items[0]).toMatchObject({ + source: 'skillhub', + slug: 'skill-vetter', + installed: false, + }) + }) + + it('rejects unsupported v1 sources instead of treating them as auto', async () => { + const fetchCalls: string[] = [] + const service = createSkillMarketService({ + fetchImpl: async (url) => { + fetchCalls.push(String(url)) + return Response.json(CLAWHUB_TOP_SKILLS_RESPONSE) + }, + installedSkillNames: async () => new Set(), + }) + + await expect( + service.listSkills({ source: 'anthropic-github' as 'auto' }), + ).rejects.toThrow('Unsupported skill market source') + + expect(fetchCalls).toHaveLength(0) + }) +}) diff --git a/src/server/services/skillMarket/service.ts b/src/server/services/skillMarket/service.ts new file mode 100644 index 00000000..bc7813d3 --- /dev/null +++ b/src/server/services/skillMarket/service.ts @@ -0,0 +1,172 @@ +import { normalizeClawHubList } from './clawhubAdapter.js' +import { normalizeSkillHubList } from './skillhubAdapter.js' +import type { SkillMarketItem, SkillMarketListResult } from './types.js' + +export type SkillMarketListSource = 'auto' | 'clawhub' | 'skillhub' + +export type SkillMarketListParams = { + source?: SkillMarketListSource + limit?: number + query?: string + cursor?: string + sort?: 'downloads' | 'installs' | 'stars' | 'updated' | 'trending' +} + +type FetchImpl = typeof fetch +type InstalledSkillNamesProvider = Set | (() => Set | Promise>) + +export type SkillMarketServiceOptions = { + fetchImpl?: FetchImpl + installedSkillNames?: InstalledSkillNamesProvider +} + +export type SkillMarketService = { + listSkills: (params?: SkillMarketListParams) => Promise + list: (params?: SkillMarketListParams) => Promise +} + +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 MAX_LIMIT = 100 + +export function createSkillMarketService(options: SkillMarketServiceOptions = {}): SkillMarketService { + const fetchImpl = options.fetchImpl ?? fetch + const installedSkillNames = options.installedSkillNames + + async function listSkills(params: SkillMarketListParams = {}): Promise { + const source = params.source ?? 'auto' + + if (source === 'clawhub') { + return withInstalled(await listClawHub(params)) + } + + if (source === 'skillhub') { + return withInstalled(await listSkillHub(params, 'ok')) + } + + if (source !== 'auto') { + throw new Error(`Unsupported skill market source: ${source}`) + } + + try { + return withInstalled(await listClawHub(params)) + } catch (error) { + const fallback = await listSkillHub(params, 'fallback') + return withInstalled({ + ...fallback, + sourceStatus: 'fallback', + message: `ClawHub unavailable: ${errorMessage(error)}`, + }) + } + } + + async function listClawHub(params: SkillMarketListParams): Promise { + const url = new URL(CLAWHUB_SKILLS_URL) + url.searchParams.set('sort', clawHubSort(params.sort)) + url.searchParams.set('nonSuspiciousOnly', 'true') + url.searchParams.set('limit', String(limitFor(params.limit))) + addOptionalParam(url, 'query', params.query) + addOptionalParam(url, 'cursor', params.cursor) + + const payload = await requestJson(fetchImpl, url, 'ClawHub') + return normalizeClawHubList(payload) + } + + async function listSkillHub( + params: SkillMarketListParams, + sourceStatus: SkillMarketListResult['sourceStatus'], + ): Promise { + const url = new URL(SKILLHUB_SKILLS_URL) + url.searchParams.set('sortBy', skillHubSort(params.sort)) + url.searchParams.set('order', 'desc') + url.searchParams.set('limit', String(limitFor(params.limit))) + addOptionalParam(url, 'query', params.query) + addOptionalParam(url, 'cursor', params.cursor) + + const payload = await requestJson(fetchImpl, url, 'SkillHub') + return { + ...normalizeSkillHubList(payload), + sourceStatus, + } + } + + async function withInstalled(result: SkillMarketListResult): Promise { + const installed = await resolveInstalledSkillNames(installedSkillNames) + return { + ...result, + items: result.items.map((item): SkillMarketItem => ({ + ...item, + installed: installed.has(item.slug), + })), + } + } + + return { + listSkills, + list: listSkills, + } +} + +async function requestJson(fetchImpl: FetchImpl, url: URL, sourceName: string): Promise { + let response: Response + try { + response = await fetchImpl(url) + } catch (error) { + throw new Error(`${sourceName} request failed: ${errorMessage(error)}`) + } + + if (!response.ok) { + throw new Error(`${sourceName} request failed with status ${response.status}`) + } + + return response.json() +} + +async function resolveInstalledSkillNames(provider?: InstalledSkillNamesProvider): Promise> { + if (!provider) { + return new Set() + } + if (provider instanceof Set) { + return provider + } + return provider() +} + +function limitFor(limit: number | undefined): number { + if (!Number.isInteger(limit) || limit === undefined || limit < 1) { + return DEFAULT_LIMIT + } + return Math.min(limit, MAX_LIMIT) +} + +function clawHubSort(sort: SkillMarketListParams['sort']): string { + if (sort === 'updated') { + return 'updated' + } + if (sort === 'installs' || sort === 'stars' || sort === 'trending') { + return sort + } + return 'downloads' +} + +function skillHubSort(sort: SkillMarketListParams['sort']): string { + if (sort === 'updated') { + return 'updated_at' + } + if (sort === 'installs' || sort === 'stars') { + return sort + } + return 'downloads' +} + +function addOptionalParam(url: URL, name: string, value: string | undefined) { + const trimmed = value?.trim() + if (trimmed) { + url.searchParams.set(name, trimmed) + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +}