feat: add skill market source fallback

This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 17:48:08 +08:00
parent 7a35116ee5
commit 296f5c82e2
2 changed files with 300 additions and 0 deletions

View File

@ -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)
})
})

View File

@ -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<string> | (() => Set<string> | Promise<Set<string>>)
export type SkillMarketServiceOptions = {
fetchImpl?: FetchImpl
installedSkillNames?: InstalledSkillNamesProvider
}
export type SkillMarketService = {
listSkills: (params?: SkillMarketListParams) => Promise<SkillMarketListResult>
list: (params?: SkillMarketListParams) => Promise<SkillMarketListResult>
}
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<SkillMarketListResult> {
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<SkillMarketListResult> {
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<SkillMarketListResult> {
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<SkillMarketListResult> {
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<unknown> {
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<Set<string>> {
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)
}