From 644398011738ebe842b72103ba0575bf92c4476d Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 15 Jun 2026 12:49:18 +0900 Subject: [PATCH] Improve CLI install prompts --- README.md | 18 +- cli/bin/cli.js | 78 ++-- cli/bin/commands/skills.mjs | 417 ++++++++++++++++-- site/content/tutorials/getting-started.md | 6 +- site/pages/changelog.astro | 9 +- site/pages/docs/index.astro | 2 +- site/pages/faq.astro | 6 +- site/pages/index.astro | 12 +- site/public/llms.txt | 2 +- site/styles/home-rebuild.css | 6 +- site/styles/main.css | 29 +- skill/scripts/context.mjs | 6 +- tests/context.test.mjs | 2 +- .../antipatterns/cramped-padding.html | 2 +- tests/skill-behavior/README.md | 2 +- tests/skill-behavior/scenarios.test.mjs | 9 +- tests/skills-cli.test.js | 158 ++++++- tests/zip.test.mjs | 2 +- 18 files changed, 626 insertions(+), 140 deletions(-) diff --git a/README.md b/README.md index 7ece7103e..c829b08ec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cli/bin/cli.js b/cli/bin/cli.js index d514196aa..ebbee1817 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -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 [options] +async function main() { + const args = process.argv.slice(2); + const command = args[0]; + + if (!command || command === '--help' || command === '-h') { + console.log(`Usage: impeccable [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 --help' for command-specific options.`); - process.exit(0); +Compatibility: + impeccable skills 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); +}); diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index 2b710a6bf..0c9c22f62 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -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= 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); } } diff --git a/site/content/tutorials/getting-started.md b/site/content/tutorials/getting-started.md index 0c38fa931..a13d7d3ee 100644 --- a/site/content/tutorials/getting-started.md +++ b/site/content/tutorials/getting-started.md @@ -29,7 +29,7 @@ If you use a command often, pin it with `/impeccable pin ` 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 diff --git a/site/pages/changelog.astro b/site/pages/changelog.astro index d814d6460..b00142c5a 100644 --- a/site/pages/changelog.astro +++ b/site/pages/changelog.astro @@ -78,7 +78,7 @@ import '../styles/changelog-faq-kinpaku.css';
  • A bare /impeccable recommends your next move. 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.
  • A faster detector with no jsdom. The HTML/CSS engine was rebuilt from the ground up on htmlparser2 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.
  • Detector: 14 new rules. cream-palette, em-dash-overuse, marketing-buzzword, numbered-section-markers, aphoristic-cadence, theater-slop-phrase, oversized-h1, extreme-negative-tracking, gpt-thin-border-wide-shadow, repeating-stripes-gradient, image-hover-transform, broken-image, text-overflow, and clipped-overflow-container. 41 deterministic rules total, one canonical registry feeding the CLI, the browser extension, critique, and the evals.
  • -
  • The skill keeps itself current. On the first session of the day, Impeccable quietly checks whether a newer version shipped. If one has, it offers to run npx impeccable skills update for you. It always asks first, never nags about a version you declined, and never interrupts the task you're on. Set IMPECCABLE_NO_UPDATE_CHECK=1 to turn it off.
  • +
  • The skill keeps itself current. On the first session of the day, Impeccable quietly checks whether a newer version shipped. If one has, it offers to run npx impeccable update for you. It always asks first, never nags about a version you declined, and never interrupts the task you're on. Set IMPECCABLE_NO_UPDATE_CHECK=1 to turn it off.
  • Sharper craft under the hood. Beyond the bans, the craft itself got tighter. Small defaults landed where they pay off, like text-wrap: balance 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 /changelog and /faq pages.
  • @@ -87,9 +87,10 @@ import '../styles/changelog-faq-kinpaku.css';
    CLI v3.0.0June 14, 2026
    • Breaking: Node 24 minimum. The CLI now declares "node": ">=24". Upgrade Node before installing or running this version.
    • -
    • Clearer installer targeting. skills install 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 --providers=claude,codex,cursor and --scope=project|user.
    • -
    • Hook-aware installs and updates. impeccable skills install and impeccable skills update can prompt once for hook consent, persist the local answer, install or repair .claude/settings.local.json, .codex/hooks.json, and .cursor/hooks.json, and still honor --no-hooks for teams that want skills without editor hooks.
    • -
    • Local and submodule workflows are cleaner. skills link --source=.impeccable 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.
    • +
    • Shorter CLI commands. impeccable install, impeccable update, impeccable link, and impeccable check are now the primary command shape. The old impeccable skills ... namespace still works for backwards compatibility.
    • +
    • Clearer installer targeting. impeccable install 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 --providers=claude,codex,cursor and --scope=project|global.
    • +
    • Hook-aware installs and updates. impeccable install and impeccable update can prompt once for hook consent, persist the local answer, install or repair .claude/settings.local.json, .codex/hooks.json, and .cursor/hooks.json, and still honor --no-hooks for teams that want skills without editor hooks.
    • +
    • Local and submodule workflows are cleaner. impeccable link --source=.impeccable 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.
    • The detector got a real accuracy pass. 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.
    • Release and CI plumbing is stricter. Build commands are split between source validation and release-output sync, scripts/run-tests.mjs owns named test suites, and bun run smoke:hooks verifies provider hook manifests across the generated bundles.
    diff --git a/site/pages/docs/index.astro b/site/pages/docs/index.astro index 48082a0ed..4d4278267 100644 --- a/site/pages/docs/index.astro +++ b/site/pages/docs/index.astro @@ -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.', }, { diff --git a/site/pages/faq.astro b/site/pages/faq.astro index 5e8241361..ccb0881ae 100644 --- a/site/pages/faq.astro +++ b/site/pages/faq.astro @@ -20,7 +20,7 @@ import '../styles/changelog-faq-kinpaku.css';
    Where do I put the downloaded files?
    -

    The easiest way is npx impeccable skills install, 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 --scope=project or --scope=user for non-interactive installs.

    +

    The easiest way is npx impeccable install, 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 --scope=project or --scope=global for non-interactive installs.

    If you downloaded the universal ZIP, extract it to your project root (same level as your package.json or src/ folder). It creates hidden folders for each supported tool: .cursor/, .claude/, .gemini/, .codex/, .agents/, and .github/.

    Project-level installation takes precedence and lets you version control your skills.

    @@ -29,9 +29,9 @@ import '../styles/changelog-faq-kinpaku.css';
    How do I update to the latest version?
    -

    Run npx impeccable skills update from your project root. It downloads the latest skills and cleans up deprecated files. Not sure you're behind? npx impeccable skills check compares what you have installed against the latest release first.

    +

    Run npx impeccable update from your project root. It downloads the latest skills and cleans up deprecated files. Not sure you're behind? npx impeccable check compares what you have installed against the latest release first.

      -
    • Reinstall: npx impeccable skills install --force installs fresh.
    • +
    • Reinstall: npx impeccable install --force installs fresh.
    • Claude Code plugin: Open /plugin in Claude Code.
    • npx skills: npx skills add pbakaus/impeccable also works, but installs one shared build for all harnesses rather than the one compiled for yours.
    • Manual ZIP: Download from the homepage and extract to the project root.
    • diff --git a/site/pages/index.astro b/site/pages/index.astro index 37258ee63..90011ab4f 100644 --- a/site/pages/index.astro +++ b/site/pages/index.astro @@ -622,8 +622,8 @@ import '../styles/testimonials.css'; Install
      - npx impeccable skills install -
    -

    Works with Cursor, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, and every other major AI coding harness. install sets up the build for your harness. Then run /impeccable init inside your AI tool so the skill can save your project context. update pulls the latest version. Run npx impeccable skills check to see if you're behind. Installed the Claude Code plugin? Update it from the /plugin menu instead.

    +

    Works with Cursor, Claude Code, GitHub Copilot, Gemini CLI, Codex CLI, and every other major AI coding harness. install sets up the build for your harness. Then run /impeccable init inside your AI tool so the skill can save your project context. update pulls the latest version. Run npx impeccable check to see if you're behind. Installed the Claude Code plugin? Update it from the /plugin menu instead.

    @@ -713,7 +713,7 @@ import '../styles/testimonials.css'; Copy - The general-purpose skills installer. For now it installs one shared build for all harnesses rather than the build compiled for yours. Prefer npx impeccable skills install above for the correct setup. + The general-purpose skills installer. For now it installs one shared build for all harnesses rather than the build compiled for yours. Prefer npx impeccable install above for the correct setup.
    diff --git a/site/public/llms.txt b/site/public/llms.txt index 2e369b3e8..995e0325b 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -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 diff --git a/site/styles/home-rebuild.css b/site/styles/home-rebuild.css index 717207c3e..25a0163f4 100644 --- a/site/styles/home-rebuild.css +++ b/site/styles/home-rebuild.css @@ -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; } diff --git a/site/styles/main.css b/site/styles/main.css index 2e0c8d4c5..b8b6d2929 100644 --- a/site/styles/main.css +++ b/site/styles/main.css @@ -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 */ diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs index 3912a9060..04f334554 100644 --- a/skill/scripts/context.mjs +++ b/skill/scripts/context.mjs @@ -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.` ); } diff --git a/tests/context.test.mjs b/tests/context.test.mjs index 0f434105a..4e7b698bd 100644 --- a/tests/context.test.mjs +++ b/tests/context.test.mjs @@ -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/); }); diff --git a/tests/fixtures/antipatterns/cramped-padding.html b/tests/fixtures/antipatterns/cramped-padding.html index 251fe2a77..8ed420e1f 100644 --- a/tests/fixtures/antipatterns/cramped-padding.html +++ b/tests/fixtures/antipatterns/cramped-padding.html @@ -169,7 +169,7 @@
    long inline code chip: compact inline code should not be treated like a padded card - npx impeccable skills check + npx impeccable check

    Cards at current standards

    diff --git a/tests/skill-behavior/README.md b/tests/skill-behavior/README.md index 96fd41dd5..389346de7 100644 --- a/tests/skill-behavior/README.md +++ b/tests/skill-behavior/README.md @@ -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. diff --git a/tests/skill-behavior/scenarios.test.mjs b/tests/skill-behavior/scenarios.test.mjs index 418c6924a..1e743128e 100644 --- a/tests/skill-behavior/scenarios.test.mjs +++ b/tests/skill-behavior/scenarios.test.mjs @@ -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, diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 2263e583e..a898d380c 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -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 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); diff --git a/tests/zip.test.mjs b/tests/zip.test.mjs index 3dffe1774..2d7d4342b 100644 --- a/tests/zip.test.mjs +++ b/tests/zip.test.mjs @@ -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).