diff --git a/docs/plans/2026-02-16-clawhub-registry-checklist.md b/docs/plans/2026-02-16-clawhub-registry-checklist.md index 95e7bc9..83c5788 100644 --- a/docs/plans/2026-02-16-clawhub-registry-checklist.md +++ b/docs/plans/2026-02-16-clawhub-registry-checklist.md @@ -50,12 +50,12 @@ Tests: Checklist: -- [ ] Add `flynn skills registry list` command (table/text + `--json`). -- [ ] Add `flynn skills registry show ` command (entry detail + source fields). -- [ ] Add filtering options: - - [ ] `--search ` - - [ ] `--publisher ` -- [ ] Ensure output clearly marks trust metadata as declared/unverified. +- [x] Add `flynn skills registry list` command (table/text + `--json`). +- [x] Add `flynn skills registry show ` command (entry detail + source fields). +- [x] Add filtering options: + - [x] `--search ` + - [x] `--publisher ` +- [x] Ensure output clearly marks trust metadata as declared/unverified. Acceptance: @@ -63,7 +63,7 @@ Acceptance: Tests: -- [ ] Command tests for text + JSON output paths. +- [x] Command tests for text + JSON output paths. --- diff --git a/docs/plans/state.json b/docs/plans/state.json index 3a0fab4..5eceaeb 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -216,7 +216,7 @@ "status": "in_progress", "date": "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": [ "docs/plans/2026-02-16-clawhub-registry-checklist.md", "src/skills/registrySource.ts", @@ -224,9 +224,12 @@ ], "files_modified": [ "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" ], - "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": { "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", "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", - "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": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index ce234db..96b8004 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -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'); diff --git a/src/cli/skills.ts b/src/cli/skills.ts index 67f3d8e..dc65c69 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -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; } +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 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 ', 'Registry catalog source (local file path or HTTPS URL)') + .option('--search ', 'Filter by id/name/summary/publisher') + .option('--publisher ', '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 ') + .description('Show details for a registry entry') + .option('--source ', '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 ') .description('Install a skill from a local directory')