mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
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:
co-authored by
Claude Opus 4.8
parent
9c0012d4e1
commit
8cf2be110d
+41
-11
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user