mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
* 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>
120 lines
4.1 KiB
JavaScript
120 lines
4.1 KiB
JavaScript
/**
|
|
* 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, '\\$&');
|
|
}
|