#!/usr/bin/env node /** * `npm run validate` — distribution gate. * * Verifies the plugin manifests and every markdown frontmatter block that the * Copilot CLI / VS Code loaders depend on. Run this before tagging a release * and in CI. Exits 1 on any error. */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const errors = []; const warnings = []; const seen = []; const rel = (p) => path.relative(ROOT, p); const error = (file, msg) => errors.push(`${rel(file)}: ${msg}`); const warn = (file, msg) => warnings.push(`${rel(file)}: ${msg}`); /** Minimal YAML frontmatter reader — supports scalars, inline lists, block lists. */ function frontmatter(text) { const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text); if (!match) return null; const data = {}; let key = null; let blockList = null; let folded = null; for (const raw of match[1].split(/\r?\n/)) { const listItem = /^\s*-\s+(.*)$/.exec(raw); if (listItem && key) { blockList = blockList ?? []; blockList.push(unquote(listItem[1])); data[key] = blockList; continue; } const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(raw); if (pair) { if (folded && key) data[key] = folded.join(' ').trim(); folded = null; blockList = null; key = pair[1]; const value = pair[2].trim(); if (value === '>' || value === '|' || value === '>-' || value === '|-') { folded = []; data[key] = ''; } else if (value.startsWith('[') && value.endsWith(']')) { data[key] = value .slice(1, -1) .split(',') .map((s) => unquote(s.trim())) .filter(Boolean); } else if (value === '') { data[key] = ''; } else { data[key] = coerce(unquote(value)); } continue; } if (folded && raw.trim()) folded.push(raw.trim()); } if (folded && key) data[key] = folded.join(' ').trim(); return data; } const unquote = (s) => s.replace(/^['"]|['"]$/g, ''); const coerce = (s) => (s === 'true' ? true : s === 'false' ? false : s); function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (cause) { error(file, `invalid JSON — ${cause.message}`); return null; } } function listFiles(dir, predicate) { if (!fs.existsSync(dir)) return []; const out = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) out.push(...listFiles(full, predicate)); else if (predicate(full)) out.push(full); } return out; } // ---------------------------------------------------------------- manifests function validatePlugin() { const file = path.join(ROOT, 'plugin.json'); if (!fs.existsSync(file)) return error(file, 'missing'); const manifest = readJson(file); if (!manifest) return undefined; seen.push('plugin.json'); for (const field of ['name', 'description', 'version']) { if (!manifest[field]) error(file, `missing required field "${field}"`); } if (manifest.name && !/^[a-z0-9][a-z0-9-]*$/.test(manifest.name)) { error(file, `name "${manifest.name}" must be a lowercase slug (no scope, no slash)`); } if (manifest.version && !/^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(manifest.version)) { error(file, `version "${manifest.version}" is not semver`); } if (!manifest.$schema?.includes('agent-plugins.org')) { warn(file, 'missing the agent-plugins.org $schema reference'); } const ext = manifest.extensions?.['com.github.copilot'] ?? {}; for (const [key, target] of Object.entries(ext)) { const abs = path.resolve(ROOT, target); if (!fs.existsSync(abs)) error(file, `extensions.com.github.copilot.${key} points at missing path "${target}"`); } return manifest; } function validateMarketplace(plugin) { const file = path.join(ROOT, 'marketplace.json'); if (!fs.existsSync(file)) return warn(file, 'missing (optional)'); const manifest = readJson(file); if (!manifest) return undefined; seen.push('marketplace.json'); if (!manifest.name) error(file, 'missing "name"'); if (!Array.isArray(manifest.plugins) || !manifest.plugins.length) { error(file, 'must list at least one plugin'); return undefined; } for (const entry of manifest.plugins) { if (!entry.name) error(file, 'a plugin entry is missing "name"'); if (!entry.source) error(file, `plugin "${entry.name}" is missing "source"`); else if (!fs.existsSync(path.resolve(ROOT, entry.source))) { error(file, `plugin "${entry.name}" source "${entry.source}" does not exist`); } } if (plugin && !manifest.plugins.some((p) => p.name === plugin.name)) { error(file, `does not list the root plugin "${plugin.name}"`); } if (plugin && manifest.metadata?.version && manifest.metadata.version !== plugin.version) { warn(file, `metadata.version (${manifest.metadata.version}) differs from plugin.json (${plugin.version})`); } return manifest; } function validatePackageJson(plugin) { const file = path.join(ROOT, 'package.json'); const pkg = readJson(file); if (!pkg) return; seen.push('package.json'); if (pkg.type !== 'module') error(file, 'must set "type": "module"'); if (plugin && pkg.version !== plugin.version) { error(file, `version ${pkg.version} does not match plugin.json ${plugin.version}`); } const bin = pkg.bin?.lh; if (!bin) error(file, 'missing bin.lh'); else if (!fs.existsSync(path.resolve(ROOT, bin))) error(file, `bin.lh points at missing "${bin}"`); } // ------------------------------------------------------------ markdown docs function validateAgents() { const dir = path.join(ROOT, '.github', 'agents'); const files = listFiles(dir, (f) => f.endsWith('.agent.md')); if (!files.length) return warn(dir, 'no .agent.md files found'); const names = new Map(); const declared = new Map(); for (const file of files) { const fm = frontmatter(fs.readFileSync(file, 'utf8')); seen.push(rel(file)); if (!fm) { error(file, 'missing YAML frontmatter'); continue; } if (!fm.description) error(file, 'frontmatter "description" is required'); if (fm.name) { if (names.has(fm.name)) error(file, `duplicate agent name "${fm.name}" (also in ${names.get(fm.name)})`); names.set(fm.name, path.basename(file)); } else { warn(file, 'frontmatter "name" is recommended'); } if (fm.agents) declared.set(file, Array.isArray(fm.agents) ? fm.agents : [fm.agents]); } // Delegation targets must resolve to a real agent. for (const [file, targets] of declared) { for (const target of targets) { if (!target) continue; const known = [...names.keys()].some((n) => n.toLowerCase() === String(target).toLowerCase()); if (!known) error(file, `delegates to unknown agent "${target}"`); } } } function validateSkills() { const dir = path.join(ROOT, '.github', 'skills'); const files = listFiles(dir, (f) => path.basename(f) === 'SKILL.md'); if (!files.length) return warn(dir, 'no SKILL.md files found'); for (const file of files) { const fm = frontmatter(fs.readFileSync(file, 'utf8')); seen.push(rel(file)); if (!fm) { error(file, 'missing YAML frontmatter'); continue; } if (!fm.name) error(file, 'frontmatter "name" is required'); const dirName = path.basename(path.dirname(file)); if (fm.name && fm.name !== dirName) { error(file, `frontmatter name "${fm.name}" must match its directory "${dirName}"`); } const desc = String(fm.description ?? ''); if (!desc) error(file, 'frontmatter "description" is required'); else if (desc.length < 10 || desc.length > 1024) { error(file, `description must be 10-1024 chars, got ${desc.length}`); } } } function validateInstructions() { const dir = path.join(ROOT, '.github', 'instructions'); for (const file of listFiles(dir, (f) => f.endsWith('.instructions.md'))) { const fm = frontmatter(fs.readFileSync(file, 'utf8')); seen.push(rel(file)); if (!fm) error(file, 'missing YAML frontmatter'); else if (!fm.applyTo) error(file, 'frontmatter "applyTo" glob is required'); } } function validateMcp() { const file = path.join(ROOT, '.github', 'mcp.json'); if (!fs.existsSync(file)) return warn(file, 'missing (optional)'); const raw = fs.readFileSync(file, 'utf8'); const manifest = readJson(file); if (!manifest) return; seen.push('.github/mcp.json'); const servers = manifest.mcpServers ?? manifest.servers; if (!servers || !Object.keys(servers).length) error(file, 'declares no MCP servers'); // Hard rule: no secret may ever be committed. const leaks = [ [/ctx7sk-[A-Za-z0-9-]{8,}/, 'a Context7 key'], [/gh[pousr]_[A-Za-z0-9]{20,}/, 'a GitHub token'], [/sk-[A-Za-z0-9]{20,}/, 'an API key'], ]; for (const [pattern, what] of leaks) { if (pattern.test(raw)) error(file, `appears to contain ${what} — secrets must use \${env:...} interpolation`); } } // ------------------------------------------------------------------- report validatePackageJson(validatePlugin()); const plugin = readJson(path.join(ROOT, 'plugin.json')); validateMarketplace(plugin); validateAgents(); validateSkills(); validateInstructions(); validateMcp(); for (const w of warnings) process.stdout.write(`warn ${w}\n`); for (const e of errors) process.stdout.write(`error ${e}\n`); process.stdout.write( `\nvalidated ${seen.length} file(s) — ${errors.length} error(s), ${warnings.length} warning(s)\n`, ); process.exit(errors.length ? 1 : 0);