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:
William Valentin
2026-02-05 20:20:03 -08:00
parent cd839c7f0c
commit 7c41ffad71
10 changed files with 1221 additions and 2 deletions
+10
View File
@@ -50,6 +50,15 @@ const hooksSchema = z.object({
silent: z.array(z.string()).default([]),
}).default({});
const skillsSchema = z.object({
/** Directory for user-created workspace skills. */
workspace_dir: z.string().optional(),
/** Directory for managed (installed) skills. Defaults to ~/.flynn/workspace/skills. */
managed_dir: z.string().optional(),
/** Directory for bundled skills shipped with Flynn. */
bundled_dir: z.string().optional(),
}).default({});
const mcpServerSchema = z.object({
name: z.string(),
command: z.string(),
@@ -68,6 +77,7 @@ export const configSchema = z.object({
models: modelsSchema,
backends: backendsSchema.default({}),
hooks: hooksSchema.default({}),
skills: skillsSchema.default({}),
mcp: mcpSchema.default({ servers: [] }),
});
+31 -2
View File
@@ -9,6 +9,7 @@ import { GatewayServer } from '../gateway/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter } from '../channels/index.js';
import type { InboundMessage, OutboundMessage } from '../channels/index.js';
import { McpManager } from '../mcp/index.js';
import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js';
import { resolve } from 'path';
import { homedir } from 'os';
import { mkdirSync, readFileSync, existsSync } from 'fs';
@@ -25,6 +26,8 @@ export interface DaemonContext {
gateway: GatewayServer;
channelRegistry: ChannelRegistry;
mcpManager: McpManager;
skillRegistry: SkillRegistry;
skillInstaller: SkillInstaller;
}
function loadSystemPrompt(): string {
@@ -199,11 +202,35 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
console.log('MCP servers stopped');
});
// Initialize skills system
const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills');
const skillRegistry = new SkillRegistry();
const skillInstaller = new SkillInstaller(config.skills.managed_dir ?? defaultManagedDir);
const skills = loadAllSkills({
bundledDir: config.skills.bundled_dir,
managedDir: config.skills.managed_dir ?? defaultManagedDir,
workspaceDir: config.skills.workspace_dir,
});
for (const skill of skills) {
skillRegistry.register(skill);
}
if (skills.length > 0) {
const available = skillRegistry.listAvailable().length;
console.log(`Loaded ${skills.length} skill(s) (${available} available)`);
}
// Initialize model router
const modelRouter = createModelRouter(config);
// Load system prompt once for reuse
const systemPrompt = loadSystemPrompt();
// Load system prompt and append skill instructions
let systemPrompt = loadSystemPrompt();
const skillAdditions = skillRegistry.getSystemPromptAdditions();
if (skillAdditions) {
systemPrompt = `${systemPrompt}\n\n# Available Skills\n\n${skillAdditions}`;
}
// Initialize gateway WebSocket server
const gateway = new GatewayServer({
@@ -296,6 +323,8 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
gateway,
channelRegistry,
mcpManager,
skillRegistry,
skillInstaller,
};
}
+4
View File
@@ -0,0 +1,4 @@
export type { SkillTier, SkillRequirements, SkillManifest, Skill } from './types.js';
export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
export { SkillRegistry } from './registry.js';
export { SkillInstaller } from './installer.js';
+223
View File
@@ -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);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { mkdirSync, cpSync, rmSync, existsSync, readFileSync, readdirSync } from 'fs';
import { resolve, basename } from 'path';
import type { Skill } from './types.js';
import { loadSkill } from './loader.js';
/**
* SkillInstaller manages installing and removing skills in the managed
* skills directory (e.g., ~/.flynn/workspace/skills/).
*
* Install copies a skill directory into managedDir, uninstall removes it.
*/
export class SkillInstaller {
private managedDir: string;
constructor(managedDir: string) {
this.managedDir = managedDir;
mkdirSync(this.managedDir, { recursive: true });
}
/**
* Install a skill from a source directory into the managed area.
*
* - sourceDir must exist and contain a SKILL.md file.
* - Skill name is read from manifest.json if present, otherwise falls back
* to the directory basename.
* - If the skill is already installed, it is replaced (upgrade).
*
* Returns the loaded Skill, or null if loading failed after copy.
*/
install(sourceDir: string): Skill | null {
// Validate source directory exists and contains SKILL.md
if (!existsSync(sourceDir)) {
throw new Error(`Source directory does not exist: ${sourceDir}`);
}
const skillMdPath = resolve(sourceDir, 'SKILL.md');
if (!existsSync(skillMdPath)) {
throw new Error(`Source directory does not contain SKILL.md: ${sourceDir}`);
}
// Determine skill name from manifest.json, or fall back to directory basename
let skillName = basename(sourceDir);
const manifestPath = resolve(sourceDir, 'manifest.json');
if (existsSync(manifestPath)) {
try {
const manifestContent = readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(manifestContent) as { name?: string };
if (manifest.name) {
skillName = manifest.name;
}
} catch {
// Fall back to basename if manifest can't be parsed
}
}
const destination = resolve(this.managedDir, skillName);
// Remove existing installation if present (upgrade path)
if (existsSync(destination)) {
rmSync(destination, { recursive: true, force: true });
}
// Copy source to managed directory
cpSync(sourceDir, destination, { recursive: true });
// Load and return the installed skill
const skill = loadSkill(destination, 'managed');
console.log(`Skill '${skillName}' installed to ${destination}`);
return skill;
}
/**
* Uninstall a managed skill by name.
*
* Returns true if the skill was found and removed, false otherwise.
*/
uninstall(name: string): boolean {
const dir = resolve(this.managedDir, name);
if (!existsSync(dir)) {
return false;
}
rmSync(dir, { recursive: true, force: true });
console.log(`Skill '${name}' uninstalled`);
return true;
}
/**
* List basenames of all installed managed skills.
*
* A valid installed skill is a subdirectory containing a SKILL.md file.
*/
listInstalled(): string[] {
if (!existsSync(this.managedDir)) {
return [];
}
const entries = readdirSync(this.managedDir, { withFileTypes: true });
return entries
.filter(entry => {
if (!entry.isDirectory()) return false;
const skillMd = resolve(this.managedDir, entry.name, 'SKILL.md');
return existsSync(skillMd);
})
.map(entry => entry.name);
}
/** Check whether a skill with the given name is installed. */
isInstalled(name: string): boolean {
return existsSync(resolve(this.managedDir, name, 'SKILL.md'));
}
}
+369
View File
@@ -0,0 +1,369 @@
import { describe, it, expect, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from 'fs';
import { join } from 'path';
import { tmpdir, platform } from 'os';
import { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
import type { SkillRequirements } from './types.js';
describe('checkRequirements', () => {
// Objective: verify that skill availability gating works for all requirement types.
it('returns available=true when no requirements are provided', () => {
// Positive: undefined requirements should always pass.
const result = checkRequirements(undefined);
expect(result.available).toBe(true);
expect(result.reasons).toHaveLength(0);
});
it('returns available=true with empty requirements object', () => {
// Positive: an empty object has no checks to fail.
const result = checkRequirements({});
expect(result.available).toBe(true);
expect(result.reasons).toHaveLength(0);
});
it('passes when OS matches current platform', () => {
// Positive: listing the current platform should succeed.
const requirements: SkillRequirements = { os: [platform()] };
const result = checkRequirements(requirements);
expect(result.available).toBe(true);
expect(result.reasons).toHaveLength(0);
});
it('fails when OS does not match current platform', () => {
// Negative: a fake platform name should cause an OS mismatch.
const requirements: SkillRequirements = { os: ['fakeos'] };
const result = checkRequirements(requirements);
expect(result.available).toBe(false);
expect(result.reasons).toHaveLength(1);
expect(result.reasons[0]).toContain('Unsupported platform');
expect(result.reasons[0]).toContain('fakeos');
});
it('passes when a required binary exists in PATH', () => {
// Positive: 'node' is guaranteed to exist in the test runner.
const requirements: SkillRequirements = { binaries: ['node'] };
const result = checkRequirements(requirements);
expect(result.available).toBe(true);
expect(result.reasons).toHaveLength(0);
});
it('fails when a required binary is missing from PATH', () => {
// Negative: a nonsense binary name should not be found.
const requirements: SkillRequirements = { binaries: ['nonexistent-binary-xyz123'] };
const result = checkRequirements(requirements);
expect(result.available).toBe(false);
expect(result.reasons).toHaveLength(1);
expect(result.reasons[0]).toContain("'nonexistent-binary-xyz123'");
expect(result.reasons[0]).toContain('not found in PATH');
});
it('passes when a required env var is set', () => {
// Positive: temporarily set an env var and verify it passes.
process.env.FLYNN_TEST_VAR = '1';
try {
const requirements: SkillRequirements = { env: ['FLYNN_TEST_VAR'] };
const result = checkRequirements(requirements);
expect(result.available).toBe(true);
expect(result.reasons).toHaveLength(0);
} finally {
delete process.env.FLYNN_TEST_VAR;
}
});
it('fails when a required env var is missing', () => {
// Negative: an unset env var should cause a failure.
const requirements: SkillRequirements = { env: ['FLYNN_NONEXISTENT_VAR_XYZ'] };
const result = checkRequirements(requirements);
expect(result.available).toBe(false);
expect(result.reasons).toHaveLength(1);
expect(result.reasons[0]).toContain('FLYNN_NONEXISTENT_VAR_XYZ');
expect(result.reasons[0]).toContain('not set');
});
it('collects multiple failure reasons across requirement types', () => {
// Negative: combining failures from OS, binary, and env should collect all reasons.
const requirements: SkillRequirements = {
os: ['fakeos'],
binaries: ['nonexistent-binary-xyz123'],
env: ['FLYNN_NONEXISTENT_VAR_XYZ'],
};
const result = checkRequirements(requirements);
expect(result.available).toBe(false);
expect(result.reasons).toHaveLength(3);
expect(result.reasons[0]).toContain('Unsupported platform');
expect(result.reasons[1]).toContain('nonexistent-binary-xyz123');
expect(result.reasons[2]).toContain('FLYNN_NONEXISTENT_VAR_XYZ');
});
});
describe('loadSkill', () => {
// Objective: verify that a single skill directory is correctly loaded into a Skill object.
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('loads a skill with manifest.json and SKILL.md', () => {
// Positive: a well-formed skill directory should produce a valid Skill.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'my-skill');
mkdirSync(skillDir);
writeFileSync(
join(skillDir, 'manifest.json'),
JSON.stringify({ name: 'my-skill', description: 'A test skill', version: '1.2.0' }),
);
writeFileSync(join(skillDir, 'SKILL.md'), '# My Skill\nDo something useful.');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).not.toBeNull();
expect(skill!.manifest.name).toBe('my-skill');
expect(skill!.manifest.description).toBe('A test skill');
expect(skill!.manifest.version).toBe('1.2.0');
expect(skill!.instructions).toBe('# My Skill\nDo something useful.');
expect(skill!.available).toBe(true);
expect(skill!.directory).toBe(skillDir);
});
it('returns null when SKILL.md is missing', () => {
// Negative: SKILL.md is required — without it the skill cannot be loaded.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'no-instructions');
mkdirSync(skillDir);
writeFileSync(
join(skillDir, 'manifest.json'),
JSON.stringify({ name: 'no-instructions', description: 'Missing MD', version: '1.0.0' }),
);
const skill = loadSkill(skillDir, 'bundled');
expect(skill).toBeNull();
});
it('infers manifest when manifest.json is missing', () => {
// Positive: without manifest.json, name comes from dirname, description from first line.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'inferred-skill');
mkdirSync(skillDir);
writeFileSync(join(skillDir, 'SKILL.md'), 'A useful skill description\nMore details here.');
const skill = loadSkill(skillDir, 'workspace');
expect(skill).not.toBeNull();
expect(skill!.manifest.name).toBe('inferred-skill');
expect(skill!.manifest.description).toBe('A useful skill description');
expect(skill!.manifest.version).toBe('0.0.0');
expect(skill!.manifest.tier).toBe('workspace');
});
it('returns null when manifest.json has invalid JSON', () => {
// Negative: unparseable JSON should cause the loader to bail.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'bad-json');
mkdirSync(skillDir);
writeFileSync(join(skillDir, 'manifest.json'), '{ not valid json !!!');
writeFileSync(join(skillDir, 'SKILL.md'), '# Bad JSON Skill');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).toBeNull();
});
it('returns null when manifest.json is missing required fields', () => {
// Negative: manifest must have name, description, and version.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'missing-fields');
mkdirSync(skillDir);
writeFileSync(join(skillDir, 'manifest.json'), JSON.stringify({ name: 'only-name' }));
writeFileSync(join(skillDir, 'SKILL.md'), '# Missing Fields');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).toBeNull();
});
it('strips markdown heading markers from inferred description', () => {
// Positive: a first line like "## My Heading" should become "My Heading".
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'heading-skill');
mkdirSync(skillDir);
writeFileSync(join(skillDir, 'SKILL.md'), '## Heading Skill Title\nBody text.');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).not.toBeNull();
expect(skill!.manifest.description).toBe('Heading Skill Title');
});
it('sets tier from the argument, not from manifest content', () => {
// Positive: tier in manifest should be overridden by the tier argument.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'tier-test');
mkdirSync(skillDir);
writeFileSync(
join(skillDir, 'manifest.json'),
JSON.stringify({ name: 'tier-test', description: 'Tier test', version: '1.0.0', tier: 'bundled' }),
);
writeFileSync(join(skillDir, 'SKILL.md'), '# Tier Test');
const skill = loadSkill(skillDir, 'managed');
expect(skill).not.toBeNull();
expect(skill!.manifest.tier).toBe('managed');
});
it('marks skill unavailable when requirements are not met', () => {
// Negative: unmet requirements should set available=false with reasons.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'unavailable-skill');
mkdirSync(skillDir);
writeFileSync(
join(skillDir, 'manifest.json'),
JSON.stringify({
name: 'unavailable-skill',
description: 'Needs fake binary',
version: '1.0.0',
requirements: { binaries: ['nonexistent-binary-xyz123'] },
}),
);
writeFileSync(join(skillDir, 'SKILL.md'), '# Unavailable Skill');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).not.toBeNull();
expect(skill!.available).toBe(false);
expect(skill!.unavailableReasons).toBeDefined();
expect(skill!.unavailableReasons!.length).toBeGreaterThan(0);
expect(skill!.unavailableReasons![0]).toContain('nonexistent-binary-xyz123');
});
});
describe('discoverSkills', () => {
// Objective: verify directory scanning discovers only valid skill subdirectories.
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('returns empty array for nonexistent directory', () => {
// Negative: a path that does not exist should not throw, just return [].
const skills = discoverSkills('/tmp/does-not-exist-xyz-abc-123', 'bundled');
expect(skills).toEqual([]);
});
it('discovers multiple skills in a directory', () => {
// Positive: each valid subdirectory with SKILL.md should be discovered.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillA = join(tmpDir, 'skill-a');
mkdirSync(skillA);
writeFileSync(join(skillA, 'SKILL.md'), '# Skill A');
const skillB = join(tmpDir, 'skill-b');
mkdirSync(skillB);
writeFileSync(join(skillB, 'SKILL.md'), '# Skill B');
const skills = discoverSkills(tmpDir, 'bundled');
expect(skills).toHaveLength(2);
const names = skills.map((s) => s.manifest.name);
expect(names).toContain('skill-a');
expect(names).toContain('skill-b');
});
it('skips non-directory entries', () => {
// Positive: regular files at the base level should be ignored.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'real-skill');
mkdirSync(skillDir);
writeFileSync(join(skillDir, 'SKILL.md'), '# Real Skill');
// Create a plain file at the base level — should be skipped
writeFileSync(join(tmpDir, 'not-a-skill.txt'), 'just a file');
const skills = discoverSkills(tmpDir, 'bundled');
expect(skills).toHaveLength(1);
expect(skills[0].manifest.name).toBe('real-skill');
});
});
describe('loadAllSkills', () => {
// Objective: verify that all three tier directories are scanned and merged.
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it('loads from all three tier directories', () => {
// Positive: each tier directory should contribute its skills.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const bundledDir = join(tmpDir, 'bundled');
const managedDir = join(tmpDir, 'managed');
const workspaceDir = join(tmpDir, 'workspace');
mkdirSync(join(bundledDir, 'skill-b'), { recursive: true });
writeFileSync(join(bundledDir, 'skill-b', 'SKILL.md'), '# Bundled');
mkdirSync(join(managedDir, 'skill-m'), { recursive: true });
writeFileSync(join(managedDir, 'skill-m', 'SKILL.md'), '# Managed');
mkdirSync(join(workspaceDir, 'skill-w'), { recursive: true });
writeFileSync(join(workspaceDir, 'skill-w', 'SKILL.md'), '# Workspace');
const skills = loadAllSkills({ bundledDir, managedDir, workspaceDir });
expect(skills).toHaveLength(3);
const tiers = skills.map((s) => s.manifest.tier);
expect(tiers).toContain('bundled');
expect(tiers).toContain('managed');
expect(tiers).toContain('workspace');
});
it('skips undefined and missing directories without error', () => {
// Positive: undefined config entries and nonexistent paths should be gracefully skipped.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const bundledDir = join(tmpDir, 'bundled');
mkdirSync(join(bundledDir, 'skill-only'), { recursive: true });
writeFileSync(join(bundledDir, 'skill-only', 'SKILL.md'), '# Only Bundled');
const skills = loadAllSkills({
bundledDir,
managedDir: undefined,
workspaceDir: '/tmp/does-not-exist-xyz-abc-123',
});
expect(skills).toHaveLength(1);
expect(skills[0].manifest.tier).toBe('bundled');
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* Skill loader — discovers and loads skills from disk.
*
* Skills are loaded synchronously at startup from up to three directories
* (bundled, managed, workspace), each mapped to a SkillTier.
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
import { resolve, join, basename } from 'path';
import { execSync } from 'child_process';
import { platform } from 'os';
import type { Skill, SkillManifest, SkillRequirements, SkillTier } from './types.js';
/**
* Check whether a skill's system requirements are met.
*
* Validates OS platform, binary availability (via `which`/`where`),
* and environment variable presence.
*/
export function checkRequirements(requirements?: SkillRequirements): { available: boolean; reasons: string[] } {
const reasons: string[] = [];
if (!requirements) {
return { available: true, reasons: [] };
}
// Check OS platform
if (requirements.os && requirements.os.length > 0) {
const currentPlatform = platform();
if (!requirements.os.includes(currentPlatform)) {
reasons.push(`Unsupported platform '${currentPlatform}' (requires: ${requirements.os.join(', ')})`);
}
}
// Check binaries in PATH
if (requirements.binaries) {
const whichCmd = platform() === 'win32' ? 'where' : 'which';
for (const binary of requirements.binaries) {
try {
execSync(`${whichCmd} ${binary}`, { stdio: 'ignore' });
} catch {
reasons.push(`Required binary '${binary}' not found in PATH`);
}
}
}
// Check environment variables
if (requirements.env) {
for (const envVar of requirements.env) {
if (!process.env[envVar]) {
reasons.push(`Required environment variable '${envVar}' is not set`);
}
}
}
return {
available: reasons.length === 0,
reasons,
};
}
/**
* Load a single skill from a directory.
*
* Reads manifest.json and SKILL.md. If manifest.json is missing, infers a
* minimal manifest from the directory name and SKILL.md first line.
* Returns null if SKILL.md doesn't exist or the manifest is unparseable.
*/
export function loadSkill(directory: string, tier: SkillTier): Skill | null {
const absDir = resolve(directory);
const manifestPath = join(absDir, 'manifest.json');
const instructionsPath = join(absDir, 'SKILL.md');
// SKILL.md is required — no instructions, no skill
if (!existsSync(instructionsPath)) {
return null;
}
const instructions = readFileSync(instructionsPath, 'utf-8');
let manifest: SkillManifest;
if (existsSync(manifestPath)) {
// Parse manifest.json
try {
const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
// Validate required fields
if (!raw.name || !raw.description || !raw.version) {
console.warn(`Skill manifest at ${manifestPath} missing required fields (name, description, version)`);
return null;
}
manifest = {
...raw,
tier, // Override tier from argument
};
} catch (error) {
console.warn(
`Failed to parse manifest.json at ${manifestPath}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return null;
}
} else {
// Infer minimal manifest from directory name and SKILL.md content
const name = basename(absDir);
const firstLine = instructions.split('\n').find((line) => line.trim().length > 0) ?? name;
// Strip leading markdown heading markers for a cleaner description
const description = firstLine.replace(/^#+\s*/, '').trim();
manifest = {
name,
description,
version: '0.0.0',
tier,
};
}
// Check system requirements
const { available, reasons } = checkRequirements(manifest.requirements);
const skill: Skill = {
manifest,
instructions,
directory: absDir,
available,
};
if (!available) {
skill.unavailableReasons = reasons;
}
return skill;
}
/**
* Discover all skills in a base directory.
*
* Lists subdirectories and attempts to load each as a skill.
* Skips entries that aren't directories or fail to load.
*/
export function discoverSkills(baseDir: string, tier: SkillTier): Skill[] {
const absBase = resolve(baseDir);
if (!existsSync(absBase)) {
return [];
}
const entries = readdirSync(absBase);
const skills: Skill[] = [];
for (const entry of entries) {
const entryPath = join(absBase, entry);
// Only process directories
try {
if (!statSync(entryPath).isDirectory()) {
continue;
}
} catch {
continue;
}
const skill = loadSkill(entryPath, tier);
if (skill) {
skills.push(skill);
}
}
return skills;
}
/**
* Load all skills from configured directories.
*
* Scans bundled, managed, and workspace directories (if provided and existing)
* and returns a flat array of all discovered skills.
*/
export function loadAllSkills(config: {
bundledDir?: string;
managedDir?: string;
workspaceDir?: string;
}): Skill[] {
const skills: Skill[] = [];
if (config.bundledDir) {
skills.push(...discoverSkills(config.bundledDir, 'bundled'));
}
if (config.managedDir) {
skills.push(...discoverSkills(config.managedDir, 'managed'));
}
if (config.workspaceDir) {
skills.push(...discoverSkills(config.workspaceDir, 'workspace'));
}
return skills;
}
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import { SkillRegistry } from './registry.js';
import type { Skill } from './types.js';
/**
* Helper to create a Skill with sensible defaults. The `name` convenience
* field sets `manifest.name` so callers don't have to nest it every time.
*/
function makeSkill(overrides: Partial<Skill> & { name?: string } = {}): Skill {
const name = overrides.name ?? overrides.manifest?.name ?? 'test-skill';
return {
manifest: {
name,
description: 'Test skill',
version: '1.0.0',
tier: 'bundled' as const,
...overrides.manifest,
},
instructions: overrides.instructions ?? '# Test\nDo things.',
directory: overrides.directory ?? `/fake/path/${name}`,
available: overrides.available ?? true,
...overrides,
};
}
describe('SkillRegistry', () => {
// Objective: verify the in-memory skill registry for registration, lookup,
// listing, filtering, unregistration, and system prompt generation.
it('registers and retrieves a skill by name', () => {
// Positive: a registered skill should be returned by get().
const registry = new SkillRegistry();
const skill = makeSkill({ name: 'git' });
registry.register(skill);
expect(registry.get('git')).toBe(skill);
});
it('returns undefined for an unknown skill', () => {
// Negative: looking up a name that was never registered should yield undefined.
const registry = new SkillRegistry();
expect(registry.get('nonexistent')).toBeUndefined();
});
it('lists all registered skills', () => {
// Positive: list() should return every registered skill regardless of availability.
const registry = new SkillRegistry();
const skillA = makeSkill({ name: 'skill-a' });
const skillB = makeSkill({ name: 'skill-b', available: false });
registry.register(skillA);
registry.register(skillB);
const all = registry.list();
expect(all).toHaveLength(2);
expect(all.map((s) => s.manifest.name)).toContain('skill-a');
expect(all.map((s) => s.manifest.name)).toContain('skill-b');
});
it('listAvailable returns only available skills', () => {
// Positive: unavailable skills should be filtered out.
const registry = new SkillRegistry();
const available = makeSkill({ name: 'available', available: true });
const unavailable = makeSkill({ name: 'unavailable', available: false });
registry.register(available);
registry.register(unavailable);
const result = registry.listAvailable();
expect(result).toHaveLength(1);
expect(result[0].manifest.name).toBe('available');
});
it('replaces an existing skill with the same name', () => {
// Positive: re-registering a name should overwrite (managed overrides bundled).
const registry = new SkillRegistry();
const original = makeSkill({ name: 'web', instructions: '# Original' });
const replacement = makeSkill({ name: 'web', instructions: '# Replacement' });
registry.register(original);
registry.register(replacement);
expect(registry.list()).toHaveLength(1);
expect(registry.get('web')!.instructions).toBe('# Replacement');
});
it('unregisters a skill by name', () => {
// Positive: unregister should remove the skill and return true.
const registry = new SkillRegistry();
const skill = makeSkill({ name: 'removable' });
registry.register(skill);
const result = registry.unregister('removable');
expect(result).toBe(true);
expect(registry.get('removable')).toBeUndefined();
expect(registry.list()).toHaveLength(0);
});
it('returns false when unregistering a nonexistent skill', () => {
// Negative: unregistering a name that doesn't exist should return false.
const registry = new SkillRegistry();
expect(registry.unregister('ghost')).toBe(false);
});
it('getSystemPromptAdditions returns empty string with no skills', () => {
// Negative: with nothing registered the prompt additions should be empty.
const registry = new SkillRegistry();
expect(registry.getSystemPromptAdditions()).toBe('');
});
it('getSystemPromptAdditions formats available skills correctly', () => {
// Positive: each available skill should appear as a "## Skill: <name>" section.
const registry = new SkillRegistry();
registry.register(makeSkill({ name: 'alpha', instructions: 'Alpha instructions.' }));
registry.register(makeSkill({ name: 'beta', instructions: 'Beta instructions.' }));
const prompt = registry.getSystemPromptAdditions();
expect(prompt).toContain('## Skill: alpha');
expect(prompt).toContain('Alpha instructions.');
expect(prompt).toContain('## Skill: beta');
expect(prompt).toContain('Beta instructions.');
});
it('getSystemPromptAdditions excludes unavailable skills', () => {
// Positive: only available skills contribute to the prompt.
const registry = new SkillRegistry();
registry.register(makeSkill({ name: 'enabled', instructions: 'Enabled content.' }));
registry.register(
makeSkill({ name: 'disabled', instructions: 'Disabled content.', available: false }),
);
const prompt = registry.getSystemPromptAdditions();
expect(prompt).toContain('## Skill: enabled');
expect(prompt).not.toContain('## Skill: disabled');
expect(prompt).not.toContain('Disabled content.');
});
it('getSkillNames returns names of available skills only', () => {
// Positive: unavailable skills should not appear in the name list.
const registry = new SkillRegistry();
registry.register(makeSkill({ name: 'active', available: true }));
registry.register(makeSkill({ name: 'dormant', available: false }));
const names = registry.getSkillNames();
expect(names).toContain('active');
expect(names).not.toContain('dormant');
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { Skill } from './types.js';
/**
* SkillRegistry holds loaded skills and generates system prompt additions.
*
* Skills are keyed by name. Managed/workspace skills may override bundled
* skills by registering with the same name.
*/
export class SkillRegistry {
private skills: Map<string, Skill> = new Map();
/** Register a skill. Replaces any existing skill with the same name. */
register(skill: Skill): void {
this.skills.set(skill.manifest.name, skill);
console.log(
`Skill '${skill.manifest.name}' registered (${skill.manifest.tier}, ${skill.available ? 'available' : 'unavailable'})`
);
}
/** Unregister a skill by name. Returns true if the skill existed. */
unregister(name: string): boolean {
return this.skills.delete(name);
}
/** Look up a skill by name. */
get(name: string): Skill | undefined {
return this.skills.get(name);
}
/** Return all registered skills. */
list(): Skill[] {
return Array.from(this.skills.values());
}
/** Return only skills whose requirements are met (available === true). */
listAvailable(): Skill[] {
return this.list().filter(skill => skill.available);
}
/**
* Generate system prompt additions from all available skills.
*
* Each skill's SKILL.md content is formatted as a markdown section:
* ```
* ## Skill: <name>
* <instructions>
* ```
*
* Returns an empty string if no skills are available.
*/
getSystemPromptAdditions(): string {
const available = this.listAvailable();
if (available.length === 0) {
return '';
}
return available
.map(skill => `## Skill: ${skill.manifest.name}\n${skill.instructions}`)
.join('\n\n');
}
/** Return the names of all available skills. */
getSkillNames(): string[] {
return this.listAvailable().map(skill => skill.manifest.name);
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Skills system type definitions.
*
* Skills are modular capability packages that extend Flynn's abilities.
* Each skill lives in its own directory with a manifest.json and SKILL.md.
*/
/** Three tiers of skills: shipped with Flynn, installed via CLI, or user-created. */
export type SkillTier = 'bundled' | 'managed' | 'workspace';
/** Requirements that must be met for a skill to be available. */
export interface SkillRequirements {
/** OS platforms the skill supports (e.g., ['linux', 'darwin']). Empty = all. */
os?: string[];
/** Binaries that must exist in PATH (e.g., ['git', 'docker']). */
binaries?: string[];
/** Environment variables that must be set (e.g., ['GITHUB_TOKEN']). */
env?: string[];
}
/** Manifest for a skill (manifest.json). */
export interface SkillManifest {
/** Unique skill name (e.g., 'git', 'web-search'). */
name: string;
/** Human-readable description. */
description: string;
/** Semantic version string. */
version: string;
/** Author name or identifier. */
author?: string;
/** Skill tier. */
tier: SkillTier;
/** System requirements for availability gating. */
requirements?: SkillRequirements;
/** Tool names this skill provides (informational — not enforced). */
tools?: string[];
/** npm/system dependencies needed. */
dependencies?: string[];
}
/** A loaded skill ready for use. */
export interface Skill {
/** The parsed manifest. */
manifest: SkillManifest;
/** Content of SKILL.md — injected into the system prompt. */
instructions: string;
/** Absolute path to the skill directory. */
directory: string;
/** Whether the skill's requirements are met on this system. */
available: boolean;
/** If not available, the reason(s) why. */
unavailableReasons?: string[];
}