feat: add skill market api route

This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 18:35:00 +08:00
parent cc3d16a3a5
commit da37f4b1ed
4 changed files with 258 additions and 1 deletions

View File

@ -1,4 +1,9 @@
import { describe, expect, it } from 'bun:test'
import { afterEach, describe, expect, it, mock } from 'bun:test'
import {
handleSkillMarketApi,
resetSkillMarketServiceFactoryForTests,
setSkillMarketServiceFactoryForTests,
} from '../api/skill-market.js'
import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js'
import { analyzeSkillRisk } from '../services/skillMarket/risk.js'
import { createSkillMarketService } from '../services/skillMarket/service.js'
@ -738,3 +743,119 @@ describe('skill market risk analysis', () => {
expect(risk).toEqual([])
})
})
describe('skill market API', () => {
afterEach(() => {
resetSkillMarketServiceFactoryForTests()
})
it('rejects unsupported methods', async () => {
const url = new URL('/api/skill-market', 'http://localhost:3456')
const req = new Request(url, { method: 'DELETE' })
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market'])
expect(res.status).toBe(405)
})
it('rejects install requests with target paths', async () => {
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'clawhub', slug: 'skill-vetter', targetPath: '/tmp/escape' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: 'target_path_not_allowed' })
})
it('returns install skeleton response for safe install requests', async () => {
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: JSON.stringify({ source: 'clawhub', slug: 'skill-vetter' }),
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(501)
await expect(res.json()).resolves.toMatchObject({ error: 'install_not_wired' })
})
it('rejects invalid install JSON', async () => {
const url = new URL('/api/skill-market/install', 'http://localhost:3456')
const req = new Request(url, {
method: 'POST',
body: '{not-json',
})
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install'])
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: 'invalid_json' })
})
it('routes skill market requests through the API router without network access', async () => {
mock.module('@whiskeysockets/baileys', () => ({
DisconnectReason: { loggedOut: 401 },
fetchLatestBaileysVersion: async () => ({ version: [2, 3000, 0] }),
makeCacheableSignalKeyStore: () => ({}),
makeWASocket: () => ({ ev: { on: () => {} }, ws: { on: () => {} } }),
useMultiFileAuthState: async () => ({
state: { creds: {}, keys: {} },
saveCreds: async () => {},
}),
}))
const { handleApiRequest } = await import('../router.js')
const url = new URL('/api/skill-market?source=unsupported', 'http://localhost:3456')
const res = await handleApiRequest(new Request(url, { method: 'GET' }), url)
expect(res.status).toBe(400)
await expect(res.json()).resolves.toMatchObject({ error: 'unsupported_source' })
})
it('lists through the service with validated query params', async () => {
let capturedParams: unknown
let hasInstalledProvider = false
setSkillMarketServiceFactoryForTests((options) => {
hasInstalledProvider = typeof options.installedSkillNames === 'function'
return {
list: async (params) => {
capturedParams = params
return {
items: [],
nextCursor: null,
source: 'skillhub',
sourceStatus: 'ok',
}
},
listSkills: async (params) => {
capturedParams = params
return {
items: [],
nextCursor: null,
source: 'skillhub',
sourceStatus: 'ok',
}
},
}
})
const url = new URL('/api/skill-market?source=skillhub&sort=updated&q=vetter&cursor=abc&limit=12', 'http://localhost:3456')
const req = new Request(url, { method: 'GET' })
const res = await handleSkillMarketApi(req, url, ['api', 'skill-market'])
expect(res.status).toBe(200)
expect(hasInstalledProvider).toBe(true)
expect(capturedParams).toEqual({
source: 'skillhub',
sort: 'updated',
query: 'vetter',
cursor: 'abc',
limit: 12,
})
})
})

View File

@ -0,0 +1,127 @@
import {
createSkillMarketService,
type SkillMarketListParams,
type SkillMarketService,
type SkillMarketServiceOptions,
} from '../services/skillMarket/service.js'
import { collectUserSkillNames } from './skills.js'
type SkillMarketServiceFactory = (options: SkillMarketServiceOptions) => SkillMarketService
const SUPPORTED_SOURCES = new Set(['auto', 'clawhub', 'skillhub'])
const SUPPORTED_SORTS = new Set(['downloads', 'installs', 'stars', 'updated', 'trending'])
const MAX_LIMIT = 100
let skillMarketServiceFactory: SkillMarketServiceFactory = createSkillMarketService
export function setSkillMarketServiceFactoryForTests(factory: SkillMarketServiceFactory): void {
skillMarketServiceFactory = factory
}
export function resetSkillMarketServiceFactoryForTests(): void {
skillMarketServiceFactory = createSkillMarketService
}
export async function handleSkillMarketApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
const action = segments[2]
if (req.method === 'GET' && action === undefined) {
const params = parseListParams(url)
if (params instanceof Response) {
return params
}
const service = skillMarketServiceFactory({
installedSkillNames: collectUserSkillNames,
})
const result = await service.list(params)
return Response.json(result)
}
if (action === 'install') {
if (req.method !== 'POST') {
return jsonError('method_not_allowed', 'Method not allowed for skill market install.', 405)
}
return handleInstallSkeleton(req)
}
if (action !== undefined) {
return jsonError('not_found', `Unknown skill market endpoint: ${action}`, 404)
}
return jsonError('method_not_allowed', 'Method not allowed for skill market.', 405)
}
function parseListParams(url: URL): SkillMarketListParams | Response {
const params: SkillMarketListParams = {}
const source = url.searchParams.get('source')
const sort = url.searchParams.get('sort')
const limit = url.searchParams.get('limit')
const query = url.searchParams.get('query') ?? url.searchParams.get('q')
const cursor = url.searchParams.get('cursor')
if (source !== null) {
if (!SUPPORTED_SOURCES.has(source)) {
return jsonError('unsupported_source', `Unsupported skill market source: ${source}`, 400)
}
params.source = source as SkillMarketListParams['source']
}
if (sort !== null) {
if (!SUPPORTED_SORTS.has(sort)) {
return jsonError('unsupported_sort', `Unsupported skill market sort: ${sort}`, 400)
}
params.sort = sort as SkillMarketListParams['sort']
}
if (limit !== null) {
const parsedLimit = Number(limit)
if (!Number.isInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > MAX_LIMIT) {
return jsonError('invalid_limit', 'Skill market limit must be an integer from 1 to 100.', 400)
}
params.limit = parsedLimit
}
if (query !== null) {
params.query = query
}
if (cursor !== null) {
params.cursor = cursor
}
return params
}
async function handleInstallSkeleton(req: Request): Promise<Response> {
const body = await parseJsonObject(req)
if (!body) {
return jsonError('invalid_json', 'Request body must be a JSON object.', 400)
}
if ('targetPath' in body || 'target' in body || 'path' in body) {
return jsonError('target_path_not_allowed', 'Install target is computed by the server.', 400)
}
return jsonError('install_not_wired', 'Skill market install is not wired yet.', 501)
}
async function parseJsonObject(req: Request): Promise<Record<string, unknown> | null> {
try {
const body = await req.json()
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return null
}
return body as Record<string, unknown>
} catch {
return null
}
}
function jsonError(error: string, message: string, status: number): Response {
return Response.json({ error, message }, { status })
}

View File

@ -403,6 +403,11 @@ async function collectAllSkills(cwd?: string): Promise<SkillMeta[]> {
return skills
}
export async function collectUserSkillNames(): Promise<Set<string>> {
const skills = await collectAllSkills(undefined)
return new Set(skills.filter((skill) => skill.source === 'user').map((skill) => skill.name))
}
export async function listSkillSlashCommands(cwd?: string): Promise<SkillSlashCommand[]> {
const requestedCwd = cwd || getCwd()
const [skills, legacyCommands] = await Promise.all([

View File

@ -16,6 +16,7 @@ import { handleProvidersApi } from './api/providers.js'
import { handleAdaptersApi } from './api/adapters.js'
import { handlePluginsApi } from './api/plugins.js'
import { handleSkillsApi } from './api/skills.js'
import { handleSkillMarketApi } from './api/skill-market.js'
import { handleComputerUseApi } from './api/computer-use.js'
import { handleHahaOAuthApi } from './api/haha-oauth.js'
import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js'
@ -90,6 +91,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'skills':
return handleSkillsApi(req, url, segments)
case 'skill-market':
return handleSkillMarketApi(req, url, segments)
case 'mcp':
return handleMcpApi(req, url, segments)