diff --git a/docs/plans/state.json b/docs/plans/state.json index 623d8bd..c0d07f9 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1228,6 +1228,15 @@ "src/cli/skills.test.ts" ], "test_status": "typecheck + targeted skills CLI tests + full test suite + lint (warnings only, 0 errors) + build passing" + }, + "skills_install_command_local": { + "status": "completed", + "description": "Added `flynn skills install ` local-directory command dispatch using SkillInstaller with config-aware managed_dir resolution", + "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" } } }, @@ -1268,7 +1277,7 @@ }, "overall_progress": { - "total_test_count": 1495, + "total_test_count": 1497, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1288,7 +1297,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 install ` command dispatch" + "next_up": "Skills infrastructure Phase 1: add `flynn skills uninstall ` 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 2c19215..25ddb71 100644 --- a/src/cli/skills.test.ts +++ b/src/cli/skills.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { toSkillListRows, renderSkillsTable, renderSkillInfo } from './skills.js'; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { SkillInstaller } from '../skills/index.js'; +import { toSkillListRows, renderSkillsTable, renderSkillInfo, installSkillFromDirectory } from './skills.js'; import type { Skill } from '../skills/index.js'; function buildSkill(overrides: Partial): Skill { @@ -91,4 +95,38 @@ describe('skills CLI helpers', () => { expect(output).toContain('Status: unavailable'); expect(output).toContain('Unavailable reasons: Required binary not found'); }); + + it('installs a local skill directory', () => { + const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-')); + const sourceDir = join(root, 'source-skill'); + const managedDir = join(root, 'managed'); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(join(sourceDir, 'SKILL.md'), '# My Skill\nInstructions'); + writeFileSync( + join(sourceDir, 'manifest.json'), + JSON.stringify({ name: 'my-skill', description: 'My skill', version: '1.0.0' }), + 'utf-8', + ); + + const installer = new SkillInstaller(managedDir); + const result = installSkillFromDirectory(installer, sourceDir); + + expect(result.error).toBeUndefined(); + expect(result.skill?.manifest.name).toBe('my-skill'); + expect(existsSync(join(managedDir, 'my-skill', 'SKILL.md'))).toBe(true); + + rmSync(root, { recursive: true, force: true }); + }); + + it('returns an error when source directory is missing', () => { + const root = mkdtempSync(join(tmpdir(), 'flynn-skills-cli-')); + const installer = new SkillInstaller(join(root, 'managed')); + + const result = installSkillFromDirectory(installer, join(root, 'does-not-exist')); + + expect(result.skill).toBeUndefined(); + expect(result.error).toContain('does not exist'); + + rmSync(root, { recursive: true, force: true }); + }); }); diff --git a/src/cli/skills.ts b/src/cli/skills.ts index 266125b..aaf02b9 100644 --- a/src/cli/skills.ts +++ b/src/cli/skills.ts @@ -2,7 +2,7 @@ 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 { loadAllSkills, SkillInstaller } from '../skills/index.js'; import { loadConfigSafe } from './shared.js'; export interface SkillListRow { @@ -87,6 +87,19 @@ function loadSkillsFromConfig(configPath?: string): { skills?: Skill[]; error?: return { skills }; } +export function installSkillFromDirectory(installer: SkillInstaller, sourcePath: string): { skill?: Skill; error?: string } { + const sourceDir = resolve(sourcePath); + try { + const skill = installer.install(sourceDir); + if (!skill) { + return { error: `Failed to load skill from '${sourceDir}' after installation.` }; + } + return { skill }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + export function registerSkillsCommand(program: Command): void { const skills = program .command('skills') @@ -144,4 +157,29 @@ export function registerSkillsCommand(program: Command): void { console.log(renderSkillInfo(skill)); }); + + skills + .command('install ') + .description('Install a skill from a local directory') + .option('-c, --config ', 'Config file path') + .action((pathArg: string, opts: { 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 installer = new SkillInstaller(loaded.config.skills.managed_dir ?? defaultManagedDir); + const result = installSkillFromDirectory(installer, pathArg); + + if (result.error || !result.skill) { + console.error(result.error ?? `Failed to install skill from '${pathArg}'.`); + process.exitCode = 1; + return; + } + + console.log(`Installed skill '${result.skill.manifest.name}' (${result.skill.manifest.version}).`); + }); }