feat(skills): add registry discovery list/show commands

This commit is contained in:
William Valentin
2026-02-16 00:17:50 -08:00
parent 4391c6e5b3
commit f2b03b8836
4 changed files with 496 additions and 12 deletions
+242
View File
@@ -39,6 +39,12 @@ import {
resolveSkillInstallerCommandRunner,
runSkillExecuteAction,
runSkillInstallAction,
toSkillRegistryListRows,
renderSkillRegistryTable,
renderSkillRegistryEntry,
filterSkillRegistryEntries,
resolveSkillRegistrySource,
describeRegistryTrust,
registerSkillsCommand,
} from './skills.js';
import type { Skill } from '../skills/index.js';
@@ -102,6 +108,33 @@ function writeSkillsCliConfig(
);
}
function writeSkillRegistryCatalog(path: string): void {
writeFileSync(
path,
JSON.stringify({
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',
},
{
id: 'calendar',
name: 'Calendar',
version: '2.0.0',
source: './skills/calendar',
summary: 'Calendar sync',
},
],
}),
'utf-8',
);
}
describe('skills CLI helpers', () => {
it('maps and sorts skill rows', () => {
const rows = toSkillListRows([
@@ -164,6 +197,105 @@ describe('skills CLI helpers', () => {
expect(output).toContain('Directory: /opt/flynn/skills/deploy');
});
it('maps and sorts registry rows with trust status', () => {
const rows = toSkillRegistryListRows([
{
id: 'zeta',
name: 'Zeta',
version: '1.0.0',
source: 'https://example.com/zeta.git',
summary: 'zeta',
},
{
id: 'alpha',
name: 'Alpha',
version: '1.0.0',
source: 'https://example.com/alpha.git',
summary: 'alpha',
publisher: 'Acme',
},
]);
expect(rows.map((row) => row.id)).toEqual(['alpha', 'zeta']);
expect(rows[0]?.trust).toBe('declared_unverified');
expect(rows[1]?.trust).toBe('none_declared');
});
it('filters registry entries by search and publisher', () => {
const entries = [
{
id: 'todoist',
name: 'Todoist',
version: '1.2.3',
source: 'https://example.com/todoist.git',
summary: 'Task manager',
publisher: 'Acme',
},
{
id: 'calendar',
name: 'Calendar',
version: '2.0.0',
source: 'https://example.com/calendar.git',
summary: 'Calendar sync',
publisher: 'Orbit',
},
];
expect(filterSkillRegistryEntries(entries, { search: 'task' }).map((entry) => entry.id)).toEqual(['todoist']);
expect(filterSkillRegistryEntries(entries, { publisher: 'acme' }).map((entry) => entry.id)).toEqual(['todoist']);
expect(filterSkillRegistryEntries(entries, { search: 'calendar', publisher: 'acme' })).toEqual([]);
});
it('renders no-registry-items text when empty', () => {
expect(renderSkillRegistryTable([])).toBe('No registry skills found.');
});
it('renders registry entry with trust note and declared fields', () => {
const output = renderSkillRegistryEntry({
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',
sha256: 'abc123',
});
expect(output).toContain('ID: todoist');
expect(output).toContain('Trust: declared (unverified)');
expect(output).toContain('Publisher (declared): Acme');
expect(output).toContain('SHA256 (declared): abc123');
});
it('describes trust metadata and resolves registry source values', () => {
const declaredTrust = describeRegistryTrust({
id: 'todoist',
name: 'Todoist',
version: '1.2.3',
source: 'https://example.com/skills/todoist.git',
summary: 'Task manager integration',
publisher: 'Acme',
});
expect(declaredTrust.status).toBe('declared_unverified');
const noneTrust = describeRegistryTrust({
id: 'calendar',
name: 'Calendar',
version: '2.0.0',
source: './skills/calendar',
summary: 'Calendar sync',
});
expect(noneTrust.status).toBe('none_declared');
expect(resolveSkillRegistrySource('./registry.json').source).toEqual({ type: 'file', path: './registry.json' });
expect(resolveSkillRegistrySource('https://registry.example/catalog.json').source).toEqual({
type: 'url',
url: 'https://registry.example/catalog.json',
});
expect(resolveSkillRegistrySource('http://registry.example/catalog.json').error).toContain('https://');
});
it('renders unavailable reasons when skill is unavailable', () => {
const output = renderSkillInfo(
buildSkill({
@@ -1569,6 +1701,116 @@ describe('skills CLI helpers', () => {
rmSync(root, { recursive: true, force: true });
});
it('skills registry list renders text output from source file', async () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const registryPath = join(root, 'registry.json');
writeSkillRegistryCatalog(registryPath);
const program = new Command();
registerSkillsCommand(program);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
process.exitCode = undefined;
await program.parseAsync(['skills', 'registry', 'list', '--source', registryPath], { from: 'user' });
expect(logSpy).toHaveBeenCalledTimes(1);
const output = String(logSpy.mock.calls[0]?.[0]);
expect(output).toContain('ID');
expect(output).toContain('todoist');
expect(output).toContain('declared_unverified');
expect(errorSpy).not.toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
logSpy.mockRestore();
errorSpy.mockRestore();
process.exitCode = undefined;
rmSync(root, { recursive: true, force: true });
});
it('skills registry list supports json output and filtering', async () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const registryPath = join(root, 'registry.json');
writeSkillRegistryCatalog(registryPath);
const program = new Command();
registerSkillsCommand(program);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
process.exitCode = undefined;
await program.parseAsync(
['skills', 'registry', 'list', '--source', registryPath, '--search', 'task', '--publisher', 'acme', '--json'],
{ from: 'user' },
);
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload).toHaveLength(1);
expect(payload[0]?.id).toBe('todoist');
expect(payload[0]?.trust_label).toBe('declared (unverified)');
expect(process.exitCode).toBeUndefined();
logSpy.mockRestore();
process.exitCode = undefined;
rmSync(root, { recursive: true, force: true });
});
it('skills registry show outputs entry details and json trust metadata', async () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const registryPath = join(root, 'registry.json');
writeSkillRegistryCatalog(registryPath);
const program = new Command();
registerSkillsCommand(program);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
process.exitCode = undefined;
await program.parseAsync(['skills', 'registry', 'show', 'todoist', '--source', registryPath], { from: 'user' });
const text = String(logSpy.mock.calls[0]?.[0]);
expect(text).toContain('ID: todoist');
expect(text).toContain('Trust note:');
logSpy.mockClear();
await program.parseAsync(['skills', 'registry', 'show', 'todoist', '--source', registryPath, '--json'], { from: 'user' });
const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.id).toBe('todoist');
expect(payload.trust_metadata.status).toBe('declared_unverified');
logSpy.mockRestore();
process.exitCode = undefined;
rmSync(root, { recursive: true, force: true });
});
it('skills registry commands report source/lookup errors', async () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const registryPath = join(root, 'registry.json');
writeSkillRegistryCatalog(registryPath);
const program = new Command();
registerSkillsCommand(program);
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
process.exitCode = undefined;
await program.parseAsync(['skills', 'registry', 'list', '--source', 'http://registry.example/catalog.json'], { from: 'user' });
expect(errorSpy).toHaveBeenCalledWith("Registry URL must use https:// (http://registry.example/catalog.json)");
expect(process.exitCode).toBe(1);
errorSpy.mockClear();
process.exitCode = undefined;
await program.parseAsync(['skills', 'registry', 'show', 'missing', '--source', registryPath], { from: 'user' });
expect(errorSpy).toHaveBeenCalledWith("Registry skill 'missing' not found.");
expect(process.exitCode).toBe(1);
errorSpy.mockRestore();
process.exitCode = undefined;
rmSync(root, { recursive: true, force: true });
});
it('skills install reports invalid runner via CLI option parsing path', async () => {
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
const configPath = join(root, 'config.yaml');
+241 -2
View File
@@ -8,8 +8,8 @@ import { auditLogger } from '../audit/index.js';
import { queryAuditLogs } from '../audit/export.js';
import type { AuditEvent } from '../audit/types.js';
import type { Config } from '../config/schema.js';
import type { Skill } from '../skills/index.js';
import { loadAllSkills, SkillInstaller, buildInstallerPlan, loadSkill } from '../skills/index.js';
import type { Skill, SkillRegistryEntry, SkillRegistrySource } from '../skills/index.js';
import { loadAllSkills, SkillInstaller, buildInstallerPlan, loadSkill, loadSkillRegistryCatalog } from '../skills/index.js';
import type { SkillPermissions } from '../skills/types.js';
import { loadConfigSafe } from './shared.js';
@@ -20,6 +20,15 @@ export interface SkillListRow {
reason?: string;
}
export interface SkillRegistryListRow {
id: string;
name: string;
version: string;
publisher: string;
trust: 'declared_unverified' | 'none_declared';
summary: string;
}
export interface SkillRefreshSummary {
total: number;
available: number;
@@ -27,6 +36,143 @@ export interface SkillRefreshSummary {
tiers: Record<Skill['manifest']['tier'], number>;
}
const SKILL_REGISTRY_SOURCE_ENV = 'FLYNN_SKILLS_REGISTRY_SOURCE';
function hasDeclaredTrustMetadata(entry: SkillRegistryEntry): boolean {
return Boolean(entry.publisher || entry.homepage || entry.sha256);
}
export function describeRegistryTrust(entry: SkillRegistryEntry): {
status: 'declared_unverified' | 'none_declared';
label: string;
note: string;
} {
if (hasDeclaredTrustMetadata(entry)) {
return {
status: 'declared_unverified',
label: 'declared (unverified)',
note: 'Trust metadata is declared by registry publishers and not verified by Flynn.',
};
}
return {
status: 'none_declared',
label: 'none declared',
note: 'No trust metadata was declared by the registry; all metadata remains unverified by Flynn.',
};
}
export function toSkillRegistryListRows(entries: SkillRegistryEntry[]): SkillRegistryListRow[] {
return entries
.map((entry) => {
const trust = describeRegistryTrust(entry);
return {
id: entry.id,
name: entry.name,
version: entry.version,
publisher: entry.publisher ?? '-',
trust: trust.status,
summary: entry.summary,
};
})
.sort((a, b) => a.id.localeCompare(b.id));
}
export function filterSkillRegistryEntries(
entries: SkillRegistryEntry[],
opts: { search?: string; publisher?: string },
): SkillRegistryEntry[] {
const search = opts.search?.trim().toLowerCase();
const publisher = opts.publisher?.trim().toLowerCase();
return entries.filter((entry) => {
if (publisher) {
const value = (entry.publisher ?? '').toLowerCase();
if (value !== publisher) {
return false;
}
}
if (!search) {
return true;
}
const haystack = [entry.id, entry.name, entry.summary, entry.publisher ?? ''].join(' ').toLowerCase();
return haystack.includes(search);
});
}
export function renderSkillRegistryTable(rows: SkillRegistryListRow[]): string {
if (rows.length === 0) {
return 'No registry skills found.';
}
const idWidth = Math.max('ID'.length, ...rows.map((row) => row.id.length));
const nameWidth = Math.max('NAME'.length, ...rows.map((row) => row.name.length));
const versionWidth = Math.max('VERSION'.length, ...rows.map((row) => row.version.length));
const publisherWidth = Math.max('PUBLISHER'.length, ...rows.map((row) => row.publisher.length));
const trustWidth = Math.max('TRUST'.length, ...rows.map((row) => row.trust.length));
const lines = [
`${'ID'.padEnd(idWidth)} ${'NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)} ${'PUBLISHER'.padEnd(publisherWidth)} ${'TRUST'.padEnd(trustWidth)} SUMMARY`,
`${'-'.repeat(idWidth)} ${'-'.repeat(nameWidth)} ${'-'.repeat(versionWidth)} ${'-'.repeat(publisherWidth)} ${'-'.repeat(trustWidth)} -------`,
];
for (const row of rows) {
lines.push(
`${row.id.padEnd(idWidth)} ${row.name.padEnd(nameWidth)} ${row.version.padEnd(versionWidth)} ${row.publisher.padEnd(publisherWidth)} ${row.trust.padEnd(trustWidth)} ${row.summary}`,
);
}
return lines.join('\n');
}
export function renderSkillRegistryEntry(entry: SkillRegistryEntry): string {
const trust = describeRegistryTrust(entry);
const lines = [
`ID: ${entry.id}`,
`Name: ${entry.name}`,
`Version: ${entry.version}`,
`Summary: ${entry.summary}`,
`Source: ${entry.source}`,
`Trust: ${trust.label}`,
`Trust note: ${trust.note}`,
];
if (entry.publisher) {
lines.push(`Publisher (declared): ${entry.publisher}`);
}
if (entry.homepage) {
lines.push(`Homepage (declared): ${entry.homepage}`);
}
if (entry.sha256) {
lines.push(`SHA256 (declared): ${entry.sha256}`);
}
return lines.join('\n');
}
export function resolveSkillRegistrySource(
sourceArg?: string,
): { source?: SkillRegistrySource; error?: string } {
const raw = sourceArg?.trim() || process.env[SKILL_REGISTRY_SOURCE_ENV]?.trim();
if (!raw) {
return {
error: `Registry source is required. Pass --source <path-or-https-url> or set ${SKILL_REGISTRY_SOURCE_ENV}.`,
};
}
if (raw.startsWith('http://')) {
return { error: `Registry URL must use https:// (${raw})` };
}
if (raw.startsWith('https://')) {
return { source: { type: 'url', url: raw } };
}
return { source: { type: 'file', path: raw } };
}
export interface SkillInstallerPlanView {
skill: {
name: string;
@@ -1292,6 +1438,99 @@ export function registerSkillsCommand(program: Command): void {
console.log(renderSkillInfo(skill));
});
const registry = skills
.command('registry')
.description('Discover skills from a registry catalog')
.action(() => {
registry.outputHelp();
});
registry
.command('list')
.description('List skills from a registry catalog')
.option('--source <path-or-url>', 'Registry catalog source (local file path or HTTPS URL)')
.option('--search <term>', 'Filter by id/name/summary/publisher')
.option('--publisher <name>', 'Filter by exact publisher name (case-insensitive)')
.option('--json', 'Output as JSON')
.action(async (opts: { source?: string; search?: string; publisher?: string; json?: boolean }) => {
const sourceResult = resolveSkillRegistrySource(opts.source);
if (sourceResult.error || !sourceResult.source) {
console.error(sourceResult.error ?? 'Failed to resolve registry source.');
process.exitCode = 1;
return;
}
try {
const catalog = await loadSkillRegistryCatalog(sourceResult.source);
const filtered = filterSkillRegistryEntries(catalog.skills, {
search: opts.search,
publisher: opts.publisher,
});
if (opts.json) {
const rows = toSkillRegistryListRows(filtered).map((row) => ({
...row,
trust_label: row.trust === 'declared_unverified' ? 'declared (unverified)' : 'none declared',
}));
console.log(JSON.stringify(rows, null, 2));
return;
}
console.log(renderSkillRegistryTable(toSkillRegistryListRows(filtered)));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
});
registry
.command('show <id>')
.description('Show details for a registry entry')
.option('--source <path-or-url>', 'Registry catalog source (local file path or HTTPS URL)')
.option('--json', 'Output as JSON')
.action(async (id: string, opts: { source?: string; json?: boolean }) => {
const sourceResult = resolveSkillRegistrySource(opts.source);
if (sourceResult.error || !sourceResult.source) {
console.error(sourceResult.error ?? 'Failed to resolve registry source.');
process.exitCode = 1;
return;
}
try {
const catalog = await loadSkillRegistryCatalog(sourceResult.source);
const normalizedId = id.trim().toLowerCase();
const entry = catalog.skills.find((item) => item.id.toLowerCase() === normalizedId);
if (!entry) {
console.error(`Registry skill '${id}' not found.`);
process.exitCode = 1;
return;
}
const trust = describeRegistryTrust(entry);
if (opts.json) {
console.log(
JSON.stringify(
{
...entry,
trust_metadata: {
status: trust.status,
label: trust.label,
note: trust.note,
},
},
null,
2,
),
);
return;
}
console.log(renderSkillRegistryEntry(entry));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
});
skills
.command('install <path>')
.description('Install a skill from a local directory')