inspector/scripts/verify-skills-cli.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

101 lines
3.2 KiB
JavaScript

#!/usr/bin/env node
// Runs `claude plugin validate` — the AUTHORITATIVE skill schema — as a
// guaranteed step, resolving the CLI rather than hoping it is installed (#2163).
//
// `verify:skills` (inside `validate`) reimplements the parse and treats a
// missing CLI as a skip, deliberately: `npm run validate` is the fast inner
// loop, it must work offline, and a Node contributor should not need Claude Code
// installed to run it. But "skips when absent" plus "usually absent" adds up to
// "never runs", which made the acceptance criterion aspirational (Copilot).
//
// So the authoritative check gets its own step, in `local:gate` and in CI, and
// it does NOT skip: an already-installed CLI new enough to have the subcommand
// is used as-is, and otherwise a PINNED one is fetched with `npx -y`. Pinned
// rather than @latest because a validator that moves on its own can start
// failing a PR that changed nothing, which is how a gate loses its credibility.
//
// It needs no authentication — verified against a scrubbed environment and a
// clean HOME.
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
compareVersions,
parseClaudeVersion,
PINNED_CLI_VERSION,
PLUGIN_VALIDATE_MIN_VERSION,
} from "./lib/skill-manifest.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SKILLS_DIR = ".claude/skills";
/**
* The argv that runs a usable validator: the local CLI when it is new enough,
* otherwise the pinned one through `npx`.
*
* @param {{ version: number[] | null }} local
* @returns {{ command: string, args: string[], via: string }}
*/
export function validatorCommand({ version }) {
if (
version !== null &&
compareVersions(version, PLUGIN_VALIDATE_MIN_VERSION) >= 0
) {
return {
command: "claude",
args: ["plugin", "validate", SKILLS_DIR],
via: `local claude ${version.join(".")}`,
};
}
return {
command: "npx",
args: [
"-y",
`@anthropic-ai/claude-code@${PINNED_CLI_VERSION}`,
"plugin",
"validate",
SKILLS_DIR,
],
via:
version === null
? `npx @anthropic-ai/claude-code@${PINNED_CLI_VERSION} (no local CLI)`
: `npx @anthropic-ai/claude-code@${PINNED_CLI_VERSION} (local ${version.join(".")} predates the subcommand)`,
};
}
function main() {
const probe = spawnSync("claude", ["--version"], { encoding: "utf8" });
const version =
probe.error || probe.status !== 0
? null
: parseClaudeVersion(probe.stdout ?? "");
const { command, args, via } = validatorCommand({ version });
console.log(`verify:skills:cli — validating ${SKILLS_DIR} via ${via}…`);
const res = spawnSync(command, args, {
cwd: ROOT,
stdio: "inherit",
shell: process.platform === "win32",
});
if (res.error) {
console.error(
`\nverify:skills:cli — could not run \`${command}\`: ${res.error.message}`,
);
process.exit(1);
}
if (res.status !== 0) {
console.error(
"\nverify:skills:cli — `claude plugin validate` rejected the skills.",
);
process.exit(1);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main();
}