mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* Add OpenAI plugin submission bundle Build a Codex-native OpenAI plugin with bundled hooks, public listing metadata, submission guidance, privacy coverage, and regression tests. AI assistance: OpenAI Codex prepared and validated these changes under maintainer direction. * Fix provider script command rendering Replace heuristic rewrites across executable scripts with one explicit provider marker, render pinned shortcuts per target harness, and remove the personal email from the public publisher manifest. Addresses automated review feedback on PR #363. AI assistance: OpenAI Codex prepared and validated these changes under maintainer direction.
847 lines
35 KiB
JavaScript
847 lines
35 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 { generateApiData } from './lib/api-data.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 { 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;
|
|
|
|
// Write generated counts module
|
|
const genDir = path.join(rootDir, 'site/public/js/generated');
|
|
fs.mkdirSync(genDir, { recursive: true });
|
|
fs.writeFileSync(path.join(genDir, 'counts.js'),
|
|
`// GENERATED by build.js — do not edit\n` +
|
|
`export const COMMAND_COUNT = ${commandCount};\n` +
|
|
`export const DETECTION_COUNT = ${detectionCount};\n`
|
|
);
|
|
|
|
// Validate counts in key files
|
|
const filesToCheck = [
|
|
'site/pages/index.astro',
|
|
'README.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.
|
|
const detectPattern = /\b(\d+)\s+(deterministic\s+)?(checks|patterns|rules|detections)/gi;
|
|
for (const match of strippedContent.matchAll(detectPattern)) {
|
|
const num = parseInt(match[1]);
|
|
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;
|
|
}
|
|
|
|
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 = [
|
|
'site/components',
|
|
'site/content',
|
|
'site/layouts',
|
|
'site/pages',
|
|
'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 = ['site/pages/slop'];
|
|
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 validateSiteHeader(_rootDir) {
|
|
// With Astro, the shared header is a component (site/components/Header.astro).
|
|
// There's nothing to validate per-page — the component is imported by Base.astro
|
|
// and rendered identically everywhere. This function is kept as a no-op so the
|
|
// call site doesn't need to change.
|
|
console.log('✓ Site header is a shared Astro component (no per-page validation needed)');
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Guard the kinpaku default. Kinpaku is the site-wide default theme: the legacy
|
|
* --color-* names in tokens.css now carry dark-lacquer / gold-accent values, and
|
|
* the per-page kinpaku styling assumes that. If someone reintroduces the retired
|
|
* light-mode palette (white --color-paper, magenta --color-accent) the whole site
|
|
* silently regresses to light. Fail the build instead. Scoped to the token source
|
|
* — that's where the default lives; page CSS may still use light values locally
|
|
* for the deliberate AI-slop demonstrations.
|
|
*/
|
|
function validateTheme(rootDir) {
|
|
const tokensPath = path.join(rootDir, 'site', 'styles', 'tokens.css');
|
|
if (!fs.existsSync(tokensPath)) {
|
|
console.log('✓ Theme guard skipped (tokens.css not found)');
|
|
return 0;
|
|
}
|
|
const css = fs.readFileSync(tokensPath, 'utf8');
|
|
let errors = 0;
|
|
|
|
// Surfaces must be dark lacquer: either a --ks-* reference or a dark oklch
|
|
// (lightness < 35%). A high-lightness oklch means the light palette is back.
|
|
for (const name of ['color-paper', 'color-cream', 'color-bg']) {
|
|
const m = css.match(new RegExp(`--${name}:\\s*([^;]+);`));
|
|
if (!m) continue; // token removed entirely is fine
|
|
const val = m[1].trim();
|
|
if (val.includes('var(--ks-')) continue;
|
|
const light = val.match(/oklch\(\s*([\d.]+)%/);
|
|
if (light && Number(light[1]) >= 35) {
|
|
console.error(` ❌ tokens.css: --${name} is light (${val}). Kinpaku is the default; surfaces must be dark lacquer (var(--ks-lacquer*) or oklch < 35%).`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
// Accent must be kinpaku gold, not the retired magenta (hue ~350).
|
|
const accent = css.match(/--color-accent:\s*([^;]+);/);
|
|
if (accent && !accent[1].includes('var(--ks-')) {
|
|
const hue = accent[1].match(/oklch\(\s*[\d.]+%?\s+[\d.]+\s+([\d.]+)/);
|
|
if (hue && Number(hue[1]) >= 300 && Number(hue[1]) <= 360) {
|
|
console.error(` ❌ tokens.css: --color-accent is magenta (${accent[1].trim()}). The accent is kinpaku gold — use var(--ks-kinpaku).`);
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
if (errors > 0) {
|
|
console.error(`\n❌ ${errors} theme regression(s): light-mode defaults reintroduced in tokens.css.`);
|
|
} else {
|
|
console.log('✓ Theme defaults are kinpaku (dark surfaces, gold accent)');
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
/**
|
|
* Copy directory recursively
|
|
*/
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
function syncRootHookManifests(rootDir) {
|
|
const synced = [];
|
|
for (const config of Object.values(PROVIDERS)) {
|
|
if (!config.emitHooks) continue;
|
|
const manifest = hooksJsonFor(config.emitHooks);
|
|
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
|
|
.github/ -> GitHub Copilot
|
|
.kiro/ -> Kiro
|
|
.opencode/ -> OpenCode
|
|
.pi/ -> Pi
|
|
.trae-cn/ -> Trae China
|
|
.trae/ -> Trae International
|
|
|
|
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)
|
|
*/
|
|
function generateCFConfig(buildDir) {
|
|
// _headers: security + cache headers
|
|
const headers = `/*
|
|
X-Content-Type-Options: nosniff
|
|
X-Frame-Options: SAMEORIGIN
|
|
|
|
# HTML pages: browser always revalidates, CDN caches 1h
|
|
/*.html
|
|
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=600
|
|
|
|
# Hashed JS/CSS bundles: immutable (filename changes on content change)
|
|
/assets/*.js
|
|
Cache-Control: public, max-age=31536000, immutable
|
|
|
|
/assets/*.css
|
|
Cache-Control: public, max-age=31536000, immutable
|
|
|
|
# Static images and logos: 1 week + 1 day stale
|
|
/assets/*.png
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/assets/*.svg
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/assets/*.webp
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/antipattern-images/*
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
# Root static assets (favicon, og-image, etc.)
|
|
/favicon.svg
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/og-image.jpg
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
/apple-touch-icon.png
|
|
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
|
|
|
|
# ZIP downloads: 1h cache
|
|
/dist/*.zip
|
|
Cache-Control: public, max-age=3600, stale-while-revalidate=600
|
|
|
|
# API routes: CDN caches 24h
|
|
/api/*
|
|
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
|
|
|
|
/_data/api/*
|
|
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
|
|
`;
|
|
fs.writeFileSync(path.join(buildDir, '_headers'), headers);
|
|
|
|
// _redirects: rewrite JSON API routes to static files (200 = rewrite, not redirect).
|
|
// Plus permanent redirects for legacy URLs.
|
|
const redirects = `/api/skills /_data/api/skills.json 200
|
|
/api/commands /_data/api/commands.json 200
|
|
/api/version /_data/api/version.json 200
|
|
/api/patterns /_data/api/patterns.json 200
|
|
/api/command-source/:id /_data/api/command-source/:id.json 200
|
|
/gallery /slop#try-it-live 301
|
|
/cheatsheet /docs 301
|
|
/skills /docs 301
|
|
/skills/teach /docs/init 301
|
|
/skills/:id /docs/:id 301
|
|
/docs/teach /docs/init 301
|
|
/anti-patterns /slop#catalog 301
|
|
/visual-mode /slop#see-it 301
|
|
/neon-mirai /neo-mirai/ 301
|
|
/neon-mirai/ /neo-mirai/ 301
|
|
/cases/neon-mirai /cases/neo-mirai 301
|
|
/cases/neon-mirai/ /cases/neo-mirai 301
|
|
`;
|
|
fs.writeFileSync(path.join(buildDir, '_redirects'), redirects);
|
|
|
|
// _routes.json: tell Cloudflare Pages which paths invoke Functions
|
|
// Without this, the SPA fallback serves index.html for function routes
|
|
const routes = {
|
|
version: 1,
|
|
include: ['/api/download/*'],
|
|
exclude: [],
|
|
};
|
|
fs.writeFileSync(path.join(buildDir, '_routes.json'), JSON.stringify(routes, null, 2));
|
|
|
|
console.log('✓ Generated Cloudflare Pages config (_headers, _redirects, _routes.json)');
|
|
}
|
|
|
|
/**
|
|
* Main build process
|
|
*/
|
|
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.
|
|
|
|
// Copy browser detector to site/public/js/ so the antipattern examples can
|
|
// reference it (Astro serves site/public/ as-is).
|
|
const detectorSrc = path.join(ROOT_DIR, 'cli', 'engine', 'detect-antipatterns-browser.js');
|
|
if (fs.existsSync(detectorSrc)) {
|
|
const jsDir = path.join(ROOT_DIR, 'site', 'public', 'js');
|
|
fs.mkdirSync(jsDir, { recursive: true });
|
|
fs.copyFileSync(detectorSrc, path.join(jsDir, 'detect-antipatterns-browser.js'));
|
|
}
|
|
|
|
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);
|
|
|
|
// Generate static API data and Cloudflare Pages config
|
|
// Write API data and CF config to site/public/ so Astro copies them to build/.
|
|
// Astro wipes build/ before writing, so anything written directly to build/
|
|
// during build:skills would be destroyed when build:site runs.
|
|
const publicDir = path.join(ROOT_DIR, 'site', 'public');
|
|
generateApiData(publicDir, skills, patterns, ROOT_DIR);
|
|
generateCFConfig(publicDir);
|
|
|
|
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)
|
|
// across the rm + recopy. The build intentionally doesn't ship them,
|
|
// so without this the sync destroys local state on every rebuild.
|
|
const stashed = stashPerProjectArtifacts(skillsDest);
|
|
if (fs.existsSync(skillsDest)) fs.rmSync(skillsDest, { recursive: true });
|
|
copyDirSync(skillsSrc, skillsDest);
|
|
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 Claude Code plugin subtree at ./plugin/.
|
|
// The 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 (~291 MB on the previous "./" source). The harness
|
|
// dirs above stay where they are because `npx skills add pbakaus/impeccable`
|
|
// reads them directly from the GitHub repo at install time.
|
|
const pluginRoot = path.join(ROOT_DIR, 'plugin');
|
|
const pluginManifestDir = path.join(pluginRoot, '.claude-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(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');
|
|
const pluginAgentEntries = fs.existsSync(claudeAgentsSrc)
|
|
? fs.readdirSync(claudeAgentsSrc)
|
|
.filter(file => file.endsWith('.md'))
|
|
.sort()
|
|
.map(file => `./agents/${file}`)
|
|
: [];
|
|
// 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/' };
|
|
if (pluginAgentEntries.length) {
|
|
pluginManifest.agents = pluginAgentEntries;
|
|
} else {
|
|
delete pluginManifest.agents;
|
|
}
|
|
fs.mkdirSync(pluginManifestDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginManifestDir, 'plugin.json'),
|
|
JSON.stringify(pluginManifest, 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
|
|
// auto-discovers `hooks/hooks.json` at the plugin root, so marketplace /
|
|
// `/plugin install` users get the PostToolUse hook without it being merged
|
|
// into their project `.claude/settings.json` (that path is the CLI's job).
|
|
fs.mkdirSync(pluginHooksDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginHooksDir, 'hooks.json'),
|
|
JSON.stringify(buildClaudePluginHooksManifest(), null, 2) + '\n',
|
|
);
|
|
|
|
console.log('📦 Built Claude Code 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);
|
|
|
|
// Verify every hand-authored HTML page carries the shared site header
|
|
const headerErrors = validateSiteHeader(ROOT_DIR);
|
|
|
|
// Guard the kinpaku default: fail if light-mode token values are reintroduced
|
|
const themeErrors = validateTheme(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 || headerErrors > 0 || themeErrors > 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);
|
|
});
|