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 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>
68 lines
2.9 KiB
JavaScript
68 lines
2.9 KiB
JavaScript
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
import {
|
|
getHookConsent,
|
|
setHookConsent,
|
|
getLocalConfigPath,
|
|
getConfigPath,
|
|
ensureConfigGitExclude,
|
|
} from '../../cli/lib/impeccable-config.mjs';
|
|
|
|
describe('cli/lib/impeccable-config', () => {
|
|
let root;
|
|
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'imp-cfg-')); });
|
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
|
|
test('getHookConsent is undefined until a decision is recorded, then round-trips', () => {
|
|
expect(getHookConsent(root)).toBeUndefined();
|
|
setHookConsent(root, 'declined');
|
|
expect(getHookConsent(root)).toBe('declined');
|
|
setHookConsent(root, 'accepted');
|
|
expect(getHookConsent(root)).toBe('accepted');
|
|
});
|
|
|
|
test('setHookConsent preserves unrelated keys in config.local.json', () => {
|
|
mkdirSync(join(root, '.impeccable'), { recursive: true });
|
|
writeFileSync(getLocalConfigPath(root), JSON.stringify({ updateCheck: false, hook: { quiet: true } }));
|
|
setHookConsent(root, 'declined');
|
|
const raw = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8'));
|
|
expect(raw.updateCheck).toBe(false);
|
|
expect(raw.hook.quiet).toBe(true);
|
|
expect(raw.hook.consent).toBe('declined');
|
|
});
|
|
|
|
test('config.local.json (per-developer) overrides config.json for consent', () => {
|
|
mkdirSync(join(root, '.impeccable'), { recursive: true });
|
|
writeFileSync(getConfigPath(root), JSON.stringify({ hook: { consent: 'accepted' } }));
|
|
writeFileSync(getLocalConfigPath(root), JSON.stringify({ hook: { consent: 'declined' } }));
|
|
expect(getHookConsent(root)).toBe('declined');
|
|
});
|
|
|
|
test('malformed config is tolerated (no throw, undefined consent)', () => {
|
|
mkdirSync(join(root, '.impeccable'), { recursive: true });
|
|
writeFileSync(getLocalConfigPath(root), '{ not json');
|
|
expect(getHookConsent(root)).toBeUndefined();
|
|
});
|
|
|
|
test('writing consent gitignores config.local.json via .git/info/exclude', () => {
|
|
execFileSync('git', ['init', '-q'], { cwd: root });
|
|
setHookConsent(root, 'declined');
|
|
const exclude = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
|
|
expect(exclude).toContain('.impeccable/config.local.json');
|
|
// Idempotent: a second write does not duplicate the marker block.
|
|
ensureConfigGitExclude(root);
|
|
const again = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8');
|
|
expect((again.match(/impeccable-config-ignore-start/g) || []).length).toBe(1);
|
|
// It uses .git/info/exclude, not a tracked .gitignore.
|
|
expect(existsSync(join(root, '.gitignore'))).toBe(false);
|
|
});
|
|
|
|
test('ensureConfigGitExclude is a no-op outside a git repo', () => {
|
|
expect(ensureConfigGitExclude(root)).toBe(false);
|
|
});
|
|
});
|