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
+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 });
}