import { readFileSync } from 'fs'; import { resolve } from 'path'; export interface SkillRegistryEntry { id: string; name: string; version: string; source: string; summary: string; publisher?: string; homepage?: string; sha256?: string; } export interface SkillRegistryCatalog { skills: SkillRegistryEntry[]; } export type SkillRegistrySource = | { type: 'file'; path: string } | { type: 'url'; url: string }; export interface SkillRegistryLoadOptions { timeoutMs?: number; fetchImpl?: typeof fetch; } const DEFAULT_TIMEOUT_MS = 10_000; const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/; function describePath(ctx: string, key: string): string { return `${ctx}.${key}`; } function readRequiredString(value: unknown, ctx: string, key: string): string { if (typeof value !== 'string' || value.trim().length === 0) { throw new Error(`Invalid registry entry: expected non-empty string at ${describePath(ctx, key)}`); } return value.trim(); } function readOptionalString(value: unknown, ctx: string, key: string): string | undefined { if (value === undefined) { return undefined; } if (typeof value !== 'string' || value.trim().length === 0) { throw new Error(`Invalid registry entry: expected non-empty string at ${describePath(ctx, key)}`); } return value.trim(); } function parseSkillEntry(raw: unknown, index: number): SkillRegistryEntry { const ctx = `skills[${index}]`; if (!raw || typeof raw !== 'object') { throw new Error(`Invalid registry entry: expected object at ${ctx}`); } const obj = raw as Record; const id = readRequiredString(obj.id, ctx, 'id'); if (!SKILL_ID_RE.test(id)) { throw new Error(`Invalid registry entry: ${ctx}.id must match ${SKILL_ID_RE.source}`); } const source = readRequiredString(obj.source, ctx, 'source'); if (!source.includes('/') && !source.includes(':') && !source.startsWith('.')) { throw new Error(`Invalid registry entry: ${ctx}.source must be a path or URL`); } return { id, name: readRequiredString(obj.name, ctx, 'name'), version: readRequiredString(obj.version, ctx, 'version'), source, summary: readRequiredString(obj.summary, ctx, 'summary'), publisher: readOptionalString(obj.publisher, ctx, 'publisher'), homepage: readOptionalString(obj.homepage, ctx, 'homepage'), sha256: readOptionalString(obj.sha256, ctx, 'sha256'), }; } export function parseSkillRegistryCatalog(raw: unknown): SkillRegistryCatalog { if (!raw || typeof raw !== 'object') { throw new Error('Invalid registry catalog: expected top-level object'); } const obj = raw as Record; if (!Array.isArray(obj.skills)) { throw new Error('Invalid registry catalog: expected skills array'); } return { skills: obj.skills.map((entry, index) => parseSkillEntry(entry, index)), }; } async function fetchRegistryJson(url: string, opts: SkillRegistryLoadOptions): Promise { if (!url.startsWith('https://')) { throw new Error(`Registry URL must use https:// (${url})`); } const fetchImpl = opts.fetchImpl ?? fetch; const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; const signal = AbortSignal.timeout(timeoutMs); const response = await fetchImpl(url, { signal }); if (!response.ok) { throw new Error(`Registry fetch failed: HTTP ${response.status}`); } return response.json(); } export async function loadSkillRegistryCatalog( source: SkillRegistrySource, opts: SkillRegistryLoadOptions = {}, ): Promise { if (source.type === 'file') { const absPath = resolve(source.path); const raw = readFileSync(absPath, 'utf8'); return parseSkillRegistryCatalog(JSON.parse(raw) as unknown); } const json = await fetchRegistryJson(source.url, opts); return parseSkillRegistryCatalog(json); }