feat: add skills system for extensible capability packages
Implement a three-tier skill system (bundled/managed/workspace) that extends Flynn's abilities via SKILL.md instructions injected into the system prompt. - SkillManifest/Skill types with requirements gating (OS, binaries, env) - Loader: discovers skills from directories, validates manifests, checks system requirements, infers manifest from SKILL.md if missing - SkillRegistry: holds skills, generates system prompt additions, supports override by name (workspace > managed > bundled) - SkillInstaller: copies/removes skills in managed directory with upgrade support - Config: add skills.workspace_dir, managed_dir, bundled_dir options - Daemon: loads all skills at startup, injects available skill instructions into the system prompt - Tests: 45 new tests (loader 22, registry 11, installer 12)
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, rmSync, mkdtempSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { SkillInstaller } from './installer.js';
|
||||
|
||||
describe('SkillInstaller', () => {
|
||||
// Objective: verify that the installer correctly copies, upgrades, and
|
||||
// removes skill directories in the managed area.
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
/** Create a temp directory and track it for cleanup. */
|
||||
function makeTmpDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Create a minimal source skill directory with SKILL.md and optional manifest. */
|
||||
function makeSourceSkill(
|
||||
parentDir: string,
|
||||
dirName: string,
|
||||
options: { manifest?: Record<string, unknown>; instructions?: string } = {},
|
||||
): string {
|
||||
const skillDir = join(parentDir, dirName);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), options.instructions ?? `# ${dirName}\nDefault instructions.`);
|
||||
if (options.manifest) {
|
||||
writeFileSync(join(skillDir, 'manifest.json'), JSON.stringify(options.manifest));
|
||||
}
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tmpDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tmpDirs.length = 0;
|
||||
});
|
||||
|
||||
it('creates managedDir in constructor if it does not exist', () => {
|
||||
// Positive: the constructor should ensure the managed directory exists.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed', 'nested');
|
||||
|
||||
new SkillInstaller(managedDir);
|
||||
|
||||
expect(existsSync(managedDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('installs a skill from a source directory', () => {
|
||||
// Positive: install should copy SKILL.md and return a loaded Skill.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'my-skill', {
|
||||
manifest: { name: 'my-skill', description: 'A skill', version: '1.0.0' },
|
||||
instructions: '# My Skill\nDo the thing.',
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
const skill = installer.install(sourceDir);
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.name).toBe('my-skill');
|
||||
expect(skill!.instructions).toBe('# My Skill\nDo the thing.');
|
||||
expect(existsSync(join(managedDir, 'my-skill', 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('installed skill has tier "managed"', () => {
|
||||
// Positive: regardless of source manifest, installed skill tier must be 'managed'.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'tier-skill', {
|
||||
manifest: { name: 'tier-skill', description: 'Tier test', version: '1.0.0', tier: 'bundled' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
const skill = installer.install(sourceDir);
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.tier).toBe('managed');
|
||||
});
|
||||
|
||||
it('uses manifest.json name field for the installed directory name', () => {
|
||||
// Positive: directory in managed area should match manifest name, not source dirname.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'source-dirname', {
|
||||
manifest: { name: 'manifest-name', description: 'Named via manifest', version: '1.0.0' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceDir);
|
||||
|
||||
expect(existsSync(join(managedDir, 'manifest-name', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(managedDir, 'source-dirname'))).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to basename when no manifest.json is present', () => {
|
||||
// Positive: without manifest.json, directory name should come from source basename.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'fallback-name');
|
||||
// No manifest.json written
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceDir);
|
||||
|
||||
expect(existsSync(join(managedDir, 'fallback-name', 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('upgrades an existing skill by replacing it', () => {
|
||||
// Positive: re-installing should overwrite the old version.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceV1 = makeSourceSkill(tmp, 'v1-source', {
|
||||
manifest: { name: 'upgradable', description: 'Version 1', version: '1.0.0' },
|
||||
instructions: '# V1',
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceV1);
|
||||
|
||||
// Create v2 source
|
||||
const sourceV2 = makeSourceSkill(tmp, 'v2-source', {
|
||||
manifest: { name: 'upgradable', description: 'Version 2', version: '2.0.0' },
|
||||
instructions: '# V2',
|
||||
});
|
||||
|
||||
const skill = installer.install(sourceV2);
|
||||
|
||||
expect(skill).not.toBeNull();
|
||||
expect(skill!.manifest.version).toBe('2.0.0');
|
||||
expect(skill!.instructions).toBe('# V2');
|
||||
});
|
||||
|
||||
it('throws when source directory does not exist', () => {
|
||||
// Negative: install should throw a descriptive error for missing sources.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
|
||||
expect(() => installer.install('/tmp/does-not-exist-xyz-abc-123')).toThrow(
|
||||
'Source directory does not exist',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when source directory has no SKILL.md', () => {
|
||||
// Negative: install should throw when SKILL.md is missing.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const emptyDir = join(tmp, 'empty-skill');
|
||||
mkdirSync(emptyDir);
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
|
||||
expect(() => installer.install(emptyDir)).toThrow('does not contain SKILL.md');
|
||||
});
|
||||
|
||||
it('uninstalls a skill', () => {
|
||||
// Positive: uninstall should remove the directory and return true.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'removable', {
|
||||
manifest: { name: 'removable', description: 'Will be removed', version: '1.0.0' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceDir);
|
||||
|
||||
const result = installer.uninstall('removable');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(existsSync(join(managedDir, 'removable'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when uninstalling a nonexistent skill', () => {
|
||||
// Negative: uninstalling a name that was never installed should return false.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
|
||||
expect(installer.uninstall('ghost')).toBe(false);
|
||||
});
|
||||
|
||||
it('listInstalled lists all installed skills', () => {
|
||||
// Positive: every installed skill should appear in the list.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceA = makeSourceSkill(tmp, 'skill-a', {
|
||||
manifest: { name: 'skill-a', description: 'Skill A', version: '1.0.0' },
|
||||
});
|
||||
const sourceB = makeSourceSkill(tmp, 'skill-b', {
|
||||
manifest: { name: 'skill-b', description: 'Skill B', version: '1.0.0' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceA);
|
||||
installer.install(sourceB);
|
||||
|
||||
const installed = installer.listInstalled();
|
||||
|
||||
expect(installed).toHaveLength(2);
|
||||
expect(installed).toContain('skill-a');
|
||||
expect(installed).toContain('skill-b');
|
||||
});
|
||||
|
||||
it('isInstalled checks correctly', () => {
|
||||
// Positive/Negative: should return true for installed, false for not installed.
|
||||
const tmp = makeTmpDir();
|
||||
const managedDir = join(tmp, 'managed');
|
||||
const sourceDir = makeSourceSkill(tmp, 'check-me', {
|
||||
manifest: { name: 'check-me', description: 'Check me', version: '1.0.0' },
|
||||
});
|
||||
|
||||
const installer = new SkillInstaller(managedDir);
|
||||
installer.install(sourceDir);
|
||||
|
||||
expect(installer.isInstalled('check-me')).toBe(true);
|
||||
expect(installer.isInstalled('not-installed')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user