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
+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, '\\$&');
}