feat(skills): add registry discovery list/show commands
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user