feat(skills): expose list command for skill visibility

This commit is contained in:
William Valentin
2026-02-12 16:42:00 -08:00
parent 90ce622080
commit b3e5aee333
5 changed files with 168 additions and 4 deletions
+19 -4
View File
@@ -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 <name>` command dispatch"
},
"soul_md_and_cron_create": {
"date": "2026-02-11",
+1
View File
@@ -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', () => {
+2
View File
@@ -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;
}
+61
View File
@@ -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>): 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.');
});
});
+85
View File
@@ -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 <path>', '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));
});
}