Build: ship the launcher instead of bundling the JS engine

readSourceFiles no longer copies cli/engine into the skill; the scripts
payload is the launcher (executable bit preserved through dist, plugin/, and
universal.zip), impeccable.cmd, VERSION (synced from ENGINE_VERSION on every
build), the page JS, and command-metadata.json. Hook manifests call
`<scripts>/impeccable hook` behind an existence guard (Codex adds a
commandWindows sibling calling impeccable.cmd; Cursor runs hook-before-edit;
GitHub keeps the git rev-parse form; Grok mirrors Claude); the Node probe and
systemMessage notice are gone. build:release fetches the pinned engine for
every target (lenient) and stages bin/<os-arch>/ into the dist skill copies
after root harness dirs and plugin/ were synced, so git-delivered trees stay
launcher-only. The detection-rule count check reads the vendored
extension/detector/antipatterns.json and is skipped when absent.
build:browser is a stub; the codex prefix rewrite leaves
`{{scripts_path}}/impeccable` alone.

Prepared with AI assistance (Claude Code).
This commit is contained in:
Paul Bakaus
2026-08-31 19:57:32 -07:00
parent dac3f77d89
commit 11a1ea64a6
13 changed files with 336 additions and 1348 deletions
-30
View File
@@ -1,30 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
const BROWSER_DETECTOR_MODULES = [
'cli/engine/shared/constants.mjs',
'cli/engine/registry/antipatterns.mjs',
'cli/engine/shared/color.mjs',
'cli/engine/shared/fonts.mjs',
'cli/engine/rules/checks.mjs',
'cli/engine/browser/injected/index.mjs',
];
function browserSafeModule(root, relPath) {
let code = fs.readFileSync(path.join(root, relPath), 'utf-8');
if (relPath === 'cli/engine/registry/antipatterns.mjs') {
const match = code.match(/const ANTIPATTERNS = \[[\s\S]*?\n\];/);
if (!match) throw new Error('Could not extract browser antipattern registry');
code = match[0];
}
code = code.replace(/^import[\s\S]*?;\n/gm, '');
code = code.replace(/^export\s+\{[^}]*\};\n?/gm, '');
code = code.replace(/^export\s+\{[\s\S]*?^};\n?/gm, '');
return `// --- ${relPath} ---\n${code.trim()}\n`;
}
export function bundleBrowserDetectorModules(root) {
return BROWSER_DETECTOR_MODULES
.map((relPath) => browserSafeModule(root, relPath))
.join('\n');
}
+7 -1
View File
@@ -1,3 +1,4 @@
import fs from 'fs';
import path from 'path';
import {
cleanDir,
@@ -359,7 +360,12 @@ export function createTransformer(config) {
ensureDir(scriptsOutDir);
for (const script of skill.scripts) {
const scriptContent = replaceScriptProviderMarker(script.content, placeholderKey, provider);
writeFile(path.join(scriptsOutDir, script.name), scriptContent);
const outPath = path.join(scriptsOutDir, script.name);
writeFile(outPath, scriptContent);
// The launcher must stay executable in every provider copy; a
// plain write would drop the bit and `impeccable context` would
// fail with EACCES on the first session.
if (script.mode) fs.chmodSync(outPath, script.mode);
scriptCount++;
}
}
+60 -98
View File
@@ -22,7 +22,7 @@
* correct wherever Claude Code unpacks the plugin.
*/
export const IMPECCABLE_HOOK_COMMAND_MARKER = 'skills/impeccable/scripts/hook.mjs';
export const IMPECCABLE_HOOK_COMMAND_MARKER = 'skills/impeccable/scripts/impeccable';
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
@@ -34,12 +34,37 @@ const STATUS_MESSAGE = 'Checking UI changes';
const STOP_TIMEOUT_SECONDS = 30;
const STOP_STATUS_MESSAGE = 'Design deep pass';
function stopEntry(command) {
// The hook is a verb of the impeccable launcher that ships in the skill's
// scripts dir: `<scripts>/impeccable hook` (per-edit and Stop passes) and
// `<scripts>/impeccable hook-before-edit` (Cursor's preToolUse). The launcher
// runs the platform binary next to it, or downloads it once; no runtime probe
// is needed and there is no Node on the path to check.
export const LAUNCHER_NAME = 'impeccable';
export const LAUNCHER_NAME_WINDOWS = 'impeccable.cmd';
// A hook manifest can be copied into a user-level settings file (issue #399:
// user-level hooks fire in every project, where a project-relative path may
// not exist). Guard the invocation so a missing launcher exits 0 without
// swallowing the hook's real exit code when it is present: the `[ ! -f X ] ||
// X verb` form (not `... || true`) preserves the launcher's exit code, so
// Claude's exit-2 blocking signal still reaches the agent.
export const guardedLauncher = (launcherPath, verb = 'hook') =>
`[ ! -f "${launcherPath}" ] || "${launcherPath}" ${verb}`;
// cmd.exe form for harnesses that read a `commandWindows` sibling (Codex
// 0.146.0+ selects it on Windows; issue #452). `exit /b` forwards the
// launcher's errorlevel. Paths keep forward slashes; cmd.exe accepts them in
// quoted paths and it is the form the CLI already writes.
export const windowsLauncherCommand = (launcherCmdPath, verb = 'hook') =>
`if exist "${launcherCmdPath}" ("${launcherCmdPath}" ${verb} & exit /b)`;
function stopEntry(command, commandWindows) {
return {
hooks: [
{
type: 'command',
command,
...(commandWindows ? { commandWindows } : {}),
timeout: STOP_TIMEOUT_SECONDS,
statusMessage: STOP_STATUS_MESSAGE,
},
@@ -47,50 +72,31 @@ function stopEntry(command) {
};
}
const CLAUDE_PROJECT_HOOK = '${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs';
// The Node major the hook runtime requires, kept equal to the engines floor in
// package.json. The probe and the notice both derive from it so they cannot
// disagree about the supported version.
const NODE_MAJOR_FLOOR = 22;
// A hook manifest can be copied into a user-level settings file (issue #399:
// user-level hooks fire in every project, where a project-relative path may
// not exist). Guard node invocations so a missing file exits 0 without
// swallowing node's real exit code when the file is present.
//
// The runtime is guarded too (issue #410): a `node` on PATH too old for the
// hook's ESM syntax dies while hook.mjs is still being parsed, before the
// script's own always-exit-0 contract can run, so the harness reported a hook
// error on every edit and every Stop. Nothing written in ESM can report that
// condition, so the command string itself checks the version floor first, in
// ES5-only syntax that parses on any node old enough to fail it, and exits 0
// when the runtime is unsupported or missing.
//
// `notice` reports the dead runtime to the user. It is passed per harness
// because only some have a channel for it, checked against each harness's own
// hook reference on the events we hook:
// Claude Code / Codex: `systemMessage` on stdout is shown to the user -> notice
// Cursor: preToolUse output is permission-shaped and its `user_message`
// renders only on DENY, so warning would block the edit -> probe only
// Grok Build: PostToolUse stdout is ignored; Stop additionalContext
// reaches the model, but the node-version notice has no systemMessage
// channel on this harness -> probe only
// Copilot: output contract unconfirmed; do not guess a shape -> probe only
//
// The clamp avoids `<` and `>` deliberately: Volta's Windows shims run through
// `cmd /C`, which reads an angle bracket in the `-e` payload as redirection, so
// `>=` failed before node ran at all and the guard reported a missing runtime on
// a machine that had a supported one (volta-cli/volta#1791). Newlines break the
// same way, so this payload also has to stay on one line.
const NODE_PROBE = `node -e "process.exit(Math.min(parseInt(process.versions.node,10),${NODE_MAJOR_FLOOR})===${NODE_MAJOR_FLOOR}?0:1)" 2>/dev/null`;
const guardedNode = (hookPath, notice = '') => {
const probe = notice
? `! { ${NODE_PROBE} || { ${notice}; exit 0; }; }`
: `! ${NODE_PROBE}`;
return `[ ! -f "${hookPath}" ] || ${probe} || node "${hookPath}"`;
};
const launcherIn = (scriptsDir) => `${scriptsDir}/${LAUNCHER_NAME}`;
const launcherCmdIn = (scriptsDir) => `${scriptsDir}/${LAUNCHER_NAME_WINDOWS}`;
function buildClaudeCompatibleHooks(matcher, hookPath, notice = '') {
const command = guardedNode(hookPath, notice);
const CLAUDE_PROJECT_SCRIPTS = '${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts';
const CLAUDE_PLUGIN_SCRIPTS = '${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts';
const CODEX_PLUGIN_SCRIPTS = '${PLUGIN_ROOT}/skills/impeccable/scripts';
// Codex reads project hooks from `.codex/hooks.json`, but the skill payload the
// hook invokes lives under the install's own skills dir: a `.codex`-directory
// install keeps it at `.codex/skills/...`, while a `.agents` (Codex repo-skills)
// install keeps it at `.agents/skills/...`. Derive the path from the install dir
// so each generated manifest points at its own payload rather than a hardcoded
// `.agents`; otherwise the guarded hook silently no-ops on `.codex` installs.
const codexProjectScripts = (skillDir) => `${skillDir}/skills/impeccable/scripts`;
const CURSOR_SCRIPTS = '.cursor/skills/impeccable/scripts';
const GITHUB_PROJECT_SCRIPTS = '$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts';
// Grok project hooks are relative to the git/workspace root. Claude tool names
// in the matcher (Edit|Write|MultiEdit) alias to Grok's search_replace family.
const GROK_PROJECT_SCRIPTS = '.grok/skills/impeccable/scripts';
// `windows: true` adds the `commandWindows` sibling; only Codex-shaped
// consumers honor it, and an unknown key would fail Codex's strict parser if
// it were the other way round, so it stays opt-in per manifest.
function buildClaudeCompatibleHooks(matcher, scriptsDir, { windows = false } = {}) {
const command = guardedLauncher(launcherIn(scriptsDir));
const commandWindows = windows ? windowsLauncherCommand(launcherCmdIn(scriptsDir)) : undefined;
return {
PostToolUse: [
{
@@ -99,52 +105,21 @@ function buildClaudeCompatibleHooks(matcher, hookPath, notice = '') {
{
type: 'command',
command,
...(commandWindows ? { commandWindows } : {}),
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
Stop: [stopEntry(command)],
Stop: [stopEntry(command, commandWindows)],
};
}
// The message says `on PATH` deliberately: the common cause is a hook shell
// whose PATH misses the version manager, so a user already running Node 22
// needs to know the hook's PATH is at issue and not their install. Apostrophes
// cannot appear in it, since it travels inside a single-quoted shell string.
const NODE_NOTICE_TEXT = `The impeccable design hook is not running: no Node ${NODE_MAJOR_FLOOR} or newer on PATH. `
+ 'Install one, or remove the impeccable hook from your harness settings.';
// Claude Code and Codex both read `systemMessage`, so one payload serves both.
// The marker under ~/.impeccable holds it to one notice per machine (not per
// harness or per edit), and printf runs only after the marker write succeeds,
// so an unwritable HOME degrades to silence rather than a notice on every edit.
const SYSTEM_MESSAGE_NOTICE = 'D="$HOME/.impeccable"; [ -f "$D/node-unsupported" ] || '
+ '{ mkdir -p "$D" 2>/dev/null && : > "$D/node-unsupported" 2>/dev/null && '
+ `printf '%s' '{"systemMessage":"${NODE_NOTICE_TEXT}"}'; }`;
const CLAUDE_PLUGIN_HOOK = '${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs';
const CODEX_PLUGIN_HOOK = '${PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs';
// Codex reads project hooks from `.codex/hooks.json`, but the skill payload the
// hook invokes lives under the install's own skills dir: a `.codex`-directory
// install keeps it at `.codex/skills/...`, while a `.agents` (Codex repo-skills)
// install keeps it at `.agents/skills/...`. Derive the path from the install dir
// so each generated manifest points at its own payload rather than a hardcoded
// `.agents` — otherwise the guarded hook silently no-ops on `.codex` installs.
const codexProjectHook = (skillDir) => `${skillDir}/skills/impeccable/scripts/hook.mjs`;
const CURSOR_BEFORE_EDIT_SCRIPT = '.cursor/skills/impeccable/scripts/hook-before-edit.mjs';
const GITHUB_PROJECT_HOOK = '$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs';
// Grok project hooks are relative to the git/workspace root. Claude tool names
// in the matcher (Edit|Write|MultiEdit) alias to Grok's search_replace family.
const GROK_PROJECT_HOOK = '.grok/skills/impeccable/scripts/hook.mjs';
export function buildClaudeSettingsManifest() {
return {
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: buildClaudeCompatibleHooks(
'Edit|Write',
CLAUDE_PROJECT_HOOK,
SYSTEM_MESSAGE_NOTICE,
),
hooks: buildClaudeCompatibleHooks('Edit|Write', CLAUDE_PROJECT_SCRIPTS),
};
}
@@ -156,11 +131,7 @@ export function buildClaudeSettingsManifest() {
// than `hooks`, failing the whole manifest (issue #330).
export function buildClaudePluginHooksManifest() {
return {
hooks: buildClaudeCompatibleHooks(
'Edit|Write',
CLAUDE_PLUGIN_HOOK,
SYSTEM_MESSAGE_NOTICE,
),
hooks: buildClaudeCompatibleHooks('Edit|Write', CLAUDE_PLUGIN_SCRIPTS),
};
}
@@ -169,11 +140,7 @@ export function buildClaudePluginHooksManifest() {
// instead of relying on its Claude compatibility alias.
export function buildCodexPluginHooksManifest() {
return {
hooks: buildClaudeCompatibleHooks(
'Edit|Write|apply_patch',
CODEX_PLUGIN_HOOK,
SYSTEM_MESSAGE_NOTICE,
),
hooks: buildClaudeCompatibleHooks('Edit|Write|apply_patch', CODEX_PLUGIN_SCRIPTS, { windows: true }),
};
}
@@ -181,13 +148,8 @@ export function buildCodexPluginHooksManifest() {
// emitted command points at that install's payload. Defaults to `.codex` for the
// Codex provider, whose self-consistent bundle keeps the skill at `.codex/skills`.
export function buildCodexHooksManifest(skillDir = '.codex') {
const hookPath = codexProjectHook(skillDir);
return {
hooks: buildClaudeCompatibleHooks(
'Edit|Write|apply_patch',
hookPath,
SYSTEM_MESSAGE_NOTICE,
),
hooks: buildClaudeCompatibleHooks('Edit|Write|apply_patch', codexProjectScripts(skillDir), { windows: true }),
};
}
@@ -197,7 +159,7 @@ export function buildCursorHooksManifest() {
hooks: {
preToolUse: [
{
command: guardedNode(CURSOR_BEFORE_EDIT_SCRIPT),
command: guardedLauncher(launcherIn(CURSOR_SCRIPTS), 'hook-before-edit'),
timeout: TIMEOUT_SECONDS,
},
],
@@ -224,7 +186,7 @@ export function buildGitHubHooksManifest() {
{
type: 'command',
matcher: 'edit|create|apply_patch',
bash: guardedNode(GITHUB_PROJECT_HOOK),
bash: guardedLauncher(launcherIn(GITHUB_PROJECT_SCRIPTS)),
timeoutSec: TIMEOUT_SECONDS,
},
],
@@ -239,7 +201,7 @@ export function buildGitHubHooksManifest() {
// https://docs.x.ai/build/features/hooks
export function buildGrokHooksManifest() {
return {
hooks: buildClaudeCompatibleHooks('Edit|Write|MultiEdit', GROK_PROJECT_HOOK),
hooks: buildClaudeCompatibleHooks('Edit|Write|MultiEdit', GROK_PROJECT_SCRIPTS),
};
}
+20 -61
View File
@@ -9,18 +9,11 @@ import path from 'path';
// New installs write project config at .impeccable/live/config.json instead.
export const PER_PROJECT_SCRIPT_ARTIFACTS = new Set(['config.json']);
const DETECTOR_BUNDLE_DIR = 'cli/engine';
// Detector source files that live OUTSIDE `cli/engine` but are imported by the
// bundled engine. `cli/engine/cli/main.mjs` imports `../../lib/impeccable-config.mjs`,
// which in the source CLI resolves to `cli/lib/impeccable-config.mjs`. The detector
// bundle copies `cli/engine/**` to `scripts/detector/**`, so from the bundled
// `scripts/detector/cli/main.mjs` that same `../../lib/...` import resolves to
// `scripts/lib/impeccable-config.mjs`. Copy the dependency there or the bundled
// detector fails at import time with "Cannot find module .../lib/impeccable-config.mjs".
const DETECTOR_EXTERNAL_DEPS = [
{ src: 'cli/lib/impeccable-config.mjs', dest: 'lib/impeccable-config.mjs' },
];
// Platform binaries under `scripts/bin/<os>-<arch>/` are fetched per machine
// (scripts/fetch-engine.mjs) and never part of the source skill read: the
// release build stages them into provider output separately, and the launcher
// downloads them on first run when they are absent.
export const SKILL_BINARY_DIR = 'bin';
// Walk the harness-dir skill tree and return any per-project script
// artifacts found, ready for restoration after a full sync rm+recopy.
@@ -52,46 +45,6 @@ export function restorePerProjectArtifacts(rootDir, stashed) {
}
}
function readDetectorBundleScripts(rootDir) {
const detectorDir = path.join(rootDir, DETECTOR_BUNDLE_DIR);
if (!fs.existsSync(detectorDir)) return [];
const scripts = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
continue;
}
if (!entry.isFile()) continue;
const relPath = path.relative(detectorDir, entryPath).split(path.sep).join('/');
scripts.push({
name: `detector/${relPath}`,
content: fs.readFileSync(entryPath, 'utf-8'),
filePath: entryPath,
generated: true,
});
}
};
walk(detectorDir);
// Pull in engine dependencies that live outside the bundle dir so the
// generated detector is self-contained (see DETECTOR_EXTERNAL_DEPS).
for (const { src, dest } of DETECTOR_EXTERNAL_DEPS) {
const srcPath = path.join(rootDir, src);
if (!fs.existsSync(srcPath)) continue;
scripts.push({
name: dest,
content: fs.readFileSync(srcPath, 'utf-8'),
filePath: srcPath,
generated: true,
});
}
return scripts;
}
function readSkillScripts(scriptsDir) {
const scripts = [];
@@ -102,6 +55,7 @@ function readSkillScripts(scriptsDir) {
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (dir === scriptsDir && entry.name === SKILL_BINARY_DIR) continue;
walk(entryPath);
continue;
}
@@ -109,10 +63,13 @@ function readSkillScripts(scriptsDir) {
if (PER_PROJECT_SCRIPT_ARTIFACTS.has(entry.name)) continue;
const relPath = path.relative(scriptsDir, entryPath).split(path.sep).join('/');
// `mode` travels with the entry so the launcher keeps its executable
// bit in every provider copy (see writeScriptFile in the transformer).
scripts.push({
name: relPath,
content: fs.readFileSync(entryPath, 'utf-8'),
filePath: entryPath,
mode: fs.statSync(entryPath).mode & 0o777,
});
}
};
@@ -242,8 +199,8 @@ export function readFilesRecursive(dir, fileList = []) {
* The source manifest is `SKILL.src.md`, NOT `SKILL.md`, on purpose: the
* `vercel-labs/skills` CLI discovers a skill by finding a literal `SKILL.md`
* and copies that directory verbatim. If `skill/SKILL.md` existed, `npx skills`
* would install the UNCOMPILED source (unresolved `{{placeholders}}`, no vendored
* detector). Naming it `SKILL.src.md` hides it from discovery so the CLI falls
* would install the UNCOMPILED source (unresolved `{{placeholders}}` and
* `{{scripts_path}}`). Naming it `SKILL.src.md` hides it from discovery so the CLI falls
* through to a compiled harness dir (`.agents/skills/impeccable`) instead.
*/
export function readSourceFiles(rootDir) {
@@ -280,7 +237,6 @@ export function readSourceFiles(rootDir) {
if (fs.existsSync(scriptsDir)) {
scripts.push(...readSkillScripts(scriptsDir));
}
scripts.push(...readDetectorBundleScripts(rootDir));
const agents = [];
const agentsDir = path.join(skillDir, 'agents');
@@ -676,13 +632,15 @@ export function replacePlaceholders(content, provider, commandNames = [], allSki
// Replace `/skillname` invocations with the correct command prefix for this provider
// (e.g., `/normalize` → `$normalize` for Codex). Require the slash to be
// outside a path or URL so `.github/hooks/impeccable.json` and
// `.codex/skills/impeccable` remain untouched.
// outside a path or URL so `.github/hooks/impeccable.json`,
// `.codex/skills/impeccable`, and the launcher invocation
// `{{scripts_path}}/impeccable <verb>` (a `}}`-preceded path segment,
// resolved after this pass) remain untouched.
if (cmdPrefix !== '/' && allSkillNames.length > 0) {
const sorted = [...allSkillNames].sort((a, b) => b.length - a.length);
for (const name of sorted) {
result = result.replace(
new RegExp(`(?<![a-zA-Z0-9_./-])\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
new RegExp(`(?<![a-zA-Z0-9_./}>-])\\/(?=${escapeRegex(name)}(?:[^a-zA-Z0-9_-]|$))`, 'g'),
cmdPrefix
);
}
@@ -695,9 +653,10 @@ export function replacePlaceholders(content, provider, commandNames = [], allSki
* Render the one explicit provider marker allowed in executable skill scripts.
*
* Do not run replacePlaceholders() across JavaScript source: slash-command
* heuristics can collide with regex literals and runtime paths. Scripts import
* their command prefix from lib/provider.mjs, whose declaration is replaced
* here by an exact string match.
* heuristics can collide with regex literals and runtime paths. Only exact
* marker lines are replaced. The shipped scripts today (the launcher and the
* page JS) carry no marker; the binary derives its provider from its install
* path or IMPECCABLE_PROVIDER_ID at run time.
*/
export function replaceScriptProviderMarker(content, provider, buildProvider = provider) {
const placeholders = PROVIDER_PLACEHOLDERS[provider] || PROVIDER_PLACEHOLDERS.cursor;