7c41ffad71
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)
224 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|