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)
157 lines
5.7 KiB
TypeScript
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');
|
|
});
|
|
});
|