Files
flynn/src/skills/installer.test.ts
T
William Valentin 7c41ffad71 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)
2026-02-05 20:20:03 -08:00

224 lines
8.0 KiB
TypeScript

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);
});
});