mirror of
https://github.com/addyosmani/agent-skills.git
synced 2026-10-26 23:15:04 +00:00
`descriptionFromMd` in validate-commands.js splits each frontmatter line on its first colon — the same forgiving read the skill linter used before the strict rules landed. So a command whose frontmatter is not valid YAML passes every check here, and Claude Code parses that frontmatter when the command is loaded. Same class of gap as the SKILL.md one, on the other set of files: the #494 thread verified all 25 SKILL.md files *and* the command files by hand. This makes the second half a check too, sharing the rules rather than restating them. All nine current commands already pass — confirmed independently against ruby's psych before writing this — so it is a guard, not a repair. That is the same shape the skills half had, and the same reason: the manual verification was real, it just could not stay true on its own. Scoped to `.claude/commands/*.md`. The `.gemini/` and `commands/` directories are TOML and already go through a real TOML parser, so a malformed one surfaces as a missing description in the existing check. A test pins that the YAML rules are NOT applied to them, since a TOML description legitimately carries a colon and running these rules over it would fail nearly every command. The test sandbox copies the validator into a temp tree, so it now copies scripts/lib/skill-lint.js beside it. Without that every existing test fails with a module-not-found, which is how I found it. Five new tests. Mutation-tested 2/2: skipping the check, and reporting its errors without counting them, each fail two. Every validator exits 0 and all six script suites pass. Refs #494
223 lines
8.5 KiB
JavaScript
223 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* validate-commands.js
|
|
*
|
|
* Guards against silent drift across the three slash-command directories:
|
|
* .claude/commands/ (.md — Claude Code)
|
|
* .gemini/commands/ (.toml — Gemini CLI)
|
|
* commands/ (.toml — Antigravity CLI)
|
|
*
|
|
* Checks (errors block CI):
|
|
* - Every command present in one directory exists in all three
|
|
* - The 'description' field is identical across all three equivalents
|
|
* - Claude command frontmatter is valid YAML, not merely splittable
|
|
*
|
|
* What this does NOT check:
|
|
* Prompt body differences are intentional — each tool has its own
|
|
* syntax ($ARGUMENTS, agent-skills: prefixes, GEMINI.md vs CLAUDE.md).
|
|
*
|
|
* Exit codes: 0 = all clear, 1 = one or more errors
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// The same frontmatter-validity rules validate-skills applies to SKILL.md.
|
|
// `descriptionFromMd` below splits each line on its first colon, exactly as the
|
|
// skill reader used to, so a command whose frontmatter is not valid YAML passes
|
|
// every check here — and Claude Code parses that frontmatter when the command is
|
|
// loaded. The #494 thread verified all 25 SKILL.md files AND the command files by
|
|
// hand; this makes the second half a check too.
|
|
const { frontmatterYamlErrors } = require(path.join(__dirname, 'lib', 'skill-lint.js'));
|
|
|
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
|
|
const DIRS = {
|
|
claude: { dir: path.join(ROOT, '.claude', 'commands'), ext: '.md' },
|
|
gemini: { dir: path.join(ROOT, '.gemini', 'commands'), ext: '.toml' },
|
|
antigravity:{ dir: path.join(ROOT, 'commands'), ext: '.toml' },
|
|
};
|
|
|
|
// Commands where the file stem differs between Claude and the TOML dirs.
|
|
// Key = Claude stem, value = TOML stem.
|
|
const NAME_MAP = {
|
|
plan: 'planning',
|
|
};
|
|
const NAME_MAP_REVERSE = Object.fromEntries(
|
|
Object.entries(NAME_MAP).map(([k, v]) => [v, k])
|
|
);
|
|
|
|
// ─── Parsers ──────────────────────────────────────────────────────────────────
|
|
|
|
function descriptionFromMd(filePath) {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/);
|
|
if (!match) return null;
|
|
for (const line of match[1].split(/\r?\n/)) {
|
|
const colonIdx = line.indexOf(':');
|
|
if (colonIdx === -1) continue;
|
|
if (line.slice(0, colonIdx).trim() === 'description') {
|
|
return line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function descriptionFromToml(filePath) {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const doubleMatch = content.match(/^description\s*=\s*"((?:[^"\\]|\\.)*)"/m);
|
|
if (doubleMatch) return doubleMatch[1].replace(/\\"/g, '"');
|
|
const singleMatch = content.match(/^description\s*=\s*'([^']*)'/m);
|
|
return singleMatch ? singleMatch[1] : null;
|
|
}
|
|
|
|
// ─── Loader ───────────────────────────────────────────────────────────────────
|
|
|
|
function loadCommands({ dir, ext }) {
|
|
if (!fs.existsSync(dir)) return {};
|
|
return Object.fromEntries(
|
|
fs.readdirSync(dir)
|
|
.filter(f => f.endsWith(ext))
|
|
.map(f => {
|
|
const stem = path.basename(f, ext);
|
|
const full = path.join(dir, f);
|
|
try {
|
|
const desc = ext === '.md' ? descriptionFromMd(full) : descriptionFromToml(full);
|
|
return [stem, desc];
|
|
} catch (e) {
|
|
console.log(` ✗ ${stem} — cannot read file: ${e.message}`);
|
|
return [stem, null];
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
function main() {
|
|
const byTool = {
|
|
claude: loadCommands(DIRS.claude),
|
|
gemini: loadCommands(DIRS.gemini),
|
|
antigravity: loadCommands(DIRS.antigravity),
|
|
};
|
|
|
|
// Canonical command list: use Claude stems as the reference.
|
|
// Map each Claude stem to its TOML equivalent for lookup.
|
|
const claudeStems = Object.keys(byTool.claude).sort();
|
|
const allTomlStems = new Set([
|
|
...Object.keys(byTool.gemini),
|
|
...Object.keys(byTool.antigravity),
|
|
]);
|
|
const allCanonicalStems = new Set([
|
|
...claudeStems,
|
|
...[...allTomlStems].map(s => NAME_MAP_REVERSE[s] ?? s),
|
|
]);
|
|
|
|
let errors = 0;
|
|
|
|
// ── Parity check ────────────────────────────────────────────────────────────
|
|
console.log('Checking command parity...');
|
|
|
|
// Commands in Claude not found in TOML dirs
|
|
for (const stem of claudeStems) {
|
|
const tomlStem = NAME_MAP[stem] ?? stem;
|
|
const missing = [];
|
|
if (!(tomlStem in byTool.gemini)) missing.push('.gemini/commands');
|
|
if (!(tomlStem in byTool.antigravity)) missing.push('commands');
|
|
if (missing.length) {
|
|
console.log(` ✗ ${stem} — missing in: ${missing.join(', ')}`);
|
|
errors++;
|
|
} else {
|
|
console.log(` ✓ ${stem}${stem !== tomlStem ? ` (${tomlStem} in toml dirs)` : ''}`);
|
|
}
|
|
}
|
|
|
|
// Commands in TOML dirs not found in Claude
|
|
for (const stem of [...allTomlStems].sort()) {
|
|
const claudeStem = NAME_MAP_REVERSE[stem] ?? stem;
|
|
if (!(claudeStem in byTool.claude)) {
|
|
console.log(` ✗ ${stem} — present in toml dirs but missing in .claude/commands`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
// ── Claude frontmatter validity ─────────────────────────────────────────────
|
|
// Only the .md directory: the TOML dirs are parsed by a real TOML parser
|
|
// already, so a malformed one surfaces as a missing description above.
|
|
console.log('\nChecking Claude command frontmatter...');
|
|
|
|
for (const stem of claudeStems) {
|
|
const full = path.join(DIRS.claude.dir, `${stem}${DIRS.claude.ext}`);
|
|
let yamlErrors = [];
|
|
try {
|
|
yamlErrors = frontmatterYamlErrors(fs.readFileSync(full, 'utf8'));
|
|
} catch (e) {
|
|
console.log(` ✗ ${stem} — cannot read file: ${e.message}`);
|
|
errors++;
|
|
continue;
|
|
}
|
|
if (yamlErrors.length === 0) {
|
|
console.log(` ✓ ${stem}`);
|
|
continue;
|
|
}
|
|
console.log(` ✗ ${stem}`);
|
|
for (const message of yamlErrors) {
|
|
console.log(` ${message}`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
// ── Description sync check ──────────────────────────────────────────────────
|
|
console.log('\nChecking description sync...');
|
|
|
|
for (const claudeStem of claudeStems) {
|
|
const tomlStem = NAME_MAP[claudeStem] ?? claudeStem;
|
|
const descClaude = byTool.claude[claudeStem];
|
|
const descGemini = byTool.gemini[tomlStem];
|
|
const descAgy = byTool.antigravity[tomlStem];
|
|
|
|
const malformed = [
|
|
['.claude/commands', byTool.claude, claudeStem],
|
|
['.gemini/commands', byTool.gemini, tomlStem],
|
|
['commands/', byTool.antigravity, tomlStem],
|
|
].filter(([, commands, stem]) => Object.prototype.hasOwnProperty.call(commands, stem) && commands[stem] == null);
|
|
|
|
if (malformed.length) {
|
|
console.log(` ✗ ${claudeStem}`);
|
|
for (const [toolDir, , stem] of malformed) {
|
|
console.log(` ${toolDir}/${stem} — missing or malformed description`);
|
|
}
|
|
errors++;
|
|
continue;
|
|
}
|
|
|
|
if (descClaude == null || descGemini == null || descAgy == null) {
|
|
// Missing file already flagged by parity check
|
|
continue;
|
|
}
|
|
|
|
const allMatch = descClaude === descGemini && descGemini === descAgy;
|
|
|
|
if (allMatch) {
|
|
console.log(` ✓ ${claudeStem}`);
|
|
} else {
|
|
console.log(` ✗ ${claudeStem}`);
|
|
console.log(` .claude: ${descClaude}`);
|
|
console.log(` .gemini: ${descGemini}`);
|
|
console.log(` commands/: ${descAgy}`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
const status = errors > 0 ? 'FAILED' : 'PASSED';
|
|
console.log(`\n${allCanonicalStems.size} commands checked — ${errors} error(s) — ${status}`);
|
|
|
|
if (errors > 0) process.exit(1);
|
|
}
|
|
|
|
main();
|