refactor(manifest): give the frontmatter-derived artifacts their own module

`claude-code.json`'s skills array and the `/axiom:ask` built from it are both
generated from router frontmatter, but that generation lived inline in
`set-version.js` — a version script that refuses to run without a version
argument. A one-line description edit therefore had no regeneration path that did
not look like a release action, and the failing check pointed at the version
script. On 2026-09-17 that cost four consecutive red CI runs.

`scripts/manifest.ts` now owns the generation. Both callers share it:
`npm run build:manifest` for a content-only regeneration, and `set-version.js`
for a release, which folds the same write set into its existing atomic pass.
Versions are untouched by the former.

The generator had no test at all, which is the reason moving it looked risky. It
now has five, covering contracts that have actually broken: the agent list must
come from disk rather than the manifest's always-empty `agents` array (it once
shipped "0 autonomous agents" that way), every manifest skill must appear, no
template placeholder may survive rendering, generation must be deterministic, and
a stale committed description must be replaced by the frontmatter's.

Verified byte-identical to the pre-refactor output: the no-op run and a full
regeneration both reproduce the committed files exactly, and `set-version.js`
through the shared function does too. The tests were shown to fail — injecting
the historical agent-list defect fails the guard and nothing else.

CI now calls `npm run check:ci` (unit suite plus content gate), so the local and
CI gates are one definition instead of two that drift.

Verified: npm test (static clean), npm run check:ci (524/524, leak scan clean).
This commit is contained in:
Charles Wiltgen
2026-09-17 10:45:24 -07:00
parent 4fec142381
commit 5ed60b9e05
8 changed files with 465 additions and 228 deletions
+13 -11
View File
@@ -87,17 +87,19 @@ jobs:
- run: npm ci
# The unit suite. It carries the version-parity gate, the generated-marker
# rules, the docs conventions and the leak scanner's own rules — none of
# which had ever run off a maintainer's machine.
- run: npm run test:unit
# The content gate on shipped content. It lives in a pre-commit hook and
# inside pre-deploy, so it does not run on a fresh clone, in CI, or for
# anyone who commits with --no-verify. This is the backstop for that.
# Plain node: the script is TypeScript, stripped natively by Node 24.
- run: node scripts/leak-scan.ts
# One definition, shared with a maintainer's machine: `npm run check:ci` is
# the unit suite plus the content gate. Before it existed the two gates were
# written twice and checked different things, so "green locally" was not
# evidence about CI — a manifest drift that this job caught took four red
# runs to surface, because the obvious local command was a strict subset.
#
# The unit suite carries the version-parity gate, the generated-marker
# rules, the docs conventions and the leak scanner's own rules. The content
# gate lives in a pre-commit hook and inside pre-deploy, so it does not run
# on a fresh clone or for anyone committing with --no-verify; this is the
# backstop for that. (leak-scan is TypeScript, stripped natively by Node 24.)
#
# Deliberately NOT `npm run test` (pre-deploy --static). Its first check
# shells out to `claude plugin validate` and treats ANY failure — including
# the CLI being absent — as a gate failure, so it cannot run here yet.
- run: npm run check:ci
+2
View File
@@ -18,7 +18,9 @@
"build:codex": "node scripts/build-codex.ts",
"build:cursor": "node scripts/build-cursor.ts",
"build:docs": "vitepress build docs",
"build:manifest": "node scripts/build-manifest.js",
"build:mcp": "cd axiom-mcp && pnpm run build:bundle",
"check:ci": "npm run test:unit && node scripts/leak-scan.ts",
"check:cursor": "node scripts/build-cursor.ts --check",
"check:refs": "node scripts/check-cross-refs.js",
"docs:build": "npm run build:docs",
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Regenerate the artifacts derived from SKILL.md frontmatter.
*
* npm run build:manifest
*
* Two committed files are generated rather than hand-written: the `skills[]`
* array in `claude-code.json`, and the `/axiom:ask` built from that array plus
* the agents on disk. Editing any router's description invalidates both.
*
* Versions are deliberately not touched here — stamping is `set-version.js`'s
* job, and it calls the same `manifestUpdates()` this does, so a content-only
* regeneration and a release run one implementation and cannot diverge.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { manifestUpdates } from "./manifest.ts";
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(scriptsDir, "..");
const pluginDir = path.join(root, ".claude-plugin/plugins/axiom");
const claudeCodePath = path.join(pluginDir, "claude-code.json");
if (!fs.existsSync(claudeCodePath)) {
throw new Error(`Plugin manifest not found: ${claudeCodePath}`);
}
const claudeCode = JSON.parse(fs.readFileSync(claudeCodePath, "utf8"));
const updates = manifestUpdates(claudeCode, pluginDir);
let written = 0;
for (const update of updates) {
const current = fs.existsSync(update.path)
? fs.readFileSync(update.path, "utf8")
: null;
if (current === update.content) {
console.log(` = ${update.label} (already current)`);
continue;
}
fs.writeFileSync(update.path, update.content);
console.log(`${update.label}`);
written++;
}
console.log(
written === 0
? "\nNothing to do — manifest and ask.md already match the frontmatter."
: `\nRegenerated ${written} file(s).`,
);
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { generateAskMd, manifestUpdates, readAgentsFromDisk } from "./manifest.ts";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const pluginDir = path.join(root, ".claude-plugin/plugins/axiom");
const agentsDir = path.join(pluginDir, "agents");
const claudeCodePath = path.join(pluginDir, "claude-code.json");
function committedManifest() {
return JSON.parse(fs.readFileSync(claudeCodePath, "utf8"));
}
// The generator is what turns frontmatter into two committed files, and it had
// no test: the drift that took four CI runs to surface on 2026-09-17 was caught
// by a different test's assertion, not by anything covering this code.
test("ask.md lists the agents on disk rather than the empty manifest array", () => {
// Regression guard for a shipped defect the source comments record: the
// generator read `claudeCode.agents`, which is always empty because agents are
// deliberately absent from claude-code.json, so /axiom:ask advertised "0
// autonomous agents" with an empty Agents Reference and could not route to any.
const agents = readAgentsFromDisk(agentsDir);
assert.ok(agents.length > 0, "expected agent files on disk");
const md = generateAskMd(committedManifest(), agentsDir);
assert.ok(
md.includes(`${agents.length} autonomous agents`),
`prose should report ${agents.length} agents`,
);
for (const agent of agents) {
assert.ok(md.includes(`**${agent.name}**`), `agent missing from ask.md: ${agent.name}`);
}
});
test("ask.md covers every skill in the manifest", () => {
const claudeCode = committedManifest();
const skills = claudeCode.skills ?? [];
assert.ok(skills.length > 0, "expected manifest skills");
const md = generateAskMd(claudeCode, agentsDir);
assert.ok(md.includes(`${skills.length} specialized Axiom skills`));
const missing = skills.filter((s: { name: string }) => !md.includes(`**${s.name}**`));
assert.deepEqual(missing.map((s: { name: string }) => s.name), []);
});
test("no template placeholder survives rendering", () => {
// A missed replacement ships a literal {{skillCount}} into the one command
// users are told to reach for when they don't know what they want.
const md = generateAskMd(committedManifest(), agentsDir);
assert.doesNotMatch(md, /\{\{\w+\}\}/);
});
test("generation is deterministic", () => {
const claudeCode = committedManifest();
assert.equal(
generateAskMd(claudeCode, agentsDir),
generateAskMd(committedManifest(), agentsDir),
);
});
test("manifestUpdates regenerates the description from frontmatter, not the committed copy", () => {
// This is the contract the drift gate depends on: `skills[]` is derived, so a
// stale committed description must be replaced by the frontmatter's, and both
// derived artifacts must be in the write set.
const claudeCode = committedManifest();
const first = claudeCode.skills?.[0]?.name;
assert.ok(first, "expected at least one manifest skill");
claudeCode.skills[0].description = "STALE — must be replaced by the frontmatter";
const updates = manifestUpdates(claudeCode, pluginDir);
const labels = updates.map((u) => u.label);
assert.ok(labels.some((l) => l.endsWith("claude-code.json")), "manifest not in write set");
assert.ok(labels.some((l) => l.endsWith("commands/ask.md")), "ask.md not in write set");
const manifestUpdate = updates.find((u) => u.label.endsWith("claude-code.json"));
assert.ok(manifestUpdate);
const regenerated = JSON.parse(manifestUpdate.content);
const entry = regenerated.skills.find((s: { name: string }) => s.name === first);
assert.notEqual(
entry.description,
"STALE — must be replaced by the frontmatter",
"stale description survived regeneration",
);
assert.equal(
entry.description,
committedManifest().skills.find((s: { name: string }) => s.name === first).description,
);
});
+301
View File
@@ -0,0 +1,301 @@
/**
* Owns the artifacts derived from SKILL.md frontmatter.
*
* Two committed files are generated from the router frontmatter rather than
* hand-written:
*
* - the `skills[]` array in `claude-code.json`, and
* - `commands/ask.md`, built from that array plus the agents on disk.
*
* Both used to live inline in `set-version.js`, which meant the only way to
* regenerate a *content* derivation was to run a *version* script — and that
* script refuses to run without a version argument. On 2026-09-17 a one-line
* description edit that way sat stale through a green local gate and four red CI
* runs, and the failing check's own message pointed at the version script.
*
* Keeping the generation here gives it a name that says what it does, and lets
* both callers share one definition instead of diverging copies:
* `scripts/build-manifest.js` for a content-only regeneration, and
* `scripts/set-version.js` for a release (which must also stamp versions).
*/
import fs from "node:fs";
import path from "node:path";
import { manifestSkillsFromDisk } from "./skill-listing.ts";
export interface ListingSkill {
name: string;
description: string;
}
export interface ManifestUpdate {
path: string;
content: string;
label: string;
}
// Category mapping patterns for skills
// Ordered from most specific to least specific to prevent greedy matching
const CATEGORY_PATTERNS: Record<string, string[]> = {
Utility: ["getting-started"],
Testing: ["testing", "ui-testing", "simulator"],
"Persistence & Storage": [
"swiftdata",
"grdb",
"sqlite",
"cloudkit",
"icloud",
"storage",
"realm",
"core-data",
"database",
"cloud-sync",
],
Integration: [
"networking",
"app-intent",
"storekit",
"in-app",
"foundation-model",
"extension",
"widget",
"avfoundation",
"now-playing",
"app-shortcut",
"core-spotlight",
"app-discovera",
"network-framework",
],
"Build & Environment": ["build", "xcode"],
"Code Quality": ["concurrency", "codable"],
"UI & Design": [
"swiftui",
"hig",
"liquid-glass",
"layout",
"nav",
"gesture",
"textkit",
"typography",
"animation",
"auto-layout",
"accessibility",
],
Debugging: ["debugging", "profiling", "memory", "objc-block"],
};
/** Categorize a skill based on its name and description. */
function categorizeSkill(skillName: string, description: string): string {
const lowerName = skillName.toLowerCase();
const lowerDesc = description.toLowerCase();
// First pass: match by NAME only (more reliable)
for (const [category, patterns] of Object.entries(CATEGORY_PATTERNS)) {
for (const pattern of patterns) {
if (lowerName.includes(pattern)) {
return category;
}
}
}
// Second pass: match by description (fallback)
for (const [category, patterns] of Object.entries(CATEGORY_PATTERNS)) {
for (const pattern of patterns) {
if (lowerDesc.includes(pattern)) {
return category;
}
}
}
// Default to Debugging for diagnostic skills
if (skillName.endsWith("-diag")) {
return "Debugging";
}
// Default category for unmatched skills
return "Integration";
}
/** Group skills by category. */
function categorizeSkills(skills: ListingSkill[]): Record<string, ListingSkill[]> {
const categories: Record<string, ListingSkill[]> = {};
for (const skill of skills) {
const category = categorizeSkill(skill.name, skill.description);
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(skill);
}
// Sort skills within each category by name
for (const category of Object.keys(categories)) {
categories[category].sort((a, b) => a.name.localeCompare(b.name));
}
return categories;
}
/** Generate skills section markdown. */
function generateSkillsSection(categories: Record<string, ListingSkill[]>): string {
let markdown = "## Skills Reference\n\n";
// Define category order (matching our docs structure)
const categoryOrder = [
"Utility",
"Build & Environment",
"UI & Design",
"Code Quality",
"Debugging",
"Persistence & Storage",
"Integration",
"Testing",
];
for (const category of categoryOrder) {
const skills = categories[category];
if (!skills || skills.length === 0) continue;
markdown += `### ${category}\n\n`;
for (const skill of skills) {
// Truncate description to first sentence or 120 chars
let desc = skill.description;
const firstSentence = desc.match(/^[^.!?]+[.!?]/);
if (firstSentence) {
desc = firstSentence[0];
} else if (desc.length > 120) {
desc = desc.substring(0, 120) + "...";
}
markdown += `- **${skill.name}** — ${desc}\n`;
}
markdown += "\n";
}
return markdown;
}
/** Generate agents section markdown. */
function generateAgentsSection(agents: ListingSkill[]): string {
let markdown = "## Agents Reference\n\n";
markdown +=
'When user asks to "audit", "review", "scan", or "check" code, launch the appropriate agent:\n\n';
// Sort agents by name
const sortedAgents = [...agents].sort((a, b) => a.name.localeCompare(b.name));
for (const agent of sortedAgents) {
// Extract key phrase from description (first clause before dash or comma)
let desc = agent.description;
const match = desc.match(/^[^—,]+/);
if (match) {
desc = match[0].trim();
// Remove "Use this agent when" prefix if present
desc = desc.replace(/^Use this agent when (the user mentions )?/i, "");
desc = desc.replace(/^Automatically (runs|scans)/i, "Scans for");
}
markdown += `- **${agent.name}** — ${desc}\n`;
}
markdown += "\n";
return markdown;
}
// Read agents from disk — name + description from each agent's frontmatter.
//
// Agents are deliberately NOT listed in claude-code.json (see
// .claude/rules/skill-descriptions.md: only router skills go in the
// manifest, to stay under the description budget). Reading
// `claudeCode.agents` therefore always yielded [], so /axiom:ask shipped
// claiming "0 autonomous agents" with an empty Agents Reference — the
// natural-language entry point could not route to any of them.
export function readAgentsFromDisk(agentsDir: string): ListingSkill[] {
if (!fs.existsSync(agentsDir)) return [];
return fs
.readdirSync(agentsDir)
.filter((f) => f.endsWith(".md"))
.map((f) => {
const content = fs.readFileSync(path.join(agentsDir, f), "utf8");
const fm = content.match(/^---\n([\s\S]*?)\n---/);
const name = f.replace(/\.md$/, "");
if (!fm) return { name, description: "" };
// description is a `|` block scalar in every agent; take its first
// non-empty line, which is the trigger sentence.
const lines = fm[1].split("\n");
let description = "";
for (let i = 0; i < lines.length; i++) {
if (!/^description:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
for (let j = i + 1; j < lines.length; j++) {
if (/^[a-zA-Z][\w-]*:/.test(lines[j])) break;
const text = lines[j].trim();
if (text) {
description = text;
break;
}
}
break;
}
return { name, description };
});
}
/** Generate the complete ask.md from the template plus the manifest and agents. */
export function generateAskMd(claudeCode: { skills?: ListingSkill[] }, agentsDir: string): string {
const skills = claudeCode.skills || [];
const agents = readAgentsFromDisk(agentsDir);
// Group skills by category
const categories = categorizeSkills(skills);
// Generate sections
const skillsSection = generateSkillsSection(categories);
const agentsSection = generateAgentsSection(agents);
// Read template and replace placeholders
const templatePath = path.join(import.meta.dirname, "templates/ask.md.template");
const template = fs.readFileSync(templatePath, "utf8");
return template
.replace("{{skillCount}}", String(skills.length))
.replace("{{agentCount}}", String(agents.length))
.replace("{{skillsSection}}", skillsSection)
.replace("{{agentsSection}}", agentsSection);
}
/**
* Regenerate both frontmatter-derived artifacts.
*
* Mutates `claudeCode.skills` in place (preserving the committed ordering) and
* returns the write set, so callers can fold it into their own atomic-write pass
* — `set-version.js` has one, and `build-manifest.js` writes them directly.
*
* Version is left untouched: stamping belongs to the caller.
*/
export function manifestUpdates(
claudeCode: { skills?: ListingSkill[] },
pluginDir: string,
): ManifestUpdate[] {
claudeCode.skills = manifestSkillsFromDisk(
pluginDir,
(claudeCode.skills ?? []).map((s) => s.name),
);
return [
{
path: path.join(pluginDir, "claude-code.json"),
content: JSON.stringify(claudeCode, null, 2) + "\n",
label: ".claude-plugin/plugins/axiom/claude-code.json",
},
{
path: path.join(pluginDir, "commands/ask.md"),
content: generateAskMd(claudeCode, path.join(pluginDir, "agents")),
label: ".claude-plugin/plugins/axiom/commands/ask.md",
},
];
}
+1 -1
View File
@@ -376,7 +376,7 @@ if (claudeCode) {
if (drifted.length > 0) {
error(
"manifest-drift",
`${drifted.length} manifest description(s) drifted from SKILL.md frontmatter, starting with "${drifted[0].name}" — regenerate with scripts/set-version.js`,
`${drifted.length} manifest description(s) drifted from SKILL.md frontmatter, starting with "${drifted[0].name}" — regenerate with \`npm run build:manifest\``,
);
} else {
console.log(
+5 -215
View File
@@ -8,7 +8,7 @@ import { isCursorGeneratedPath } from './cursor-output.js';
import { isCodexGeneratedPath } from './codex-output.js';
import { DOC_STAT_FILES, docStatValues, applyDocStats, checkMarkerSpec } from './doc-stats.js';
import { isGeneratedSubSkill } from './inline-auditors.ts';
import { manifestSkillsFromDisk } from './skill-listing.ts';
import { manifestUpdates } from './manifest.ts';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -29,198 +29,6 @@ if (!version?.match(VERSION_RE)) {
const root = path.join(__dirname, '..');
const pluginDir = path.join(root, '.claude-plugin/plugins/axiom');
// Category mapping patterns for skills
// Ordered from most specific to least specific to prevent greedy matching
const CATEGORY_PATTERNS = {
'Utility': ['getting-started'],
'Testing': ['testing', 'ui-testing', 'simulator'],
'Persistence & Storage': ['swiftdata', 'grdb', 'sqlite', 'cloudkit', 'icloud', 'storage', 'realm', 'core-data', 'database', 'cloud-sync'],
'Integration': ['networking', 'app-intent', 'storekit', 'in-app', 'foundation-model', 'extension', 'widget', 'avfoundation', 'now-playing', 'app-shortcut', 'core-spotlight', 'app-discovera', 'network-framework'],
'Build & Environment': ['build', 'xcode'],
'Code Quality': ['concurrency', 'codable'],
'UI & Design': ['swiftui', 'hig', 'liquid-glass', 'layout', 'nav', 'gesture', 'textkit', 'typography', 'animation', 'auto-layout', 'accessibility'],
'Debugging': ['debugging', 'profiling', 'memory', 'objc-block']
};
// Categorize a skill based on its name and description
function categorizeSkill(skillName, description) {
const lowerName = skillName.toLowerCase();
const lowerDesc = description.toLowerCase();
// First pass: match by NAME only (more reliable)
for (const [category, patterns] of Object.entries(CATEGORY_PATTERNS)) {
for (const pattern of patterns) {
if (lowerName.includes(pattern)) {
return category;
}
}
}
// Second pass: match by description (fallback)
for (const [category, patterns] of Object.entries(CATEGORY_PATTERNS)) {
for (const pattern of patterns) {
if (lowerDesc.includes(pattern)) {
return category;
}
}
}
// Default to Debugging for diagnostic skills
if (skillName.endsWith('-diag')) {
return 'Debugging';
}
// Default category for unmatched skills
return 'Integration';
}
// Group skills by category
function categorizeSkills(skills) {
const categories = {};
for (const skill of skills) {
const category = categorizeSkill(skill.name, skill.description);
if (!categories[category]) {
categories[category] = [];
}
categories[category].push(skill);
}
// Sort skills within each category by name
for (const category of Object.keys(categories)) {
categories[category].sort((a, b) => a.name.localeCompare(b.name));
}
return categories;
}
// Generate skills section markdown
function generateSkillsSection(categories) {
let markdown = '## Skills Reference\n\n';
// Define category order (matching our docs structure)
const categoryOrder = [
'Utility',
'Build & Environment',
'UI & Design',
'Code Quality',
'Debugging',
'Persistence & Storage',
'Integration',
'Testing'
];
for (const category of categoryOrder) {
const skills = categories[category];
if (!skills || skills.length === 0) continue;
markdown += `### ${category}\n\n`;
for (const skill of skills) {
// Truncate description to first sentence or 120 chars
let desc = skill.description;
const firstSentence = desc.match(/^[^.!?]+[.!?]/);
if (firstSentence) {
desc = firstSentence[0];
} else if (desc.length > 120) {
desc = desc.substring(0, 120) + '...';
}
markdown += `- **${skill.name}** — ${desc}\n`;
}
markdown += '\n';
}
return markdown;
}
// Generate agents section markdown
function generateAgentsSection(agents) {
let markdown = '## Agents Reference\n\n';
markdown += 'When user asks to "audit", "review", "scan", or "check" code, launch the appropriate agent:\n\n';
// Sort agents by name
const sortedAgents = [...agents].sort((a, b) => a.name.localeCompare(b.name));
for (const agent of sortedAgents) {
// Extract key phrase from description (first clause before dash or comma)
let desc = agent.description;
const match = desc.match(/^[^—,]+/);
if (match) {
desc = match[0].trim();
// Remove "Use this agent when" prefix if present
desc = desc.replace(/^Use this agent when (the user mentions )?/i, '');
desc = desc.replace(/^Automatically (runs|scans)/i, 'Scans for');
}
markdown += `- **${agent.name}** — ${desc}\n`;
}
markdown += '\n';
return markdown;
}
// Read agents from disk — name + description from each agent's frontmatter.
//
// Agents are deliberately NOT listed in claude-code.json (see
// .claude/rules/skill-descriptions.md: only router skills go in the
// manifest, to stay under the description budget). Reading
// `claudeCode.agents` therefore always yielded [], so /axiom:ask shipped
// claiming "0 autonomous agents" with an empty Agents Reference — the
// natural-language entry point could not route to any of them.
function readAgentsFromDisk(agentsDir) {
if (!fs.existsSync(agentsDir)) return [];
return fs.readdirSync(agentsDir)
.filter((f) => f.endsWith('.md'))
.map((f) => {
const content = fs.readFileSync(path.join(agentsDir, f), 'utf8');
const fm = content.match(/^---\n([\s\S]*?)\n---/);
const name = f.replace(/\.md$/, '');
if (!fm) return { name, description: '' };
// description is a `|` block scalar in every agent; take its first
// non-empty line, which is the trigger sentence.
const lines = fm[1].split('\n');
let description = '';
for (let i = 0; i < lines.length; i++) {
if (!/^description:\s*[|>][-+]?\s*$/.test(lines[i])) continue;
for (let j = i + 1; j < lines.length; j++) {
if (/^[a-zA-Z][\w-]*:/.test(lines[j])) break;
const text = lines[j].trim();
if (text) { description = text; break; }
}
break;
}
return { name, description };
});
}
// Generate complete ask.md from template
function generateAskMd(claudeCode, agentsDir) {
const skills = claudeCode.skills || [];
const agents = readAgentsFromDisk(agentsDir);
// Group skills by category
const categories = categorizeSkills(skills);
// Generate sections
const skillsSection = generateSkillsSection(categories);
const agentsSection = generateAgentsSection(agents);
// Read template and replace placeholders
const templatePath = path.join(__dirname, 'templates/ask.md.template');
const template = fs.readFileSync(templatePath, 'utf8');
return template
.replace('{{skillCount}}', skills.length)
.replace('{{agentCount}}', agents.length)
.replace('{{skillsSection}}', skillsSection)
.replace('{{agentsSection}}', agentsSection);
}
try {
// Auto-count components
const skillsDir = path.join(pluginDir, 'skills');
@@ -337,28 +145,10 @@ try {
throw new Error(`Failed to parse claude-code.json: ${err.message}`);
}
claudeCode.version = version;
// Regenerate the skills array from SKILL.md frontmatter. Claude Code builds
// its listing from the frontmatter and never reads this file, so a
// hand-edited array silently drifts — seven descriptions had, and /axiom:ask
// (generated below from this array) shipped the stale text to users.
claudeCode.skills = manifestSkillsFromDisk(
pluginDir,
(claudeCode.skills ?? []).map((s) => s.name),
);
updates.push({
path: claudeCodePath,
content: JSON.stringify(claudeCode, null, 2) + '\n',
label: '.claude-plugin/plugins/axiom/claude-code.json'
});
// Generate ask.md from template + manifest skills + on-disk agents
const askMdPath = path.join(pluginDir, 'commands/ask.md');
const askMdContent = generateAskMd(claudeCode, agentsDir);
updates.push({
path: askMdPath,
content: askMdContent,
label: '.claude-plugin/plugins/axiom/commands/ask.md'
});
// Regenerate the frontmatter-derived artifacts — the skills array and the
// /axiom:ask built from it. Same code path as `npm run build:manifest`, so a
// content-only regeneration and a release cannot diverge.
updates.push(...manifestUpdates(claudeCode, pluginDir));
// 1b. Prepare .claude-plugin/plugin.json — the manifest Claude Code actually
// reads. Without it the plugin name falls back to the install directory (a
+1 -1
View File
@@ -145,7 +145,7 @@ test("the committed manifest matches what disk generates", () => {
entry.description,
byName.get(entry.name),
`${entry.name}: claude-code.json has drifted from SKILL.md frontmatter — ` +
`regenerate with scripts/set-version.js`,
`regenerate with \`npm run build:manifest\``,
);
}
assert.deepEqual(