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.
291 lines
10 KiB
JavaScript
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);
|