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