inspector/scripts/lib/skill-manifest.mjs
cliffhall d05e76d470 chore: address Copilot review round 4
- verify:skills:cli — the authoritative validator is now a GUARANTEED step in
  `local:gate` and CI, not a best-effort hand-off. It resolves the CLI itself
  (an installed one at 2.1.233+, else a pinned `npx -y`), so both tiers run the
  same script and cannot drift. `verify:skills` keeps skipping when the CLI is
  absent, which is right for `validate`: fast, offline, no Claude Code required.
- skill-manifest: the 1,536-char listing-entry cap covers `description` +
  `when_to_use` together; checking the description alone passed an over-cap
  entry. `when_to_use` is now parsed, validated and counted, with boundary tests
  at 1,536/1,537.
- verify-skills: report the budget as *this repo's contribution*. The real
  listing budget is shared with bundled skills and the contributor's own
  ~/.claude/skills, none of which is visible from here, so the number was
  claiming more than it measured.
- skill-eval: a negative case now means "no skill OF THIS REPO'S fired". It
  failed on any Skill invocation, so a contributor with an unrelated global
  skill got a false failure about their environment rather than these skills.
- docs/architecture + local-dev: the Node backend runner gets its
  `@inspector/core` alias from `clients/web/tsup.runner.config.ts`, not from
  Vite. The old text sent runner work to the wrong config.
- local-dev: the tarball does not ship "only each client's build/" — the
  allowlist also carries `clients/web/dist`, `clients/web/static` and the
  install helper. States the invariant that actually matters instead.
- issue-triage: the test for needing triage is "no board card". Adding "and no
  milestone" would skip the approved-but-unboarded case the same section then
  handles. Also corrected the `$M` note: the lookup is `--state all`, so closed
  issues DO have entries — `isopen` is about the invariants, not the lookup.
- issue-create: replaced "all five" with a per-version table. Steps 3 and 6
  (milestone, Priority) do not exist for v1, so the old list opened with a rule
  the same skill later tells you to violate.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-29 01:47:43 -04:00

265 lines
9.5 KiB
JavaScript

// Pure parsers for the `.claude/skills/**/SKILL.md` contract, extracted so
// `verify-skills.mjs` can be unit-tested without a filesystem (#2163).
//
// The failure this guards is silent by construction: Claude Code reads a
// SKILL.md's frontmatter only when the opening `---` is the file's FIRST line
// and the YAML between the fences parses. Otherwise the body loads with EMPTY
// metadata — `/skill-name` still works, so a manual spot check passes, but
// there is no `description` left for the model to match against and the skill
// never auto-fires again. A stray unquoted colon in a description is enough.
//
// So every check here is deny-by-default: a file we cannot read the way Claude
// Code reads it is an error, not a skip.
import { parse as parseYaml } from "yaml";
/**
* Per-entry cap Claude Code applies to a skill listing entry. It covers
* `description` **and** `when_to_use` together, so checking `description` alone
* would pass an over-cap entry (Copilot).
*/
export const DESCRIPTION_CAP = 1536;
/**
* Character budget for the skill LISTING — the sum of name + description over
* the skills that occupy it. Only model-invoked skills do: a skill with
* `disable-model-invocation: true` is reachable solely by name, so its
* description never enters context.
*
* The listing is truncated when it overflows the model's budget, and the
* entries dropped first are the ones invoked least — i.e. exactly the
* model-invoked skills that must fire on their own. Recording the number here
* is what makes a regression visible when a skill is added later.
*/
export const LISTING_BUDGET = 4000;
const FRONTMATTER_FENCE = "---";
/**
* Split a SKILL.md into its raw frontmatter and body the way Claude Code does.
* Returns `{ error }` rather than throwing, so the caller can report every
* offending file in one pass instead of dying on the first.
*
* @param {string} text Full file contents.
* @returns {{ frontmatter?: string, body?: string, error?: string }}
*/
export function splitFrontmatter(text) {
// A BOM or a leading blank line means the fence is not the first line, and
// Claude Code then treats the WHOLE file — `---` markers included — as body.
if (!text.startsWith(FRONTMATTER_FENCE + "\n")) {
return {
error:
"frontmatter must open with `---` on the very first line (no BOM, no leading blank line)",
};
}
const rest = text.slice(FRONTMATTER_FENCE.length + 1);
// The closing fence must occupy the WHOLE line. Matching a mere prefix would
// accept `---oops` as a terminator, silently truncating the frontmatter to
// whatever preceded it — the very failure this function exists to catch.
const close = /\n---[ \t]*(\r?\n|$)/.exec(rest);
if (close === null)
return { error: "frontmatter is never closed by a `---` line" };
return {
frontmatter: rest.slice(0, close.index),
body: rest.slice(close.index + close[0].length),
};
}
/**
* Parse and validate one skill's frontmatter.
*
* @param {string} dirName Directory the SKILL.md lives in — the skill's identity.
* @param {string} text Full file contents.
* @returns {{ name?: string, description?: string, modelInvoked?: boolean,
* userInvocable?: boolean, paths?: string[], errors: string[] }}
*/
export function parseSkill(dirName, text) {
const errors = [];
const { frontmatter, error } = splitFrontmatter(text);
if (error) return { errors: [error] };
let meta;
try {
meta = parseYaml(frontmatter);
} catch (e) {
// The whole point of the guard: this is what strips the description.
return {
errors: [`frontmatter is not valid YAML — ${e.message.split("\n")[0]}`],
};
}
if (meta === null || typeof meta !== "object" || Array.isArray(meta)) {
return { errors: ["frontmatter must parse to a mapping"] };
}
const name = meta.name;
if (typeof name !== "string" || name.length === 0) {
errors.push("`name` is required and must be a string");
} else if (name !== dirName) {
errors.push(
`\`name: ${name}\` does not match its directory \`${dirName}\``,
);
} else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
errors.push(`\`name: ${name}\` must be kebab-case`);
}
const description = meta.description;
if (typeof description !== "string" || description.trim().length === 0) {
errors.push("`description` is required and must be a non-empty string");
}
const whenToUse = meta["when_to_use"];
if ("when_to_use" in meta && typeof whenToUse !== "string") {
errors.push("`when_to_use` must be a string");
}
const entryLength =
(typeof description === "string" ? description.length : 0) +
(typeof whenToUse === "string" ? whenToUse.length : 0);
if (entryLength > DESCRIPTION_CAP) {
errors.push(
`\`description\` + \`when_to_use\` is ${entryLength} chars; Claude Code caps a listing entry at ${DESCRIPTION_CAP}`,
);
}
// Every skill states its invocation mode rather than inheriting a default,
// so the eval surface is knowable by reading the files.
if (!("disable-model-invocation" in meta)) {
errors.push(
"`disable-model-invocation` must be declared explicitly (true = invoked by name only, false = the model may fire it)",
);
}
const dmi = meta["disable-model-invocation"];
if ("disable-model-invocation" in meta && typeof dmi !== "boolean") {
errors.push("`disable-model-invocation` must be a boolean");
}
const modelInvoked = dmi === false;
if ("user-invocable" in meta && typeof meta["user-invocable"] !== "boolean") {
errors.push("`user-invocable` must be a boolean");
}
if ("paths" in meta) {
if (
!Array.isArray(meta.paths) ||
meta.paths.some((p) => typeof p !== "string")
) {
errors.push("`paths` must be a list of glob strings");
}
}
// A skill nothing can reach is dead weight that still costs a directory.
if (!modelInvoked && meta["user-invocable"] === false) {
errors.push(
"skill is unreachable: `disable-model-invocation: true` with `user-invocable: false`",
);
}
return {
name: typeof name === "string" ? name : undefined,
description: typeof description === "string" ? description : undefined,
whenToUse: typeof whenToUse === "string" ? whenToUse : undefined,
modelInvoked,
userInvocable: meta["user-invocable"] !== false,
paths: Array.isArray(meta.paths) ? meta.paths : undefined,
errors,
};
}
/**
* Cost of the skill listing Claude Code loads into context, counting only the
* skills that appear in it.
*
* The number is this repo's CONTRIBUTION to the listing, not the whole listing:
* bundled skills and a contributor's own `~/.claude/skills` share the same
* budget, and none of those is visible from here (Copilot). Keeping the repo's
* share well under the cap is what leaves room for them.
*
* @param {Array<{ name?: string, description?: string, whenToUse?: string, modelInvoked?: boolean }>} skills
*/
export function listingCost(skills) {
return skills
.filter((s) => s.modelInvoked)
.reduce(
(n, s) =>
n +
(s.name?.length ?? 0) +
(s.description?.length ?? 0) +
(s.whenToUse?.length ?? 0),
0,
);
}
/**
* Validate an `evals/evals.json` payload for a model-invoked skill.
*
* A skill that fires on everything is a context regression, not a win, and it
* is the failure nobody notices by hand — so negatives are required, not
* optional.
*
* @param {string} skillName
* @param {unknown} cases Parsed JSON.
* @returns {string[]} errors
*/
export function validateEvalCases(skillName, cases) {
if (!Array.isArray(cases) || cases.length === 0) {
return ["evals.json must be a non-empty array of cases"];
}
const errors = [];
cases.forEach((c, i) => {
if (c === null || typeof c !== "object" || Array.isArray(c)) {
errors.push(`case ${i}: must be an object`);
return;
}
if (typeof c.prompt !== "string" || c.prompt.trim() === "") {
errors.push(`case ${i}: \`prompt\` must be a non-empty string`);
}
if (
!("expect" in c) ||
(c.expect !== null && typeof c.expect !== "string")
) {
errors.push(`case ${i}: \`expect\` must be a skill name or null`);
}
});
const positives = cases.filter((c) => c && c.expect === skillName).length;
const negatives = cases.filter((c) => c && c.expect === null).length;
if (positives === 0) {
errors.push(`no positive case expects \`${skillName}\``);
}
if (negatives === 0) {
errors.push(
"no negative case (`expect: null`) — a skill that fires on everything is a regression",
);
}
return errors;
}
/**
* Claude Code version the authoritative validator is pinned to when it has to
* be fetched. Pinned rather than @latest: a validator that moves on its own can
* start failing a PR that changed nothing.
*/
export const PINNED_CLI_VERSION = "2.1.250";
/** First Claude Code release with `claude plugin validate`. */
export const PLUGIN_VALIDATE_MIN_VERSION = [2, 1, 233];
/**
* Read a semver triple out of `claude --version` output ("2.1.250 (Claude Code)").
*
* @param {string} text
* @returns {number[] | null}
*/
export function parseClaudeVersion(text) {
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
}
/**
* @param {number[]} a
* @param {number[]} b
* @returns {number} negative if a < b, 0 if equal, positive if a > b.
*/
export function compareVersions(a, b) {
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}