From 81d04357a13a7d39eca3f673cfc4370e25d4c7b1 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 12 Feb 2026 17:52:53 -0800 Subject: [PATCH] feat(skills): validate manifest installer specs --- docs/plans/state.json | 21 +++++++++--- src/skills/index.ts | 12 ++++++- src/skills/loader.test.ts | 69 +++++++++++++++++++++++++++++++++++++++ src/skills/loader.ts | 46 ++++++++++++++++++++++++++ src/skills/types.ts | 34 +++++++++++++++++++ 5 files changed, 177 insertions(+), 5 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 4c36721..7f9e5ef 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1333,9 +1333,22 @@ }, "phase_3_installer_specs": { "priority": "P1", - "status": "not_started", + "status": "in_progress", "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": { - "total_test_count": 1517, + "total_test_count": 1520, "all_tests_passing": true, "p0_completion": "3/3 (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", "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", - "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": { "date": "2026-02-11", diff --git a/src/skills/index.ts b/src/skills/index.ts index 6a78234..6eab42f 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -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'; diff --git a/src/skills/loader.test.ts b/src/skills/loader.test.ts index 8432d14..3a8ddad 100644 --- a/src/skills/loader.test.ts +++ b/src/skills/loader.test.ts @@ -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-')); diff --git a/src/skills/loader.ts b/src/skills/loader.ts index 46f03e1..1b8dc97 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -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, diff --git a/src/skills/types.ts b/src/skills/types.ts index 78f1ff8..18e5352 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -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. */