feat(cli): interactive hook consent + unified .impeccable/config.json (#245)

* feat(cli): interactive hook consent + unified .impeccable/config.json

Make the design-hook install a conscious choice and unify scattered config
into one file.

Interactive consent
- On an interactive `skills install`/`update`, the CLI explains what the hook
  does and offers to install it (default yes), then records the per-developer
  decision in the gitignored `.impeccable/config.local.json`, so it never
  re-asks. A recorded decision or an already-installed hook short-circuits;
  `-y`/non-TTY keeps the historical install-by-default behavior; `--no-hooks`
  is a one-off skip that records nothing. The trigger keys on "is the hook
  installed?" + "is there a recorded decision?", not a brittle version check.

Unified config
- `.impeccable/config.json` (shared) and `.impeccable/config.local.json`
  (gitignored) now hold all Impeccable settings: hook settings under a `hook`
  key, plus top-level `updateCheck`. `/impeccable hooks` writes the `hook`
  subtree, preserving siblings. The hook runtime reads `hook.quiet` and
  `hook.auditLog`; context boot reads `updateCheck`. The legacy
  `IMPECCABLE_HOOK_DISABLED|QUIET|LOG` and `IMPECCABLE_NO_UPDATE_CHECK` env vars
  still work and override config; docs now lead with config and treat env vars
  as a legacy note.
- No backward compat for the pre-unification `hook.json`/`hook.local.json`
  (the hook shipped an hour ago; nothing in the wild uses it). This repo's own
  hook config is migrated to `.impeccable/config.json`.

The CLI and skill scripts are separate trees, so a small CLI-side config module
(cli/lib/impeccable-config.mjs) duplicates the config-path and .git/info/exclude
handling; comments flag the duplication.

Tests: new cli config unit test; skills-cli consent tests (declined skips,
accepted installs, --no-hooks records nothing); hook.test.mjs back-compat
removed and quiet/auditLog-from-config + gitexclude coverage added. Full suite
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): preserve sibling config fields + resolve audit log from event cwd (Bugbot)

Two Bugbot findings:

- High: `/impeccable hooks` edits replaced the whole `hook` object with the
  merge-helper output, dropping fields those helpers don't manage — so an
  `ignore-value --local` could wipe the recorded install consent and make the
  CLI re-prompt. writeConfig now merges over the existing hook object, keeping
  consent/quiet/auditLog.
- Medium: config-based audit logging resolved hook.auditLog from process.cwd(),
  which can differ from the hook event's project root (and Cursor's pre-edit
  hook passed no cwd). The hook now stamps the resolved project root on the
  audit entry, and writeAuditLog reads config from entry.cwd when present.

Tests: a /impeccable hooks edit preserves consent + quiet; writeAuditLog
resolves config auditLog from entry.cwd, not the fallback cwd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): resolve a relative auditLog path against the project root (Bugbot)

A relative hook.auditLog was read from the project root but written relative to
the hook process cwd, so when those differ the log went to the wrong place.
writeAuditLog now resolves a relative target (from env or config) against the
same project root it reads config from. Absolute and ~/ paths are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix hook consent recovery and smoke config

* Fix hook consent explainer for Cursor

* Fix empty hook target consent

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-06-14 02:42:19 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 9c0012d4e1
commit 8cf2be110d
19 changed files with 910 additions and 109 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ Thumbs.db
.impeccable/live/pending-manual-edits.json
.impeccable/live/deferred-svelte-component-accepts.json
.impeccable/history/
.impeccable/hook.local.json
.impeccable/config.local.json
.impeccable/hook.pending.json
.impeccable/provider-smoke/
src/__impeccable_provider_smoke_*.html
+15
View File
@@ -0,0 +1,15 @@
{
"hook": {
"enabled": true,
"ignoreRules": [],
"ignoreFiles": [
"tests/fixtures/**",
"site/pages/slop/**"
],
"ignoreValues": [],
"limits": {
"maxFindings": 5,
"maxChars": 8000
}
}
}
-13
View File
@@ -1,13 +0,0 @@
{
"enabled": true,
"ignoreRules": [],
"ignoreFiles": [
"tests/fixtures/**",
"site/pages/slop/**"
],
"ignoreValues": [],
"limits": {
"maxFindings": 5,
"maxChars": 8000
}
}
+2 -2
View File
@@ -270,9 +270,9 @@ Installed hook surfaces:
The installer preserves unrelated hook entries and settings. If a hook manifest is malformed, install/update aborts by default; rerun with `--force` to back up the malformed file as `.bak` and replace it.
If you want skills without hook manifests, pass `--no-hooks` to `npx impeccable skills install` or `npx impeccable skills update`.
On an interactive `install`/`update`, Impeccable explains the hook and offers to install it (default yes). Your choice is remembered per-developer in the gitignored `.impeccable/config.local.json`, so you are not asked again; `--no-hooks` skips it for that run without recording anything. Hook settings (enable/ignore rules, etc.) live under the `hook` key of `.impeccable/config.json`, managed with `/impeccable hooks`.
For debugging, set `IMPECCABLE_HOOK_LOG=/path/to/hook.ndjson` to write one NDJSON line per hook invocation. Leave it unset for normal use.
For debugging, set `hook.auditLog` in `.impeccable/config.json` to a path (or the legacy `IMPECCABLE_HOOK_LOG` env var) to write one NDJSON line per hook invocation. Leave it unset for normal use.
Codex requires one platform step that Impeccable cannot safely skip: open `/hooks` after install or update and approve the project hook. There is no Codex marketplace/plugin install flow for this hook.
+45 -4
View File
@@ -17,6 +17,7 @@ import { get } from 'node:https';
import { createHash } from 'node:crypto';
import { tmpdir, homedir } from 'node:os';
import extract from 'extract-zip';
import { getHookConsent, setHookConsent } from '../../lib/impeccable-config.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const API_BASE = 'https://impeccable.style';
@@ -650,6 +651,40 @@ function copyProviderHooks(bundleDir, root, providers, { force = false } = {}) {
return [...new Set(written)];
}
const HOOK_EXPLAINER = [
'',
'Impeccable can install a design hook for this project. In Claude/Codex it',
'checks UI files after edits; in Cursor it checks proposed writes before they',
'land and can block writes with detector findings. It feeds results back to',
'your agent so design slop gets caught as you build. Change it later with',
'/impeccable hooks on|off.',
'',
].join('\n');
// Decide whether to install the design hook. Prompts once (default yes) the
// first time, records the answer in .impeccable/config.local.json, and never
// re-asks: a recorded decision or an already-installed hook short-circuits, and
// non-interactive runs keep the historical install-by-default behavior.
async function decideHookInstall(root, targets, { yes } = {}) {
if (targets.length === 0) return false;
const consent = getHookConsent(root);
if (consent === 'declined') return false;
if (consent === 'accepted') return true;
// Existing hook users (hook already wired up) are never nagged.
if (targets.length > 0 && targets.every(provider => hookInstalledForProvider(root, provider))) {
return true;
}
// Undecided and not yet installed. Non-interactive (-y or no TTY) keeps the
// historical default-on behavior without recording a (re-promptable) decision.
if (yes || !process.stdin.isTTY) return true;
process.stdout.write(HOOK_EXPLAINER);
const ans = await ask('Install the design hook? (Y/n) ');
const accepted = !(ans === 'n' || ans === 'no');
setHookConsent(root, accepted ? 'accepted' : 'declined');
return accepted;
}
function resolveLinkSource(sourceValue, root) {
const sourcePath = sourceValue || '.impeccable';
const checkoutRoot = isAbsolute(sourcePath) ? sourcePath : resolve(root, sourcePath);
@@ -795,7 +830,8 @@ async function install(flags) {
if (existing && !force) {
console.log(`Impeccable skills are already installed (found in ${existing}/).`);
const targets = providersValue ? resolveInstallTargets(root, providersValue) : findInstalledProviders(root);
const missingHookTargets = installHooks
const wantHooks = installHooks && await decideHookInstall(root, targets, { yes });
const missingHookTargets = wantHooks
? targets.filter(provider => !hookInstalledForProvider(root, provider))
: [];
if (missingHookTargets.length > 0) {
@@ -836,6 +872,8 @@ async function install(flags) {
}
}
const wantHooks = installHooks && await decideHookInstall(root, targets, { yes });
console.log('\nDownloading impeccable skills...');
let bundleDir;
try {
@@ -853,7 +891,7 @@ async function install(flags) {
let hookTargets = [];
try {
written = copyProviderSkills(bundleDir, root, targets);
hookTargets = installHooks ? copyProviderHooks(bundleDir, root, targets, { force }) : [];
hookTargets = wantHooks ? copyProviderHooks(bundleDir, root, targets, { force }) : [];
} catch (e) {
rmSync(bundleDir, { recursive: true, force: true });
console.error(`Install failed: ${e.message}`);
@@ -988,7 +1026,8 @@ async function update(flags = []) {
// Compare local vs remote -- skip if already up to date
if (isUpToDate(root, copyProviders, tmpDir)) {
try {
const hookTargets = installHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
rmSync(tmpDir, { recursive: true, force: true });
const v = getSkillsVersion(root);
console.log(`Skills are up to date${v ? ` (v${v})` : ''}.`);
@@ -1039,7 +1078,8 @@ async function update(flags = []) {
updated++;
}
}
const hookTargets = installHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
rmSync(tmpDir, { recursive: true, force: true });
@@ -1073,6 +1113,7 @@ function copyDirSync(src, dest) {
export {
copyProviderHooks,
copyProviderSkills,
decideHookInstall,
expectedHookDests,
linkProviderSkills,
mergeHookManifests,
+119
View File
@@ -0,0 +1,119 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs — the config-path layout and the
* `.git/info/exclude` handling. Keep the schema and exclude marker in sync if
* either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* { "hook": { "consent": "accepted" | "declined", ... }, "updateCheck": bool }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+79 -14
View File
@@ -54,8 +54,11 @@ const smokeFiles = {
};
const results = [];
const hookConfigFiles = ['.impeccable/config.json', '.impeccable/config.local.json'];
const originalHookConfigFiles = new Map();
main().catch((error) => {
restoreHookConfigFiles();
if (!results.some((result) => !result.pass)) {
record('fatal', false, String(error?.message || error), 'fatal');
}
@@ -67,6 +70,7 @@ main().catch((error) => {
async function main() {
assertPath(targetRepo, 'target repo');
assertPath(bundlePath, 'universal bundle');
snapshotHookConfigFiles();
mkdirSync(smokeDir, { recursive: true });
ensureTargetGitExclude();
@@ -85,6 +89,7 @@ async function main() {
cleanSmokeFiles();
clearRuntimeState();
restoreHookConfigFiles();
writeSummary();
const failed = results.filter((result) => !result.pass);
@@ -238,7 +243,7 @@ function cleanInstalledImpeccable() {
rmSync(join(targetRepo, rel), { recursive: true, force: true });
}
for (const rel of ['.claude/settings.json', '.cursor/hooks.json', '.codex/hooks.json']) {
for (const rel of ['.claude/settings.json', '.claude/settings.local.json', '.cursor/hooks.json', '.codex/hooks.json']) {
stripManifest(rel);
}
@@ -319,6 +324,47 @@ function stripManifest(rel) {
}
}
function snapshotHookConfigFiles() {
for (const rel of hookConfigFiles) {
const file = join(targetRepo, rel);
originalHookConfigFiles.set(rel, existsSync(file) ? readFileSync(file, 'utf8') : null);
}
}
function restoreHookConfigFiles() {
if (originalHookConfigFiles.size === 0) return;
for (const [rel, content] of originalHookConfigFiles.entries()) {
const file = join(targetRepo, rel);
if (content === null) {
rmSync(file, { force: true });
} else {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, content);
}
}
}
function resetHookConfigForSmoke() {
for (const rel of hookConfigFiles) {
const file = join(targetRepo, rel);
if (!existsSync(file)) continue;
let raw;
try {
raw = JSON.parse(readFileSync(file, 'utf8'));
} catch {
rmSync(file, { force: true });
continue;
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
rmSync(file, { force: true });
} else {
writeFileSync(file, `${JSON.stringify(rest, null, 2)}\n`);
}
}
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (containsImpeccableHook(entry)) return null;
@@ -338,7 +384,7 @@ function containsImpeccableHook(value) {
}
function verifyInstallShape() {
const claude = readText('.claude/settings.json');
const claude = readText('.claude/settings.local.json');
const codex = readText('.codex/hooks.json');
const cursor = readText('.cursor/hooks.json');
assertCount(claude, '.claude/skills/impeccable/scripts/hook.mjs', 1, 'Claude hook.mjs');
@@ -464,14 +510,13 @@ function runConfirmedExceptionPersistenceChecks() {
for (const provider of providers) {
runConfirmedExceptionForProvider(provider);
}
record('confirmed exception persistence', true, `${providers.join(', ')} ignored confirmed overused-font values through shared hook.json, not source comments`);
record('confirmed exception persistence', true, `${providers.join(', ')} ignored confirmed overused-font values through shared config.json, not source comments`);
}
function runConfirmedExceptionForProvider(provider) {
clearRuntimeState();
const rel = confirmedSmokeFile(provider);
const file = writeConfirmedFixture(rel);
const configPath = join(targetRepo, '.impeccable', 'hook.json');
const beforeLog = `${provider}-confirmed-before.ndjson`;
const afterLog = `${provider}-confirmed-after.ndjson`;
@@ -480,9 +525,7 @@ function runConfirmedExceptionForProvider(provider) {
const first = runInstalledProviderHook(provider, file, beforeLog);
requireRuleFinding(`${provider} confirmed exception first hook`, `${first.stdout}\n${first.stderr}\n${readMaybe(join(smokeDir, beforeLog))}`, 'overused-font');
if (existsSync(configPath)) {
throw new Error(`${provider} hook wrote .impeccable/hook.json before explicit confirmation`);
}
assertNoSpecificFontIgnoreConfig(provider);
run('node', [
providerAdminScript(provider),
@@ -498,7 +541,7 @@ function runConfirmedExceptionForProvider(provider) {
timeoutMs: 60 * 1000,
});
const config = readJson(configPath);
const config = readSharedHookConfig();
assertSpecificFontIgnoreConfig(provider, config);
clearTransientHookState();
@@ -539,7 +582,6 @@ function runAgentChosenFontExceptionForProvider(provider) {
clearRuntimeState();
const rel = agentChoiceSmokeFile(provider);
const file = writeConfirmedFixture(rel);
const configPath = join(targetRepo, '.impeccable', 'hook.json');
const beforeLog = `${provider}-agent-choice-before.ndjson`;
const afterLog = `${provider}-agent-choice-after.ndjson`;
@@ -548,13 +590,11 @@ function runAgentChosenFontExceptionForProvider(provider) {
const first = runInstalledProviderHook(provider, file, beforeLog);
requireRuleFinding(`${provider} agent-choice first hook`, `${first.stdout}\n${first.stderr}\n${readMaybe(join(smokeDir, beforeLog))}`, 'overused-font');
if (existsSync(configPath)) {
throw new Error(`${provider} hook wrote .impeccable/hook.json before explicit confirmation`);
}
assertNoSpecificFontIgnoreConfig(provider);
runProviderAgentFontException(provider, rel);
const config = readJson(configPath);
const config = readSharedHookConfig();
assertSpecificFontIgnoreConfig(provider, config);
clearTransientHookState();
@@ -648,6 +688,30 @@ function assertSpecificFontIgnoreConfig(provider, config) {
}
}
function assertNoSpecificFontIgnoreConfig(provider) {
const file = join(targetRepo, '.impeccable', 'config.json');
if (!existsSync(file)) return;
const raw = readJson(file);
const config = raw && typeof raw === 'object' && !Array.isArray(raw) && raw.hook && typeof raw.hook === 'object'
? raw.hook
: null;
if (!config) return;
const broad = Array.isArray(config.ignoreRules) && config.ignoreRules.includes('overused-font');
const specific = Array.isArray(config.ignoreValues)
&& config.ignoreValues.some((entry) => entry.rule === 'overused-font' && entry.value === 'roboto');
if (broad || specific) {
throw new Error(`${provider} hook config already suppressed overused-font=roboto before explicit confirmation`);
}
}
function readSharedHookConfig() {
const raw = readJson(join(targetRepo, '.impeccable', 'config.json'));
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !raw.hook || typeof raw.hook !== 'object') {
throw new Error('Missing .impeccable/config.json hook config');
}
return raw.hook;
}
function runInstalledProviderHook(provider, file, logName) {
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, logName) };
if (provider === 'claude') {
@@ -891,7 +955,7 @@ function fontExceptionPrompt(provider, rel) {
return [
`Read the installed Impeccable hooks reference for ${provider}, then persist a confirmed hook exception for Roboto specifically in ${rel}.`,
'The user confirms Roboto is intentional for this fixture, but did not ask to ignore overused fonts generally.',
'Use the /impeccable hooks / hook-admin flow; do not edit .impeccable/hook.json by hand and do not edit the source fixture.',
'Use the /impeccable hooks / hook-admin flow; do not edit .impeccable/config.json by hand and do not edit the source fixture.',
'The final config must use ignoreValues for overused-font=roboto and must not add overused-font to ignoreRules.',
'After updating the config, stop.',
].join(' ');
@@ -959,6 +1023,7 @@ function cleanSmokeFiles() {
}
function clearRuntimeState() {
resetHookConfigForSmoke();
for (const rel of [
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
+1
View File
@@ -41,6 +41,7 @@ export const SUITES = {
'tests/lib/provider-blocks.test.js',
'tests/lib/transformers/provider-blocks.test.js',
'tests/lib/utils.test.js',
'tests/lib/impeccable-config.test.js',
'tests/lib/transformers/factory.test.js',
'tests/lib/transformers/providers.test.js',
'tests/docs-integrity.test.js',
+11 -11
View File
@@ -2,9 +2,9 @@
Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless `IMPECCABLE_HOOK_QUIET=1` is set. Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
This command toggles the hook **per project** by editing `.impeccable/hook.json`. Local-only ignore policy lives in `.impeccable/hook.local.json`, which is gitignored. To disable globally, set `IMPECCABLE_HOOK_DISABLED=1` in your shell environment.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
@@ -17,12 +17,12 @@ The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/hook.json`. |
| `off` | Set `enabled: false` in `.impeccable/hook.json`. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/hook.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/hook.local.json`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
@@ -36,7 +36,7 @@ The first argument is the action. Defaults to `status`.
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `{{command_prefix}}impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/hook.json`; add `--local` only when the user explicitly asks for a private exception.
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
@@ -45,7 +45,7 @@ The hook itself never writes ignore config. Persist an exception only after the
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/hook.json` by default.
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- For `overused-font`, use `ignore-value` when the user confirms a specific font. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
@@ -71,12 +71,12 @@ node {{scripts_path}}/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
## Constraints
- Never modify `.impeccable/hook.json` or `.impeccable/hook.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
## Failure modes
- If `.impeccable/hook.json` or `.impeccable/hook.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, suggest both options: `IMPECCABLE_HOOK_DISABLED=1` env var (one-shot, follows the shell), and `{{command_prefix}}impeccable hooks off` (persistent for this project, committable).
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `{{command_prefix}}impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
+14
View File
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
+284 -10
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
* via .impeccable/hook.json and .impeccable/hook.local.json in the current
* project.
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
* in the current project.
*
* Usage:
* node hook-admin.mjs status # print current state
@@ -35,6 +35,81 @@ import {
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
@@ -45,16 +120,28 @@ function readRawConfigFile(filePath) {
}
}
// The hook settings to edit: the unified file's `hook` subtree.
function readRawConfig(cwd, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
return readRawConfigFile(filePath).raw;
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
return unified.hook;
}
return null;
}
function writeConfig(cwd, config, opts = {}) {
// Write the hook config back under the `hook` key of the unified file, leaving
// any sibling keys (e.g. updateCheck) untouched.
function writeConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
@@ -102,8 +189,8 @@ function statusReport(cwd) {
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/hook.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/hook.local.json';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
@@ -132,7 +219,178 @@ function setEnabled(cwd, value) {
const config = mergeConfig(readRawConfig(cwd));
config.enabled = value;
const target = writeConfig(cwd, config);
return `Design hook ${value ? 'enabled' : 'disabled'} for this project (wrote ${path.relative(cwd, target) || target}).`;
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
@@ -256,7 +514,23 @@ function addIgnoreValue(cwd, args) {
function reset(cwd) {
const removed = [];
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd), getCachePath(cwd), getPendingPath(cwd)]) {
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook subtree and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
const { hook, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
+1
View File
@@ -382,6 +382,7 @@ async function main() {
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
+41 -11
View File
@@ -71,6 +71,8 @@ export const TRUTHY = /^(1|true|yes|on)$/i;
export const DEFAULT_CONFIG = Object.freeze({
enabled: true,
quiet: false,
auditLog: null,
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
@@ -80,7 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
export const HOOK_LOCAL_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
]);
const HOOK_IGNORE_MARKER_OPEN = '# impeccable-hook-ignore-start';
@@ -109,11 +111,11 @@ function safeReadJson(filePath) {
}
export function getConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.json');
return path.join(cwd, '.impeccable', 'config.json');
}
export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.local.json');
return path.join(cwd, '.impeccable', 'config.local.json');
}
export function getCachePath(cwd) {
@@ -133,11 +135,19 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
export function readConfig(cwd) {
const config = cloneDefaultConfig();
applyConfigSource(config, safeReadJson(getConfigPath(cwd)));
applyConfigSource(config, safeReadJson(getLocalConfigPath(cwd)));
// Hook settings live under the `hook` key of config.json (shared) and
// config.local.json (per-developer, gitignored); local wins.
applyConfigSource(config, hookSection(safeReadJson(getConfigPath(cwd))));
applyConfigSource(config, hookSection(safeReadJson(getLocalConfigPath(cwd))));
return config;
}
// The hook settings subtree of a unified config.json / config.local.json.
function hookSection(raw) {
if (!raw || typeof raw !== 'object') return null;
return raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function numberOr(value, fallback) {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
@@ -157,6 +167,12 @@ function applyConfigSource(config, raw) {
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
config.enabled = raw.enabled === false ? false : true;
}
if (Object.prototype.hasOwnProperty.call(raw, 'quiet')) {
config.quiet = raw.quiet === true;
}
if (typeof raw.auditLog === 'string' && raw.auditLog.trim()) {
config.auditLog = raw.auditLog.trim();
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
@@ -861,13 +877,26 @@ export function expandScanTargets(primaryTargets, projectCwd) {
return ordered;
}
export function writeAuditLog(env, entry) {
const target = env?.IMPECCABLE_HOOK_LOG;
export function writeAuditLog(env, entry, cwd = process.cwd()) {
// The event's project root (entry.cwd) when present, else the passed cwd. Both
// config reads and relative log paths resolve against this, since the hook
// process cwd can differ from the project being edited.
const baseCwd = entry && typeof entry.cwd === 'string' && entry.cwd ? entry.cwd : cwd;
// Env wins; otherwise fall back to the unified config's hook.auditLog path.
let target = env?.IMPECCABLE_HOOK_LOG;
if (!target || typeof target !== 'string') {
try { target = readConfig(baseCwd).auditLog; } catch { target = null; }
}
if (!target || typeof target !== 'string') return false;
try {
const expanded = target.startsWith('~/')
? path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2))
: target;
let expanded;
if (target.startsWith('~/')) {
expanded = path.join(process.env.HOME || process.env.USERPROFILE || '.', target.slice(2));
} else if (path.isAbsolute(target)) {
expanded = target;
} else {
expanded = path.resolve(baseCwd, target);
}
fs.mkdirSync(path.dirname(expanded), { recursive: true });
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n';
fs.appendFileSync(expanded, line);
@@ -1010,6 +1039,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
audit.harness = harness;
const projectCwd = event.cwd || cwd;
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
@@ -1149,7 +1179,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
if (truthy(env.IMPECCABLE_HOOK_QUIET)) {
if (truthy(env.IMPECCABLE_HOOK_QUIET) || config.quiet === true) {
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function main() {
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit);
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
+1 -1
View File
@@ -33,7 +33,7 @@ const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
+162 -39
View File
@@ -135,12 +135,14 @@ describe('readConfig()', () => {
it('parses enabled, ignoreRules, ignoreFiles, limits', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.impeccable', 'hook.json'), JSON.stringify({
enabled: false,
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
minSeverity: 'error',
limits: { maxFindings: 2, maxChars: 1000 },
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
hook: {
enabled: false,
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
minSeverity: 'error',
limits: { maxFindings: 2, maxChars: 1000 },
},
}));
const cfg = readConfig(cwd);
assert.equal(cfg.enabled, false);
@@ -154,25 +156,29 @@ describe('readConfig()', () => {
it('merges shared config first and local config second', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
enabled: false,
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
ignoreValues: [
{ rule: 'overused-font', value: 'inter', reason: 'team default' },
],
minSeverity: 'error',
limits: { maxFindings: 2, maxChars: 1000 },
hook: {
enabled: false,
ignoreRules: ['side-tab'],
ignoreFiles: ['src/legacy/**'],
ignoreValues: [
{ rule: 'overused-font', value: 'inter', reason: 'team default' },
],
minSeverity: 'error',
limits: { maxFindings: 2, maxChars: 1000 },
},
}));
fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({
enabled: true,
ignoreRules: ['gradient-text', 'side-tab'],
ignoreFiles: ['src/local/**'],
ignoreValues: [
{ rule: 'overused-font', value: 'Roboto' },
{ rule: 'overused-font', value: 'Inter', reason: 'local override' },
],
minSeverity: 'warning',
limits: { maxFindings: 4 },
hook: {
enabled: true,
ignoreRules: ['gradient-text', 'side-tab'],
ignoreFiles: ['src/local/**'],
ignoreValues: [
{ rule: 'overused-font', value: 'Roboto' },
{ rule: 'overused-font', value: 'Inter', reason: 'local override' },
],
minSeverity: 'warning',
limits: { maxFindings: 4 },
},
}));
const cfg = readConfig(cwd);
@@ -189,7 +195,7 @@ describe('readConfig()', () => {
it('tolerates malformed JSON and falls back to defaults', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.impeccable', 'hook.json'), '{ not json');
fs.writeFileSync(getConfigPath(cwd), '{ not json');
const cfg = readConfig(cwd);
assert.equal(cfg.enabled, true);
});
@@ -197,9 +203,11 @@ describe('readConfig()', () => {
it('ignores malformed local config while preserving valid shared config', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
enabled: false,
ignoreRules: ['side-tab'],
limits: { maxFindings: 3 },
hook: {
enabled: false,
ignoreRules: ['side-tab'],
limits: { maxFindings: 3 },
},
}));
fs.writeFileSync(getLocalConfigPath(cwd), '{ not json');
const cfg = readConfig(cwd);
@@ -207,6 +215,16 @@ describe('readConfig()', () => {
assert.deepEqual(cfg.ignoreRules, ['side-tab']);
assert.equal(cfg.limits.maxFindings, 3);
});
it('parses the new quiet and auditLog fields from the unified config', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
hook: { quiet: true, auditLog: '~/hook.ndjson' },
}));
const cfg = readConfig(cwd);
assert.equal(cfg.quiet, true);
assert.equal(cfg.auditLog, '~/hook.ndjson');
});
});
describe('readCache / persistCache / bumpEditCount', () => {
@@ -258,7 +276,7 @@ describe('ensureHookGitExcludes()', () => {
const exclude = fs.readFileSync(path.join(cwd, '.git', 'info', 'exclude'), 'utf-8');
assert.match(exclude, /\.impeccable\/hook\.cache\.json/);
assert.match(exclude, /\.impeccable\/hook\.pending\.json/);
assert.match(exclude, /\.impeccable\/hook\.local\.json/);
assert.match(exclude, /\.impeccable\/config\.local\.json/);
const second = ensureHookGitExcludes(cwd);
assert.equal(second.changed, false);
@@ -365,7 +383,7 @@ describe('hook-admin.mjs', () => {
const out = runAdmin(['ignore-value', 'overused-font', 'Inter', '--reason', 'User confirmed Inter']);
assert.match(out, /overused-font=inter/);
assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
assert.equal(shared.enabled, true);
assert.deepEqual(shared.ignoreRules, []);
assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [
@@ -377,7 +395,7 @@ describe('hook-admin.mjs', () => {
it('ignore-value --shared remains accepted for shared config', () => {
runAdmin(['ignore-value', 'overused-font', 'Open', 'Sans', '--shared', '--reason', 'Brand font']);
assert.equal(fs.existsSync(getLocalConfigPath(cwd)), false);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
assert.deepEqual(shared.ignoreValues.map(({ rule, value, reason }) => ({ rule, value, reason })), [
{ rule: 'overused-font', value: 'open sans', reason: 'Brand font' },
]);
@@ -387,16 +405,69 @@ describe('hook-admin.mjs', () => {
runAdmin(['ignore-value', 'overused-font', 'Inter', '--local']);
runAdmin(['ignore-value', 'OVERUSED-FONT', '"Inter"', '--local', '--reason', 'Still intentional']);
assert.equal(fs.existsSync(getConfigPath(cwd)), false);
const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8'));
const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook;
assert.equal(local.enabled, undefined, 'local ignore should not override shared enabled state');
assert.equal(local.ignoreValues.length, 1);
assert.equal(local.ignoreValues[0].reason, 'Still intentional');
const status = runAdmin(['status']);
assert.match(status, /local file:\s+\.impeccable\/hook\.local\.json/);
assert.match(status, /local file:\s+\.impeccable\/config\.local\.json/);
assert.match(status, /ignoreValues:\s+overused-font=inter/);
});
it('a /impeccable hooks edit preserves sibling hook fields (consent, quiet)', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
// A recorded per-developer consent in the local file...
fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { consent: 'declined' } }));
runAdmin(['ignore-value', 'overused-font', 'Inter', '--local']);
const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook;
assert.equal(local.consent, 'declined', 'consent must survive a local ignore-value edit');
assert.equal(local.ignoreValues.length, 1);
// ...and a shared quiet flag survives an on/off toggle.
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true } }));
runAdmin(['off']);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
assert.equal(shared.enabled, false);
assert.equal(shared.quiet, true, 'quiet must survive an enable/disable toggle');
});
it('hooks on accepts declined consent and installs missing provider manifests', () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getLocalConfigPath(cwd), JSON.stringify({ hook: { consent: 'declined', quiet: true } }));
for (const provider of ['.claude', '.agents', '.cursor']) {
fs.mkdirSync(path.join(cwd, provider, 'skills', 'impeccable', 'scripts'), { recursive: true });
}
fs.mkdirSync(path.join(cwd, '.claude'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.claude', 'settings.local.json'), JSON.stringify({
hooks: {
PostToolUse: [
{ matcher: 'OtherTool', hooks: [{ type: 'command', command: 'node "./local-hook.mjs"' }] },
{ matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] },
],
},
}));
const out = runAdmin(['on']);
assert.match(out, /Recorded local hook consent/);
assert.match(out, /Installed or repaired hook manifests for: \.claude, \.agents, \.cursor/);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
assert.equal(shared.enabled, true);
const local = JSON.parse(fs.readFileSync(getLocalConfigPath(cwd), 'utf-8')).hook;
assert.equal(local.consent, 'accepted');
assert.equal(local.quiet, true, 'unrelated local hook fields survive consent repair');
const claude = fs.readFileSync(path.join(cwd, '.claude', 'settings.local.json'), 'utf-8');
assert.match(claude, /local-hook\.mjs/);
assert.equal(claude.split('skills/impeccable/scripts/hook.mjs').length - 1, 1);
const codex = fs.readFileSync(path.join(cwd, '.codex', 'hooks.json'), 'utf-8');
assert.match(codex, /\.agents\/skills\/impeccable\/scripts\/hook\.mjs/);
const cursor = fs.readFileSync(path.join(cwd, '.cursor', 'hooks.json'), 'utf-8');
assert.match(cursor, /\.cursor\/skills\/impeccable\/scripts\/hook-before-edit\.mjs/);
});
it('ignore-rule overused-font requires explicit broad suppression', () => {
assert.throws(
() => runAdmin(['ignore-rule', 'overused-font']),
@@ -408,14 +479,14 @@ describe('hook-admin.mjs', () => {
it('ignore-rule overused-font --all-values writes a whole-rule suppression', () => {
const out = runAdmin(['ignore-rule', 'overused-font', '--all-values', '--reason', 'User asked to ignore overused fonts generally']);
assert.match(out, /Added "overused-font" to ignoreRules/);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
assert.deepEqual(shared.ignoreRules, ['overused-font']);
assert.deepEqual(shared.ignoreValues, []);
});
it('ignore-rule still allows non-value rules without --all-values', () => {
runAdmin(['ignore-rule', 'side-tab']);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
assert.deepEqual(shared.ignoreRules, ['side-tab']);
});
@@ -433,7 +504,7 @@ describe('hook-admin.mjs', () => {
runAdmin(['ignore-file', 'src/ConfirmedCard.html']);
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8'));
const shared = JSON.parse(fs.readFileSync(getConfigPath(cwd), 'utf-8')).hook;
assert.deepEqual(shared.ignoreFiles, ['src/ConfirmedCard.html']);
const r = await runHook({
@@ -543,7 +614,47 @@ describe('writeAuditLog()', () => {
});
it('is a no-op when IMPECCABLE_HOOK_LOG is unset', () => {
assert.equal(writeAuditLog({}, { event: 'x' }), false);
assert.equal(writeAuditLog({}, { event: 'x' }, cwd), false);
});
it('falls back to the unified config hook.auditLog when the env var is unset', () => {
const log = path.join(cwd, 'from-config.ndjson');
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { auditLog: log } }));
assert.equal(writeAuditLog({}, { event: 'PostToolUse' }, cwd), true);
assert.equal(fs.readFileSync(log, 'utf-8').trim().split('\n').length, 1);
});
it('prefers the env var over config hook.auditLog', () => {
const envLog = path.join(cwd, 'from-env.ndjson');
const cfgLog = path.join(cwd, 'from-config.ndjson');
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { auditLog: cfgLog } }));
writeAuditLog({ IMPECCABLE_HOOK_LOG: envLog }, { event: 'PostToolUse' }, cwd);
assert.equal(fs.existsSync(envLog), true);
assert.equal(fs.existsSync(cfgLog), false);
});
it('resolves config auditLog from entry.cwd (the event project root), not the fallback cwd', () => {
const projectDir = path.join(cwd, 'project');
const log = path.join(cwd, 'event-cwd.ndjson');
fs.mkdirSync(path.join(projectDir, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(projectDir, '.impeccable', 'config.json'),
JSON.stringify({ hook: { auditLog: log } }));
// The fallback cwd (root) has no config; entry.cwd points at the project.
assert.equal(writeAuditLog({}, { event: 'PostToolUse', cwd: projectDir }, cwd), true);
assert.equal(fs.existsSync(log), true);
});
it('resolves a relative auditLog path against the project root, not the process cwd', () => {
const projectDir = path.join(cwd, 'project');
fs.mkdirSync(path.join(projectDir, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(projectDir, '.impeccable', 'config.json'),
JSON.stringify({ hook: { auditLog: 'logs/hook.ndjson' } }));
assert.equal(writeAuditLog({}, { event: 'PostToolUse', cwd: projectDir }, cwd), true);
// Written under the project root, not the fallback cwd.
assert.equal(fs.existsSync(path.join(projectDir, 'logs', 'hook.ndjson')), true);
assert.equal(fs.existsSync(path.join(cwd, 'logs', 'hook.ndjson')), false);
});
});
@@ -686,6 +797,18 @@ describe('runHook()', () => {
assert.equal(rFindings.audit.emitted, true);
});
it('config quiet:true suppresses the clean ack like the env switch', async () => {
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { quiet: true } }));
const file = writeFixture('src/Quiet.tsx', 'noop');
const r = await runHook({
stdinJson: JSON.stringify(eventFor(file)),
env: {}, cwd, detector: fakeDetector([]),
});
assert.equal(r.stdout, '');
assert.equal(r.audit.quiet, true);
});
it('re-entrancy guard short-circuits when IMPECCABLE_HOOK_DEPTH is set', async () => {
const file = writeFixture('src/Card.tsx', 'noop');
const det = fakeDetector([finding('side-tab', 1)]);
@@ -730,7 +853,7 @@ describe('runHook()', () => {
it('config-disabled silences cleanly', async () => {
const file = writeFixture('src/Card.tsx', 'noop');
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.impeccable', 'hook.json'), JSON.stringify({ enabled: false }));
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { enabled: false } }));
const det = fakeDetector([finding('side-tab', 1)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
assert.equal(r.stdout, '');
@@ -771,8 +894,8 @@ describe('runHook()', () => {
it('config ignoreFiles glob suppresses', async () => {
const file = writeFixture('src/legacy/Foo.tsx', 'noop');
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.impeccable', 'hook.json'), JSON.stringify({
ignoreFiles: ['src/legacy/**'],
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
hook: { ignoreFiles: ['src/legacy/**'] },
}));
const det = fakeDetector([finding('side-tab', 1)]);
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
+67
View File
@@ -0,0 +1,67 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import {
getHookConsent,
setHookConsent,
getLocalConfigPath,
getConfigPath,
ensureConfigGitExclude,
} from '../../cli/lib/impeccable-config.mjs';
describe('cli/lib/impeccable-config', () => {
let root;
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'imp-cfg-')); });
afterEach(() => rmSync(root, { recursive: true, force: true }));
test('getHookConsent is undefined until a decision is recorded, then round-trips', () => {
expect(getHookConsent(root)).toBeUndefined();
setHookConsent(root, 'declined');
expect(getHookConsent(root)).toBe('declined');
setHookConsent(root, 'accepted');
expect(getHookConsent(root)).toBe('accepted');
});
test('setHookConsent preserves unrelated keys in config.local.json', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getLocalConfigPath(root), JSON.stringify({ updateCheck: false, hook: { quiet: true } }));
setHookConsent(root, 'declined');
const raw = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8'));
expect(raw.updateCheck).toBe(false);
expect(raw.hook.quiet).toBe(true);
expect(raw.hook.consent).toBe('declined');
});
test('config.local.json (per-developer) overrides config.json for consent', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getConfigPath(root), JSON.stringify({ hook: { consent: 'accepted' } }));
writeFileSync(getLocalConfigPath(root), JSON.stringify({ hook: { consent: 'declined' } }));
expect(getHookConsent(root)).toBe('declined');
});
test('malformed config is tolerated (no throw, undefined consent)', () => {
mkdirSync(join(root, '.impeccable'), { recursive: true });
writeFileSync(getLocalConfigPath(root), '{ not json');
expect(getHookConsent(root)).toBeUndefined();
});
test('writing consent gitignores config.local.json via .git/info/exclude', () => {
execFileSync('git', ['init', '-q'], { cwd: root });
setHookConsent(root, 'declined');
const exclude = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
expect(exclude).toContain('.impeccable/config.local.json');
// Idempotent: a second write does not duplicate the marker block.
ensureConfigGitExclude(root);
const again = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
expect((again.match(/impeccable-config-ignore-start/g) || []).length).toBe(1);
// It uses .git/info/exclude, not a tracked .gitignore.
expect(existsSync(join(root, '.gitignore'))).toBe(false);
});
test('ensureConfigGitExclude is a no-op outside a git repo', () => {
expect(ensureConfigGitExclude(root)).toBe(false);
});
});
+2 -2
View File
@@ -100,14 +100,14 @@ it('gitignores local Impeccable runtime artifacts', () => {
'.impeccable/live/manual-edit-evidence/example.json',
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/hook.local.json',
'.impeccable/config.local.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
], { cwd: REPO_ROOT, encoding: 'utf-8' });
assert.match(ignored, /\.impeccable\/live\/manual-edit-apply-transaction\.json/);
assert.match(ignored, /\.impeccable\/live\/manual-edit-evidence\/example\.json/);
assert.match(ignored, /\.impeccable\/hook\.cache\.json/);
assert.match(ignored, /\.impeccable\/hook\.pending\.json/);
assert.match(ignored, /\.impeccable\/hook\.local\.json/);
assert.match(ignored, /\.impeccable\/config\.local\.json/);
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
});
+64
View File
@@ -17,6 +17,7 @@ import { tmpdir } from 'os';
import {
copyProviderHooks,
copyProviderSkills,
decideHookInstall,
expectedHookDests,
mergeHookManifests,
migrateUnprefixImpeccable,
@@ -529,6 +530,69 @@ describe('skills install/update: local universal bundle e2e', () => {
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('recorded consent "declined" skips the hook (no prompt, no --no-hooks needed)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-declined-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
writeFileSync(join(tmp, '.impeccable', 'config.local.json'),
JSON.stringify({ hook: { consent: 'declined' } }));
const output = run('skills install -y --providers=claude', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Installed impeccable into: .claude');
expect(output).not.toContain('Installed hooks into');
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('recorded consent "accepted" installs the hook even non-interactively', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-accepted-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
writeFileSync(join(tmp, '.impeccable', 'config.local.json'),
JSON.stringify({ hook: { consent: 'accepted' } }));
const output = run('skills install -y --providers=claude', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Installed hooks into: .claude');
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('--no-hooks records no consent decision (one-off skip)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-nohooks-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
run('skills install -y --providers=claude --no-hooks', {
cwd: tmp,
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(existsSync(join(tmp, '.impeccable', 'config.local.json'))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('does not opt into hooks when no provider targets are installed', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-no-targets-'));
execSync('git init', { cwd: tmp });
const wantHooks = await decideHookInstall(tmp, [], { yes: true });
expect(wantHooks).toBe(false);
expect(existsSync(join(tmp, '.impeccable', 'config.local.json'))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
}, 15000);
test('--no-hooks installs skills without hook manifests', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-no-hooks-'));
execSync('git init', { cwd: tmp });