From b3e5aee33321226674a37c482d4ee664b9cda6ac Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 16:42:00 -0800 Subject: [PATCH] feat(skills): expose list command for skill visibility --- docs/plans/state.json | 23 ++++++++++-- src/cli/index.test.ts | 1 + src/cli/index.ts | 2 + src/cli/skills.test.ts | 61 ++++++++++++++++++++++++++++++ src/cli/skills.ts | 85 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 src/cli/skills.test.ts create mode 100644 src/cli/skills.ts diff --git a/docs/plans/state.json b/docs/plans/state.json index 37221be..227fc82 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1203,9 +1203,24 @@ "phases": { "phase_1_command_dispatch": { "priority": "P0", - "status": "not_started", + "status": "in_progress", "description": "flynn skills CLI commands (list/info/install/uninstall/refresh) with doctor enhancement", - "effort": "2-3 hours" + "effort": "2-3 hours", + "sub_slices": { + "skills_list_command": { + "status": "completed", + "description": "Added `flynn skills list` command dispatch with table/JSON output and CLI registration", + "files_created": [ + "src/cli/skills.ts", + "src/cli/skills.test.ts" + ], + "files_modified": [ + "src/cli/index.ts", + "src/cli/index.test.ts" + ], + "test_status": "typecheck + targeted skills/index CLI tests + full test suite + lint (warnings only, 0 errors) + build passing" + } + } }, "phase_2_skills_watcher": { "priority": "P1", @@ -1244,7 +1259,7 @@ }, "overall_progress": { - "total_test_count": 1490, + "total_test_count": 1493, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1264,7 +1279,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: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Skills infrastructure implementation (Phase 1: command dispatch)" + "next_up": "Skills infrastructure Phase 1: add `flynn skills info ` command dispatch" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index f7d36cb..2c99d1b 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -12,6 +12,7 @@ describe('CLI program', () => { expect(commandNames).toContain('sessions'); expect(commandNames).toContain('doctor'); expect(commandNames).toContain('config'); + expect(commandNames).toContain('skills'); }); it('has version info', () => { diff --git a/src/cli/index.ts b/src/cli/index.ts index 0d2897f..9a8930c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,6 +18,7 @@ import { registerGcalAuthCommand } from './gcal-auth.js'; import { registerGdocsAuthCommand } from './gdocs-auth.js'; import { registerGdriveAuthCommand } from './gdrive-auth.js'; import { registerGtasksAuthCommand } from './gtasks-auth.js'; +import { registerSkillsCommand } from './skills.js'; export function createProgram(): Command { const program = new Command(); @@ -40,6 +41,7 @@ export function createProgram(): Command { registerGdocsAuthCommand(program); registerGdriveAuthCommand(program); registerGtasksAuthCommand(program); + registerSkillsCommand(program); return program; } diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts new file mode 100644 index 0000000..748f041 --- /dev/null +++ b/src/cli/skills.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { toSkillListRows, renderSkillsTable } from './skills.js'; +import type { Skill } from '../skills/index.js'; + +function buildSkill(overrides: Partial): Skill { + return { + manifest: { + name: 'sample-skill', + description: 'Sample skill', + version: '1.0.0', + tier: 'workspace', + ...overrides.manifest, + }, + instructions: '# Sample skill', + directory: '/tmp/sample-skill', + available: true, + ...overrides, + }; +} + +describe('skills CLI helpers', () => { + it('maps and sorts skill rows', () => { + const rows = toSkillListRows([ + buildSkill({ + manifest: { + name: 'zeta', + description: 'zeta', + version: '1.0.0', + tier: 'managed', + }, + }), + buildSkill({ + manifest: { + name: 'alpha', + description: 'alpha', + version: '1.0.0', + tier: 'bundled', + }, + }), + ]); + + expect(rows.map((row) => row.name)).toEqual(['alpha', 'zeta']); + expect(rows[0]?.status).toBe('available'); + }); + + it('includes unavailable reason text', () => { + const rows = toSkillListRows([ + buildSkill({ + available: false, + unavailableReasons: ['Required binary not found', 'Missing API key'], + }), + ]); + + expect(rows[0]?.status).toBe('unavailable'); + expect(rows[0]?.reason).toBe('Required binary not found; Missing API key'); + }); + + it('renders a no-skills message when empty', () => { + expect(renderSkillsTable([])).toBe('No skills found.'); + }); +}); diff --git a/src/cli/skills.ts b/src/cli/skills.ts new file mode 100644 index 0000000..83b043b --- /dev/null +++ b/src/cli/skills.ts @@ -0,0 +1,85 @@ +import type { Command } from 'commander'; +import { resolve } from 'path'; +import { homedir } from 'os'; +import type { Skill } from '../skills/index.js'; +import { loadAllSkills } from '../skills/index.js'; +import { loadConfigSafe } from './shared.js'; + +export interface SkillListRow { + name: string; + tier: Skill['manifest']['tier']; + status: 'available' | 'unavailable'; + reason?: string; +} + +export function toSkillListRows(skills: Skill[]): SkillListRow[] { + return skills + .map((skill) => ({ + name: skill.manifest.name, + tier: skill.manifest.tier, + status: (skill.available ? 'available' : 'unavailable') as SkillListRow['status'], + reason: skill.unavailableReasons?.join('; '), + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function renderSkillsTable(rows: SkillListRow[]): string { + if (rows.length === 0) { + return 'No skills found.'; + } + + const nameWidth = Math.max('NAME'.length, ...rows.map((row) => row.name.length)); + const tierWidth = Math.max('TIER'.length, ...rows.map((row) => row.tier.length)); + const statusWidth = Math.max('STATUS'.length, ...rows.map((row) => row.status.length)); + + const lines = [ + `${'NAME'.padEnd(nameWidth)} ${'TIER'.padEnd(tierWidth)} ${'STATUS'.padEnd(statusWidth)} REASON`, + `${'-'.repeat(nameWidth)} ${'-'.repeat(tierWidth)} ${'-'.repeat(statusWidth)} ------`, + ]; + + for (const row of rows) { + lines.push( + `${row.name.padEnd(nameWidth)} ${row.tier.padEnd(tierWidth)} ${row.status.padEnd(statusWidth)} ${row.reason ?? ''}`, + ); + } + + return lines.join('\n'); +} + +export function registerSkillsCommand(program: Command): void { + const skills = program + .command('skills') + .description('Manage Flynn skills') + .action(() => { + skills.outputHelp(); + }); + + skills + .command('list') + .description('List discovered skills') + .option('--json', 'Output as JSON') + .option('-c, --config ', 'Config file path') + .action((opts: { json?: boolean; config?: string }) => { + const loaded = loadConfigSafe(opts.config); + if (loaded.error || !loaded.config) { + console.error(loaded.error ?? 'Failed to load config'); + process.exitCode = 1; + return; + } + + const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills'); + const skills = loadAllSkills({ + bundledDir: loaded.config.skills.bundled_dir, + managedDir: loaded.config.skills.managed_dir ?? defaultManagedDir, + workspaceDir: loaded.config.skills.workspace_dir, + }); + + const rows = toSkillListRows(skills); + if (opts.json) { + console.log(JSON.stringify(rows, null, 2)); + return; + } + + console.log(renderSkillsTable(rows)); + }); +}