feat(skills): validate manifest installer specs

This commit is contained in:
William Valentin
2026-02-12 17:52:53 -08:00
parent bd29afeaff
commit 81d04357a1
5 changed files with 177 additions and 5 deletions
+11 -1
View File
@@ -1,4 +1,14 @@
export type { SkillTier, SkillRequirements, SkillManifest, Skill } from './types.js';
export type {
SkillTier,
SkillRequirements,
SkillManifest,
Skill,
SkillInstallerSpec,
BrewInstallerSpec,
NodeInstallerSpec,
GoInstallerSpec,
DownloadInstallerSpec,
} from './types.js';
export { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
export { SkillRegistry } from './registry.js';
export { SkillInstaller } from './installer.js';
+69
View File
@@ -233,6 +233,75 @@ describe('loadSkill', () => {
expect(skill!.manifest.tier).toBe('managed');
});
it('accepts valid manifest installers definitions', () => {
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'installer-spec-skill');
mkdirSync(skillDir);
writeFileSync(
join(skillDir, 'manifest.json'),
JSON.stringify({
name: 'installer-spec-skill',
description: 'Has installer specs',
version: '1.0.0',
installers: [
{ type: 'brew', packages: ['jq'] },
{ type: 'node', packages: ['typescript'] },
{ type: 'go', packages: ['golang.org/x/tools/cmd/stringer'] },
{ type: 'download', url: 'https://example.com/tool.tgz', destination: '/tmp/tool.tgz' },
],
}),
);
writeFileSync(join(skillDir, 'SKILL.md'), '# Installer Spec Skill');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).not.toBeNull();
expect(skill!.manifest.installers).toHaveLength(4);
});
it('returns null when installers is not an array', () => {
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'invalid-installers-type');
mkdirSync(skillDir);
writeFileSync(
join(skillDir, 'manifest.json'),
JSON.stringify({
name: 'invalid-installers-type',
description: 'Invalid installers type',
version: '1.0.0',
installers: { type: 'brew', packages: ['jq'] },
}),
);
writeFileSync(join(skillDir, 'SKILL.md'), '# Invalid Installers Type');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).toBeNull();
});
it('returns null when installer entries are invalid', () => {
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
const skillDir = join(tmpDir, 'invalid-installer-entry');
mkdirSync(skillDir);
writeFileSync(
join(skillDir, 'manifest.json'),
JSON.stringify({
name: 'invalid-installer-entry',
description: 'Invalid installer entry',
version: '1.0.0',
installers: [
{ type: 'brew', packages: ['jq'] },
{ type: 'download' },
],
}),
);
writeFileSync(join(skillDir, 'SKILL.md'), '# Invalid Installer Entry');
const skill = loadSkill(skillDir, 'bundled');
expect(skill).toBeNull();
});
it('marks skill unavailable when requirements are not met', () => {
// Negative: unmet requirements should set available=false with reasons.
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
+46
View File
@@ -11,6 +11,48 @@ import { execSync } from 'child_process';
import { platform } from 'os';
import type { Skill, SkillManifest, SkillRequirements, SkillTier } from './types.js';
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function hasValidInstallers(manifest: unknown): boolean {
if (!manifest || typeof manifest !== 'object') {
return false;
}
const candidate = manifest as { installers?: unknown };
if (candidate.installers === undefined) {
return true;
}
if (!Array.isArray(candidate.installers)) {
return false;
}
return candidate.installers.every((installer) => {
if (!installer || typeof installer !== 'object') {
return false;
}
const typedInstaller = installer as { type?: unknown; packages?: unknown; url?: unknown; destination?: unknown };
if (typedInstaller.type === 'brew' || typedInstaller.type === 'node' || typedInstaller.type === 'go') {
return isStringArray(typedInstaller.packages);
}
if (typedInstaller.type === 'download') {
if (typeof typedInstaller.url !== 'string') {
return false;
}
if (typedInstaller.destination !== undefined && typeof typedInstaller.destination !== 'string') {
return false;
}
return true;
}
return false;
});
}
/**
* Check whether a skill's system requirements are met.
*
@@ -90,6 +132,10 @@ export function loadSkill(directory: string, tier: SkillTier): Skill | null {
console.warn(`Skill manifest at ${manifestPath} missing required fields (name, description, version)`);
return null;
}
if (!hasValidInstallers(raw)) {
console.warn(`Skill manifest at ${manifestPath} has invalid installers specification`);
return null;
}
manifest = {
...raw,
+34
View File
@@ -18,6 +18,38 @@ export interface SkillRequirements {
env?: string[];
}
/** Installer spec for Homebrew packages. */
export interface BrewInstallerSpec {
type: 'brew';
packages: string[];
}
/** Installer spec for Node.js packages. */
export interface NodeInstallerSpec {
type: 'node';
packages: string[];
}
/** Installer spec for Go packages. */
export interface GoInstallerSpec {
type: 'go';
packages: string[];
}
/** Installer spec for direct downloads. */
export interface DownloadInstallerSpec {
type: 'download';
url: string;
destination?: string;
}
/** Supported installer variants declared in skill manifests. */
export type SkillInstallerSpec =
| BrewInstallerSpec
| NodeInstallerSpec
| GoInstallerSpec
| DownloadInstallerSpec;
/** Manifest for a skill (manifest.json). */
export interface SkillManifest {
/** Unique skill name (e.g., 'git', 'web-search'). */
@@ -36,6 +68,8 @@ export interface SkillManifest {
tools?: string[];
/** npm/system dependencies needed. */
dependencies?: string[];
/** Optional dependency installers for future automated setup. */
installers?: SkillInstallerSpec[];
}
/** A loaded skill ready for use. */