Files
mozempk 0e0e7d6bd4 fix: remove invalid $schema/extensions from plugin manifest; this was why the installed plugin exposed zero agents/skills
Root-caused live: declaring the agent-plugins.org $schema in plugin.json
opts Copilot CLI into Open Plugin Spec mode, under which the flat
agents/skills fields are silently ignored (0 loaded, no error). Combined
with the pre-existing nested extensions.com.github.copilot.* structure
(also not a real field), the installed plugin contributed zero agents and
zero skills to any consuming project — only this repo's own working copy
worked, because Copilot CLI separately auto-loads .github/agents and
.github/skills for the current git root regardless of any plugin.

Fix: drop $schema entirely, use flat top-level agents/skills/mcpServers
fields (matches the documented, non-spec plugin.json schema). Updated
validate.mjs to error on $schema/extensions instead of recommending them,
and to check the real agents/skills path fields.
2026-09-10 01:26:38 +02:00

291 lines
10 KiB
JavaScript

#!/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`);
}
// NOTE: do NOT set $schema to the agent-plugins.org Open Plugin Spec URL.
// Verified against a real Copilot CLI 1.0.83 install: declaring $schema
// opts the manifest into Open Plugin Spec mode, which silently drops the
// plugin's agents/skills (0 loaded) instead of reading the flat fields
// below. Component paths must be flat top-level fields, not nested under
// an "extensions" object (that key means something unrelated in the
// non-spec schema and is also silently ignored when present as an object).
if (manifest.$schema) {
error(file, '"$schema" must not be set \u2014 it silently disables agent/skill loading in Copilot CLI');
}
if (manifest.extensions) {
error(file, '"extensions" is not a valid location for agents/skills \u2014 use top-level "agents"/"skills" fields');
}
for (const key of ['agents', 'skills']) {
const target = manifest[key];
if (!target) continue;
for (const rel of Array.isArray(target) ? target : [target]) {
const abs = path.resolve(ROOT, rel);
if (!fs.existsSync(abs)) error(file, `${key} points at missing path "${rel}"`);
}
}
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);