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
217 lines
8.4 KiB
JavaScript
217 lines
8.4 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { spawnSync } = require('node:child_process');
|
|
const { afterEach, test } = require('node:test');
|
|
|
|
const VALIDATOR = path.join(__dirname, 'validate-commands.js');
|
|
// The validator shares the frontmatter-validity rules with validate-skills, so
|
|
// the sandbox needs the lib alongside it, not just the script.
|
|
const SKILL_LINT = path.join(__dirname, 'lib', 'skill-lint.js');
|
|
const sandboxes = [];
|
|
|
|
function makeSandbox() {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-skills-validate-commands-test-'));
|
|
const scriptsDir = path.join(root, 'scripts');
|
|
fs.mkdirSync(scriptsDir, { recursive: true });
|
|
fs.copyFileSync(VALIDATOR, path.join(scriptsDir, 'validate-commands.js'));
|
|
fs.mkdirSync(path.join(scriptsDir, 'lib'), { recursive: true });
|
|
fs.copyFileSync(SKILL_LINT, path.join(scriptsDir, 'lib', 'skill-lint.js'));
|
|
sandboxes.push(root);
|
|
return root;
|
|
}
|
|
|
|
function writeFile(root, relativePath, content) {
|
|
const file = path.join(root, relativePath);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, content);
|
|
}
|
|
|
|
function writeClaudeCommand(root, stem, descriptionLine) {
|
|
writeFile(
|
|
root,
|
|
path.join('.claude', 'commands', `${stem}.md`),
|
|
`---\n${descriptionLine}\n---\n\n# Command\n`,
|
|
);
|
|
}
|
|
|
|
function writeTomlCommand(root, directory, stem, descriptionLine) {
|
|
writeFile(root, path.join(directory, `${stem}.toml`), `${descriptionLine}\nprompt = "Run command"\n`);
|
|
}
|
|
|
|
function writeMatchingCommands(root, stem, description) {
|
|
writeClaudeCommand(root, stem, `description: ${description}`);
|
|
writeTomlCommand(root, path.join('.gemini', 'commands'), stem, `description = "${description}"`);
|
|
writeTomlCommand(root, 'commands', stem, `description = "${description}"`);
|
|
}
|
|
|
|
function run(root) {
|
|
return spawnSync(process.execPath, [path.join(root, 'scripts', 'validate-commands.js')], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const root of sandboxes.splice(0)) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('passes matching command twins and maps plan to planning', () => {
|
|
const root = makeSandbox();
|
|
const description = 'Break work into ordered tasks';
|
|
writeClaudeCommand(root, 'plan', `description: ${description}`);
|
|
writeTomlCommand(root, path.join('.gemini', 'commands'), 'planning', `description = '${description}'`);
|
|
writeTomlCommand(root, 'commands', 'planning', `description = '${description}'`);
|
|
|
|
const result = run(root);
|
|
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
assert.match(result.stdout, /✓ plan \(planning in toml dirs\)/);
|
|
assert.match(result.stdout, /1 commands checked — 0 error\(s\) — PASSED/);
|
|
});
|
|
|
|
test('fails when a Claude command is missing a TOML twin', () => {
|
|
const root = makeSandbox();
|
|
const description = 'Review a change';
|
|
writeClaudeCommand(root, 'review', `description: ${description}`);
|
|
writeTomlCommand(root, path.join('.gemini', 'commands'), 'review', `description = "${description}"`);
|
|
|
|
const result = run(root);
|
|
|
|
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
assert.match(result.stdout, /review — missing in: commands/);
|
|
assert.match(result.stdout, /1 commands checked — 1 error\(s\) — FAILED/);
|
|
});
|
|
|
|
test('fails when a TOML command has no Claude twin', () => {
|
|
const root = makeSandbox();
|
|
writeTomlCommand(root, path.join('.gemini', 'commands'), 'deploy', 'description = "Deploy a change"');
|
|
writeTomlCommand(root, 'commands', 'deploy', 'description = "Deploy a change"');
|
|
|
|
const result = run(root);
|
|
|
|
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
assert.match(result.stdout, /deploy — present in toml dirs but missing in \.claude\/commands/);
|
|
assert.match(result.stdout, /1 commands checked — 1 error\(s\) — FAILED/);
|
|
});
|
|
|
|
test('reports all descriptions when command twins drift', () => {
|
|
const root = makeSandbox();
|
|
writeClaudeCommand(root, 'review', 'description: Review a change');
|
|
writeTomlCommand(root, path.join('.gemini', 'commands'), 'review', 'description = "Inspect a change"');
|
|
writeTomlCommand(root, 'commands', 'review', 'description = "Audit a change"');
|
|
|
|
const result = run(root);
|
|
|
|
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
assert.match(result.stdout, /\.claude:\s+Review a change/);
|
|
assert.match(result.stdout, /\.gemini:\s+Inspect a change/);
|
|
assert.match(result.stdout, /commands\/:\s+Audit a change/);
|
|
});
|
|
|
|
test('fails with an actionable error for a malformed description', () => {
|
|
const root = makeSandbox();
|
|
writeMatchingCommands(root, 'review', 'Review a change');
|
|
writeFile(
|
|
root,
|
|
path.join('.gemini', 'commands', 'review.toml'),
|
|
'prompt = "Missing description"\n',
|
|
);
|
|
|
|
const result = run(root);
|
|
|
|
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
assert.match(result.stdout, /\.gemini\/commands\/review — missing or malformed description/);
|
|
assert.match(result.stdout, /1 commands checked — 1 error\(s\) — FAILED/);
|
|
});
|
|
|
|
test('parses escaped quotes in double-quoted TOML descriptions', () => {
|
|
const root = makeSandbox();
|
|
writeClaudeCommand(root, 'review', 'description: Review "important" changes');
|
|
writeTomlCommand(
|
|
root,
|
|
path.join('.gemini', 'commands'),
|
|
'review',
|
|
'description = "Review \\"important\\" changes"',
|
|
);
|
|
writeTomlCommand(
|
|
root,
|
|
'commands',
|
|
'review',
|
|
'description = "Review \\"important\\" changes"',
|
|
);
|
|
|
|
const result = run(root);
|
|
|
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
assert.match(result.stdout, /1 commands checked — 0 error\(s\) — PASSED/);
|
|
});
|
|
|
|
// Claude parses a command's frontmatter as YAML when the command is loaded, and
|
|
// `descriptionFromMd` splits each line on its first colon — so a command whose
|
|
// frontmatter is not valid YAML passed every check here. Same class as the
|
|
// SKILL.md gap, on the other set of files the #494 thread checked by hand.
|
|
|
|
test('a command whose frontmatter is valid YAML passes', () => {
|
|
const root = makeSandbox();
|
|
writeMatchingCommands(root, 'build', 'Build the thing');
|
|
const result = run(root);
|
|
assert.equal(result.status, 0, result.stdout);
|
|
assert.match(result.stdout, /Checking Claude command frontmatter/);
|
|
});
|
|
|
|
test('an unquoted colon in a command description is rejected', () => {
|
|
const root = makeSandbox();
|
|
// Valid to the splitter, rejected by a real YAML parser: it reads a nested
|
|
// mapping. The TOML siblings keep the same text so only the .md is at fault.
|
|
writeClaudeCommand(root, 'build', 'description: Build the thing: quickly');
|
|
writeTomlCommand(root, path.join('.gemini', 'commands'), 'build', 'description = "Build the thing: quickly"');
|
|
writeTomlCommand(root, 'commands', 'build', 'description = "Build the thing: quickly"');
|
|
|
|
const result = run(root);
|
|
assert.equal(result.status, 1, result.stdout);
|
|
assert.match(result.stdout, /unquoted value containing/);
|
|
});
|
|
|
|
test('quoting the same description makes it pass', () => {
|
|
const root = makeSandbox();
|
|
writeClaudeCommand(root, 'build', 'description: "Build the thing: quickly"');
|
|
writeTomlCommand(root, path.join('.gemini', 'commands'), 'build', 'description = "Build the thing: quickly"');
|
|
writeTomlCommand(root, 'commands', 'build', 'description = "Build the thing: quickly"');
|
|
|
|
const result = run(root);
|
|
assert.equal(result.status, 0, result.stdout);
|
|
});
|
|
|
|
test('a tab-indented command frontmatter is rejected', () => {
|
|
const root = makeSandbox();
|
|
writeFile(
|
|
root,
|
|
path.join('.claude', 'commands', 'build.md'),
|
|
'---\ndescription: Build the thing\nmeta:\n\tlevel: core\n---\n\n# Command\n',
|
|
);
|
|
writeTomlCommand(root, path.join('.gemini', 'commands'), 'build', 'description = "Build the thing"');
|
|
writeTomlCommand(root, 'commands', 'build', 'description = "Build the thing"');
|
|
|
|
const result = run(root);
|
|
assert.equal(result.status, 1, result.stdout);
|
|
assert.match(result.stdout, /indents with a tab/);
|
|
});
|
|
|
|
test('the TOML directories are not put through the YAML rules', () => {
|
|
const root = makeSandbox();
|
|
// A TOML description legitimately carries a colon. Running the YAML rules over
|
|
// these files would fail every command that has one.
|
|
writeMatchingCommands(root, 'build', 'Build the thing quickly');
|
|
const result = run(root);
|
|
assert.equal(result.status, 0, result.stdout);
|
|
assert.doesNotMatch(result.stdout, /\.gemini.*unquoted value/);
|
|
});
|