mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* test: guard the plugin loader contract that PR #494 exposed The agents manifest key shipped for months and silently loaded zero of the four subagents; no validator looked at the generated plugin manifest's shape and claude plugin validate never checks it. Three layers now do: - scripts/lib/validate-plugin-manifest.js pins the verified loader contract (KNOWN_LOADER_KEYS allowlist, no agents key, trailing-slash skills path from issue #86, every skill/agents/*.md shipped in plugin/agents/), unit-tested in tests/validate-plugin-manifest.test.js including a check of the real committed subtree. - The same check gates bun run build next to the version-drift guard. - tests/plugin-e2e.test.mjs installs the committed ./plugin subtree into a real Claude Code (sandboxed via CLAUDE_CONFIG_DIR in a temp dir) and asserts the component inventory: skill parses, all agents visible, hooks discovered. In the default suite; runs in about a second and skips cleanly when the claude CLI is absent, so CI is unaffected. All three failed against the pre-#494 tree for the shipped reason (Agents 0 of 4) and pass against current main. AI-assisted via Claude Code under maintainer direction. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: address PR review bot findings - Copilot: guard collectPluginManifestFindings against valid JSON that is not an object (null, string, number, array) so a broken manifest is a finding instead of a build crash; unit test added - Copilot: update the plugin-e2e header comment, the suite is in the default lineup rather than opt-in AI-assisted via Claude Code under maintainer direction. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: harden plugin E2E sandbox isolation Bugbot: create the sandbox CLAUDE_CONFIG_DIR up front and redirect HOME and USERPROFILE into the temp workDir too, so a CLI code path that derives config or cache locations from the home directory instead of CLAUDE_CONFIG_DIR still cannot touch the developer's real Claude config when the default suite runs. AI-assisted via Claude Code under maintainer direction. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: agent parity check mirrors the build's emit rules Bugbot: the shipped filename is `${claude-name || name}.md` and a providers: list may exclude claude-code, so comparing raw source basenames could fail the build on a renamed or provider-scoped agent with a build:release hint that cannot fix it. The validator now derives expected filenames the same way the transformer factory does (shared parseFrontmatter, same providers gate) with unit coverage for renames, name overrides, and provider-scoped agents. AI-assisted via Claude Code under maintainer direction. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix: run the plugin E2E through a shell on Windows Bugbot: the claude CLI is a .cmd shim on Windows and Node refuses to spawn those via execFile without a shell, so the availability probe always failed and the suite silently skipped there. Windows now invokes through a shell with every argument double-quoted (temp paths routinely contain spaces); the POSIX path is unchanged. AI-assisted via Claude Code under maintainer direction. Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
753 lines
32 KiB
JavaScript
753 lines
32 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Build System for Cross-Provider Design Skills
|
|
*
|
|
* Transforms source skills into provider-specific formats:
|
|
* - Cursor: .cursor/skills/
|
|
* - Claude Code: .claude/skills/
|
|
* - Gemini: .gemini/skills/
|
|
* - Codex: dist/codex/ only (OpenAI-metadata bundle; not synced to repo root)
|
|
* - Agents: .agents/skills/ (Codex repo/user installs)
|
|
* - GitHub: .github/skills/ (GitHub Copilot)
|
|
*
|
|
* Also assembles a universal ZIP containing all providers,
|
|
* and builds Tailwind CSS for production deployment.
|
|
*/
|
|
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js';
|
|
import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
|
|
import { hooksJsonFor, buildClaudePluginHooksManifest } from './lib/transformers/hooks.js';
|
|
import { createAllZips, createProviderZip } from './lib/zip.js';
|
|
import { collectPluginVersions } from './lib/validate-plugin-versions.js';
|
|
import { collectPluginManifestFindings } from './lib/validate-plugin-manifest.js';
|
|
import { stageOpenAIPlugin } from './lib/openai-plugin.js';
|
|
import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
|
|
// Sub-page generation is now handled by Astro content collections.
|
|
|
|
/**
|
|
* Generate authoritative counts from source data and write to site/public/js/generated/counts.js.
|
|
* Also validates that key HTML files reference the correct numbers.
|
|
*/
|
|
function generateCounts(rootDir, skills, buildDir) {
|
|
// Count active commands. After the v3.0 consolidation, commands are sub-commands
|
|
// of /impeccable. Count them from the command router table in SKILL.md.
|
|
const impeccableSkill = skills.find(s => s.name === 'impeccable');
|
|
let commandCount;
|
|
if (impeccableSkill) {
|
|
// Count lines in the command table that start with | `...` | — tolerant
|
|
// of argument hints inside the backticks (e.g. `craft [feature]`) and of
|
|
// multi-word commands (e.g. `pin <command>`).
|
|
const routerMatches = impeccableSkill.body.match(/^\| `[^`]+` \|/gm);
|
|
commandCount = routerMatches ? routerMatches.length : 0;
|
|
} else {
|
|
// Fallback: count user-invocable skills
|
|
const activeCommands = skills.filter(s => {
|
|
if (!s.userInvocable) return false;
|
|
const content = fs.readFileSync(s.filePath, 'utf-8');
|
|
return !content.includes('DEPRECATED');
|
|
});
|
|
commandCount = activeCommands.length;
|
|
}
|
|
|
|
// Count detection rules from the detector registry.
|
|
const detectionCount = new Set(ANTIPATTERNS.map(rule => rule.id)).size;
|
|
|
|
// Validate counts in key files
|
|
const filesToCheck = [
|
|
'site/pages/index.astro',
|
|
'README.md',
|
|
'README.npm.md',
|
|
'AGENTS.md',
|
|
'.claude-plugin/plugin.json',
|
|
'.claude-plugin/marketplace.json',
|
|
];
|
|
|
|
let errors = 0;
|
|
for (const relPath of filesToCheck) {
|
|
const absPath = path.join(rootDir, relPath);
|
|
if (!fs.existsSync(absPath)) continue;
|
|
const content = fs.readFileSync(absPath, 'utf-8');
|
|
|
|
// Check for stale command counts (look for "N commands" or "N skills" patterns)
|
|
// Strip changelog list content to avoid flagging historical counts
|
|
const strippedContent = content.replace(/<ul class="changelog-items">[\s\S]*?<\/ul>/g, '');
|
|
const countPattern = /\b(\d+)\s+(design\s+)?(commands|sub-commands|skills|steering commands)/gi;
|
|
for (const match of strippedContent.matchAll(countPattern)) {
|
|
const num = parseInt(match[1]);
|
|
// Allow 1 (for "1 skill") and the correct count
|
|
if (num !== commandCount && num !== 1) {
|
|
console.error(` ❌ ${relPath}: found "${match[0]}" but active command count is ${commandCount}`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
// Check for stale detection counts. Use the changelog-stripped content
|
|
// so historical counts in changelog entries (e.g. "28 rules" from an
|
|
// older release) don't flag against the current detector total.
|
|
// "detector" as an infix ("60 deterministic detector rules") and
|
|
// qualified "issues" both evaded the old pattern, which is how five
|
|
// stale counts shipped while the validator reported clean.
|
|
const detectPattern = /\b(\d+)\s+(deterministic\s+)?(detector\s+)?(checks|patterns|rules|detections|issues)\b/gi;
|
|
for (const match of strippedContent.matchAll(detectPattern)) {
|
|
const num = parseInt(match[1]);
|
|
if (match[4] === 'issues' && !match[2]) continue; // plain "issues" is prose, not a count claim
|
|
if (num !== detectionCount && num > 10) { // ignore small numbers like "3 patterns"
|
|
console.error(` ❌ ${relPath}: found "${match[0]}" but detection count is ${detectionCount}`);
|
|
errors++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors > 0) {
|
|
console.error(`\n❌ ${errors} stale count reference(s) found. Update them to match source of truth.`);
|
|
}
|
|
|
|
console.log(`✓ Generated counts: ${commandCount} commands, ${detectionCount} detection rules`);
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Guard against plugin/skill version drift (issue #274). The pure comparison
|
|
* lives in ./lib/validate-plugin-versions.js (so it's unit-tested directly);
|
|
* this wrapper owns the console output and the error count the build gates on.
|
|
*/
|
|
function validatePluginVersions(rootDir) {
|
|
const { source, mismatches, errors } = collectPluginVersions(rootDir);
|
|
// No root manifest at all → nothing to check (source null with no errors).
|
|
if (source == null && errors.length === 0) return 0;
|
|
|
|
for (const { relPath, reason } of errors) {
|
|
console.error(` ❌ ${relPath}: ${reason}`);
|
|
}
|
|
for (const { relPath, found, expected } of mismatches) {
|
|
console.error(
|
|
` ❌ ${relPath}: version "${found}" disagrees with .claude-plugin/plugin.json "${expected}"`,
|
|
);
|
|
}
|
|
|
|
const total = errors.length + mismatches.length;
|
|
if (total > 0) {
|
|
console.error(
|
|
`\n❌ ${total} plugin/skill version problem(s). Bump every version together and run ` +
|
|
`\`bun run build:release\` to regenerate the ./plugin subtree (issue #274).`,
|
|
);
|
|
} else {
|
|
console.log(`✓ Plugin/skill versions agree: ${source}`);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Guard against unverified plugin manifest shapes (PR #494). The pure check
|
|
* lives in ./lib/validate-plugin-manifest.js (so it's unit-tested directly);
|
|
* this wrapper owns the console output and the error count the build gates on.
|
|
*/
|
|
function validatePluginManifestShape(rootDir) {
|
|
const findings = collectPluginManifestFindings(rootDir);
|
|
for (const { relPath, reason } of findings) {
|
|
console.error(` ❌ ${relPath}: ${reason}`);
|
|
}
|
|
if (findings.length > 0) {
|
|
console.error(
|
|
`\n❌ ${findings.length} plugin manifest shape problem(s). Every manifest key is a ` +
|
|
'claim about the Claude Code loader; see scripts/lib/validate-plugin-manifest.js (PR #494).',
|
|
);
|
|
} else {
|
|
console.log('✓ Plugin manifest shape matches the verified loader contract');
|
|
}
|
|
return findings.length;
|
|
}
|
|
|
|
function validateSkillFrontmatter(skills) {
|
|
let errors = 0;
|
|
|
|
for (const skill of skills) {
|
|
if (skill.description && skill.description.length > 1024) {
|
|
console.error(`❌ ${skill.filePath}: invalid description: exceeds maximum length of 1024 characters (${skill.description.length})`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Scan user-facing copy for AI-prose anti-patterns:
|
|
* - em dashes (— or —)
|
|
* - double-hyphen substitutes (` -- `)
|
|
* - denylisted phrases that read as AI tells in marketing copy
|
|
*
|
|
* The denylist is the editorial brief in docs/STYLE.md, enforced. Each rule has a
|
|
* rationale that prints with the failure so the next author understands why.
|
|
*
|
|
* Scope: every surface a reader sees. Not skill/, where
|
|
* LLM-facing reference instructions can use technical phrasings the marketing
|
|
* copy can't.
|
|
*
|
|
* Returns the number of occurrences found. Build fails if > 0.
|
|
*/
|
|
function validateProse(rootDir) {
|
|
const targets = [
|
|
'README.md',
|
|
'README.npm.md',
|
|
];
|
|
const extensions = new Set(['.html', '.md', '.js', '.mjs', '.css', '.astro']);
|
|
// The slop catalog documents every antipattern by example, so it must
|
|
// contain em dashes, buzzwords, and the rest as specimens. Exempt it from
|
|
// the prose gate: its job is to show the slop, not to avoid it.
|
|
const excludedPrefixes = [];
|
|
const emDashPatterns = [/—/g, /—/gi, /—/gi, /—/gi];
|
|
// Phrase rules: { re, rationale }. Add to docs/STYLE.md when adding here.
|
|
const phraseRules = [
|
|
{ re: /\bload-bearing\b/i, rationale: 'AI tell. Stolen-engineer diction; almost always vague. Name what the thing actually does.' },
|
|
{ re: /\bhighest-leverage\b/i, rationale: 'AI tell. Vague claim of impact. Say what specifically pays off.' },
|
|
{ re: /\bbiggest unlock\b/i, rationale: 'AI tell. Marketing-speak. Describe the actual change.' },
|
|
{ re: /\breflex defaults?\b/i, rationale: 'Internal jargon leaking into user-facing copy. Say "instincts" or "first guesses".' },
|
|
{ re: /\bcollapses? into monoculture\b/i, rationale: 'Internal eval-speak. Describe what actually went wrong.' },
|
|
{ re: /\bdata-driven\b/i, rationale: 'Empty marketing adjective. Cite the data instead.' },
|
|
{ re: /\bseamless(?:ly)?\b/i, rationale: 'Hollow positive. Say what specifically works without friction.' },
|
|
{ re: /\brobust(?:ness)?\b/i, rationale: 'Hollow positive. Cite the failure mode it handles.' },
|
|
{ re: /\bdelves?\b|\bdelved\b|\bdelving\b/i, rationale: 'Top AI tell. Use "explore", "look at", or just delete.' },
|
|
{ re: /\belevate(?:s|d)?\b/i, rationale: 'Marketing verb. Use the specific verb (improve, raise, sharpen).' },
|
|
{ re: /\bempower(?:s|ed|ing)?\b/i, rationale: 'Marketing verb. Use "let you" or "make possible".' },
|
|
{ re: /\bunderscore(?:s|d)?\b/i, rationale: 'AI tell. Use "show" or "make clear".' },
|
|
{ re: /\bpivotal\b/i, rationale: 'Hollow positive. Use "central", "key", or describe the role.' },
|
|
{ re: /\bin today's\b/i, rationale: 'Throat-clearing opener. Cut the clause; start at the point.' },
|
|
{ re: /\bgone are the days\b/i, rationale: 'Throat-clearing. Make the point directly.' },
|
|
{ re: /\bwhether you're\b/i, rationale: 'Audience-pandering. Pick one reader; write to them.' },
|
|
{ re: /\blet's dive in\b/i, rationale: 'Throat-clearing. Just start.' },
|
|
{ re: /\bin summary\b|\bin conclusion\b/i, rationale: 'Summarizing closer. End on the strongest sentence; trust the reader.' },
|
|
{ re: /\bmoreover\b|\bfurthermore\b/i, rationale: 'Transition crutch on a metronome. Drop, or use "also".' },
|
|
{ re: /\btapestry\b/i, rationale: 'AI scenery noun. Cut.' },
|
|
];
|
|
let errors = 0;
|
|
|
|
const checkLine = (line, rel, lineNum) => {
|
|
for (const re of emDashPatterns) {
|
|
if (re.test(line)) {
|
|
console.error(` ❌ ${rel}:${lineNum}: em dash → ${line.trim().slice(0, 120)}`);
|
|
console.error(` Use commas, colons, semicolons, periods, or parentheses.`);
|
|
errors++;
|
|
re.lastIndex = 0;
|
|
break;
|
|
}
|
|
re.lastIndex = 0;
|
|
}
|
|
if (/ -- /.test(line)) {
|
|
console.error(` ❌ ${rel}:${lineNum}: \` -- \` em-dash substitute → ${line.trim().slice(0, 120)}`);
|
|
console.error(` Worse than the em dash. Pick real punctuation.`);
|
|
errors++;
|
|
}
|
|
for (const rule of phraseRules) {
|
|
if (rule.re.test(line)) {
|
|
const matched = line.match(rule.re)?.[0] ?? '';
|
|
console.error(` ❌ ${rel}:${lineNum}: "${matched}" → ${line.trim().slice(0, 120)}`);
|
|
console.error(` ${rule.rationale}`);
|
|
errors++;
|
|
}
|
|
}
|
|
};
|
|
|
|
const scan = (absPath, rel) => {
|
|
// Normalize to POSIX separators so the forward-slash excludedPrefixes match
|
|
// on Windows, where path.join() produces backslash-separated rel paths.
|
|
const relPosix = rel.split(path.sep).join('/');
|
|
if (excludedPrefixes.some(p => relPosix === p || relPosix.startsWith(p + '/'))) return;
|
|
const stat = fs.statSync(absPath);
|
|
if (stat.isDirectory()) {
|
|
for (const entry of fs.readdirSync(absPath)) {
|
|
scan(path.join(absPath, entry), path.join(rel, entry));
|
|
}
|
|
return;
|
|
}
|
|
if (!extensions.has(path.extname(absPath))) return;
|
|
const src = fs.readFileSync(absPath, 'utf-8');
|
|
const lines = src.split('\n');
|
|
lines.forEach((line, i) => checkLine(line, rel, i + 1));
|
|
};
|
|
|
|
for (const target of targets) {
|
|
const full = path.join(rootDir, target);
|
|
if (fs.existsSync(full)) scan(full, target);
|
|
}
|
|
|
|
if (errors === 0) {
|
|
console.log(`✓ Prose validator: no AI tells in user-facing copy`);
|
|
} else {
|
|
console.error(`\n❌ ${errors} prose issue(s) in user-facing copy. See docs/STYLE.md for the rules.`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Narrow prose check for the impeccable skill source.
|
|
*
|
|
* The full validateProse rules don't fit LLM-facing reference instructions:
|
|
* the hardening repetition and triadic checklists those files use exist on
|
|
* purpose, and the structural-prose rules in docs/STYLE.md require human judgment.
|
|
* This validator only enforces the mechanical wins: em dashes (which are
|
|
* pure punctuation laziness regardless of audience) and the small handful
|
|
* of denylisted phrases that have no technical reading. Em-dash creep is the
|
|
* only thing likely to come back at scale once humans stop watching.
|
|
*
|
|
* Returns the number of occurrences found. Build fails if > 0.
|
|
*/
|
|
function validateSkillProse(rootDir) {
|
|
const target = 'skill';
|
|
const extensions = new Set(['.md']);
|
|
const emDashPatterns = [/—/g, /—/gi, /—/gi, /—/gi];
|
|
// Tighter than validateProse: only the rules that have no technical reading.
|
|
// Skipping `data-driven` here would be a mistake (it slipped through twice
|
|
// in live.md before this pass); but `seamless`, `robust`, etc. have
|
|
// legitimate technical uses elsewhere we may want to allow.
|
|
const phraseRules = [
|
|
{ re: /\bload-bearing\b/i, rationale: 'AI tell. Name what the thing actually does.' },
|
|
{ re: /\bhighest-leverage\b/i, rationale: 'AI tell. Say what specifically pays off.' },
|
|
{ re: /\bbiggest unlock\b/i, rationale: 'Marketing-speak. Describe the actual change.' },
|
|
{ re: /\breflex defaults?\b/i, rationale: 'Internal jargon. Say "instincts" or "first guesses".' },
|
|
{ re: /\bcollapses? into monoculture\b/i, rationale: 'Eval-speak. Describe what actually went wrong.' },
|
|
{ re: /\bdata-driven\b/i, rationale: 'Empty marketing adjective. Cite the data instead.' },
|
|
{ re: /\bdelves?\b|\bdelved\b|\bdelving\b/i, rationale: 'Top AI tell. Use "explore" or "look at".' },
|
|
{ re: /\btapestry\b/i, rationale: 'AI scenery noun. Cut.' },
|
|
{ re: /\bin today's\b/i, rationale: 'Throat-clearing opener. Start at the point.' },
|
|
{ re: /\bgone are the days\b/i, rationale: 'Throat-clearing. Make the point directly.' },
|
|
{ re: /\blet's dive in\b/i, rationale: 'Throat-clearing. Just start.' },
|
|
{ re: /\bin summary\b|\bin conclusion\b/i, rationale: 'Summarizing closer. End on the strongest sentence.' },
|
|
];
|
|
let errors = 0;
|
|
|
|
const checkLine = (line, rel, lineNum) => {
|
|
for (const re of emDashPatterns) {
|
|
if (re.test(line)) {
|
|
console.error(` ❌ ${rel}:${lineNum}: em dash → ${line.trim().slice(0, 120)}`);
|
|
console.error(` Use commas, colons, semicolons, periods, or parentheses.`);
|
|
errors++;
|
|
re.lastIndex = 0;
|
|
break;
|
|
}
|
|
re.lastIndex = 0;
|
|
}
|
|
if (/ -- /.test(line)) {
|
|
console.error(` ❌ ${rel}:${lineNum}: \` -- \` em-dash substitute → ${line.trim().slice(0, 120)}`);
|
|
console.error(` Worse than the em dash. Pick real punctuation.`);
|
|
errors++;
|
|
}
|
|
for (const rule of phraseRules) {
|
|
if (rule.re.test(line)) {
|
|
const matched = line.match(rule.re)?.[0] ?? '';
|
|
console.error(` ❌ ${rel}:${lineNum}: "${matched}" → ${line.trim().slice(0, 120)}`);
|
|
console.error(` ${rule.rationale}`);
|
|
errors++;
|
|
}
|
|
}
|
|
};
|
|
|
|
const scan = (absPath, rel) => {
|
|
const stat = fs.statSync(absPath);
|
|
if (stat.isDirectory()) {
|
|
for (const entry of fs.readdirSync(absPath)) {
|
|
scan(path.join(absPath, entry), path.join(rel, entry));
|
|
}
|
|
return;
|
|
}
|
|
if (!extensions.has(path.extname(absPath))) return;
|
|
const src = fs.readFileSync(absPath, 'utf-8');
|
|
const lines = src.split('\n');
|
|
lines.forEach((line, i) => checkLine(line, rel, i + 1));
|
|
};
|
|
|
|
const full = path.join(rootDir, target);
|
|
if (fs.existsSync(full)) scan(full, target);
|
|
|
|
if (errors === 0) {
|
|
console.log(`✓ Skill prose validator: skill/ is clean`);
|
|
} else {
|
|
console.error(`\n❌ ${errors} prose issue(s) in skill/. See docs/STYLE.md.`);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Validate that every hand-authored HTML page carries the shared site header.
|
|
* The partial is stamped with `<!-- site-header v1 -->` so drift is loud.
|
|
*
|
|
* Returns the number of validation errors. Build fails if > 0.
|
|
*/
|
|
function copyDirSync(src, dest) {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const srcPath = path.join(src, entry.name);
|
|
const destPath = path.join(dest, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copyDirSync(srcPath, destPath);
|
|
} else {
|
|
fs.copyFileSync(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make an existing directory match a generated source without removing the
|
|
* destination root. Provider skill roots can be watched by the running agent;
|
|
* replacing that root fails on some platforms and can invalidate the live
|
|
* skill path mid-session.
|
|
*/
|
|
function mirrorDirContentsSync(src, dest) {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
const sourceEntries = new Map(
|
|
fs.readdirSync(src, { withFileTypes: true }).map(entry => [entry.name, entry]),
|
|
);
|
|
|
|
for (const destEntry of fs.readdirSync(dest, { withFileTypes: true })) {
|
|
if (!sourceEntries.has(destEntry.name)) {
|
|
fs.rmSync(path.join(dest, destEntry.name), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
for (const entry of sourceEntries.values()) {
|
|
const srcPath = path.join(src, entry.name);
|
|
const destPath = path.join(dest, entry.name);
|
|
const destStat = fs.existsSync(destPath) ? fs.lstatSync(destPath) : null;
|
|
if (entry.isDirectory()) {
|
|
if (destStat && !destStat.isDirectory()) fs.rmSync(destPath, { recursive: true, force: true });
|
|
mirrorDirContentsSync(srcPath, destPath);
|
|
} else {
|
|
if (destStat?.isDirectory()) fs.rmSync(destPath, { recursive: true, force: true });
|
|
fs.copyFileSync(srcPath, destPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function syncRootHookManifests(rootDir) {
|
|
const synced = [];
|
|
for (const config of Object.values(PROVIDERS)) {
|
|
if (!config.emitHooks) continue;
|
|
const manifest = hooksJsonFor(config.emitHooks, { configDir: config.configDir });
|
|
if (!manifest) continue;
|
|
const rel = config.hooksManifestRel || path.join('hooks', 'hooks.json');
|
|
const dest = path.join(rootDir, config.configDir, rel);
|
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
fs.writeFileSync(dest, JSON.stringify(manifest, null, 2) + '\n');
|
|
synced.push(path.join(config.configDir, rel).split(path.sep).join('/'));
|
|
}
|
|
return synced;
|
|
}
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const ROOT_DIR = path.resolve(__dirname, '..');
|
|
const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
|
|
|
function parseBuildOptions(argv = process.argv.slice(2)) {
|
|
const skipRootSync = argv.includes('--skip-root-sync') || argv.includes('--no-root-sync');
|
|
return {
|
|
syncRootOutputs: !skipRootSync,
|
|
};
|
|
}
|
|
|
|
const BUILD_OPTIONS = parseBuildOptions();
|
|
|
|
// buildStaticSite (Bun HTML bundler) removed — now handled by Astro.
|
|
|
|
/**
|
|
* Assemble universal directory from all provider outputs
|
|
*/
|
|
function assembleUniversal(distDir) {
|
|
const universalDir = path.join(distDir, 'universal');
|
|
|
|
// Clean and recreate
|
|
if (fs.existsSync(universalDir)) {
|
|
fs.rmSync(universalDir, { recursive: true, force: true });
|
|
}
|
|
|
|
const providerConfigs = Object.values(PROVIDERS);
|
|
|
|
for (const { provider, configDir } of providerConfigs) {
|
|
const src = path.join(distDir, provider, configDir);
|
|
const dest = path.join(universalDir, configDir);
|
|
if (fs.existsSync(src)) {
|
|
copyDirSync(src, dest);
|
|
}
|
|
}
|
|
|
|
// Add a visible README so macOS users don't see an empty folder
|
|
// (all provider dirs are dotfiles, hidden by default in Finder)
|
|
fs.writeFileSync(path.join(universalDir, 'README.txt'),
|
|
`Impeccable. Design fluency for AI harnesses.
|
|
https://impeccable.style
|
|
|
|
This folder contains skills for all supported tools:
|
|
|
|
.cursor/ -> Cursor
|
|
.claude/ -> Claude Code
|
|
.gemini/ -> Gemini CLI
|
|
.codex/ -> Codex custom agents (Codex skills use .agents/)
|
|
.agents/ -> Codex CLI
|
|
.agent/ -> Antigravity
|
|
.github/ -> GitHub Copilot
|
|
.grok/ -> Grok Build
|
|
.kiro/ -> Kiro
|
|
.opencode/ -> OpenCode
|
|
.pi/ -> Pi
|
|
.trae-cn/ -> Trae China
|
|
.trae/ -> Trae International
|
|
.rovodev/ -> Rovo Dev
|
|
.vibe/ -> Mistral Vibe
|
|
.qoder/ -> Qoder
|
|
|
|
To install, copy the relevant folder(s) into your project root.
|
|
For Codex, repo and user skill installs come from .agents/skills.
|
|
These are hidden folders (dotfiles). Press Cmd+Shift+. in Finder to see them.
|
|
`);
|
|
|
|
console.log(`✓ Assembled universal directory (${providerConfigs.length} providers)`);
|
|
}
|
|
|
|
/**
|
|
* Copy dist files to build output for Cloudflare Pages Functions access.
|
|
* Download functions use env.ASSETS.fetch() to read these files.
|
|
*/
|
|
function copyDistToBuild(distDir, buildDir) {
|
|
const destDir = path.join(buildDir, '_data', 'dist');
|
|
copyDirSync(distDir, destDir);
|
|
console.log('✓ Copied dist files to build output');
|
|
}
|
|
|
|
/**
|
|
* Generate Cloudflare Pages config files (_headers, _redirects)
|
|
*/
|
|
async function build() {
|
|
console.log('🔨 Building cross-provider design skills...\n');
|
|
|
|
// Sub-page generation, HTML bundling, and static-asset copying are now
|
|
// handled by Astro (bun run build:site). This script focuses on skills,
|
|
// API data, and Cloudflare config.
|
|
|
|
const buildDir = path.join(ROOT_DIR, 'build');
|
|
|
|
// Read source files (unified skills architecture)
|
|
const { skills } = readSourceFiles(ROOT_DIR);
|
|
const patterns = readPatterns(ROOT_DIR);
|
|
const userInvocableCount = skills.filter(s => s.userInvocable).length;
|
|
console.log(`📖 Read ${skills.length} skills (${userInvocableCount} user-invocable) and ${patterns.patterns.length + patterns.antipatterns.length} pattern categories\n`);
|
|
|
|
const frontmatterErrors = validateSkillFrontmatter(skills);
|
|
if (frontmatterErrors > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
// Read skills version from plugin.json
|
|
const pluginJson = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
|
|
const skillsVersion = pluginJson.version;
|
|
|
|
// Transform for each provider
|
|
for (const config of Object.values(PROVIDERS)) {
|
|
const transform = createTransformer(config);
|
|
transform(skills, DIST_DIR, { skillsVersion });
|
|
}
|
|
|
|
// Assemble universal directory
|
|
assembleUniversal(DIST_DIR);
|
|
|
|
// Create ZIP bundles (individual + universal)
|
|
await createAllZips(DIST_DIR);
|
|
|
|
|
|
if (BUILD_OPTIONS.syncRootOutputs) {
|
|
// Copy all provider outputs to project root for direct GitHub installs and
|
|
// submodule users. `.codex/` is intentionally excluded: Codex no longer
|
|
// consumes that layout; keep generated Codex bundles under dist/ only.
|
|
const syncConfigs = Object.values(PROVIDERS).filter(({ configDir }) => configDir !== '.codex');
|
|
|
|
for (const { provider, configDir } of syncConfigs) {
|
|
const skillsSrc = path.join(DIST_DIR, provider, configDir, 'skills');
|
|
const skillsDest = path.join(ROOT_DIR, configDir, 'skills');
|
|
|
|
if (fs.existsSync(skillsSrc)) {
|
|
// Preserve legacy per-project script artifacts (e.g. live-mode config.json)
|
|
// while replacing only skills generated by this build. Removing the
|
|
// whole provider skills directory can erase unrelated repo-local skills,
|
|
// and watched directories such as `.agents/skills` may reject the parent
|
|
// removal while Codex is using them.
|
|
const stashed = stashPerProjectArtifacts(skillsDest);
|
|
fs.mkdirSync(skillsDest, { recursive: true });
|
|
for (const entry of fs.readdirSync(skillsSrc, { withFileTypes: true })) {
|
|
const generatedDest = path.join(skillsDest, entry.name);
|
|
if (entry.isDirectory()) mirrorDirContentsSync(path.join(skillsSrc, entry.name), generatedDest);
|
|
else fs.copyFileSync(path.join(skillsSrc, entry.name), generatedDest);
|
|
}
|
|
restorePerProjectArtifacts(skillsDest, stashed);
|
|
}
|
|
}
|
|
|
|
for (const { provider, configDir, agentFormat } of Object.values(PROVIDERS)) {
|
|
if (!agentFormat) continue;
|
|
|
|
const agentsSrc = path.join(DIST_DIR, provider, configDir, 'agents');
|
|
const agentsDest = path.join(ROOT_DIR, configDir, 'agents');
|
|
|
|
if (fs.existsSync(agentsDest)) fs.rmSync(agentsDest, { recursive: true, force: true });
|
|
if (fs.existsSync(agentsSrc)) {
|
|
copyDirSync(agentsSrc, agentsDest);
|
|
}
|
|
}
|
|
|
|
const syncedHooks = syncRootHookManifests(ROOT_DIR);
|
|
if (syncedHooks.length > 0) {
|
|
console.log(`🪝 Synced hook manifests to: ${syncedHooks.join(', ')}`);
|
|
}
|
|
|
|
// Remove deprecated skill stubs from local harness dirs. They exist
|
|
// in dist/ so the cleanup script can redirect users, but they should
|
|
// not clutter the repo's own skill directories.
|
|
const deprecatedLocalSkills = [
|
|
'frontend-design', 'teach-impeccable',
|
|
'arrange', 'normalize', 'onboard', 'extract',
|
|
// v3.0 consolidation: standalone skills -> /impeccable sub-commands
|
|
'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize',
|
|
'critique', 'delight', 'distill', 'harden', 'layout', 'optimize',
|
|
'overdrive', 'polish', 'quieter', 'shape', 'typeset',
|
|
];
|
|
for (const { configDir } of syncConfigs) {
|
|
for (const name of deprecatedLocalSkills) {
|
|
const p = path.join(ROOT_DIR, configDir, 'skills', name);
|
|
if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
console.log(`📋 Synced skills to: ${syncConfigs.map(p => p.configDir).join(', ')}`);
|
|
|
|
// Build the shared plugin subtree at ./plugin/.
|
|
// Claude Code marketplace is configured with `source: "./plugin"`, so the
|
|
// plugin cache only copies this slim directory (~0.3 MB) instead of the
|
|
// entire monorepo. Grok Build installs the same subtree via
|
|
// `grok plugin install pbakaus/impeccable#plugin --trust` (or the
|
|
// marketplace source). The harness dirs above stay where they are because
|
|
// `npx skills add pbakaus/impeccable` reads them from the GitHub repo.
|
|
const pluginRoot = path.join(ROOT_DIR, 'plugin');
|
|
const pluginManifestDir = path.join(pluginRoot, '.claude-plugin');
|
|
const grokPluginManifestDir = path.join(pluginRoot, '.grok-plugin');
|
|
const pluginSkillsDir = path.join(pluginRoot, 'skills');
|
|
const pluginAgentsDir = path.join(pluginRoot, 'agents');
|
|
const pluginHooksDir = path.join(pluginRoot, 'hooks');
|
|
if (fs.existsSync(pluginManifestDir)) fs.rmSync(pluginManifestDir, { recursive: true });
|
|
if (fs.existsSync(grokPluginManifestDir)) fs.rmSync(grokPluginManifestDir, { recursive: true });
|
|
if (fs.existsSync(pluginSkillsDir)) fs.rmSync(pluginSkillsDir, { recursive: true });
|
|
if (fs.existsSync(pluginAgentsDir)) fs.rmSync(pluginAgentsDir, { recursive: true });
|
|
if (fs.existsSync(pluginHooksDir)) fs.rmSync(pluginHooksDir, { recursive: true });
|
|
// Clean up the short-lived mixed-provider subtree from early OpenAI plugin
|
|
// development. The canonical Codex preview now lives in dist/openai/.
|
|
for (const staleRel of ['.codex-plugin', 'assets']) {
|
|
const stalePath = path.join(pluginRoot, staleRel);
|
|
if (fs.existsSync(stalePath)) fs.rmSync(stalePath, { recursive: true });
|
|
}
|
|
|
|
const rootManifest = JSON.parse(fs.readFileSync(path.join(ROOT_DIR, '.claude-plugin/plugin.json'), 'utf-8'));
|
|
const claudeAgentsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'agents');
|
|
// Trailing slash on the skills path matches the documented schema in
|
|
// code.claude.com/docs/en/plugins-reference. Issue #86 has 3 reporters
|
|
// converging on "add trailing slash to fix slash commands not registering";
|
|
// the docs schema example consistently uses `"./custom/skills/"` form.
|
|
const pluginManifest = { ...rootManifest, skills: './skills/' };
|
|
// No `agents` key: Claude Code discovers agents/*.md by itself, and the
|
|
// identifier it uses is the file name. Declaring the key as an array of
|
|
// file paths makes it load ZERO agents, so the four shipped subagents were
|
|
// never reachable. The other plausible shapes are worse: a string, or an
|
|
// array containing a directory, and the whole plugin fails to load.
|
|
// Omitting the key is the only shape that works.
|
|
delete pluginManifest.agents;
|
|
fs.mkdirSync(pluginManifestDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginManifestDir, 'plugin.json'),
|
|
JSON.stringify(pluginManifest, null, 2) + '\n',
|
|
);
|
|
|
|
// Native Grok plugin manifest. Grok also reads `.claude-plugin/`; dual
|
|
// manifests keep both marketplaces and `grok plugin validate` happy when
|
|
// Claude compat is disabled.
|
|
// https://docs.x.ai/build/features/skills-plugins-marketplaces
|
|
const grokPluginManifest = {
|
|
name: pluginManifest.name,
|
|
version: pluginManifest.version,
|
|
description: pluginManifest.description,
|
|
author: pluginManifest.author,
|
|
homepage: pluginManifest.homepage,
|
|
repository: pluginManifest.repository,
|
|
license: pluginManifest.license || 'MIT',
|
|
keywords: ['design', 'frontend', 'ui', 'ux', 'skills', 'hooks'],
|
|
};
|
|
fs.mkdirSync(grokPluginManifestDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(grokPluginManifestDir, 'plugin.json'),
|
|
JSON.stringify(grokPluginManifest, null, 2) + '\n',
|
|
);
|
|
|
|
const claudeSkillsSrc = path.join(DIST_DIR, 'claude-code', '.claude', 'skills', 'impeccable');
|
|
if (fs.existsSync(claudeSkillsSrc)) {
|
|
fs.mkdirSync(pluginSkillsDir, { recursive: true });
|
|
copyDirSync(claudeSkillsSrc, path.join(pluginSkillsDir, 'impeccable'));
|
|
}
|
|
|
|
if (fs.existsSync(claudeAgentsSrc)) {
|
|
copyDirSync(claudeAgentsSrc, pluginAgentsDir);
|
|
}
|
|
|
|
// Ship the design detector as a plugin-packaged hook. Claude Code and
|
|
// Grok Build both auto-discover `hooks/hooks.json` at the plugin root
|
|
// (Grok aliases CLAUDE_PLUGIN_ROOT → GROK_PLUGIN_ROOT), so marketplace /
|
|
// plugin-install users get PostToolUse + Stop without merging into project
|
|
// settings (that path remains the CLI's job for project-scoped installs).
|
|
fs.mkdirSync(pluginHooksDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginHooksDir, 'hooks.json'),
|
|
JSON.stringify(buildClaudePluginHooksManifest(), null, 2) + '\n',
|
|
);
|
|
|
|
console.log('📦 Built Claude Code / Grok Build plugin subtree at ./plugin/');
|
|
} else {
|
|
console.log('📋 Skipped root harness and plugin sync (--skip-root-sync)');
|
|
}
|
|
|
|
// The public OpenAI plugin is a Codex artifact, not a copy of the tracked
|
|
// Claude marketplace subtree. Build it on every source-first build so the
|
|
// upload ZIP and local preview directory cannot drift behind provider output.
|
|
const openAiPluginRoot = stageOpenAIPlugin(ROOT_DIR, DIST_DIR);
|
|
await createProviderZip(openAiPluginRoot, DIST_DIR, 'openai-plugin');
|
|
|
|
// Generate authoritative counts and validate references
|
|
const countErrors = generateCounts(ROOT_DIR, skills, buildDir);
|
|
|
|
// Guard plugin/skill version drift: marketplace + ./plugin subtree must
|
|
// match root plugin.json so marketplace installs never ship a stale version.
|
|
const versionErrors = validatePluginVersions(ROOT_DIR);
|
|
|
|
// Guard the generated plugin manifest's shape: a key the Claude Code loader
|
|
// does not honor (like the agents array, PR #494) ships silently broken.
|
|
const manifestShapeErrors = validatePluginManifestShape(ROOT_DIR);
|
|
|
|
// Scan user-facing copy for AI tells (em dashes, marketing fluff, denylisted phrases)
|
|
const proseErrors = validateProse(ROOT_DIR);
|
|
|
|
// Narrow scan of LLM-facing skill instructions: em dashes + a tighter denylist
|
|
// that has no technical reading. Hardening repetition is intentionally allowed.
|
|
const skillProseErrors = validateSkillProse(ROOT_DIR);
|
|
|
|
if (countErrors > 0 || versionErrors > 0 || manifestShapeErrors > 0 || proseErrors > 0 || skillProseErrors > 0) {
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\n✨ Build complete!');
|
|
}
|
|
|
|
// Run the build. A rejection here (e.g. the release zip failing to build) must
|
|
// exit non-zero so a broken artifact never deploys silently.
|
|
build().catch((err) => {
|
|
console.error(`\n❌ Build failed: ${err.message}`);
|
|
process.exit(1);
|
|
});
|