Files
flynn/src/skills/registry.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

157 lines
5.7 KiB
TypeScript

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