feat(skills): add ClawHub registry catalog parser and loader
This commit is contained in:
@@ -14,5 +14,7 @@ export { buildInstallerPlan } from './planner.js';
|
||||
export type { InstallerPlan, InstallerPlanStep, InstallerPlanSkip, InstallerPlanningOptions } from './planner.js';
|
||||
export { SkillRegistry } from './registry.js';
|
||||
export { SkillInstaller } from './installer.js';
|
||||
export { loadSkillRegistryCatalog, parseSkillRegistryCatalog } from './registrySource.js';
|
||||
export type { SkillRegistryCatalog, SkillRegistryEntry, SkillRegistrySource, SkillRegistryLoadOptions } from './registrySource.js';
|
||||
export { SkillsWatcher } from './watcher.js';
|
||||
export type { SkillsWatcherConfig, SkillsWatcherEvent } from './watcher.js';
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadSkillRegistryCatalog, parseSkillRegistryCatalog } from './registrySource.js';
|
||||
|
||||
describe('parseSkillRegistryCatalog', () => {
|
||||
it('parses a valid registry catalog', () => {
|
||||
const catalog = parseSkillRegistryCatalog({
|
||||
skills: [
|
||||
{
|
||||
id: 'todoist',
|
||||
name: 'Todoist',
|
||||
version: '1.2.3',
|
||||
source: 'https://example.com/skills/todoist.git',
|
||||
summary: 'Task manager integration',
|
||||
publisher: 'acme',
|
||||
homepage: 'https://example.com/todoist',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(catalog.skills).toHaveLength(1);
|
||||
expect(catalog.skills[0]?.id).toBe('todoist');
|
||||
expect(catalog.skills[0]?.publisher).toBe('acme');
|
||||
});
|
||||
|
||||
it('rejects missing required fields', () => {
|
||||
expect(() =>
|
||||
parseSkillRegistryCatalog({
|
||||
skills: [
|
||||
{
|
||||
id: 'missing-summary',
|
||||
name: 'Broken',
|
||||
version: '0.1.0',
|
||||
source: 'https://example.com/skills/broken.git',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/summary/);
|
||||
});
|
||||
|
||||
it('rejects invalid ids', () => {
|
||||
expect(() =>
|
||||
parseSkillRegistryCatalog({
|
||||
skills: [
|
||||
{
|
||||
id: 'Not Valid!',
|
||||
name: 'Broken',
|
||||
version: '0.1.0',
|
||||
source: 'https://example.com/skills/broken.git',
|
||||
summary: 'Broken',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSkillRegistryCatalog', () => {
|
||||
it('loads catalog from a local file path', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'flynn-registry-test-'));
|
||||
const filePath = join(dir, 'registry.json');
|
||||
|
||||
writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
skills: [
|
||||
{
|
||||
id: 'notes',
|
||||
name: 'Notes',
|
||||
version: '0.0.1',
|
||||
source: './skills/notes',
|
||||
summary: 'Local notes skill',
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const catalog = await loadSkillRegistryCatalog({ type: 'file', path: filePath });
|
||||
expect(catalog.skills).toHaveLength(1);
|
||||
expect(catalog.skills[0]?.source).toBe('./skills/notes');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('loads catalog from HTTPS URL with injected fetch', async () => {
|
||||
const catalog = await loadSkillRegistryCatalog(
|
||||
{ type: 'url', url: 'https://registry.example.com/catalog.json' },
|
||||
{
|
||||
fetchImpl: async () =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
skills: [
|
||||
{
|
||||
id: 'calendar',
|
||||
name: 'Calendar',
|
||||
version: '2.0.0',
|
||||
source: 'https://example.com/skills/calendar.git',
|
||||
summary: 'Calendar integration',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}) as Response,
|
||||
},
|
||||
);
|
||||
|
||||
expect(catalog.skills).toHaveLength(1);
|
||||
expect(catalog.skills[0]?.id).toBe('calendar');
|
||||
});
|
||||
|
||||
it('rejects non-HTTPS URL sources', async () => {
|
||||
await expect(
|
||||
loadSkillRegistryCatalog(
|
||||
{ type: 'url', url: 'http://registry.example.com/catalog.json' },
|
||||
{
|
||||
fetchImpl: async () =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ skills: [] }),
|
||||
}) as Response,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/https/);
|
||||
});
|
||||
|
||||
it('propagates HTTP fetch failures with status', async () => {
|
||||
await expect(
|
||||
loadSkillRegistryCatalog(
|
||||
{ type: 'url', url: 'https://registry.example.com/catalog.json' },
|
||||
{
|
||||
fetchImpl: async () =>
|
||||
({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: async () => ({}),
|
||||
}) as Response,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow(/502/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<unknown> {
|
||||
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 await response.json();
|
||||
}
|
||||
|
||||
export async function loadSkillRegistryCatalog(
|
||||
source: SkillRegistrySource,
|
||||
opts: SkillRegistryLoadOptions = {},
|
||||
): Promise<SkillRegistryCatalog> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user