mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-16 08:06:24 +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
@@ -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,
|
||||
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
Reference in New Issue
Block a user