#!/usr/bin/env node import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; const ROOT = process.cwd(); const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; function walk(dir, predicate, out = []) { if (!existsSync(dir)) return out; for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) walk(path, predicate, out); else if (predicate(path, entry.name)) out.push(path); } return out; } function skillFiles() { return walk(join(ROOT, "agent", "skills"), (_, name) => name === "SKILL.md").sort(); } function promptFiles() { return walk(join(ROOT, "agent", "prompts"), (_, name) => name.endsWith(".md")).sort(); } function frontmatter(path) { const lines = readFileSync(path, "utf8").split(/\r?\n/); if (lines[0] !== "---") throw new Error("missing opening frontmatter fence"); const end = lines.indexOf("---", 1); if (end === -1) throw new Error("missing closing frontmatter fence"); return lines.slice(1, end); } function unquote(value) { const trimmed = value.trim(); if ( (trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) return trimmed.slice(1, -1); return trimmed; } function readIndented(lines, index) { const block = []; while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) block.push(lines[++index].trim()); return [block, index]; } function parseFrontmatter(lines) { const data = {}; for (let i = 0; i < lines.length; i++) { const match = /^(\w[\w-]*):(?:\s*(.*))?$/.exec(lines[i]); if (!match) continue; const [, key, raw = ""] = match; const value = raw.trim(); if (value === ">" || value === "|") { const [block, next] = readIndented(lines, i); i = next; data[key] = block.join(value === ">" ? " " : "\n"); } else if (!value && i + 1 < lines.length && /^\s+/.test(lines[i + 1])) { const [block, next] = readIndented(lines, i); i = next; data[key] = unquote(block.join(" ")); } else { data[key] = unquote(raw); } } return data; } function validateFrontmatter(path) { try { return parseFrontmatter(frontmatter(path)); } catch (error) { return { __error: error.message }; } } function validateSkill(path) { const data = validateFrontmatter(path); if (data.__error) return [data.__error]; const errors = []; const dir = path.split(/[\\/]/).at(-2); if (!data.name) errors.push("missing string field: name"); else if (!NAME_RE.test(data.name)) errors.push(`invalid skill name: ${JSON.stringify(data.name)}`); else if (dir !== data.name) { errors.push(`directory/name mismatch: ${JSON.stringify(dir)} != ${JSON.stringify(data.name)}`); } if (!data.description?.trim()) errors.push("missing string field: description"); else if (data.description.length > 1024) errors.push(`description too long: ${data.description.length} > 1024`); return errors; } function validatePrompt(path) { const data = validateFrontmatter(path); if (data.__error) return [data.__error]; if (!data.description?.trim()) return ["missing string field: description"]; return []; } let failed = false; for ( const [kind, files, validate] of [ ["skill", skillFiles(), validateSkill], ["prompt", promptFiles(), validatePrompt], ] ) { if (files.length === 0) { failed = true; console.log(`no ${kind} files found`); } for (const path of files) { for (const error of validate(path)) { failed = true; console.log(`${relative(ROOT, path)}: ${error}`); } } } process.exit(failed ? 1 : 0);