feat(skills): add install dispatch for local skill setup

This commit is contained in:
William Valentin
2026-02-12 16:50:25 -08:00
parent 0d84a6bccc
commit d5b7d72e5d
3 changed files with 89 additions and 4 deletions
+39 -1
View File
@@ -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>): 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 });
});
});
+39 -1
View File
@@ -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 <path>')
.description('Install a skill from a local directory')
.option('-c, --config <path>', '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}).`);
});
}