feat(skills): add registry discovery list/show commands
This commit is contained in:
@@ -50,12 +50,12 @@ Tests:
|
|||||||
|
|
||||||
Checklist:
|
Checklist:
|
||||||
|
|
||||||
- [ ] Add `flynn skills registry list` command (table/text + `--json`).
|
- [x] Add `flynn skills registry list` command (table/text + `--json`).
|
||||||
- [ ] Add `flynn skills registry show <id>` command (entry detail + source fields).
|
- [x] Add `flynn skills registry show <id>` command (entry detail + source fields).
|
||||||
- [ ] Add filtering options:
|
- [x] Add filtering options:
|
||||||
- [ ] `--search <term>`
|
- [x] `--search <term>`
|
||||||
- [ ] `--publisher <name>`
|
- [x] `--publisher <name>`
|
||||||
- [ ] Ensure output clearly marks trust metadata as declared/unverified.
|
- [x] Ensure output clearly marks trust metadata as declared/unverified.
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Acceptance:
|
|||||||
|
|
||||||
Tests:
|
Tests:
|
||||||
|
|
||||||
- [ ] Command tests for text + JSON output paths.
|
- [x] Command tests for text + JSON output paths.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -216,7 +216,7 @@
|
|||||||
"status": "in_progress",
|
"status": "in_progress",
|
||||||
"date": "2026-02-16",
|
"date": "2026-02-16",
|
||||||
"updated": "2026-02-16",
|
"updated": "2026-02-16",
|
||||||
"summary": "Started ClawHub registry milestone with Phase 1 complete: added validated registry catalog loader/parser with local file + HTTPS source support and tests. Next step is CLI registry discovery UX.",
|
"summary": "Completed Phase 2 CLI discovery UX for ClawHub registry: added `flynn skills registry list/show` with JSON/text output, search and publisher filtering, source resolution via flag/env, and explicit declared/unverified trust metadata labeling.",
|
||||||
"files_created": [
|
"files_created": [
|
||||||
"docs/plans/2026-02-16-clawhub-registry-checklist.md",
|
"docs/plans/2026-02-16-clawhub-registry-checklist.md",
|
||||||
"src/skills/registrySource.ts",
|
"src/skills/registrySource.ts",
|
||||||
@@ -224,9 +224,12 @@
|
|||||||
],
|
],
|
||||||
"files_modified": [
|
"files_modified": [
|
||||||
"src/skills/index.ts",
|
"src/skills/index.ts",
|
||||||
|
"src/cli/skills.ts",
|
||||||
|
"src/cli/skills.test.ts",
|
||||||
|
"docs/plans/2026-02-16-clawhub-registry-checklist.md",
|
||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/skills/registrySource.test.ts + pnpm typecheck passing"
|
"test_status": "pnpm test:run src/skills/registrySource.test.ts src/cli/skills.test.ts + pnpm typecheck passing"
|
||||||
},
|
},
|
||||||
"credential-system-v2-api-and-oauth": {
|
"credential-system-v2-api-and-oauth": {
|
||||||
"file": "2026-02-15-credential-system-v2-api-and-oauth-checklist.md",
|
"file": "2026-02-15-credential-system-v2-api-and-oauth-checklist.md",
|
||||||
@@ -2767,7 +2770,7 @@
|
|||||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||||
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
||||||
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
||||||
"next_up": "ClawHub registry Phase 2: add CLI discovery commands (registry list/show with JSON + filtering)"
|
"next_up": "ClawHub registry Phase 3: add install-by-registry-id flow with source resolution and confirmation gates"
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ import {
|
|||||||
resolveSkillInstallerCommandRunner,
|
resolveSkillInstallerCommandRunner,
|
||||||
runSkillExecuteAction,
|
runSkillExecuteAction,
|
||||||
runSkillInstallAction,
|
runSkillInstallAction,
|
||||||
|
toSkillRegistryListRows,
|
||||||
|
renderSkillRegistryTable,
|
||||||
|
renderSkillRegistryEntry,
|
||||||
|
filterSkillRegistryEntries,
|
||||||
|
resolveSkillRegistrySource,
|
||||||
|
describeRegistryTrust,
|
||||||
registerSkillsCommand,
|
registerSkillsCommand,
|
||||||
} from './skills.js';
|
} from './skills.js';
|
||||||
import type { Skill } from '../skills/index.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', () => {
|
describe('skills CLI helpers', () => {
|
||||||
it('maps and sorts skill rows', () => {
|
it('maps and sorts skill rows', () => {
|
||||||
const rows = toSkillListRows([
|
const rows = toSkillListRows([
|
||||||
@@ -164,6 +197,105 @@ describe('skills CLI helpers', () => {
|
|||||||
expect(output).toContain('Directory: /opt/flynn/skills/deploy');
|
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', () => {
|
it('renders unavailable reasons when skill is unavailable', () => {
|
||||||
const output = renderSkillInfo(
|
const output = renderSkillInfo(
|
||||||
buildSkill({
|
buildSkill({
|
||||||
@@ -1569,6 +1701,116 @@ describe('skills CLI helpers', () => {
|
|||||||
rmSync(root, { recursive: true, force: true });
|
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 () => {
|
it('skills install reports invalid runner via CLI option parsing path', async () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
|
const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-'));
|
||||||
const configPath = join(root, 'config.yaml');
|
const configPath = join(root, 'config.yaml');
|
||||||
|
|||||||
+241
-2
@@ -8,8 +8,8 @@ import { auditLogger } from '../audit/index.js';
|
|||||||
import { queryAuditLogs } from '../audit/export.js';
|
import { queryAuditLogs } from '../audit/export.js';
|
||||||
import type { AuditEvent } from '../audit/types.js';
|
import type { AuditEvent } from '../audit/types.js';
|
||||||
import type { Config } from '../config/schema.js';
|
import type { Config } from '../config/schema.js';
|
||||||
import type { Skill } from '../skills/index.js';
|
import type { Skill, SkillRegistryEntry, SkillRegistrySource } from '../skills/index.js';
|
||||||
import { loadAllSkills, SkillInstaller, buildInstallerPlan, loadSkill } from '../skills/index.js';
|
import { loadAllSkills, SkillInstaller, buildInstallerPlan, loadSkill, loadSkillRegistryCatalog } from '../skills/index.js';
|
||||||
import type { SkillPermissions } from '../skills/types.js';
|
import type { SkillPermissions } from '../skills/types.js';
|
||||||
import { loadConfigSafe } from './shared.js';
|
import { loadConfigSafe } from './shared.js';
|
||||||
|
|
||||||
@@ -20,6 +20,15 @@ export interface SkillListRow {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SkillRegistryListRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
publisher: string;
|
||||||
|
trust: 'declared_unverified' | 'none_declared';
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkillRefreshSummary {
|
export interface SkillRefreshSummary {
|
||||||
total: number;
|
total: number;
|
||||||
available: number;
|
available: number;
|
||||||
@@ -27,6 +36,143 @@ export interface SkillRefreshSummary {
|
|||||||
tiers: Record<Skill['manifest']['tier'], number>;
|
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 {
|
export interface SkillInstallerPlanView {
|
||||||
skill: {
|
skill: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1292,6 +1438,99 @@ export function registerSkillsCommand(program: Command): void {
|
|||||||
console.log(renderSkillInfo(skill));
|
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
|
skills
|
||||||
.command('install <path>')
|
.command('install <path>')
|
||||||
.description('Install a skill from a local directory')
|
.description('Install a skill from a local directory')
|
||||||
|
|||||||
Reference in New Issue
Block a user