From 0d84a6bccc596e8fd44ed032b74465cb5463db52 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 16:44:46 -0800 Subject: [PATCH] feat(skills): add info command for skill inspection --- docs/plans/state.json | 13 ++++++- src/cli/skills.test.ts | 35 +++++++++++++++++- src/cli/skills.ts | 84 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 118 insertions(+), 14 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 227fc82..623d8bd 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1219,6 +1219,15 @@ "src/cli/index.test.ts" ], "test_status": "typecheck + targeted skills/index CLI tests + full test suite + lint (warnings only, 0 errors) + build passing" + }, + "skills_info_command": { + "status": "completed", + "description": "Added `flynn skills info ` command dispatch with human-readable and JSON output for discovered skills", + "files_modified": [ + "src/cli/skills.ts", + "src/cli/skills.test.ts" + ], + "test_status": "typecheck + targeted skills CLI tests + full test suite + lint (warnings only, 0 errors) + build passing" } } }, @@ -1259,7 +1268,7 @@ }, "overall_progress": { - "total_test_count": 1493, + "total_test_count": 1495, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1279,7 +1288,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 Phase 1: add `flynn skills info ` command dispatch" + "next_up": "Skills infrastructure Phase 1: add `flynn skills install ` command dispatch" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/cli/skills.test.ts b/src/cli/skills.test.ts index 748f041..2c19215 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { toSkillListRows, renderSkillsTable } from './skills.js'; +import { toSkillListRows, renderSkillsTable, renderSkillInfo } from './skills.js'; import type { Skill } from '../skills/index.js'; function buildSkill(overrides: Partial): Skill { @@ -58,4 +58,37 @@ describe('skills CLI helpers', () => { it('renders a no-skills message when empty', () => { expect(renderSkillsTable([])).toBe('No skills found.'); }); + + it('renders detailed skill info for available skill', () => { + const output = renderSkillInfo( + buildSkill({ + manifest: { + name: 'deploy', + description: 'Deployment helper', + version: '2.0.0', + tier: 'bundled', + author: 'Flynn', + tools: ['shell.exec', 'git.status'], + }, + directory: '/opt/flynn/skills/deploy', + }), + ); + + expect(output).toContain('Name: deploy'); + expect(output).toContain('Status: available'); + expect(output).toContain('Tools: shell.exec, git.status'); + expect(output).toContain('Directory: /opt/flynn/skills/deploy'); + }); + + it('renders unavailable reasons when skill is unavailable', () => { + const output = renderSkillInfo( + buildSkill({ + available: false, + unavailableReasons: ['Required binary not found'], + }), + ); + + expect(output).toContain('Status: unavailable'); + expect(output).toContain('Unavailable reasons: Required binary not found'); + }); }); diff --git a/src/cli/skills.ts b/src/cli/skills.ts index 83b043b..266125b 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -46,6 +46,47 @@ export function renderSkillsTable(rows: SkillListRow[]): string { return lines.join('\n'); } +export function renderSkillInfo(skill: Skill): string { + const lines: string[] = [ + `Name: ${skill.manifest.name}`, + `Description: ${skill.manifest.description}`, + `Version: ${skill.manifest.version}`, + `Tier: ${skill.manifest.tier}`, + `Status: ${skill.available ? 'available' : 'unavailable'}`, + `Directory: ${skill.directory}`, + ]; + + if (skill.manifest.author) { + lines.push(`Author: ${skill.manifest.author}`); + } + + if (skill.manifest.tools && skill.manifest.tools.length > 0) { + lines.push(`Tools: ${skill.manifest.tools.join(', ')}`); + } + + if (skill.unavailableReasons && skill.unavailableReasons.length > 0) { + lines.push(`Unavailable reasons: ${skill.unavailableReasons.join('; ')}`); + } + + return lines.join('\n'); +} + +function loadSkillsFromConfig(configPath?: string): { skills?: Skill[]; error?: string } { + const loaded = loadConfigSafe(configPath); + if (loaded.error || !loaded.config) { + return { error: loaded.error ?? 'Failed to load config' }; + } + + 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, + }); + + return { skills }; +} + export function registerSkillsCommand(program: Command): void { const skills = program .command('skills') @@ -60,21 +101,14 @@ export function registerSkillsCommand(program: Command): void { .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'); + const loaded = loadSkillsFromConfig(opts.config); + if (loaded.error || !loaded.skills) { + console.error(loaded.error ?? 'Failed to load skills'); 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); + const rows = toSkillListRows(loaded.skills); if (opts.json) { console.log(JSON.stringify(rows, null, 2)); return; @@ -82,4 +116,32 @@ export function registerSkillsCommand(program: Command): void { console.log(renderSkillsTable(rows)); }); + + skills + .command('info ') + .description('Show details for a skill') + .option('--json', 'Output as JSON') + .option('-c, --config ', 'Config file path') + .action((name: string, opts: { json?: boolean; config?: string }) => { + const loaded = loadSkillsFromConfig(opts.config); + if (loaded.error || !loaded.skills) { + console.error(loaded.error ?? 'Failed to load skills'); + process.exitCode = 1; + return; + } + + const skill = loaded.skills.find((item) => item.manifest.name === name); + if (!skill) { + console.error(`Skill '${name}' not found.`); + process.exitCode = 1; + return; + } + + if (opts.json) { + console.log(JSON.stringify(skill, null, 2)); + return; + } + + console.log(renderSkillInfo(skill)); + }); }