Files
flynn/src/skills/loader.ts
T
2026-02-15 23:14:21 -08:00

213 lines
5.7 KiB
TypeScript

/**
* 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';
import { scanSkillDirectory } from './scanner.js';
import { auditLogger } from '../audit/index.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');
const scan = scanSkillDirectory(absDir);
auditLogger?.skillsScan({
skill_name: basename(absDir),
tier,
phase: 'load',
ok: scan.ok,
error_count: scan.issues.filter(i => i.severity === 'error').length,
warn_count: scan.issues.filter(i => i.severity === 'warn').length,
issue_codes: Array.from(new Set(scan.issues.map(i => i.code))),
});
const scanReasons = scan.issues.map((i) => `${i.code}: ${i.message}${i.path ? ` (${basename(i.path)})` : ''}`);
const inferManifest = (): SkillManifest => {
const name = basename(absDir);
const firstLine = instructions.split('\n').find((line) => line.trim().length > 0) ?? name;
const description = firstLine.replace(/^#+\s*/, '').trim();
return {
name,
description,
version: '0.0.0',
tier,
};
};
let manifest: SkillManifest = inferManifest();
if (existsSync(manifestPath)) {
// Parse manifest.json
try {
const raw = JSON.parse(readFileSync(manifestPath, 'utf-8'));
if (raw && typeof raw === 'object' && raw.name && raw.description && raw.version) {
manifest = {
...(raw as SkillManifest),
tier, // Override tier from argument
};
}
} catch {
// Scanner will capture this and mark the skill unavailable.
}
} else {
manifest = inferManifest();
}
// Check system requirements
const { available, reasons } = checkRequirements(manifest.requirements);
const unavailableReasons = [...reasons];
if (!scan.ok) {
unavailableReasons.push(...scanReasons);
}
const skill: Skill = {
manifest,
instructions,
directory: absDir,
available: available && scan.ok,
};
if (!skill.available) {
skill.unavailableReasons = unavailableReasons;
}
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;
}