mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: add skill market api route
This commit is contained in:
parent
cc3d16a3a5
commit
da37f4b1ed
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
127
src/server/api/skill-market.ts
Normal file
127
src/server/api/skill-market.ts
Normal 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 })
|
||||
}
|
||||
@ -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([
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user