mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +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,
|
||||
|
||||
Reference in New Issue
Block a user