diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index e3925e1a..e5022929 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -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 diff --git a/package.json b/package.json index b1ea7920..95c74b30 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-manifest.js b/scripts/build-manifest.js new file mode 100755 index 00000000..8c3ada7c --- /dev/null +++ b/scripts/build-manifest.js @@ -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).`, +); diff --git a/scripts/manifest.test.ts b/scripts/manifest.test.ts new file mode 100644 index 00000000..b2b7f7d8 --- /dev/null +++ b/scripts/manifest.test.ts @@ -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, + ); +}); diff --git a/scripts/manifest.ts b/scripts/manifest.ts new file mode 100644 index 00000000..1fa56d07 --- /dev/null +++ b/scripts/manifest.ts @@ -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 = { + 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 { + const categories: Record = {}; + + 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 { + 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", + }, + ]; +} diff --git a/scripts/pre-deploy.ts b/scripts/pre-deploy.ts index 57ef98bc..774bec6a 100644 --- a/scripts/pre-deploy.ts +++ b/scripts/pre-deploy.ts @@ -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( diff --git a/scripts/set-version.js b/scripts/set-version.js index 916dad00..abb3aa5b 100755 --- a/scripts/set-version.js +++ b/scripts/set-version.js @@ -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 diff --git a/scripts/skill-listing.test.ts b/scripts/skill-listing.test.ts index 22e51070..51be9c7f 100644 --- a/scripts/skill-listing.test.ts +++ b/scripts/skill-listing.test.ts @@ -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(