mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
feat(hooks): package design hook in plugin, install to settings.local.json (#243)
* feat(hooks): package design hook in plugin, install to settings.local.json
Three related changes to how the Impeccable design hook is distributed,
plus an unrelated build fix discovered along the way.
Package the hook in the Claude Code plugin
- The marketplace / `/plugin install` path previously shipped the skill and
agents but no hook, so those users never got the design detector. The build
now emits `plugin/hooks/hooks.json` (auto-discovered at the plugin root),
resolving the script via `${CLAUDE_PLUGIN_ROOT}` so it works wherever Claude
Code unpacks the plugin instead of assuming a `.claude/skills/` layout.
CLI installs the hook into settings.local.json, not shared settings.json
- `npx impeccable skills install/update` now writes the Claude hook to the
gitignored `.claude/settings.local.json` (a machine-local install side
effect) rather than the team-shared `settings.json`, which could otherwise
be committed and break for teammates without the skill installed.
- Graceful handling (leave-it-never-duplicate): if our hook already lives in
the shared `settings.json` (a legacy install or a deliberate user move), it
is honored in place and never duplicated into the local override, which
would otherwise run the detector twice per edit.
- The skill's `/impeccable hooks on|off` toggle is unaffected: it only writes
`.impeccable/hook.json`, never the settings files.
Fix universal.zip build failure under archiver v8
- `archiver` was bumped to v8 (now ESM, factory function removed) but
`scripts/lib/zip.js` still used the old `archiver('zip', ...)` API, so every
build silently failed to produce `dist/universal.zip` (the skill-release
artifact). Switched to `new ZipArchive({...})`.
Also folds in a pre-existing local rename of the hook status message
("Scanning design" -> "Checking UI changes") and its regenerated provider
output.
Tests: new coverage for the plugin-packaged hook manifest and the
shared-settings honor-in-place path; existing CLI assertions moved to
settings.local.json. Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): detect hook by marker, not file existence (Bugbot)
hookInstalledForProvider treated any existing settings.local.json (or
hooks.json) as proof the hook was installed. Those files commonly hold
unrelated local settings, so the already-installed `skills install` path
would skip repairing a genuinely missing hook that `update` would add.
Detect the Impeccable marker in the file instead of mere existence. Adds a
test for the exact case: a settings.local.json with only permissions still
triggers hook repair and preserves the unrelated content.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(build): fail loud on a broken release zip + cover the zip writer
Close the gap that let the archiver v8 break ship a 0-byte universal.zip
with a green test suite:
- createProviderZip no longer swallows failures. It throws on a missing
source, an archive with zero entries, or a 0-byte output, and build() now
exits non-zero on any such rejection. A build that can't produce its release
artifact fails instead of deploying an empty bundle.
- New tests/zip.test.mjs exercises the real zip writer and round-trips through
extract-zip (the unpacker the CLI uses): a valid bundle unpacks to the skill
tree, and the empty/missing-source cases throw. Wired into the core suite so
it runs in `bun run test`.
Why this matters: the prior CLI e2e tests stub the bundle as a local
directory, so they never built, downloaded, or unzipped a real archive. The
zip writer had no coverage and failed soft, so Dependabot's archiver 7->8
major bump merged green and the deploy shipped an unusable bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): scope hook marker scan to the hooks subtree + prune local dupes (Bugbot)
Two follow-ups from Bugbot:
- fileHasImpeccableHookMarker scanned the whole settings file as raw text, so
an unrelated string (e.g. a permissions allow entry that mentions the hook
path) could falsely read as an installed hook and block install/repair or
the shared-settings skip. Now it parses the JSON and scans only the `hooks`
subtree.
- When the hook is honored in the shared settings.json, copyProviderHooks
skipped the local write but left a stale hook in settings.local.json from an
earlier machine-local install, so Claude Code loaded both and ran the
detector twice per edit. It now prunes the local copy (preserving unrelated
local settings, dropping the file if only our scaffolding remained).
Adds tests for both: a permissions string mentioning the hook path still
triggers repair, and a shared hook prunes the stale local duplicate while
keeping unrelated permissions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
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
0ec64aad1b
commit
9c0012d4e1
+133
-7
@@ -137,7 +137,13 @@ describe('skills install: already-installed detection', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeSkills(tmp);
|
||||
writeFileSync(join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: {} }));
|
||||
// Seed the canonical hook target so the already-installed path sees the hook
|
||||
// wired up and doesn't try to repair it (which would need the bundle).
|
||||
writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({
|
||||
hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
|
||||
{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' },
|
||||
] }] },
|
||||
}));
|
||||
|
||||
const output = run('skills install -y', { cwd: tmp });
|
||||
expect(output).toContain('already installed');
|
||||
@@ -152,7 +158,12 @@ describe('skills install: already-installed detection', () => {
|
||||
const skillDir = join(tmp, '.cursor', 'skills', 'i-impeccable');
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: i-impeccable\n---\n');
|
||||
writeFileSync(join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ version: 1, hooks: {} }));
|
||||
// Seed the hook so the already-installed path sees it wired up and doesn't
|
||||
// try to repair it (which would need the bundle).
|
||||
writeFileSync(join(tmp, '.cursor', 'hooks.json'), JSON.stringify({
|
||||
version: 1,
|
||||
hooks: { preToolUse: [{ command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"' }] },
|
||||
}));
|
||||
|
||||
const output = run('skills install -y', { cwd: tmp });
|
||||
expect(output).toContain('already installed');
|
||||
@@ -173,7 +184,7 @@ describe('skills install: already-installed detection', () => {
|
||||
|
||||
expect(output).toContain('already installed');
|
||||
expect(output).toContain('Installed hooks into: .claude');
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
@@ -191,7 +202,56 @@ describe('skills install: already-installed detection', () => {
|
||||
|
||||
expect(output).toContain('already installed');
|
||||
expect(output).not.toContain('Installed hooks into');
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(false);
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('repairs the hook when settings.local.json exists without the Impeccable marker', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-repair-unrelated-local-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeSkills(tmp, ['impeccable'], ['.claude']);
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
// A local settings file that exists for unrelated reasons (e.g. permissions)
|
||||
// must not be mistaken for an installed hook.
|
||||
writeFileSync(join(tmp, '.claude', 'settings.local.json'),
|
||||
JSON.stringify({ permissions: { allow: ['Bash(ls:*)'] } }, null, 2));
|
||||
|
||||
const output = run('skills install -y --providers=claude', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
|
||||
expect(output).toContain('already installed');
|
||||
expect(output).toContain('Installed hooks into: .claude');
|
||||
// The hook is merged in, and the unrelated local settings are preserved.
|
||||
const merged = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8'));
|
||||
expect(JSON.stringify(merged)).toContain('skills/impeccable/scripts/hook.mjs');
|
||||
expect(merged.permissions.allow).toContain('Bash(ls:*)');
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('a permissions entry mentioning the hook path is not mistaken for an installed hook', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-marker-falsepos-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
createFakeSkills(tmp, ['impeccable'], ['.claude']);
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
// The hook path appears only inside a permissions string, not a hooks entry.
|
||||
writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({
|
||||
permissions: { allow: ['Bash(node .claude/skills/impeccable/scripts/hook.mjs:*)'] },
|
||||
}, null, 2));
|
||||
|
||||
const output = run('skills install -y --providers=claude', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
|
||||
// Detected as missing -> repaired, with the real hook added under hooks.
|
||||
expect(output).toContain('Installed hooks into: .claude');
|
||||
const merged = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8'));
|
||||
expect(merged.hooks.PostToolUse).toBeDefined();
|
||||
expect(merged.permissions.allow[0]).toContain('skills/impeccable/scripts/hook.mjs');
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
@@ -396,13 +456,79 @@ describe('skills install/update: local universal bundle e2e', () => {
|
||||
expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toContain(`Local deterministic bundle for ${provider}.`);
|
||||
expect(existsSync(join(skillDir, 'scripts', 'context.mjs'))).toBe(true);
|
||||
}
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.cursor', 'hooks.json'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.codex', 'hooks.json'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('honors an existing hook in shared settings.json and never duplicates into settings.local.json', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-shared-hook-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
|
||||
// Simulate a user who moved (or whose legacy install left) the hook in the
|
||||
// team-shared settings.json.
|
||||
mkdirSync(join(tmp, '.claude'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.claude', 'settings.json'), JSON.stringify({
|
||||
hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
|
||||
{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' },
|
||||
] }] },
|
||||
}, null, 2));
|
||||
|
||||
const output = run('skills install -y --providers=claude', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
expect(output).toContain('Done!');
|
||||
// The hook is honored in place: no local override is written, and the
|
||||
// shared file is left exactly as the user had it (one hook, no dupes).
|
||||
expect(output).not.toContain('Installed hooks into');
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false);
|
||||
const shared = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.json'), 'utf8'));
|
||||
expect(shared.hooks.PostToolUse).toHaveLength(1);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('prunes a stale local hook when the shared settings.json owns the hook', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-dedupe-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
mkdirSync(join(tmp, '.claude'), { recursive: true });
|
||||
|
||||
// The team added the hook to shared settings.json...
|
||||
writeFileSync(join(tmp, '.claude', 'settings.json'), JSON.stringify({
|
||||
hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
|
||||
{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' },
|
||||
] }] },
|
||||
}, null, 2));
|
||||
// ...while a machine-local install already wrote the hook here, alongside
|
||||
// unrelated local settings that must survive.
|
||||
writeFileSync(join(tmp, '.claude', 'settings.local.json'), JSON.stringify({
|
||||
permissions: { allow: ['Bash(ls:*)'] },
|
||||
hooks: { PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
|
||||
{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' },
|
||||
] }] },
|
||||
}, null, 2));
|
||||
|
||||
run('skills install -y --providers=claude', {
|
||||
cwd: tmp,
|
||||
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
||||
});
|
||||
|
||||
// Local duplicate is pruned (no hook left), unrelated settings preserved;
|
||||
// the shared file still owns the single hook.
|
||||
const local = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8'));
|
||||
expect(local.hooks).toBeUndefined();
|
||||
expect(local.permissions.allow).toContain('Bash(ls:*)');
|
||||
const shared = JSON.parse(readFileSync(join(tmp, '.claude', 'settings.json'), 'utf8'));
|
||||
expect(shared.hooks.PostToolUse).toHaveLength(1);
|
||||
|
||||
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 });
|
||||
@@ -417,7 +543,7 @@ describe('skills install/update: local universal bundle e2e', () => {
|
||||
for (const provider of ['.claude', '.agents', '.cursor']) {
|
||||
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
}
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(false);
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(false);
|
||||
expect(existsSync(join(tmp, '.cursor', 'hooks.json'))).toBe(false);
|
||||
expect(existsSync(join(tmp, '.codex', 'hooks.json'))).toBe(false);
|
||||
|
||||
@@ -443,7 +569,7 @@ describe('skills install/update: local universal bundle e2e', () => {
|
||||
expect(content).not.toContain('stale: true');
|
||||
expect(content).toContain('version: 9.9.9-local');
|
||||
expect(existsSync(join(skillDir, 'scripts', 'context.mjs'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.json'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
Reference in New Issue
Block a user