feat(skills): validate manifest installer specs
This commit is contained in:
+17
-4
@@ -1333,9 +1333,22 @@
|
|||||||
},
|
},
|
||||||
"phase_3_installer_specs": {
|
"phase_3_installer_specs": {
|
||||||
"priority": "P1",
|
"priority": "P1",
|
||||||
"status": "not_started",
|
"status": "in_progress",
|
||||||
"description": "Auto-install dependencies (brew/node/go/download) with package manager detection",
|
"description": "Auto-install dependencies (brew/node/go/download) with package manager detection",
|
||||||
"effort": "3-4 hours"
|
"effort": "3-4 hours",
|
||||||
|
"sub_slices": {
|
||||||
|
"manifest_installers_type_validation": {
|
||||||
|
"status": "completed",
|
||||||
|
"description": "Added manifest installers type definitions and loader validation for brew/node/go/download installer specs with focused tests",
|
||||||
|
"files_modified": [
|
||||||
|
"src/skills/types.ts",
|
||||||
|
"src/skills/index.ts",
|
||||||
|
"src/skills/loader.ts",
|
||||||
|
"src/skills/loader.test.ts"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm typecheck + pnpm test:run src/skills/loader.test.ts + pnpm test:run + pnpm lint (warnings only, 0 errors) + pnpm build passing"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1362,7 +1375,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1517,
|
"total_test_count": 1520,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
@@ -1382,7 +1395,7 @@
|
|||||||
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
"gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram",
|
||||||
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
"native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback",
|
||||||
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
"remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening",
|
||||||
"next_up": "Skills infrastructure Phase 3: installer specs kickoff (manifest installers type support with validation and tests)"
|
"next_up": "Skills infrastructure Phase 3: add installer execution planning surface (selection rules and dry-run installer plan output)"
|
||||||
},
|
},
|
||||||
"soul_md_and_cron_create": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
+11
-1
@@ -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 { checkRequirements, loadSkill, discoverSkills, loadAllSkills } from './loader.js';
|
||||||
export { SkillRegistry } from './registry.js';
|
export { SkillRegistry } from './registry.js';
|
||||||
export { SkillInstaller } from './installer.js';
|
export { SkillInstaller } from './installer.js';
|
||||||
|
|||||||
@@ -233,6 +233,75 @@ describe('loadSkill', () => {
|
|||||||
expect(skill!.manifest.tier).toBe('managed');
|
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', () => {
|
it('marks skill unavailable when requirements are not met', () => {
|
||||||
// Negative: unmet requirements should set available=false with reasons.
|
// Negative: unmet requirements should set available=false with reasons.
|
||||||
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
tmpDir = mkdtempSync(join(tmpdir(), 'flynn-test-'));
|
||||||
|
|||||||
@@ -11,6 +11,48 @@ import { execSync } from 'child_process';
|
|||||||
import { platform } from 'os';
|
import { platform } from 'os';
|
||||||
import type { Skill, SkillManifest, SkillRequirements, SkillTier } from './types.js';
|
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.
|
* 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)`);
|
console.warn(`Skill manifest at ${manifestPath} missing required fields (name, description, version)`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!hasValidInstallers(raw)) {
|
||||||
|
console.warn(`Skill manifest at ${manifestPath} has invalid installers specification`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
manifest = {
|
manifest = {
|
||||||
...raw,
|
...raw,
|
||||||
|
|||||||
@@ -18,6 +18,38 @@ export interface SkillRequirements {
|
|||||||
env?: string[];
|
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). */
|
/** Manifest for a skill (manifest.json). */
|
||||||
export interface SkillManifest {
|
export interface SkillManifest {
|
||||||
/** Unique skill name (e.g., 'git', 'web-search'). */
|
/** Unique skill name (e.g., 'git', 'web-search'). */
|
||||||
@@ -36,6 +68,8 @@ export interface SkillManifest {
|
|||||||
tools?: string[];
|
tools?: string[];
|
||||||
/** npm/system dependencies needed. */
|
/** npm/system dependencies needed. */
|
||||||
dependencies?: string[];
|
dependencies?: string[];
|
||||||
|
/** Optional dependency installers for future automated setup. */
|
||||||
|
installers?: SkillInstallerSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A loaded skill ready for use. */
|
/** A loaded skill ready for use. */
|
||||||
|
|||||||
Reference in New Issue
Block a user