From 5aeab5c206577c49bb9a037d9d123746ec540519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 3 Jul 2026 16:52:19 +0800 Subject: [PATCH] feat: normalize skill market sources --- src/server/__tests__/skill-market.test.ts | 56 +++++++++ .../services/skillMarket/clawhubAdapter.ts | 70 +++++++++++ .../services/skillMarket/skillhubAdapter.ts | 118 ++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/server/services/skillMarket/clawhubAdapter.ts create mode 100644 src/server/services/skillMarket/skillhubAdapter.ts diff --git a/src/server/__tests__/skill-market.test.ts b/src/server/__tests__/skill-market.test.ts index bd1ce956..9dddc130 100644 --- a/src/server/__tests__/skill-market.test.ts +++ b/src/server/__tests__/skill-market.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'bun:test' +import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js' +import { normalizeSkillHubDetail, normalizeSkillHubList } from '../services/skillMarket/skillhubAdapter.js' import { + CLAWHUB_SCAN_RESPONSE, CLAWHUB_TOP_SKILLS_RESPONSE, + SKILLHUB_DETAIL_RESPONSE, SKILLHUB_TOP_SKILLS_RESPONSE, } from './fixtures/skill-market.js' @@ -21,3 +25,55 @@ describe('skill market fixtures', () => { }) }) }) + +describe('skill market source normalization', () => { + it('normalizes ClawHub catalog items as primary clean candidates', () => { + const result = normalizeClawHubList(CLAWHUB_TOP_SKILLS_RESPONSE) + + expect(result.items).toHaveLength(1) + expect(result.items[0]).toMatchObject({ + source: 'clawhub', + sourceMode: 'primary', + slug: 'skill-vetter', + displayName: 'Skill Vetter', + canonicalUrl: 'https://clawhub.ai/skill-vetter', + trustState: 'clean', + installed: false, + requiresApiKey: false, + }) + }) + + it('normalizes ClawHub scan responses into trust metadata', () => { + expect(normalizeClawHubScan(CLAWHUB_SCAN_RESPONSE)).toEqual({ + trustState: 'clean', + trustSummary: 'No dangerous patterns detected.', + packageSha256: 'a'.repeat(64), + }) + }) + + it('normalizes SkillHub list items as fallback candidates with Chinese summary', () => { + const result = normalizeSkillHubList(SKILLHUB_TOP_SKILLS_RESPONSE) + + expect(result.items[0]).toMatchObject({ + source: 'skillhub', + sourceMode: 'fallback', + slug: 'skill-vetter', + summaryZh: 'AI智能体技能安全预审工具。', + canonicalUrl: 'https://clawhub.ai/spclaudehome/skill-vetter', + trustState: 'benign', + requiresApiKey: false, + }) + }) + + it('normalizes SkillHub detail security reports', () => { + const detail = normalizeSkillHubDetail(SKILLHUB_DETAIL_RESPONSE) + + expect(detail).toMatchObject({ + source: 'skillhub', + sourceMode: 'fallback', + slug: 'skill-vetter', + trustState: 'benign', + trustSummary: '安全,无风险', + }) + }) +}) diff --git a/src/server/services/skillMarket/clawhubAdapter.ts b/src/server/services/skillMarket/clawhubAdapter.ts new file mode 100644 index 00000000..063011a5 --- /dev/null +++ b/src/server/services/skillMarket/clawhubAdapter.ts @@ -0,0 +1,70 @@ +import type { SkillMarketItem, SkillMarketListResult, SkillMarketTrustState } from './types.js' + +type ClawHubListResponse = { + items?: Array<{ + slug?: string + displayName?: string + summary?: string + description?: string | null + topics?: string[] + tags?: { latest?: string } | null + stats?: { downloads?: number; installs?: number; stars?: number } + latestVersion?: { version?: string; license?: string | null } | null + }> + nextCursor?: string | null +} + +type ClawHubScanResponse = { + status?: string + hasWarnings?: boolean + sha256?: string + scanners?: Record +} + +export function normalizeClawHubList(payload: ClawHubListResponse): SkillMarketListResult { + const items = (payload.items ?? []) + .filter((item) => item.slug && item.displayName) + .map((item): SkillMarketItem => ({ + source: 'clawhub', + sourceMode: 'primary', + slug: item.slug!, + displayName: item.displayName!, + summary: item.summary || item.description || '', + owner: undefined, + canonicalUrl: `https://clawhub.ai/${item.slug}`, + license: item.latestVersion?.license ?? null, + version: item.latestVersion?.version ?? item.tags?.latest, + downloads: item.stats?.downloads, + installs: item.stats?.installs, + stars: item.stats?.stars, + tags: item.topics, + requiresApiKey: false, + trustState: 'clean', + installed: false, + })) + + return { + items, + nextCursor: payload.nextCursor ?? null, + source: 'clawhub', + sourceStatus: 'ok', + } +} + +export function normalizeClawHubScan(payload: ClawHubScanResponse): { + trustState: SkillMarketTrustState + trustSummary?: string + packageSha256?: string +} { + const scannerSummary = Object.values(payload.scanners ?? {}).find((entry) => entry.summary)?.summary + if (payload.status === 'clean' && !payload.hasWarnings) { + return { trustState: 'clean', trustSummary: scannerSummary, packageSha256: payload.sha256 } + } + if (payload.status === 'suspicious' || payload.hasWarnings) { + return { trustState: 'warning', trustSummary: scannerSummary, packageSha256: payload.sha256 } + } + if (payload.status === 'malicious' || payload.status === 'blocked') { + return { trustState: 'blocked', trustSummary: scannerSummary, packageSha256: payload.sha256 } + } + return { trustState: 'unknown', trustSummary: scannerSummary, packageSha256: payload.sha256 } +} diff --git a/src/server/services/skillMarket/skillhubAdapter.ts b/src/server/services/skillMarket/skillhubAdapter.ts new file mode 100644 index 00000000..ebddd789 --- /dev/null +++ b/src/server/services/skillMarket/skillhubAdapter.ts @@ -0,0 +1,118 @@ +import type { SkillMarketDetail, SkillMarketItem, SkillMarketListResult, SkillMarketTrustState } from './types.js' + +type SkillHubListResponse = { + code?: number + data?: { + skills?: Array<{ + slug?: string + name?: string + description?: string + description_zh?: string + downloads?: number + installs?: number + stars?: number + ownerName?: string + upstream_url?: string + version?: string + labels?: { requires_api_key?: string } + category?: string + verified?: boolean + }> + } +} + +type SkillHubDetailResponse = { + latestVersion?: { version?: string } + owner?: { handle?: string; displayName?: string } + securityReports?: Record + skill?: { + slug?: string + displayName?: string + summary?: string + summary_zh?: string + sourceUrl?: string + stats?: { downloads?: number; installs?: number; stars?: number } + labels?: { requires_api_key?: string } + category?: string + } +} + +function requiresApiKey(labels?: { requires_api_key?: string }) { + return labels?.requires_api_key === 'true' +} + +function trustFromReports(reports?: Record): { + trustState: SkillMarketTrustState + trustSummary?: string +} { + const values = Object.values(reports ?? {}) + if (values.some((report) => report.status === 'malicious' || report.status === 'blocked')) { + return { trustState: 'blocked', trustSummary: values.find((report) => report.statusText)?.statusText } + } + if (values.length > 0 && values.every((report) => report.status === 'benign')) { + return { trustState: 'benign', trustSummary: values.find((report) => report.statusText)?.statusText } + } + return { trustState: 'unknown', trustSummary: values.find((report) => report.statusText)?.statusText } +} + +export function normalizeSkillHubList(payload: SkillHubListResponse): SkillMarketListResult { + const items = (payload.data?.skills ?? []) + .filter((item) => item.slug && item.name) + .map((item): SkillMarketItem => ({ + source: 'skillhub', + sourceMode: 'fallback', + slug: item.slug!, + displayName: item.name!, + summary: item.description || item.description_zh || '', + summaryZh: item.description_zh, + owner: item.ownerName, + canonicalUrl: item.upstream_url || `https://skillhub.cn/skills/${item.slug}`, + upstreamUrl: item.upstream_url, + version: item.version, + downloads: item.downloads, + installs: item.installs, + stars: item.stars, + category: item.category, + requiresApiKey: requiresApiKey(item.labels), + trustState: item.verified ? 'signed' : 'benign', + installed: false, + })) + + return { + items, + nextCursor: null, + source: 'skillhub', + sourceStatus: 'fallback', + } +} + +export function normalizeSkillHubDetail(payload: SkillHubDetailResponse): SkillMarketDetail { + const trust = trustFromReports(payload.securityReports) + const skill = payload.skill ?? {} + const slug = skill.slug || 'unknown' + + return { + source: 'skillhub', + sourceMode: 'fallback', + slug, + displayName: skill.displayName || slug, + summary: skill.summary || skill.summary_zh || '', + summaryZh: skill.summary_zh, + owner: payload.owner?.handle || payload.owner?.displayName, + canonicalUrl: skill.sourceUrl || `https://skillhub.cn/skills/${slug}`, + version: payload.latestVersion?.version, + downloads: skill.stats?.downloads, + installs: skill.stats?.installs, + stars: skill.stats?.stars, + category: skill.category, + requiresApiKey: requiresApiKey(skill.labels), + trustState: trust.trustState, + trustSummary: trust.trustSummary, + installed: false, + files: [], + riskLabels: requiresApiKey(skill.labels) ? ['requires-api-key'] : [], + installEligibility: trust.trustState === 'blocked' + ? { status: 'blocked', reason: 'SkillHub security report blocked this skill.' } + : { status: 'installable' }, + } +}