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');