mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-20 10:06:54 +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 { tmpdir } from 'os';
|
||||
import {
|
||||
copyProviderHooks,
|
||||
copyProviderSkills,
|
||||
decideHookInstall,
|
||||
expectedHookDests,
|
||||
mergeHookManifests,
|
||||
migrateUnprefixImpeccable,
|
||||
@@ -529,6 +530,69 @@ describe('skills install/update: local universal bundle e2e', () => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('recorded consent "declined" skips the hook (no prompt, no --no-hooks needed)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-declined-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.impeccable', 'config.local.json'),
|
||||
JSON.stringify({ hook: { consent: 'declined' } }));
|
||||
|
||||
const output = run('skills install -y --providers=claude', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
expect(output).toContain('Installed impeccable into: .claude');
|
||||
expect(output).not.toContain('Installed hooks into');
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('recorded consent "accepted" installs the hook even non-interactively', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-accepted-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
mkdirSync(join(tmp, '.impeccable'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.impeccable', 'config.local.json'),
|
||||
JSON.stringify({ hook: { consent: 'accepted' } }));
|
||||
|
||||
const output = run('skills install -y --providers=claude', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
expect(output).toContain('Installed hooks into: .claude');
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('--no-hooks records no consent decision (one-off skip)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-nohooks-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
|
||||
run('skills install -y --providers=claude --no-hooks', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
expect(existsSync(join(tmp, '.impeccable', 'config.local.json'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('does not opt into hooks when no provider targets are installed', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-consent-no-targets-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
|
||||
const wantHooks = await decideHookInstall(tmp, [], { yes: true });
|
||||
|
||||
expect(wantHooks).toBe(false);
|
||||
expect(existsSync(join(tmp, '.impeccable', 'config.local.json'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('--no-hooks installs skills without hook manifests', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-no-hooks-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
|
||||
Reference in New Issue
Block a user