#!/usr/bin/env node // Check the built plugins for what the agents' own validators miss or do not cover. // Usage: node check_plugins.mjs [dist-plugins] // Antigravity has no validate command, so its manifest is checked against the schema // published in its docs. Claude Code output is checked for parity; run // `claude plugin validate --strict` as well. Exit 1 on any ERROR. import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; const out = process.argv[2] ?? "dist-plugins"; let errors = 0; const err = (m) => { errors++; console.log(`ERROR ${m}`); }; const ok = (m) => console.log(`ok ${m}`); const readJson = (p) => JSON.parse(readFileSync(p, "utf8")); function frontmatter(text) { const m = /^---\n([\s\S]*?)\n---\n/.exec(text); if (!m) return null; const fm = {}; for (const line of m[1].split("\n")) { const kv = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line); if (kv) fm[kv[1]] = kv[2].replace(/^["']|["']$/g, ""); } return fm; } function* files(dir) { for (const name of readdirSync(dir)) { const p = join(dir, name); if (statSync(p).isDirectory()) yield* files(p); else yield p; } } function checkSkillsAndAgents(root, label) { const skills = []; if (existsSync(join(root, "skills"))) { for (const name of readdirSync(join(root, "skills"))) { const p = join(root, "skills", name, "SKILL.md"); if (!existsSync(p)) { err(`${label}: skills/${name} has no SKILL.md`); continue; } const fm = frontmatter(readFileSync(p, "utf8")); if (!fm) { err(`${label}: skills/${name}/SKILL.md has no frontmatter`); continue; } if (fm.name && fm.name !== name) err(`${label}: skills/${name} frontmatter name is "${fm.name}"`); if (!fm.description || fm.description.length < 20) err(`${label}: skills/${name} needs a description that says when to use it`); skills.push(name); } } const agents = []; if (existsSync(join(root, "agents"))) { for (const name of readdirSync(join(root, "agents")).filter((n) => n.endsWith(".md"))) { const fm = frontmatter(readFileSync(join(root, "agents", name), "utf8")); if (!fm?.name || !fm?.description) err(`${label}: agents/${name} needs name and description (Antigravity requires both)`); agents.push(name); } } return { skills, agents }; } const PIN = { npx: /@\d+\.\d+\.\d+$/, uvx: /==\d+(\.\d+)*$|@\d+(\.\d+)+$/ }; function checkServers(file, label, remoteKey) { if (!existsSync(file)) return []; const { mcpServers = {} } = readJson(file); for (const [name, s] of Object.entries(mcpServers)) { if (s.command === "npx" || s.command === "uvx") { const pkg = (s.args ?? []).find((a) => !a.startsWith("-")); if (!pkg || !PIN[s.command].test(pkg)) err(`${label}: MCP server ${name} runs ${s.command} ${pkg ?? ""} without an exact version`); } if (remoteKey === "serverUrl" && (s.url || s.httpUrl)) err(`${label}: MCP server ${name} uses url/httpUrl; Antigravity reads serverUrl only`); if (remoteKey === "url" && s.serverUrl) err(`${label}: MCP server ${name} uses serverUrl; Claude Code reads url`); const remote = s[remoteKey]; if (remote && !remote.startsWith("https://")) err(`${label}: MCP server ${name} remote URL is not https`); for (const [k, v] of Object.entries({ ...(s.env ?? {}), ...(s.headers ?? {}) })) { if (typeof v === "string" && !/\$\{[A-Za-z_][\w.]*\}/.test(v) && /token|key|secret|auth|bearer/i.test(k + v)) { err(`${label}: MCP server ${name} ${k} looks like a literal credential; reference an environment variable`); } } } return Object.keys(mcpServers); } const ccRoot = join(out, "claude"); const agRoot = join(out, "antigravity"); const names = existsSync(agRoot) ? readdirSync(agRoot) : []; if (!names.length) err(`no plugins under ${agRoot}`); for (const name of names) { const ag = join(agRoot, name); const cc = join(ccRoot, name); const before = errors; // Antigravity manifest: the documented schema has additionalProperties false. const m = readJson(join(ag, "plugin.json")); const allowed = new Set(["$schema", "name", "description"]); const extra = Object.keys(m).filter((k) => !allowed.has(k)); if (extra.length) err(`antigravity/${name}/plugin.json: fields not in the schema: ${extra.join(", ")}`); if (!/^[a-zA-Z0-9-_]+$/.test(m.name ?? "")) err(`antigravity/${name}/plugin.json: name must match ^[a-zA-Z0-9-_]+$ (the CLI requires it)`); if (m.name !== name) err(`antigravity/${name}: folder name and manifest name differ`); if (existsSync(join(ag, ".claude-plugin"))) err(`antigravity/${name}: contains .claude-plugin/`); // Claude Code manifest basics (claude plugin validate --strict covers the rest). const c = readJson(join(cc, ".claude-plugin", "plugin.json")); if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(c.name)) err(`claude/${name}: name should be kebab-case`); if (/^(claude|anthropic|anthropics|cc-plugin)-/i.test(c.name)) err(`claude/${name}: name is reserved for Anthropic`); for (const k of ["version", "description", "author"]) if (!c[k]) err(`claude/${name}: ${k} missing (--strict fails)`); if (existsSync(join(cc, "CLAUDE.md"))) err(`claude/${name}: a CLAUDE.md at the plugin root is not loaded; use a skill`); if (existsSync(join(cc, "plugin.json"))) err(`claude/${name}: plugin.json belongs in .claude-plugin/`); // Same components on both sides. const a = checkSkillsAndAgents(ag, `antigravity/${name}`); const b = checkSkillsAndAgents(cc, `claude/${name}`); if (JSON.stringify(a) !== JSON.stringify(b)) err(`${name}: skills or agents differ between the two builds`); const sa = checkServers(join(ag, "mcp_config.json"), `antigravity/${name}`, "serverUrl"); const sb = checkServers(join(cc, ".mcp.json"), `claude/${name}`, "url"); if (JSON.stringify(sa) !== JSON.stringify(sb)) err(`${name}: MCP servers differ between the two builds`); // Nothing that escapes the plugin root or depends on one agent's variables in shared files. for (const f of [...files(ag)]) { const text = readFileSync(f, "utf8"); if (text.includes("${CLAUDE_PLUGIN_ROOT}")) err(`antigravity/${name}/${relative(ag, f)}: uses \${CLAUDE_PLUGIN_ROOT}, which only Claude Code substitutes`); if (/\.\.\//.test(text) && f.endsWith(".json")) err(`antigravity/${name}/${relative(ag, f)}: path with ../`); } if (errors === before) ok(`${name}: ${a.skills.length} skill(s), ${a.agents.length} agent(s), ${sa.length} MCP server(s) in both builds`); } console.log(errors ? `${errors} error(s)` : "plugins ok"); process.exit(errors ? 1 : 0);