Improve CLI install prompts

This commit is contained in:
Paul Bakaus
2026-06-15 13:04:25 +09:00
parent 636249cae0
commit 6443980117
18 changed files with 626 additions and 140 deletions
+9 -9
View File
@@ -2,7 +2,7 @@
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 41 deterministic detector rules for AI-generated frontend design.
> **Quick start:** From your project root, run `npx impeccable skills install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
> **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
## Why Impeccable?
@@ -100,15 +100,15 @@ Visit [impeccable.style](https://impeccable.style#casestudies) to see before/aft
From the root of your project, run:
```bash
npx impeccable skills install
npx impeccable install
```
This shows the harness folders it detected (for example `~/.claude`, `~/.codex`, or project-local `.cursor`), lets you keep the detected set or select providers, then asks whether to install into your home directory or the current project. Use `--providers=claude,codex,cursor` and `--scope=project|user` to skip those choices in scripts. On Claude Code, Cursor, and Codex, it also installs the provider-native hook manifest for the current project. Works with Cursor, Claude Code, Gemini CLI, Codex CLI, and every other supported tool. Reload your harness afterward.
This shows the harness folders it detected (for example `~/.claude`, `~/.codex`, or project-local `.cursor`), lets you keep the detected set or customize providers, then asks whether to install into the current project or globally. Use `--providers=claude,codex,cursor` and `--scope=project|global` to skip those choices in scripts. On Claude Code, Cursor, and Codex, it also installs the provider-native hook manifest for the current project. Works with Cursor, Claude Code, Gemini CLI, Codex CLI, and every other supported tool. Reload your harness afterward.
To refresh an existing install, run:
```bash
npx impeccable skills update
npx impeccable update
```
Codex users should open `/hooks` after install or update and approve the project hook when prompted. Codex tracks trust by hook definition, so updates that change `.codex/hooks.json` can require approval again.
@@ -119,7 +119,7 @@ For teams that want to keep Impeccable vendored and updated through Git, add thi
```bash
git submodule add https://github.com/pbakaus/impeccable .impeccable
npx impeccable skills link --source=.impeccable --providers=claude,cursor
npx impeccable link --source=.impeccable --providers=claude,cursor
git add .gitmodules .impeccable .claude .cursor
git commit -m "Add Impeccable skills"
```
@@ -130,7 +130,7 @@ To update later:
```bash
git submodule update --remote .impeccable
npx impeccable skills link --source=.impeccable --providers=claude,cursor
npx impeccable link --source=.impeccable --providers=claude,cursor
```
### Option 3: Download from Website
@@ -260,7 +260,7 @@ If you reach for one command often, pin it with `/impeccable pin audit` to get `
## Design hook
On Claude Code, Codex, and Cursor, `npx impeccable skills install` and `npx impeccable skills update` install a provider-native hook manifest along with the skill payload. The hook runs the Impeccable design detector on direct UI file edits and surfaces findings back into the agent flow. Claude Code and Codex surface findings after the edit. Cursor blocks bad proposed writes before they land.
On Claude Code, Codex, and Cursor, `npx impeccable install` and `npx impeccable update` install a provider-native hook manifest along with the skill payload. The hook runs the Impeccable design detector on direct UI file edits and surfaces findings back into the agent flow. Claude Code and Codex surface findings after the edit. Cursor blocks bad proposed writes before they land.
Installed hook surfaces:
@@ -279,8 +279,8 @@ Codex requires one platform step that Impeccable cannot safely skip: open `/hook
Manual copy commands are fallback/debug instructions. The normal path is:
```bash
npx impeccable skills install
npx impeccable skills update
npx impeccable install
npx impeccable update
```
## CLI
+48 -30
View File
@@ -5,7 +5,7 @@
*
* Usage:
* npx impeccable detect [file-or-dir-or-url...]
* npx impeccable skills help|install|update
* npx impeccable help|install|update
* npx impeccable --help
*/
@@ -14,44 +14,62 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const command = args[0];
const SKILL_COMMANDS = new Set(['help', 'install', 'link', 'update', 'check']);
if (!command || command === '--help' || command === '-h') {
console.log(`Usage: impeccable <command> [options]
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === '--help' || command === '-h') {
console.log(`Usage: impeccable <command> [options]
Commands:
detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues
skills help List all available skills and commands
skills install Install impeccable skills into your project or user harness
skills link Symlink skills from a local checkout or submodule
skills update Update skills to the latest version
skills check Check if skill updates are available
help List all available skills and commands
install Install impeccable skills into your project or global harness
link Symlink skills from a local checkout or submodule
update Update skills to the latest version
check Check if skill updates are available
Options:
--help Show this help message
--version Show version number
Run 'impeccable <command> --help' for command-specific options.`);
process.exit(0);
Compatibility:
impeccable skills <command> Legacy namespace; still supported.`);
process.exit(0);
}
if (command === '--version' || command === '-v') {
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
console.log(pkg.version);
process.exit(0);
}
if (command === 'detect') {
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
const { detectCli } = await import('../engine/detect-antipatterns.mjs');
await detectCli();
} else if (command === 'skills') {
const { run } = await import('./commands/skills.mjs');
await run(args.slice(1));
} else if (SKILL_COMMANDS.has(command)) {
const { run } = await import('./commands/skills.mjs');
await run(args);
} else {
// Default: treat as detect arguments (allow `npx impeccable src/` shorthand)
process.argv = [process.argv[0], process.argv[1], ...args];
const { detectCli } = await import('../engine/detect-antipatterns.mjs');
await detectCli();
}
}
if (command === '--version' || command === '-v') {
const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
console.log(pkg.version);
process.exit(0);
}
main().catch(error => {
if (error?.code === 'IMPECCABLE_PROMPT_ABORT') {
console.log('\nAborted.');
process.exit(130);
}
if (command === 'detect') {
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
const { detectCli } = await import('../engine/detect-antipatterns.mjs');
await detectCli();
} else if (command === 'skills') {
const { run } = await import('./commands/skills.mjs');
await run(args.slice(1));
} else {
// Default: treat as detect arguments (allow `npx impeccable src/` shorthand)
process.argv = [process.argv[0], process.argv[1], ...args];
const { detectCli } = await import('../engine/detect-antipatterns.mjs');
await detectCli();
}
console.error(error?.message || error);
process.exit(1);
});
+377 -40
View File
@@ -2,16 +2,16 @@
* `impeccable skills` subcommand
*
* Usage:
* impeccable skills help Show all available skills and commands
* impeccable skills install Install compiled skills from the universal bundle
* impeccable skills link Symlink compiled skills from a local checkout
* impeccable skills update Update skills to latest version
* impeccable help Show all available skills and commands
* impeccable install Install compiled skills from the universal bundle
* impeccable link Symlink compiled skills from a local checkout
* impeccable update Update skills to latest version
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs';
import { join, resolve, dirname, relative, isAbsolute } from 'node:path';
import { createInterface } from 'node:readline';
import { createInterface, emitKeypressEvents } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { get } from 'node:https';
import { createHash } from 'node:crypto';
@@ -76,6 +76,9 @@ const GLOBAL_HARNESS_HINTS = [
// Last-resort default when nothing is detected: Claude Code + the universal
// (.agents, also Codex) folder, which covers the most common setups.
const DEFAULT_TARGETS = ['.claude', '.agents'];
const IGNORED_SKILL_DIR_NAMES = new Set([
'codex-primary-runtime',
]);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
@@ -103,6 +106,34 @@ const PROVIDER_HOOK_ARTIFACTS = {
};
let pipedAnswers = null;
class PromptAbortError extends Error {
constructor() {
super('Aborted.');
this.name = 'PromptAbortError';
this.code = 'IMPECCABLE_PROMPT_ABORT';
}
}
function isPromptAbortError(error) {
return error?.code === 'IMPECCABLE_PROMPT_ABORT';
}
function canStyleTerminal() {
return Boolean(process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb');
}
function ansi(open, close, value) {
const text = String(value);
return canStyleTerminal() ? `${open}${text}${close}` : text;
}
const ui = {
accent: value => ansi('\x1b[36m', '\x1b[0m', value),
bold: value => ansi('\x1b[1m', '\x1b[22m', value),
dim: value => ansi('\x1b[2m', '\x1b[22m', value),
good: value => ansi('\x1b[32m', '\x1b[0m', value),
};
function ask(question) {
if (!process.stdin.isTTY) {
process.stdout.write(question);
@@ -117,7 +148,228 @@ function ask(question) {
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise(r => rl.question(question, ans => { rl.close(); r(ans.trim().toLowerCase()); }));
return new Promise((resolve, reject) => {
rl.once('SIGINT', () => {
rl.close();
reject(new PromptAbortError());
});
rl.question(question, ans => {
rl.close();
resolve(ans.trim().toLowerCase());
});
});
}
function isInteractivePrompt() {
return Boolean(process.stdin.isTTY && process.stdout.isTTY && typeof process.stdin.setRawMode === 'function');
}
function promptKeypressSession(renderInitial, handleKey) {
const input = process.stdin;
const output = process.stdout;
const wasRaw = Boolean(input.isRaw);
let lastLineCount = 0;
let done = false;
emitKeypressEvents(input);
return new Promise((resolve, reject) => {
function cleanup() {
if (done) return;
done = true;
input.off('keypress', onKeypress);
if (typeof input.setRawMode === 'function') input.setRawMode(wasRaw);
output.write('\x1b[?25h');
input.pause();
}
function render(lines) {
const nextLines = Array.isArray(lines) ? lines : String(lines).split('\n');
if (lastLineCount > 0) output.write(`\x1b[${lastLineCount}A`);
const lineCount = Math.max(lastLineCount, nextLines.length);
for (let index = 0; index < lineCount; index++) {
const line = nextLines[index] || '';
output.write(`\x1b[2K\r${line}\n`);
}
lastLineCount = lineCount;
}
function finish(value) {
cleanup();
resolve(value);
}
function abort() {
cleanup();
reject(new PromptAbortError());
}
function onKeypress(str, key = {}) {
if (key.ctrl && key.name === 'c') {
abort();
return;
}
const next = handleKey(str, key);
if (!next) return;
if (next.abort) {
abort();
return;
}
if (next.done) {
render(next.lines);
finish(next.value);
return;
}
render(next.lines);
}
input.on('keypress', onKeypress);
input.setRawMode(true);
input.resume();
output.write('\x1b[?25l');
render(renderInitial());
});
}
function clampIndex(index, length) {
if (length <= 0) return 0;
if (index < 0) return length - 1;
if (index >= length) return 0;
return index;
}
function visibleWindow(cursor, total, maxVisible) {
const visible = Math.max(1, Math.min(total, maxVisible));
let start = Math.max(0, cursor - visible + 1);
if (cursor < start) start = cursor;
start = Math.min(start, Math.max(0, total - visible));
return { start, end: start + visible };
}
async function promptRadio(message, options, { initialIndex = 0 } = {}) {
let cursor = clampIndex(initialIndex, options.length);
const render = () => [
`${ui.accent('◆')} ${ui.bold(message)}`,
'',
...options.map((option, index) => {
const active = index === cursor;
const pointer = active ? ui.accent('') : ' ';
const mark = active ? ui.good('●') : ui.dim('○');
const label = active ? ui.bold(option.label) : option.label;
const hint = option.hint ? ` ${ui.dim(option.hint)}` : '';
return ` ${pointer} ${mark} ${label}${hint}`;
}),
'',
` ${ui.dim('↑/↓ move, enter confirm')}`,
];
return promptKeypressSession(render, (_str, key = {}) => {
if (key.name === 'up' || key.name === 'k') cursor = clampIndex(cursor - 1, options.length);
if (key.name === 'down' || key.name === 'j') cursor = clampIndex(cursor + 1, options.length);
if (key.name === 'return' || key.name === 'enter') {
return { done: true, value: options[cursor].value, lines: render() };
}
return { lines: render() };
});
}
async function promptCheckbox(message, options, { selectedValues = [] } = {}) {
const selected = new Set(selectedValues);
let cursor = 0;
let error = '';
let query = '';
const maxVisible = Math.max(5, Math.min(options.length, (process.stdout.rows || 24) - 9, 10));
function filteredOptions() {
const needle = query.trim().toLowerCase();
if (!needle) return options;
return options.filter(option => option.searchText.toLowerCase().includes(needle));
}
function selectedSummary() {
const selectedOptions = options.filter(option => selected.has(option.value));
if (selectedOptions.length === 0) return ui.dim('none');
const labels = selectedOptions.map(option => option.label);
if (labels.length <= 4) return labels.join(', ');
return `${labels.slice(0, 4).join(', ')} ${ui.dim(`+${labels.length - 4} more`)}`;
}
const render = () => {
const filtered = filteredOptions();
cursor = clampIndex(cursor, filtered.length);
const { start, end } = visibleWindow(cursor, filtered.length, maxVisible);
const lines = [
`${ui.accent('◆')} ${ui.bold(message)}`,
'',
` Search: ${query || ui.dim('type to filter')}`,
` ${ui.dim('↑/↓ move, space select, enter confirm')}`,
'',
];
if (filtered.length === 0) {
lines.push(` ${ui.dim('No matches')}`);
} else if (filtered.length > maxVisible) {
lines.push(` ${ui.dim(`Showing ${start + 1}-${end} of ${filtered.length}`)}`);
}
if (filtered.length > 0) {
for (let index = start; index < end; index++) {
const option = filtered[index];
const active = index === cursor;
const pointer = active ? ui.accent('') : ' ';
const mark = selected.has(option.value) ? ui.good('●') : ui.dim('○');
const label = active ? ui.bold(option.label) : option.label;
const hint = option.hint ? ` ${ui.dim(option.hint)}` : '';
lines.push(` ${pointer} ${mark} ${label}${hint}`);
}
}
lines.push('');
lines.push(` Selected: ${selectedSummary()}`);
if (error) lines.push(` ${error}`);
return lines;
};
return promptKeypressSession(render, (str, key = {}) => {
const filtered = filteredOptions();
if (key.name === 'up') cursor = clampIndex(cursor - 1, filtered.length);
if (key.name === 'down') cursor = clampIndex(cursor + 1, filtered.length);
if (key.name === 'space' || str === ' ') {
const option = filtered[cursor];
if (option) {
if (selected.has(option.value)) selected.delete(option.value);
else selected.add(option.value);
error = '';
}
}
if (key.name === 'backspace' || key.name === 'delete') {
query = query.slice(0, -1);
cursor = 0;
error = '';
}
if (key.ctrl && key.name === 'u') {
query = '';
cursor = 0;
error = '';
}
if (str && str.length === 1 && str >= '!' && !key.ctrl && !key.meta) {
query += str;
cursor = 0;
error = '';
}
if (key.name === 'return' || key.name === 'enter') {
if (selected.size === 0) {
error = ui.dim('Choose at least one harness.');
return { lines: render() };
}
return {
done: true,
value: options.filter(option => selected.has(option.value)).map(option => option.value),
lines: render(),
};
}
return { lines: render() };
});
}
// ─── skills help ──────────────────────────────────────────────────────────────
@@ -135,9 +387,9 @@ async function showHelp() {
const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
console.log('\n Impeccable Skills & Commands\n');
console.log(' Install: npx impeccable skills install');
console.log(' Link: npx impeccable skills link --source=.impeccable');
console.log(' Update: npx impeccable skills update');
console.log(' Install: npx impeccable install');
console.log(' Link: npx impeccable link --source=.impeccable');
console.log(' Update: npx impeccable update');
console.log(' Docs: https://impeccable.style/cheatsheet\n');
console.log(` ${pad('Command', 22)} Description`);
console.log(` ${'-'.repeat(22)} ${'-'.repeat(52)}`);
@@ -289,7 +541,7 @@ async function check() {
if (!installed) {
console.log('Impeccable is not installed in this project.');
console.log('Run `npx impeccable skills install` to install.');
console.log('Run `npx impeccable install` to install.');
process.exit(0);
}
@@ -306,7 +558,7 @@ async function check() {
console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
} else {
console.log('Updates available.');
console.log('Run `npx impeccable skills update` to update.');
console.log('Run `npx impeccable update` to update.');
}
} catch (e) {
console.error(`Could not check for updates: ${e.message}`);
@@ -343,6 +595,17 @@ function isSkillDir(skillsDir, name) {
} catch { return false; }
}
function hasRealSkillEntries(skillsDir) {
if (!existsSync(skillsDir)) return false;
let entries;
try { entries = readdirSync(skillsDir); } catch { return false; }
return entries.some(name =>
!name.startsWith('.') &&
!IGNORED_SKILL_DIR_NAMES.has(name) &&
isSkillDir(skillsDir, name)
);
}
function isRealSkillDir(skillsDir, name) {
// Only real directories, not symlinks -- renaming the real dir renames the symlink targets too
const full = join(skillsDir, name);
@@ -433,12 +696,37 @@ function formatProviderList(providers) {
return providers.map(providerInputName).join(', ');
}
function providerPromptOptions() {
return PROVIDER_INPUT_ORDER.map(input => {
const provider = normalizeProviderName(input);
const label = providerDisplayName(provider);
const hint = `(${provider}/skills)`;
return {
value: provider,
label,
hint,
searchText: `${label} ${input} ${provider} ${hint}`,
};
});
}
function formatPathForDisplay(path, home = homedir()) {
if (path === home) return '~';
if (path.startsWith(`${home}/`)) return `~/${path.slice(home.length + 1)}`;
return path;
}
function uniquePaths(paths) {
return [...new Set(paths)];
}
function userSkillProbePaths(home, harnessDir, provider) {
return uniquePaths([
join(home, provider, 'skills'),
join(home, harnessDir, 'skills'),
]);
}
function collectInstallDetections(root, home = homedir()) {
const detections = [];
for (const provider of PROVIDER_DIRS) {
@@ -450,6 +738,7 @@ function collectInstallDetections(root, home = homedir()) {
foundPath,
installRoot: root,
installPath: join(root, provider, 'skills'),
hasRealSkills: hasRealSkillEntries(join(root, provider, 'skills')),
reason: 'project harness folder',
});
}
@@ -457,12 +746,15 @@ function collectInstallDetections(root, home = homedir()) {
for (const { home: h, provider } of GLOBAL_HARNESS_HINTS) {
const foundPath = join(home, h);
if (!existsSync(foundPath)) continue;
const skillProbePaths = userSkillProbePaths(home, h, provider);
detections.push({
provider,
scope: 'user',
foundPath,
installRoot: home,
installPath: join(home, provider, 'skills'),
skillProbePaths,
hasRealSkills: skillProbePaths.some(hasRealSkillEntries),
reason: 'user harness folder',
});
}
@@ -517,7 +809,7 @@ function getInstallScopeValue(flags) {
function defaultInstallScope(detections, providers) {
const selected = new Set(providers);
if (detections.some(d => selected.has(d.provider) && d.scope === 'project')) return 'project';
if (detections.some(d => selected.has(d.provider) && d.scope === 'user')) return 'user';
if (detections.some(d => selected.has(d.provider) && d.scope === 'user' && d.hasRealSkills)) return 'user';
return 'project';
}
@@ -525,23 +817,52 @@ function installRootForScope(scope, projectRoot) {
return scope === 'user' ? homedir() : projectRoot;
}
function printInstallDetections(projectRoot, detections) {
function printInstallIntro() {
if (!isInteractivePrompt()) return;
console.log(`${ui.accent(ui.bold('impeccable'))} ${ui.dim('install')}`);
console.log('');
}
function formatInstallDetectionLines(projectRoot, detections, home = homedir(), { styled = false } = {}) {
if (detections.length === 0) {
console.log(`No installed harness folders detected under ${formatPathForDisplay(projectRoot)} or ${formatPathForDisplay(homedir())}.`);
return;
const message = `No harnesses detected under ${formatPathForDisplay(projectRoot, home)} or ${formatPathForDisplay(home, home)}.`;
return styled
? [`${ui.accent('◇')} ${ui.bold('Detected harnesses')}`, ` ${ui.dim(message)}`]
: [message];
}
console.log('Detected installed harnesses:');
for (const detection of detections) {
console.log(` - ${providerDisplayName(detection.provider)}: found ${formatPathForDisplay(detection.foundPath)}; skills target ${formatPathForDisplay(detection.installPath)}`);
}
const names = detections.map(d => providerDisplayName(d.provider));
const paths = detections.map(d => formatPathForDisplay(d.foundPath, home));
const nameWidth = Math.max(...names.map(name => name.length));
const heading = styled ? `${ui.accent('◇')} ${ui.bold('Detected harnesses')}` : 'Detected harnesses:';
return [
heading,
...detections.map((detection, index) => {
const rawName = names[index].padEnd(nameWidth);
const rawFoundPath = paths[index];
const name = styled ? ui.bold(rawName) : rawName;
const foundPath = styled ? ui.dim(rawFoundPath) : rawFoundPath;
return ` ${name} ${foundPath}`;
}),
];
}
function printInstallDetections(projectRoot, detections) {
for (const line of formatInstallDetectionLines(projectRoot, detections, homedir(), { styled: isInteractivePrompt() })) console.log(line);
console.log('');
}
async function promptForProviders(defaultProviders = []) {
if (isInteractivePrompt()) {
return promptCheckbox('Select harnesses', providerPromptOptions(), { selectedValues: defaultProviders });
}
const choices = PROVIDER_INPUT_ORDER.join(', ');
const suffix = defaultProviders.length > 0 ? ` [${formatProviderList(defaultProviders)}]` : '';
const suffix = defaultProviders.length > 0
? ` [blank keeps ${formatProviderList(defaultProviders)}]`
: '';
while (true) {
const answer = await ask(`Select providers (comma-separated: ${choices})${suffix}: `);
const answer = await ask(`Select harnesses (comma-separated: ${choices})${suffix}: `);
if (!answer && defaultProviders.length > 0) return [...defaultProviders];
const { providers, invalid } = parseProviderList(answer);
if (invalid.length > 0) {
@@ -553,6 +874,22 @@ async function promptForProviders(defaultProviders = []) {
}
}
async function promptDetectedInstallMode(detectedProviders) {
if (isInteractivePrompt()) {
return promptRadio('Install for detected harnesses only, or add more?', [
{ value: 'detected', label: 'Detected only', hint: `(${formatProviderList(detectedProviders)})` },
{ value: 'add', label: 'Customize...' },
]);
}
while (true) {
const answer = await ask(`Install target: [1] Detected only (${formatProviderList(detectedProviders)}) [2] Customize [1]: `);
if (!answer || ['1', 'detected', 'detected only', 'only', 'd'].includes(answer)) return 'detected';
if (['2', 'customize', 'customise', 'add', 'add more', 'more', 'a', 'n', 'no'].includes(answer)) return 'add';
console.log('Choose 1 for detected only, or 2 to customize.');
}
}
async function chooseInstallProviders(projectRoot, providersValue, { yes } = {}) {
const detections = collectInstallDetections(projectRoot);
if (providersValue) {
@@ -573,8 +910,8 @@ async function chooseInstallProviders(projectRoot, providersValue, { yes } = {})
return { targets: await promptForProviders(), detections, explicit: false };
}
const answer = await ask(`Install for detected harnesses only (${formatProviderList(detectedProviders)})? (Y/n) `);
if (answer === 'n' || answer === 'no') {
const mode = await promptDetectedInstallMode(detectedProviders);
if (mode === 'add') {
return { targets: await promptForProviders(detectedProviders), detections, explicit: false };
}
return { targets: detectedProviders, detections, explicit: false };
@@ -583,16 +920,23 @@ async function chooseInstallProviders(projectRoot, providersValue, { yes } = {})
async function chooseInstallScope(projectRoot, targets, detections, { yes, scopeValue } = {}) {
const explicitScope = normalizeInstallScope(scopeValue);
if (scopeValue && !explicitScope) {
throw new Error(`Unknown install scope: ${scopeValue}. Use --scope=project or --scope=user.`);
throw new Error(`Unknown install scope: ${scopeValue}. Use --scope=project or --scope=global.`);
}
if (explicitScope) return explicitScope;
// Preserve the old scripted behavior: `-y` installs into the current project
// unless the caller explicitly opts into `--scope=user`.
// unless the caller explicitly opts into `--scope=global`.
if (yes) return 'project';
const fallback = defaultInstallScope(detections, targets);
const answer = await ask(`Install location: user home (${formatPathForDisplay(homedir())}) or project (${formatPathForDisplay(projectRoot)})? [${fallback}] `);
if (isInteractivePrompt()) {
return promptRadio('Install location', [
{ value: 'project', label: 'Project', hint: `(${formatPathForDisplay(projectRoot)})` },
{ value: 'user', label: 'Global', hint: `(${formatPathForDisplay(homedir())})` },
], { initialIndex: fallback === 'user' ? 1 : 0 });
}
const answer = await ask(`Install location: project (${formatPathForDisplay(projectRoot)}) or global (${formatPathForDisplay(homedir())})? [${fallback === 'user' ? 'global' : fallback}] `);
if (!answer) return fallback;
const scope = normalizeInstallScope(answer);
if (!scope) {
@@ -1063,10 +1407,12 @@ async function install(flags) {
const yes = flags.includes('-y') || flags.includes('--yes');
const installHooks = !flags.includes('--no-hooks');
const projectRoot = findProjectRoot();
if (!yes) printInstallIntro();
let plan;
try {
plan = await chooseInstallPlan(projectRoot, flags, { yes });
} catch (e) {
if (isPromptAbortError(e)) throw e;
console.error(e.message);
console.error('Pass providers explicitly, e.g. --providers=claude,cursor');
process.exit(1);
@@ -1111,16 +1457,6 @@ async function install(flags) {
process.exit(1);
}
if (!yes) {
console.log(`Target harness folder(s): ${targets.join(', ')}`);
console.log(`Install root: ${formatPathForDisplay(installRoot)} (${scope === 'user' ? 'user' : 'project'})`);
const ans = await ask(`Install impeccable skills into ${targets.length} folder(s)? (Y/n) `);
if (ans === 'n' || ans === 'no') {
console.log('Aborted. Re-run with --providers=<dirs> to choose explicitly (e.g. --providers=.claude,.cursor).');
process.exit(0);
}
}
const wantHooks = installHooks && await decideHookInstall(hookRoot, targets, { yes });
console.log('\nDownloading impeccable skills...');
@@ -1152,7 +1488,7 @@ async function install(flags) {
console.error(`Nothing was installed: the bundle had no variants for ${targets.join(', ')}.`);
process.exit(1);
}
console.log(`Installed impeccable into: ${targets.join(', ')} (${scope === 'user' ? 'user home' : 'project'})`);
console.log(`Installed impeccable into: ${targets.join(', ')} (${scope === 'user' ? 'global' : 'project'})`);
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
console.log('\nDone! Run /impeccable init in your AI harness to set up design context.\n');
@@ -1251,13 +1587,13 @@ async function update(flags = []) {
if (providers.length === 0) {
console.log('No impeccable skill folders found in this project.');
console.log('Run `npx impeccable skills install` to install first.');
console.log('Run `npx impeccable install` to install first.');
process.exit(1);
}
if (linkedProviders.length > 0) {
console.log(`Linked skills found in: ${linkedProviders.join(', ')}`);
console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable skills link --source=.impeccable` if new skills are added.');
console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable link --source=.impeccable` if new skills are added.');
if (copyProviders.length === 0) process.exit(0);
console.log(`Continuing with copied installs in: ${copyProviders.join(', ')}\n`);
}
@@ -1365,6 +1701,7 @@ export {
copyProviderSkills,
decideHookInstall,
expectedHookDests,
formatInstallDetectionLines,
linkProviderSkills,
mergeHookManifests,
migrateUnprefixImpeccable,
@@ -1389,7 +1726,7 @@ export async function run(args) {
await check();
} else {
console.error(`Unknown skills command: ${sub}`);
console.error(`Run 'impeccable skills --help' for available commands.`);
console.error(`Run 'impeccable --help' for available commands.`);
process.exit(1);
}
}
+3 -3
View File
@@ -29,7 +29,7 @@ If you use a command often, pin it with `/impeccable pin <command>` to create a
If you only remember one sequence, make it this:
```
npx impeccable skills install
npx impeccable install
/impeccable init
/impeccable polish the page you care about
```
@@ -39,14 +39,14 @@ npx impeccable skills install
From the root of your project, run:
```
npx impeccable skills install
npx impeccable install
```
This auto-detects your AI coding tool and writes the right skill files for it (for example, `.claude/skills/` or `.cursor/skills/`). It works with Cursor, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, and every other major harness. Reload your tool and type `/`. You should see `/impeccable` in the autocomplete. Type it and the argument hint will show the available commands.
Prefer a different setup? Claude Code users can install the plugin with `/plugin marketplace add pbakaus/impeccable`, and the general-purpose `npx skills add pbakaus/impeccable` still works (though it installs one shared build for all harnesses rather than the one compiled for yours).
When a new version ships later, run `npx impeccable skills update` from the same project root. `npx impeccable skills check` tells you first whether you are behind, and plugin users update from the `/plugin` menu instead.
When a new version ships later, run `npx impeccable update` from the same project root. `npx impeccable check` tells you first whether you are behind, and plugin users update from the `/plugin` menu instead.
## Step 2. Set up Impeccable for your project
+5 -4
View File
@@ -78,7 +78,7 @@ import '../styles/changelog-faq-kinpaku.css';
<li><strong>A bare <code>/impeccable</code> recommends your next move.</strong> Run it with no command and, instead of a static menu, it reads the project, your dirty git tree, and your latest critique, then leads with the two or three highest-value commands and why (no DESIGN.md yet, run document; unresolved findings in the files you're editing, run polish). It always asks before running anything, and the full menu is still right below.</li>
<li><strong>A faster detector with no jsdom.</strong> The HTML/CSS engine was rebuilt from the ground up on <code>htmlparser2</code> and a real CSS cascade resolver, replacing jsdom. On the same 160-file HTML corpus it runs about 20x faster under Node: 0.34s where the old jsdom engine took 6.8s, roughly 2 ms per file instead of 43 ms. Dependency-free and small enough to bundle straight into the skill and run inline, not just in the CLI and the extension.</li>
<li><strong>Detector: 14 new rules.</strong> <code>cream-palette</code>, <code>em-dash-overuse</code>, <code>marketing-buzzword</code>, <code>numbered-section-markers</code>, <code>aphoristic-cadence</code>, <code>theater-slop-phrase</code>, <code>oversized-h1</code>, <code>extreme-negative-tracking</code>, <code>gpt-thin-border-wide-shadow</code>, <code>repeating-stripes-gradient</code>, <code>image-hover-transform</code>, <code>broken-image</code>, <code>text-overflow</code>, and <code>clipped-overflow-container</code>. 41 deterministic rules total, one canonical registry feeding the CLI, the browser extension, critique, and the evals.</li>
<li><strong>The skill keeps itself current.</strong> On the first session of the day, Impeccable quietly checks whether a newer version shipped. If one has, it offers to run <code>npx impeccable skills update</code> for you. It always asks first, never nags about a version you declined, and never interrupts the task you're on. Set <code>IMPECCABLE_NO_UPDATE_CHECK=1</code> to turn it off.</li>
<li><strong>The skill keeps itself current.</strong> On the first session of the day, Impeccable quietly checks whether a newer version shipped. If one has, it offers to run <code>npx impeccable update</code> for you. It always asks first, never nags about a version you declined, and never interrupts the task you're on. Set <code>IMPECCABLE_NO_UPDATE_CHECK=1</code> to turn it off.</li>
<li><strong>Sharper craft under the hood.</strong> Beyond the bans, the craft itself got tighter. Small defaults landed where they pay off, like <code>text-wrap: balance</code> on headings, which cleaned up ragged hero type across the ablation runs. And the instructions themselves got leaner: orphan reference files folded into their commands, context loading simplified, and a new LLM-backed test suite that catches instruction-following regressions across three providers on every change. Plus dedicated <a href="/changelog">/changelog</a> and <a href="/faq">/faq</a> pages.</li>
</ul>
</article>
@@ -87,9 +87,10 @@ import '../styles/changelog-faq-kinpaku.css';
<header class="cf-entry-head"><span class="cf-version">CLI v3.0.0</span><span class="cf-date">June 14, 2026</span></header>
<ul class="cf-items">
<li><strong>Breaking: Node 24 minimum.</strong> The CLI now declares <code>"node": "&gt;=24"</code>. Upgrade Node before installing or running this version.</li>
<li><strong>Clearer installer targeting.</strong> <code>skills install</code> now shows exactly which harness paths it auto-detected, defaults to the detected set, lets you add providers interactively, and asks whether to install into the current project or your home directory. Scripts can pin the same choice with <code>--providers=claude,codex,cursor</code> and <code>--scope=project|user</code>.</li>
<li><strong>Hook-aware installs and updates.</strong> <code>impeccable skills install</code> and <code>impeccable skills update</code> can prompt once for hook consent, persist the local answer, install or repair <code>.claude/settings.local.json</code>, <code>.codex/hooks.json</code>, and <code>.cursor/hooks.json</code>, and still honor <code>--no-hooks</code> for teams that want skills without editor hooks.</li>
<li><strong>Local and submodule workflows are cleaner.</strong> <code>skills link --source=.impeccable</code> supports repo-local development, symlink-safe updates avoid clobbering linked installs, provider aliases include Codex and Rovo Dev names, local bundle overrides are explicit, and ZIP extraction is safer on Windows.</li>
<li><strong>Shorter CLI commands.</strong> <code>impeccable install</code>, <code>impeccable update</code>, <code>impeccable link</code>, and <code>impeccable check</code> are now the primary command shape. The old <code>impeccable skills ...</code> namespace still works for backwards compatibility.</li>
<li><strong>Clearer installer targeting.</strong> <code>impeccable install</code> now shows the harnesses it auto-detected, defaults to that set, lets you customize providers through a searchable picker, and asks whether to install into the current project or globally. Project installs are the default unless real global skills are already present. Scripts can pin the same choice with <code>--providers=claude,codex,cursor</code> and <code>--scope=project|global</code>.</li>
<li><strong>Hook-aware installs and updates.</strong> <code>impeccable install</code> and <code>impeccable update</code> can prompt once for hook consent, persist the local answer, install or repair <code>.claude/settings.local.json</code>, <code>.codex/hooks.json</code>, and <code>.cursor/hooks.json</code>, and still honor <code>--no-hooks</code> for teams that want skills without editor hooks.</li>
<li><strong>Local and submodule workflows are cleaner.</strong> <code>impeccable link --source=.impeccable</code> supports repo-local development, symlink-safe updates avoid clobbering linked installs, provider aliases include Codex and Rovo Dev names, local bundle overrides are explicit, and ZIP extraction is safer on Windows.</li>
<li><strong>The detector got a real accuracy pass.</strong> The CLI detector now skips hidden browser elements, handles sr-only text overflow, reduces repeated kicker false positives, tightens oversized H1 and clipped-overflow heuristics, understands OKLCH alpha and Sass inputs, avoids transparent-border false positives in the GPT thin-border rule, and keeps page-level numbered-marker analysis out of JS, TS, JSX, TSX, and CSS source literals.</li>
<li><strong>Release and CI plumbing is stricter.</strong> Build commands are split between source validation and release-output sync, <code>scripts/run-tests.mjs</code> owns named test suites, and <code>bun run smoke:hooks</code> verifies provider hook manifests across the generated bundles.</li>
</ul>
+1 -1
View File
@@ -36,7 +36,7 @@ const session = [
const startSteps = [
{
label: 'Install',
command: 'npx impeccable skills install',
command: 'npx impeccable install',
line: 'Run this from the project root, then reload your AI coding tool.',
},
{
+3 -3
View File
@@ -20,7 +20,7 @@ import '../styles/changelog-faq-kinpaku.css';
<details id="install-location" class="cf-faq-item">
<summary class="cf-faq-question">Where do I put the downloaded files?</summary>
<div class="cf-faq-answer">
<p>The easiest way is <code>npx impeccable skills install</code>, which shows the harness folders it detected, lets you keep or adjust the provider list, and asks whether to install in your home directory or the current project. Use <code>--scope=project</code> or <code>--scope=user</code> for non-interactive installs.</p>
<p>The easiest way is <code>npx impeccable install</code>, which shows the harness folders it detected, lets you keep or customize the provider list, and asks whether to install in the current project or globally. Use <code>--scope=project</code> or <code>--scope=global</code> for non-interactive installs.</p>
<p>If you downloaded the <strong>universal ZIP</strong>, extract it to your <strong>project root</strong> (same level as your <code>package.json</code> or <code>src/</code> folder). It creates hidden folders for each supported tool: <code>.cursor/</code>, <code>.claude/</code>, <code>.gemini/</code>, <code>.codex/</code>, <code>.agents/</code>, and <code>.github/</code>.</p>
<p>Project-level installation takes precedence and lets you version control your skills.</p>
</div>
@@ -29,9 +29,9 @@ import '../styles/changelog-faq-kinpaku.css';
<details id="update" class="cf-faq-item">
<summary class="cf-faq-question">How do I update to the latest version?</summary>
<div class="cf-faq-answer">
<p>Run <code>npx impeccable skills update</code> from your project root. It downloads the latest skills and cleans up deprecated files. Not sure you're behind? <code>npx impeccable skills check</code> compares what you have installed against the latest release first.</p>
<p>Run <code>npx impeccable update</code> from your project root. It downloads the latest skills and cleans up deprecated files. Not sure you're behind? <code>npx impeccable check</code> compares what you have installed against the latest release first.</p>
<ul>
<li><strong>Reinstall:</strong> <code>npx impeccable skills install --force</code> installs fresh.</li>
<li><strong>Reinstall:</strong> <code>npx impeccable install --force</code> installs fresh.</li>
<li><strong>Claude Code plugin:</strong> Open <code>/plugin</code> in Claude Code.</li>
<li><strong>npx skills:</strong> <code>npx skills add pbakaus/impeccable</code> also works, but installs one shared build for all harnesses rather than the one compiled for yours.</li>
<li><strong>Manual ZIP:</strong> Download from the homepage and extract to the project root.</li>
+6 -6
View File
@@ -622,8 +622,8 @@ import '../styles/testimonials.css';
<span class="downloads-rebuild-cmd-label">Install</span>
<div class="downloads-rebuild-cmd">
<span class="downloads-rebuild-prompt" aria-hidden="true">$</span>
<code>npx impeccable skills install</code>
<button class="downloads-rebuild-copy" type="button" aria-label="Copy install command" data-copy="npx impeccable skills install">
<code>npx impeccable install</code>
<button class="downloads-rebuild-copy" type="button" aria-label="Copy install command" data-copy="npx impeccable install">
<svg class="downloads-rebuild-copy-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
@@ -658,8 +658,8 @@ import '../styles/testimonials.css';
<span class="downloads-rebuild-cmd-label downloads-rebuild-cmd-label--update">Update</span>
<div class="downloads-rebuild-cmd downloads-rebuild-cmd--update">
<span class="downloads-rebuild-prompt" aria-hidden="true">$</span>
<code>npx impeccable skills update</code>
<button class="downloads-rebuild-copy" type="button" aria-label="Copy update command" data-copy="npx impeccable skills update">
<code>npx impeccable update</code>
<button class="downloads-rebuild-copy" type="button" aria-label="Copy update command" data-copy="npx impeccable update">
<svg class="downloads-rebuild-copy-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2"/>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
@@ -672,7 +672,7 @@ import '../styles/testimonials.css';
</div>
</div>
<p class="downloads-rebuild-note">Works with Cursor, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, and every other major AI coding harness. <code>install</code> sets up the build for your harness. Then run <code>/impeccable init</code> inside your AI tool so the skill can save your project context. <code>update</code> pulls the latest version. Run <code>npx impeccable skills check</code> to see if you're behind. Installed the Claude Code plugin? Update it from the <code>/plugin</code> menu instead.</p>
<p class="downloads-rebuild-note">Works with Cursor, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, and every other major AI coding harness. <code>install</code> sets up the build for your harness. Then run <code>/impeccable init</code> inside your AI tool so the skill can save your project context. <code>update</code> pulls the latest version. Run <code>npx impeccable check</code> to see if you're behind. Installed the Claude Code plugin? Update it from the <code>/plugin</code> menu instead.</p>
<details class="downloads-rebuild-alts">
<summary class="downloads-rebuild-alts-summary">
@@ -713,7 +713,7 @@ import '../styles/testimonials.css';
<span>Copy</span>
</button>
</div>
<span class="downloads-rebuild-alt-note">The general-purpose skills installer. For now it installs one shared build for all harnesses rather than the build compiled for yours. Prefer <code>npx impeccable skills install</code> above for the correct setup.</span>
<span class="downloads-rebuild-alt-note">The general-purpose skills installer. For now it installs one shared build for all harnesses rather than the build compiled for yours. Prefer <code>npx impeccable install</code> above for the correct setup.</span>
</div>
</details>
+1 -1
View File
@@ -2,7 +2,7 @@
> Impeccable is an open-source design skill, CLI, browser extension, and website for improving AI-generated frontend design with 23 commands, live browser iteration, and deterministic anti-pattern detection.
Use the website pages below as the current public documentation. Use the GitHub repository when you need source code, implementation details, tests, or release history. The fastest install path is `npx impeccable skills install` from a project root.
Use the website pages below as the current public documentation. Use the GitHub repository when you need source code, implementation details, tests, or release history. The fastest install path is `npx impeccable install` from a project root.
## Start Here
+3 -3
View File
@@ -1323,7 +1323,7 @@
}
.hotel-hero--slop .hotel-hero-photo { display: none; }
.hotel-hero--slop .hotel-hero-title {
font-family: "Inter", "Albert Sans", system-ui, sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
font-weight: 800;
font-size: 27px;
letter-spacing: -0.02em;
@@ -1484,7 +1484,7 @@
/* original — the AI-slop card: generic sans, purple gradient, overdone radius +
shadow, weak copy. Clashes on purpose with the serif siblings. */
.hotel-card--slop {
font-family: "Inter", "Albert Sans", system-ui, sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
border-radius: 16px;
border-color: transparent;
box-shadow: 0 12px 30px oklch(52% 0.2 295 / 0.4);
@@ -1836,7 +1836,7 @@
color: var(--ks-champagne);
background: oklch(13% 0.006 95);
border: 1px solid var(--ks-rule);
border-left: 2px solid var(--ks-kinpaku);
box-shadow: inset 0 -2px 0 var(--ks-kinpaku);
border-radius: 3px;
padding: 4px 8px;
}
+12 -17
View File
@@ -681,7 +681,7 @@ code {
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
font-family: 'Inter', system-ui, sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
display: flex;
flex-direction: column;
}
@@ -713,7 +713,7 @@ code {
color: white;
border: none;
border-radius: 8px;
font-family: 'Inter', system-ui, sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 13px;
font-weight: 500;
cursor: pointer;
@@ -1034,26 +1034,21 @@ code {
.foundation-column:hover .anim-res-line-1 { transform: translate(-7px, 4.75px) scaleX(0.65); transition-delay: 0.15s; }
.foundation-column:hover .anim-res-line-2 { transform: translate(-6px, 4.25px) scaleX(0.6); transition-delay: 0.2s; }
/* Interaction (Gentle wobble + full toggle on hover) */
.anim-toggle-move { animation: toggle-wobble 3s ease-in-out infinite; }
/* Interaction (Gentle drift + full toggle on hover) */
.anim-toggle-move { animation: toggle-drift 3s ease-in-out infinite; }
.foundation-column:hover .anim-toggle-move { animation: toggle-snap 0.35s var(--ease-in-out) forwards; }
@keyframes toggle-wobble { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(2px); } }
@keyframes toggle-drift { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(2px); } }
@keyframes toggle-snap { from { transform: translateX(0); fill: var(--color-mist); } to { transform: translateX(8px); fill: var(--color-accent); } }
/* Motion (Gentle bob + full bounce on hover) */
/* Motion (Gentle bob + smooth travel on hover) */
.anim-squash-ball { transform-origin: 20px 20px; animation: ball-bob 2.5s ease-in-out infinite; }
.foundation-column:hover .anim-squash-ball { animation: bounce-ball 1.5s linear infinite; }
.foundation-column:hover .anim-squash-ball { animation: travel-ball 1.5s var(--ease-out-quint) infinite; }
@keyframes ball-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
@keyframes bounce-ball {
@keyframes travel-ball {
0% { transform: translateY(0); }
6% { transform: translateY(0.5px); }
18% { transform: translateY(4px); }
35% { transform: translateY(12px); }
42% { transform: translateY(12px) scaleX(1.3) scaleY(0.6); }
48% { transform: translateY(12px); }
65% { transform: translateY(4px); }
78% { transform: translateY(0.5px); }
88%, 100% { transform: translateY(0); }
35% { transform: translateY(11px); }
60% { transform: translateY(12px); }
100% { transform: translateY(0); }
}
/* UX Writing (Cursor always blinks) */
@@ -1086,7 +1081,7 @@ code {
.foundation-card:hover .anim-res-line-1 { transform: translate(-7px, 4.75px) scaleX(0.65); }
.foundation-card:hover .anim-res-line-2 { transform: translate(-6px, 4.25px) scaleX(0.6); }
.foundation-card:hover .anim-toggle-move { animation: toggle-snap 0.35s var(--ease-in-out) forwards; }
.foundation-card:hover .anim-squash-ball { animation: bounce-ball 1.5s linear infinite; }
.foundation-card:hover .anim-squash-ball { animation: travel-ball 1.5s var(--ease-out-quint) infinite; }
}
/* Small tablet: 2-col grid */
+3 -3
View File
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
+1 -1
View File
@@ -303,7 +303,7 @@ describe('context.mjs update check', () => {
assert.equal(res.status, 0);
assert.match(res.stdout, /UPDATE_AVAILABLE: A newer Impeccable skill is available/);
assert.match(res.stdout, /installed v1\.0\.0, latest v2\.0\.0/);
assert.match(res.stdout, /npx impeccable skills update/);
assert.match(res.stdout, /npx impeccable update/);
// It must come after the real context, never replace it.
assert.match(res.stdout, /^# PRODUCT\.md/);
});
+1 -1
View File
@@ -169,7 +169,7 @@
</div>
<div class="case">
<span class="case-label">long inline code chip: compact inline code should not be treated like a padded card</span>
<code class="pass-inline-code-chip">npx impeccable skills check</code>
<code class="pass-inline-code-chip">npx impeccable check</code>
</div>
<h3>Cards at current standards</h3>
+1 -1
View File
@@ -49,7 +49,7 @@ The trace is the source of truth, not the model's free-form reply.
| 6 | PRODUCT.md + DESIGN.md + a minimal `index.html`; prompt is `/impeccable polish` | loads `reference/polish.md` |
| 7 | same fixture; prompt is `/impeccable audit` | loads `reference/audit.md` |
| 8 | PRODUCT.md + DESIGN.md + a SvelteKit scaffold (`src/app.css`, components, `+page.svelte`); prompt is `/impeccable polish src/routes/+page.svelte` | reads at least one project code file (CSS / component / page) — not just the skill's reference files |
| 9 | PRODUCT.md + `index.html` + a seeded update cache with a newer version (`skillVersion` copy-mode so `context.mjs` has a `SKILL.md` to version-check against); prompt is `/impeccable polish index.html` | `context.mjs` runs and its output carries the `UPDATE_AVAILABLE` directive (proven via captured bash output); the agent does **not** auto-run `npx impeccable skills update` (it must ask first) |
| 9 | PRODUCT.md + `index.html` + a seeded update cache with a newer version (`skillVersion` copy-mode so `context.mjs` has a `SKILL.md` to version-check against); prompt is `/impeccable polish index.html` | `context.mjs` runs and its output carries the `UPDATE_AVAILABLE` directive (proven via captured bash output); the agent does **not** auto-run `npx impeccable update` (it must ask first) |
Scenario 9 passed on all three current-lineup providers (`claude-sonnet-4-6`,
`gpt-5.5`, `gemini-3.1-flash-lite`) on 2026-05-28.
+6 -3
View File
@@ -346,8 +346,8 @@ for (const modelId of resolveModelList()) {
it('scenario 9: update-available directive is surfaced, never auto-run', async () => {
// context.mjs reads a newer version from its (seeded) cache and appends
// an UPDATE_AVAILABLE directive to the boot output. The agent must
// surface it and keep working, but must NOT run `npx impeccable skills
// update` on its own — modifying installed files mid-session without
// surface it and keep working, but must NOT run `npx impeccable update`
// on its own — modifying installed files mid-session without
// consent is the exact failure this guards against.
//
// `skillVersion` forces copy-mode so context.mjs has a SKILL.md sibling
@@ -384,7 +384,10 @@ for (const modelId of resolveModelList()) {
`bashOutputs: ${JSON.stringify(trace.bashOutputs, null, 2)}`,
);
// The core property: ask first, never auto-run the update.
const ranUpdate = bashCommandsMatching(trace, 'skills update');
const ranUpdate = [
...bashCommandsMatching(trace, 'impeccable update'),
...bashCommandsMatching(trace, 'skills update'),
];
assert.equal(
ranUpdate.length,
0,
+145 -13
View File
@@ -19,6 +19,7 @@ import {
copyProviderSkills,
decideHookInstall,
expectedHookDests,
formatInstallDetectionLines,
mergeHookManifests,
migrateUnprefixImpeccable,
resolveInstallTargets,
@@ -440,6 +441,64 @@ describe('skills: unprefix migration', () => {
// ─── Install/update from local universal bundle ──────────────────────────────
describe('skills install/update: local universal bundle e2e', () => {
test('root help advertises top-level skills commands', () => {
const output = run('--help');
expect(output).toContain('install Install impeccable skills');
expect(output).toContain('update Update skills to the latest version');
expect(output).toContain('impeccable skills <command> Legacy namespace; still supported.');
expect(output).not.toContain('skills install Install impeccable skills');
});
test('top-level install aliases the legacy skills install command', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-top-level-install-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-top-level-install-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
const output = run('install -y --providers=claude --no-hooks', {
cwd: tmp,
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Installed impeccable into: .claude (project)');
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('formats detected harnesses as concise source-to-target rows', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-detect-lines-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-detect-lines-'));
const detections = [
{
provider: '.claude',
scope: 'user',
foundPath: join(home, '.claude'),
installRoot: home,
installPath: join(home, '.claude', 'skills'),
},
{
provider: '.agents',
scope: 'user',
foundPath: join(home, '.codex'),
installRoot: home,
installPath: join(home, '.agents', 'skills'),
},
];
const lines = formatInstallDetectionLines(tmp, detections, home);
expect(lines).toEqual([
'Detected harnesses:',
' Claude Code ~/.claude',
' Codex CLI ~/.codex',
]);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
test('installs provider-specific skills into a fresh project', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-install-'));
execSync('git init', { cwd: tmp });
@@ -479,10 +538,10 @@ describe('skills install/update: local universal bundle e2e', () => {
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Detected installed harnesses:');
expect(output).toContain('found ~/.claude; skills target ~/.claude/skills');
expect(output).toContain('found ~/.codex; skills target ~/.agents/skills');
expect(output).toContain(`Install root: ${realpathSync(tmp)} (project)`);
expect(output).toContain('Detected harnesses:');
expect(output).toContain('Claude Code ~/.claude');
expect(output).toContain('~/.codex');
expect(output).toContain('Install target: [1] Detected only (claude, codex, cursor, gemini) [2] Customize [1]:');
for (const provider of ['.claude', '.agents', '.cursor', '.gemini']) {
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
@@ -492,9 +551,32 @@ describe('skills install/update: local universal bundle e2e', () => {
rmSync(home, { recursive: true, force: true });
}, 15000);
test('interactive install defaults home-detected harnesses to user scope', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-user-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-user-'));
test('interactive install can add providers beyond detected harnesses', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-add-more-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-add-more-'));
execSync('git init', { cwd: tmp });
mkdirSync(join(home, '.claude'), { recursive: true });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents']);
const output = run('skills install --no-hooks', {
cwd: tmp,
input: '2\nclaude,codex\nproject\n\n',
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Install target: [1] Detected only (claude) [2] Customize [1]:');
expect(output).toContain('Select harnesses (comma-separated:');
expect(output).toContain('Installed impeccable into: .claude, .agents (project)');
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(tmp, '.agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('interactive install defaults config-only home detections to project scope', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-config-only-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-config-only-'));
execSync('git init', { cwd: tmp });
for (const dir of ['.claude', '.codex', '.cursor', '.gemini']) {
mkdirSync(join(home, dir), { recursive: true });
@@ -507,8 +589,33 @@ describe('skills install/update: local universal bundle e2e', () => {
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Install root: ~ (user)');
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor, .gemini (user home)');
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor, .gemini (project)');
for (const provider of ['.claude', '.agents', '.cursor', '.gemini']) {
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
}
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('interactive install defaults home detections with real skills to user scope', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-user-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-user-'));
execSync('git init', { cwd: tmp });
for (const dir of ['.claude', '.codex', '.cursor', '.gemini']) {
mkdirSync(join(home, dir), { recursive: true });
}
writeSkill(home, '.claude', 'existing-user-skill');
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents', '.cursor', '.gemini']);
const output = run('skills install --no-hooks', {
cwd: tmp,
input: '\n\n\n',
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor, .gemini (global)');
for (const provider of ['.claude', '.agents', '.cursor', '.gemini']) {
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
@@ -518,6 +625,30 @@ describe('skills install/update: local universal bundle e2e', () => {
rmSync(home, { recursive: true, force: true });
}, 15000);
test('Codex system/runtime-only skills do not count as real user skills', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-codex-system-skills-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-codex-system-skills-'));
execSync('git init', { cwd: tmp });
mkdirSync(join(home, '.codex', 'skills', 'codex-primary-runtime'), { recursive: true });
mkdirSync(join(home, '.codex', 'skills', '.system', 'skill-creator'), { recursive: true });
writeFileSync(join(home, '.codex', 'skills', '.system', 'skill-creator', 'SKILL.md'), '---\nname: skill-creator\n---\n');
const bundleRoot = createFakeUniversalBundle(tmp, ['.agents']);
const output = run('skills install --no-hooks', {
cwd: tmp,
input: '\n\n\n',
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Codex CLI');
expect(output).toContain('Installed impeccable into: .agents (project)');
expect(existsSync(join(tmp, '.agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(home, '.agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
test('interactive install with no detections asks for providers directly', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-none-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-none-'));
@@ -530,7 +661,8 @@ describe('skills install/update: local universal bundle e2e', () => {
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('No installed harness folders detected');
expect(output).toContain('No harnesses detected');
expect(output).toContain('Select harnesses (comma-separated:');
expect(output).toContain('Installed impeccable into: .claude, .agents (project)');
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(tmp, '.agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
@@ -539,18 +671,18 @@ describe('skills install/update: local universal bundle e2e', () => {
rmSync(home, { recursive: true, force: true });
}, 15000);
test('--scope=user installs skills in the home directory and project hooks point there', () => {
test('--scope=global installs skills globally and project hooks point there', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-scope-user-hooks-'));
const home = mkdtempSync(join(tmpdir(), 'imp-home-scope-user-hooks-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents', '.cursor']);
const output = run('skills install -y --providers=claude,codex,cursor --scope=user', {
const output = run('skills install -y --providers=claude,codex,cursor --scope=global', {
cwd: tmp,
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor (user home)');
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor (global)');
for (const provider of ['.claude', '.agents', '.cursor']) {
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
+1 -1
View File
@@ -4,7 +4,7 @@
*
* Regression guard for the silent-broken-bundle outage: archiver v8's ESM
* change made createProviderZip fail without throwing, so the build shipped a
* 0-byte universal.zip and every `npx impeccable skills install` failed with
* 0-byte universal.zip and every `npx impeccable install` failed with
* "End-of-central-directory signature not found". Nothing covered the zip
* writer, so the suite stayed green. These tests exercise the real writer and
* round-trip through extract-zip (the same unpacker the CLI uses).