#!/usr/bin/env ts-node /** * scripts/validate.ts * * Validation script for Pisces. * Run with: npm run validate * * Checks that: * 1. package.json has required fields and a valid "pi" key * 2. SYSTEM.md exists * 3. Every skill directory has a SKILL.md with valid frontmatter * 4. extensions/index.ts exports a default factory function * 5. config/schema.json and config/defaults.json are valid and consistent * 6. All paths declared in package.json "pi" key exist on disk * * Exits with code 0 on success, 1 on any failure. */ import * as fs from "fs"; import * as path from "path"; // ─── Project Root ────────────────────────────────────────────────────────── function findProjectRoot(): string { let dir = process.cwd(); let parent = path.dirname(dir); while (parent !== dir) { if (fs.existsSync(path.join(dir, "package.json"))) return dir; dir = parent; parent = path.dirname(dir); } return fs.existsSync(path.join(dir, "package.json")) ? dir : process.cwd(); } const ROOT = findProjectRoot(); // ─── Result helpers ──────────────────────────────────────────────────────── type Severity = "error" | "warn" | "info"; interface CheckResult { severity: Severity; check: string; message: string; } const results: CheckResult[] = []; const pass = (check: string, message: string) => results.push({ severity: "info", check, message }); const warn = (check: string, message: string) => results.push({ severity: "warn", check, message }); const fail = (check: string, message: string) => results.push({ severity: "error", check, message }); function resolve(rel: string): string { return path.join(ROOT, rel); } function exists(rel: string): boolean { try { return fs.existsSync(resolve(rel)); } catch { return false; } } function read(rel: string): string | null { try { return fs.readFileSync(resolve(rel), "utf-8"); } catch { return null; } } function parseJson(content: string, label: string): T | null { try { return JSON.parse(content) as T; } catch (e) { fail("json-parse", `${label} is not valid JSON: ${(e as Error).message}`); return null; } } // ─── Check 1: package.json — required fields + "pi" key ─────────────────── interface PkgJson { name?: string; version?: string; description?: string; license?: string; keywords?: string[]; pi?: { extensions?: string[]; skills?: string[]; prompts?: string[]; }; peerDependencies?: Record; } function checkPackageJson(): PkgJson | null { const content = read("package.json"); if (!content) { fail("package-json", "package.json not found"); return null; } const pkg = parseJson(content, "package.json"); if (!pkg) return null; const required: (keyof PkgJson)[] = ["name", "version", "description", "license"]; for (const field of required) { if (!pkg[field]) fail("package-json", `package.json missing field: '${field}'`); else pass("package-json", `Field '${field}': ${String(pkg[field]).slice(0, 60)}`); } if (!pkg.pi) { fail("package-json", `package.json missing "pi" key — Pi won't discover any resources`); } else { pass("package-json", `"pi" key present with: ${Object.keys(pkg.pi).join(", ")}`); } if (!pkg.keywords?.includes("pi-package")) { warn("package-json", `keywords should include "pi-package" for gallery discoverability`); } else { pass("package-json", `"pi-package" keyword present`); } if (!pkg.peerDependencies?.["@earendil-works/pi-coding-agent"]) { warn("package-json", `"@earendil-works/pi-coding-agent" should be in peerDependencies`); } else { pass("package-json", `@earendil-works/pi-coding-agent listed in peerDependencies`); } return pkg; } // ─── Check 2: SYSTEM.md ──────────────────────────────────────────────────── function checkSystemMd(): void { if (exists("SYSTEM.md")) { const content = read("SYSTEM.md"); pass("system-md", `SYSTEM.md found (${content ? Math.round(content.length / 1024) : 0}KB)`); } else if (exists("system.md")) { fail("system-md", `Found 'system.md' but Pi expects 'SYSTEM.md' (uppercase) — rename it`); } else { warn("system-md", "No SYSTEM.md found — Pi will use its default system prompt"); } // Verify pi-package.yaml's system: field points to SYSTEM.md (exact case matters on Linux) const piPkg = read("pi-package.yaml"); if (piPkg) { const match = piPkg.match(/^system:\s*(.+)/m); const declared = match?.[1]?.trim(); if (!declared) { warn("system-md", "pi-package.yaml has no 'system:' field — Pi will use its default system prompt"); } else if (declared !== "SYSTEM.md") { fail( "system-md", `pi-package.yaml declares 'system: ${declared}' but the file is 'SYSTEM.md' — ` + `this breaks on case-sensitive filesystems (e.g. Linux CI). Fix: system: SYSTEM.md` ); } else { pass("system-md", `pi-package.yaml correctly declares 'system: SYSTEM.md'`); } } } // ─── Check 3: Skills — each must be a dir with SKILL.md + frontmatter ───── function skillDirsFromPiPackage(): string[] { const piPkgContent = read("pi-package.yaml"); if (!piPkgContent) return ["./src/skills"]; // Extract paths under the skills: block only — stop at the next top-level key const skillsBlock = piPkgContent.match(/^skills:\s*\n((?:[ \t]+.*\n)*)/m)?.[1] ?? ""; const pathMatches = [...skillsBlock.matchAll(/^\s+path:\s+(.+)/gm)]; if (pathMatches.length === 0) return ["./src/skills"]; // Each path is src/skills//SKILL.md — parent of parent is the skills dir const unique = [ ...new Set(pathMatches.map((m) => path.dirname(path.dirname(m[1].trim())))), ].filter((d) => fs.existsSync(resolve(d))); return unique.length > 0 ? unique.map((d) => `./${d}`) : ["./src/skills"]; } function checkSkills(pkg: PkgJson): void { // pi.skills is intentionally absent from package.json when skills are gated via // resources_discover; fall back to pi-package.yaml as the authoritative skill manifest. const skillDirs = pkg.pi?.skills ?? skillDirsFromPiPackage(); for (const skillDir of skillDirs) { const absDir = resolve(skillDir.replace(/^\.\//, "")); if (!fs.existsSync(absDir)) { fail("skills", `Skills directory not found: ${skillDir}`); continue; } const entries = fs.readdirSync(absDir, { withFileTypes: true }); const skillFolders = entries.filter((e) => e.isDirectory()); if (skillFolders.length === 0) { warn("skills", `No skill subdirectories found in ${skillDir} — each skill needs its own folder with SKILL.md`); continue; } for (const folder of skillFolders) { const skillMdPath = path.join(skillDir.replace(/^\.\//, ""), folder.name, "SKILL.md"); if (!exists(skillMdPath)) { fail("skills", `${folder.name}/: missing SKILL.md`); continue; } const content = read(skillMdPath); if (!content) { fail("skills", `${folder.name}/SKILL.md: could not read`); continue; } // Check frontmatter if (!content.startsWith("---")) { fail("skills", `${folder.name}/SKILL.md: missing YAML frontmatter (must start with ---)`); continue; } const fmEnd = content.indexOf("---", 3); if (fmEnd === -1) { fail("skills", `${folder.name}/SKILL.md: unclosed frontmatter`); continue; } const frontmatter = content.slice(3, fmEnd); const hasName = /^name:\s*.+/m.test(frontmatter); const hasDesc = /^description:\s*.+/m.test(frontmatter); if (!hasName) fail("skills", `${folder.name}/SKILL.md: frontmatter missing 'name' field`); if (!hasDesc) fail("skills", `${folder.name}/SKILL.md: frontmatter missing 'description' field`); if (hasName && hasDesc) { const nameMatch = frontmatter.match(/^name:\s*(.+)/m); pass("skills", `${folder.name}/SKILL.md: valid (name: ${nameMatch?.[1]?.trim()})`); } } } } // ─── Check 4: extensions/index.ts exports a default factory function ─────── function checkExtension(pkg: PkgJson): void { const extDirs = pkg.pi?.extensions ?? ["./extensions"]; for (const extPath of extDirs) { // Could be a dir (load index.ts) or a direct .ts file const normalized = extPath.replace(/^\.\//, ""); let filePath = normalized; if (fs.existsSync(resolve(normalized)) && fs.statSync(resolve(normalized)).isDirectory()) { filePath = path.join(normalized, "index.ts"); } if (!exists(filePath)) { fail("extension", `Extension file not found: ${filePath}`); continue; } const content = read(filePath); if (!content) { fail("extension", `Could not read: ${filePath}`); continue; } if (!content.includes("export default function") && !content.includes("export default async function")) { fail("extension", `${filePath}: must export a default factory function — 'export default function(pi: ExtensionAPI)'`); } else { pass("extension", `${filePath}: exports default factory function`); } if (!content.includes("@earendil-works/pi-coding-agent")) { warn("extension", `${filePath}: does not import from @earendil-works/pi-coding-agent`); } else { pass("extension", `${filePath}: imports ExtensionAPI from pi-coding-agent`); } } } // ─── Check 5: Config consistency ────────────────────────────────────────── function checkConfig(): void { const schemaContent = read("config/schema.json"); const defaultsContent = read("config/defaults.json"); if (!schemaContent) { warn("config", "config/schema.json not found"); return; } if (!defaultsContent) { warn("config", "config/defaults.json not found"); return; } const schema = parseJson<{ properties?: Record }>(schemaContent, "config/schema.json"); const defaults = parseJson>(defaultsContent, "config/defaults.json"); if (!schema || !defaults) return; const schemaKeys = Object.keys(schema.properties ?? {}); for (const key of Object.keys(defaults)) { if (!schemaKeys.includes(key)) warn("config", `defaults.json key '${key}' not in schema.json`); } pass("config", "config/schema.json and config/defaults.json are consistent"); } // ─── Check 6: Version consistency ───────────────────────────────────────── function checkVersion(pkg: PkgJson): void { if (!pkg.version) return; pass("version", `Package version: ${pkg.version}`); } // ─── Runner ──────────────────────────────────────────────────────────────── function printResults(): void { const errors = results.filter((r) => r.severity === "error"); const warnings = results.filter((r) => r.severity === "warn"); const passes = results.filter((r) => r.severity === "info"); console.log("\n┌─────────────────────────────────────────────────┐"); console.log("│ Pisces Validation Report │"); console.log("└─────────────────────────────────────────────────┘\n"); if (passes.length > 0) { console.log(`✅ PASSED (${passes.length})`); for (const r of passes) console.log(` [${r.check}] ${r.message}`); } if (warnings.length > 0) { console.log(`\n⚠️ WARNINGS (${warnings.length})`); for (const r of warnings) console.log(` [${r.check}] ${r.message}`); } if (errors.length > 0) { console.log(`\n❌ ERRORS (${errors.length})`); for (const r of errors) console.log(` [${r.check}] ${r.message}`); } console.log("\n─────────────────────────────────────────────────"); console.log( `Result: ${errors.length === 0 ? "✅ VALID" : "❌ INVALID"} — ` + `${passes.length} passed, ${warnings.length} warnings, ${errors.length} errors\n` ); } function run(): void { console.log("🐠 Validating Pisces package...\n"); const pkg = checkPackageJson(); checkSystemMd(); if (pkg) { checkSkills(pkg); checkExtension(pkg); checkVersion(pkg); } checkConfig(); printResults(); process.exit(results.some((r) => r.severity === "error") ? 1 : 0); } run();