mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
* Fix: use argv exec and single-quote escaping for the four #476 shell-injection sites JSON.stringify and raw double-quote interpolation were used as shell quoting, but /bin/sh still expands $(...), backticks, and ${} inside double quotes. - is-generated.mjs / live.mjs runScript: switch execSync string commands to execFileSync argv form, which never invokes a shell. Closes the remote path where a source file named `$(...)` executes during the live-mode walk. - skills.mjs hook command + hook-lib.mjs ignore-value suggestion: values that must stay shell strings now use POSIX single-quote escaping instead of JSON/double quotes. The doctor's hook-token parser learns the single-quoted absolute form so it keeps verifying user-level installs. Adds regression tests for the single-quoted absolute hook form and the single-quoted ignore-value suggestion. Verified end to end in a browser through a real live-mode wrap walk against a hostile-named source file. Prepared with AI assistance (Cursor) under maintainer instruction. Co-authored-by: Cursor <cursoragent@cursor.com> * Test: lock in POSIX single-quoting for a $(...) absolute install path (#476) Follow-up from security review: prove an install path embedding $(...) is single-quoted in the written hook manifest, not double-quoted. Prepared with AI assistance (Cursor) under maintainer instruction. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix: quote ignore-command args per platform so Windows cmd.exe keeps spaces (#533) Greptile flagged that switching quoteCommandArg to POSIX single quotes fixed $(...) injection on /bin/sh but regressed Windows cmd.exe, where single quotes are literal, so a --file path containing spaces was split and the ignore scope was stored malformed. The suggested command runs on the same machine the hook fired on, so branch on process.platform (the pattern skills.mjs already uses): single-quote on POSIX for the #476 fix, and keep the original double-quote escaping on Windows so that path's behavior is unchanged. Adds a regression test asserting both forms. Prepared with AI assistance (Cursor) under maintainer instruction. Co-authored-by: Cursor <cursoragent@cursor.com> * Test: prove the POSIX hook guard is inert under /bin/sh and Windows keeps double quotes (#533) Greptile's probe could not reach the generated manifest, leaving the hook command contract unverified. Convert that into committed proof: - POSIX: install with a $(touch pwned) absolute path, then actually execute the generated guard under /bin/sh from a clean cwd and assert no marker file appears and the guard exits 0 (single-quoted substitution stays inert). - Windows: drive copyProviderHooks as win32 in-process and assert the command keeps the double-quoted absolute path (usable when the install path has spaces; $(...) is inert on cmd.exe anyway). Test-only; source quoting is unchanged. Prepared with AI assistance (Cursor) under maintainer instruction. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1943 lines
90 KiB
JavaScript
1943 lines
90 KiB
JavaScript
/**
|
|
* End-to-end tests for `impeccable skills` subcommands.
|
|
*
|
|
* Creates real temp directories, runs the CLI, and verifies results.
|
|
*
|
|
* Deterministic install/update coverage uses a local universal bundle override
|
|
* and runs in the default suite. Remote smoke blocks that download the
|
|
* production universal bundle use `describeRemote` and run only under
|
|
* `bun run test:cli-remote-e2e` (IMPECCABLE_CLI_REMOTE_E2E=1), skipping
|
|
* gracefully when impeccable.style is unreachable.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { execSync, execFileSync } from 'child_process';
|
|
import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import {
|
|
copyProviderAgents,
|
|
copyProviderHooks,
|
|
copyProviderSkills,
|
|
decideHookInstall,
|
|
expectedHookDests,
|
|
formatInstallDetectionLines,
|
|
mergeHookManifests,
|
|
migrateUnprefixImpeccable,
|
|
resolveInstallTargets,
|
|
} from '../cli/bin/commands/skills.mjs';
|
|
|
|
const CLI = join(import.meta.dir, '..', 'cli', 'bin', 'cli.js');
|
|
|
|
function run(args, opts = {}) {
|
|
return execSync(`node ${CLI} ${args}`, {
|
|
encoding: 'utf8',
|
|
timeout: 60000,
|
|
...opts,
|
|
});
|
|
}
|
|
|
|
/** Create a fake skill installation in a temp dir */
|
|
function createFakeSkills(root, skills = ['audit', 'polish', 'impeccable'], providers = ['.claude']) {
|
|
for (const provider of providers) {
|
|
for (const skill of skills) {
|
|
const skillDir = join(root, provider, 'skills', skill);
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), [
|
|
'---',
|
|
`name: ${skill}`,
|
|
'user-invocable: true',
|
|
'---',
|
|
'',
|
|
'Run /audit first, then /polish to finish.',
|
|
'Use the impeccable skill for setup.',
|
|
].join('\n'));
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Write one fake skill dir with a SKILL.md naming itself. */
|
|
function writeSkill(root, provider, name) {
|
|
const dir = join(root, provider, 'skills', name);
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\n---\nRun /${name}.\n`);
|
|
}
|
|
|
|
function createFakeLinkSource(root, providers = ['.claude']) {
|
|
for (const provider of providers) {
|
|
writeSkill(join(root, '.impeccable', 'dist', 'universal'), provider, 'impeccable');
|
|
}
|
|
}
|
|
|
|
function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cursor']) {
|
|
const bundleRoot = join(root, 'universal-bundle');
|
|
for (const provider of providers) {
|
|
const skillDir = join(bundleRoot, provider, 'skills', 'impeccable');
|
|
mkdirSync(join(skillDir, 'scripts'), { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), [
|
|
'---',
|
|
'name: impeccable',
|
|
'version: 9.9.9-local',
|
|
'---',
|
|
'',
|
|
`Local deterministic bundle for ${provider}.`,
|
|
].join('\n'));
|
|
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("local bundle context");\n');
|
|
}
|
|
if (providers.includes('.claude')) {
|
|
mkdirSync(join(bundleRoot, '.claude'), { recursive: true });
|
|
writeFileSync(join(bundleRoot, '.claude', 'settings.json'), JSON.stringify({
|
|
description: 'fresh claude hook',
|
|
hooks: { PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }] },
|
|
}, null, 2));
|
|
}
|
|
if (providers.includes('.cursor')) {
|
|
mkdirSync(join(bundleRoot, '.cursor'), { recursive: true });
|
|
writeFileSync(join(bundleRoot, '.cursor', 'hooks.json'), JSON.stringify({
|
|
version: 1,
|
|
hooks: { preToolUse: [{ command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"' }] },
|
|
}, null, 2));
|
|
}
|
|
if (providers.includes('.agents')) {
|
|
mkdirSync(join(bundleRoot, '.codex'), { recursive: true });
|
|
// Mirror production: the Codex bundle's `.codex/hooks.json` targets its own
|
|
// `.codex/skills` payload. The CLI installs the skill at `.agents/skills`, so
|
|
// the installer must rewrite this command to `.agents/skills` (see below).
|
|
writeFileSync(join(bundleRoot, '.codex', 'hooks.json'), JSON.stringify({
|
|
hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".codex/skills/impeccable/scripts/hook.mjs"' }] }] },
|
|
}, null, 2));
|
|
}
|
|
// Native subagent definitions, mirroring the build's provider agents output.
|
|
if (providers.includes('.github')) {
|
|
mkdirSync(join(bundleRoot, '.github', 'agents'), { recursive: true });
|
|
writeFileSync(join(bundleRoot, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'),
|
|
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\n---\nCopilot reviewer body.\n');
|
|
writeFileSync(join(bundleRoot, '.github', 'agents', 'impeccable-asset-producer.agent.md'),
|
|
'---\nname: impeccable-asset-producer\ndescription: Produces assets.\n---\nCopilot producer body.\n');
|
|
}
|
|
if (providers.includes('.cursor')) {
|
|
mkdirSync(join(bundleRoot, '.cursor', 'agents'), { recursive: true });
|
|
writeFileSync(join(bundleRoot, '.cursor', 'agents', 'impeccable-finish-reviewer.md'),
|
|
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\nmodel: inherit\nreadonly: true\nis_background: false\n---\nCursor reviewer body.\n');
|
|
}
|
|
return bundleRoot;
|
|
}
|
|
|
|
/**
|
|
* Simulate an install from the era when the CLI offered a command prefix: the
|
|
* skill lives at `<prefix>impeccable`. Optionally drop in a third-party skill
|
|
* (one that even starts with the same prefix) that migration must NOT touch.
|
|
*/
|
|
function createPrefixedInstall(root, { prefix = 'i-', providers = ['.claude'], foreign = null } = {}) {
|
|
for (const provider of providers) {
|
|
writeSkill(root, provider, `${prefix}impeccable`);
|
|
if (foreign) writeSkill(root, provider, foreign);
|
|
}
|
|
}
|
|
|
|
// ─── Already-installed detection ─────────────────────────────────────────────
|
|
|
|
// Remote e2e blocks (real bundle downloads from impeccable.style) run only
|
|
// under `bun run test:cli-remote-e2e` (IMPECCABLE_CLI_REMOTE_E2E=1). The default
|
|
// suite skips them so it stays offline and stable; when opted in they still
|
|
// skip gracefully if the bundle endpoint is unreachable.
|
|
const WANT_CLI_REMOTE_E2E = process.env.IMPECCABLE_CLI_REMOTE_E2E === '1';
|
|
let bundleReachable = false;
|
|
if (WANT_CLI_REMOTE_E2E) {
|
|
try {
|
|
execSync('curl -sfIL --max-time 10 https://impeccable.style/api/download/bundle/universal -o /dev/null', { stdio: 'pipe' });
|
|
bundleReachable = true;
|
|
} catch {}
|
|
}
|
|
const describeRemote = (WANT_CLI_REMOTE_E2E && bundleReachable) ? describe : describe.skip;
|
|
|
|
describe('copyProviderSkills: symlink handling', () => {
|
|
test('preserves an external shared-skills symlink and writes through it (#295)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-295-ext-'));
|
|
const root = join(tmp, 'home');
|
|
const shared = join(tmp, 'shared');
|
|
mkdirSync(root, { recursive: true });
|
|
mkdirSync(join(shared, 'other-skill'), { recursive: true });
|
|
writeFileSync(join(shared, 'other-skill', 'SKILL.md'), '---\nname: other-skill\n---\n');
|
|
mkdirSync(join(root, '.claude'), { recursive: true });
|
|
symlinkSync(shared, join(root, '.claude', 'skills'), 'dir');
|
|
|
|
const bundle = createFakeUniversalBundle(tmp, ['.claude']);
|
|
copyProviderSkills(bundle, root, ['.claude']);
|
|
|
|
const skillsPath = join(root, '.claude', 'skills');
|
|
expect(lstatSync(skillsPath).isSymbolicLink()).toBe(true);
|
|
expect(realpathSync(skillsPath)).toBe(realpathSync(shared));
|
|
expect(existsSync(join(skillsPath, 'other-skill', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(shared, 'impeccable', 'SKILL.md'))).toBe(true);
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('still converts an in-project cross-provider link to a real dir', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-295-inproj-'));
|
|
mkdirSync(join(tmp, '.agents', 'skills'), { recursive: true });
|
|
mkdirSync(join(tmp, '.claude'), { recursive: true });
|
|
symlinkSync('../.agents/skills', join(tmp, '.claude', 'skills'), 'dir');
|
|
|
|
const bundle = createFakeUniversalBundle(tmp, ['.claude']);
|
|
copyProviderSkills(bundle, tmp, ['.claude']);
|
|
|
|
const skillsPath = join(tmp, '.claude', 'skills');
|
|
expect(lstatSync(skillsPath).isSymbolicLink()).toBe(false);
|
|
expect(existsSync(join(skillsPath, 'impeccable', 'SKILL.md'))).toBe(true);
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('preserves external symlinks when two providers share one external dir (#295, multi-tool)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-295-multi-'));
|
|
const root = join(tmp, 'home');
|
|
const shared = join(tmp, 'shared');
|
|
mkdirSync(root, { recursive: true });
|
|
mkdirSync(join(shared, 'other-skill'), { recursive: true });
|
|
writeFileSync(join(shared, 'other-skill', 'SKILL.md'), '---\nname: other-skill\n---\n');
|
|
for (const provider of ['.claude', '.agents']) {
|
|
mkdirSync(join(root, provider), { recursive: true });
|
|
symlinkSync(shared, join(root, provider, 'skills'), 'dir');
|
|
}
|
|
|
|
const bundle = createFakeUniversalBundle(tmp, ['.claude', '.agents']);
|
|
copyProviderSkills(bundle, root, ['.claude', '.agents']);
|
|
|
|
for (const provider of ['.claude', '.agents']) {
|
|
const skillsPath = join(root, provider, 'skills');
|
|
expect(lstatSync(skillsPath).isSymbolicLink()).toBe(true);
|
|
expect(realpathSync(skillsPath)).toBe(realpathSync(shared));
|
|
}
|
|
expect(existsSync(join(shared, 'other-skill', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(shared, 'impeccable', 'SKILL.md'))).toBe(true);
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('replaces a dangling in-project cross-provider link with a real dir', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-295-dangling-'));
|
|
// Link to another provider's in-project skills dir that does NOT exist yet.
|
|
mkdirSync(join(tmp, '.claude'), { recursive: true });
|
|
symlinkSync('../.agents/skills', join(tmp, '.claude', 'skills'), 'dir');
|
|
|
|
const bundle = createFakeUniversalBundle(tmp, ['.claude']);
|
|
copyProviderSkills(bundle, tmp, ['.claude']);
|
|
|
|
const skillsPath = join(tmp, '.claude', 'skills');
|
|
expect(lstatSync(skillsPath).isSymbolicLink()).toBe(false);
|
|
expect(existsSync(join(skillsPath, 'impeccable', 'SKILL.md'))).toBe(true);
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('copyProviderAgents: Copilot and Cursor subagents', () => {
|
|
test('project scope places agents at .github/agents/ and .cursor/agents/', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-project-'));
|
|
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
|
|
|
const results = copyProviderAgents(bundle, tmp, ['.github', '.cursor'], { scope: 'project' });
|
|
|
|
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
|
|
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-asset-producer.agent.md'))).toBe(true);
|
|
expect(existsSync(join(tmp, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
|
|
expect(results.map(r => r.provider).sort()).toEqual(['.cursor', '.github']);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('user scope places Copilot agents at ~/.copilot/agents (not ~/.github) and Cursor agents at ~/.cursor/agents, overwriting stale copies', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-user-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-agents-user-home-'));
|
|
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
|
// A stale user-level copy from an older release must be overwritten.
|
|
mkdirSync(join(home, '.copilot', 'agents'), { recursive: true });
|
|
writeFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'stale copy\n');
|
|
|
|
copyProviderAgents(bundle, home, ['.github', '.cursor'], { scope: 'user' });
|
|
|
|
const copilotAgent = readFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'utf8');
|
|
expect(copilotAgent).toContain('Copilot reviewer body.');
|
|
expect(existsSync(join(home, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
|
|
expect(existsSync(join(home, '.github', 'agents'))).toBe(false);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
test('project scope reports user-level Copilot agents that shadow the installed ones; Cursor never does (project wins there)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-home-'));
|
|
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
|
mkdirSync(join(home, '.copilot', 'agents'), { recursive: true });
|
|
writeFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'user-level copy\n');
|
|
mkdirSync(join(home, '.cursor', 'agents'), { recursive: true });
|
|
writeFileSync(join(home, '.cursor', 'agents', 'impeccable-finish-reviewer.md'), 'user-level copy\n');
|
|
|
|
const results = copyProviderAgents(bundle, tmp, ['.github', '.cursor'], { scope: 'project', home });
|
|
|
|
const github = results.find(r => r.provider === '.github');
|
|
const cursor = results.find(r => r.provider === '.cursor');
|
|
expect(github.shadowed).toEqual(['impeccable-finish-reviewer.agent.md']);
|
|
expect(cursor.shadowed).toEqual([]);
|
|
// The project copies still land; the shadow report is a warning, not a block.
|
|
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
test('fresh install lays agents down alongside skills and reports them', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-install-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-agents-install-home-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
|
|
|
const output = run('skills install -y --no-hooks --providers=github,cursor', {
|
|
cwd: tmp,
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Installed impeccable into: .github, .cursor (project)');
|
|
expect(output).toContain('Installed GitHub Copilot agents into:');
|
|
expect(output).toContain('Installed Cursor agents into:');
|
|
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
|
|
expect(existsSync(join(tmp, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
});
|
|
|
|
describe('skills install: already-installed detection', () => {
|
|
test('detects impeccable sentinel and bails', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeSkills(tmp);
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
// 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,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(output).toContain('already installed');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('already-installed projects keep working when the update check is offline', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-offline-installed-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeSkills(tmp, ['impeccable'], ['.claude']);
|
|
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 --providers=claude', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: join(tmp, 'missing-bundle') },
|
|
});
|
|
|
|
expect(output).toContain('already installed');
|
|
expect(output).toContain('Could not check for skill updates');
|
|
expect(output).toContain('Existing skills were left unchanged.');
|
|
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('detects prefixed i-impeccable', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));
|
|
execSync('git init', { cwd: tmp });
|
|
|
|
const skillDir = join(tmp, '.cursor', 'skills', 'i-impeccable');
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: i-impeccable\n---\n');
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.cursor']);
|
|
// 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,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(output).toContain('already installed');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('repairs missing hook manifests on already-installed projects', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-repair-hooks-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeSkills(tmp, ['impeccable'], ['.claude']);
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
|
|
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');
|
|
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('--no-hooks does not repair missing hook manifests on already-installed projects', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-no-hooks-repair-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeSkills(tmp, ['impeccable'], ['.claude']);
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
|
|
const output = run('skills install -y --providers=claude --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('already installed');
|
|
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('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);
|
|
});
|
|
|
|
// ─── Submodule/link installs ────────────────────────────────────────────────
|
|
|
|
describe('skills link: submodule installs', () => {
|
|
test('creates relative skill symlinks from dist/universal', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeLinkSource(tmp, ['.claude', '.cursor']);
|
|
|
|
const output = run('skills link --source=.impeccable --providers=claude,cursor -y', { cwd: tmp });
|
|
expect(output).toContain('Linked impeccable into: .claude, .cursor');
|
|
|
|
for (const provider of ['.claude', '.cursor']) {
|
|
const dest = join(tmp, provider, 'skills', 'impeccable');
|
|
const src = join(tmp, '.impeccable', 'dist', 'universal', provider, 'skills', 'impeccable');
|
|
expect(lstatSync(dest).isSymbolicLink()).toBe(true);
|
|
expect(readlinkSync(dest).startsWith('/')).toBe(false);
|
|
expect(realpathSync(dest)).toBe(realpathSync(src));
|
|
}
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('is idempotent when links already point at the same source', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-again-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeLinkSource(tmp);
|
|
|
|
run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
|
|
const before = readlinkSync(join(tmp, '.claude', 'skills', 'impeccable'));
|
|
const output = run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
|
|
|
|
expect(output).toContain('already linked');
|
|
expect(readlinkSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(before);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('does not overwrite an existing real skill unless forced', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-existing-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeLinkSource(tmp);
|
|
writeSkill(tmp, '.claude', 'impeccable');
|
|
|
|
expect(() => run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp })).toThrow();
|
|
const dest = join(tmp, '.claude', 'skills', 'impeccable');
|
|
expect(lstatSync(dest).isSymbolicLink()).toBe(false);
|
|
|
|
const output = run('skills link --source=.impeccable --providers=claude -y --force', { cwd: tmp });
|
|
expect(output).toContain('1 linked');
|
|
expect(lstatSync(dest).isSymbolicLink()).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('maps codex and rovo-dev provider aliases to their install folders', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-alias-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeLinkSource(tmp, ['.agents', '.rovodev']);
|
|
|
|
run('skills link --source=.impeccable --providers=codex,rovo-dev -y', { cwd: tmp });
|
|
|
|
expect(lstatSync(join(tmp, '.agents', 'skills', 'impeccable')).isSymbolicLink()).toBe(true);
|
|
expect(lstatSync(join(tmp, '.rovodev', 'skills', 'impeccable')).isSymbolicLink()).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('maps grok and grok-build provider aliases to .grok', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-grok-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeLinkSource(tmp, ['.grok']);
|
|
|
|
run('skills link --source=.impeccable --providers=grok -y', { cwd: tmp });
|
|
expect(lstatSync(join(tmp, '.grok', 'skills', 'impeccable')).isSymbolicLink()).toBe(true);
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
|
|
const tmp2 = mkdtempSync(join(tmpdir(), 'imp-test-link-grok-build-'));
|
|
execSync('git init', { cwd: tmp2 });
|
|
createFakeLinkSource(tmp2, ['.grok']);
|
|
|
|
run('skills link --source=.impeccable --providers=grok-build -y', { cwd: tmp2 });
|
|
expect(lstatSync(join(tmp2, '.grok', 'skills', 'impeccable')).isSymbolicLink()).toBe(true);
|
|
rmSync(tmp2, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('skills update leaves linked installs on the submodule path', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-update-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeLinkSource(tmp);
|
|
run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
|
|
|
|
const dest = join(tmp, '.claude', 'skills', 'impeccable');
|
|
const before = readlinkSync(dest);
|
|
const output = run('skills update -y', { cwd: tmp });
|
|
|
|
expect(output).toContain('Linked skills found in: .claude');
|
|
expect(readlinkSync(dest)).toBe(before);
|
|
expect(lstatSync(dest).isSymbolicLink()).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('plain install leaves linked installs on the submodule path', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-install-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeLinkSource(tmp);
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.cursor']);
|
|
run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp });
|
|
|
|
const linkedDest = join(tmp, '.claude', 'skills', 'impeccable');
|
|
const before = readlinkSync(linkedDest);
|
|
const copiedDest = join(tmp, '.cursor', 'skills', 'impeccable');
|
|
mkdirSync(join(copiedDest, 'scripts'), { recursive: true });
|
|
writeFileSync(join(copiedDest, 'SKILL.md'), '---\nname: impeccable\nstale: true\n---\nOld content.\n');
|
|
writeFileSync(join(copiedDest, 'scripts', 'context.mjs'), 'console.log("old broken script");\n');
|
|
|
|
const output = run('skills install -y --providers=claude,cursor --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Linked skills found in: .claude');
|
|
expect(output).toContain('Continuing with copied installs in: .cursor');
|
|
expect(output).toContain('Updated');
|
|
expect(readlinkSync(linkedDest)).toBe(before);
|
|
expect(lstatSync(linkedDest).isSymbolicLink()).toBe(true);
|
|
expect(readFileSync(join(copiedDest, 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
|
|
expect(readFileSync(join(copiedDest, 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("local bundle context");\n');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('deduplicates providers that share one skills directory', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-shared-'));
|
|
execSync('git init', { cwd: tmp });
|
|
createFakeLinkSource(tmp, ['.claude', '.agents']);
|
|
mkdirSync(join(tmp, '.agents', 'skills'), { recursive: true });
|
|
mkdirSync(join(tmp, '.claude'), { recursive: true });
|
|
symlinkSync('../.agents/skills', join(tmp, '.claude', 'skills'), 'dir');
|
|
|
|
run('skills link --source=.impeccable --providers=claude,codex -y', { cwd: tmp });
|
|
|
|
const dest = join(tmp, '.agents', 'skills', 'impeccable');
|
|
const src = join(tmp, '.impeccable', 'dist', 'universal', '.claude', 'skills', 'impeccable');
|
|
expect(lstatSync(dest).isSymbolicLink()).toBe(true);
|
|
expect(realpathSync(dest)).toBe(realpathSync(src));
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
});
|
|
|
|
// ─── Unprefix migration (real implementation, real filesystem) ───────────────
|
|
//
|
|
// The CLI no longer offers a command prefix (the `i-` rename only made sense
|
|
// when each command was its own skill). migrateUnprefixImpeccable retires any
|
|
// old `<prefix>impeccable` install back to the canonical `impeccable`, so an
|
|
// update lands fresh content there instead of orphaning the prefixed copy.
|
|
// These call the EXPORTED function -- not a reimplementation -- so a regression
|
|
// in the real code fails the suite.
|
|
|
|
describe('skills: unprefix migration', () => {
|
|
test('renames i-impeccable back to impeccable across every provider', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-'));
|
|
createPrefixedInstall(tmp, { prefix: 'i-', providers: ['.claude', '.cursor'] });
|
|
|
|
const migrated = migrateUnprefixImpeccable(tmp);
|
|
expect(migrated).toBe(2); // one skill x two providers
|
|
|
|
for (const provider of ['.claude', '.cursor']) {
|
|
const skills = readdirSync(join(tmp, provider, 'skills'));
|
|
expect(skills).toContain('impeccable');
|
|
expect(skills).not.toContain('i-impeccable');
|
|
}
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('migrates a custom prefix too (x-impeccable)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-x-'));
|
|
createPrefixedInstall(tmp, { prefix: 'x-' });
|
|
|
|
expect(migrateUnprefixImpeccable(tmp)).toBe(1);
|
|
expect(readdirSync(join(tmp, '.claude', 'skills'))).toContain('impeccable');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('REGRESSION: never touches third-party skills, even ones starting with i-', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-scope-'));
|
|
// A foreign skill that shares the i- prefix but is NOT impeccable.
|
|
createPrefixedInstall(tmp, { prefix: 'i-', foreign: 'i-cool-skill' });
|
|
|
|
const migrated = migrateUnprefixImpeccable(tmp);
|
|
expect(migrated).toBe(1); // only i-impeccable
|
|
|
|
const skills = readdirSync(join(tmp, '.claude', 'skills'));
|
|
expect(skills).toContain('impeccable');
|
|
expect(skills).toContain('i-cool-skill'); // untouched, NOT renamed to cool-skill
|
|
expect(skills).not.toContain('cool-skill');
|
|
|
|
const foreign = readFileSync(join(tmp, '.claude', 'skills', 'i-cool-skill', 'SKILL.md'), 'utf8');
|
|
expect(foreign).toContain('name: i-cool-skill');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('leaves a clean impeccable install alone (no-op)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-clean-'));
|
|
createFakeSkills(tmp, ['impeccable'], ['.claude']);
|
|
|
|
expect(migrateUnprefixImpeccable(tmp)).toBe(0);
|
|
expect(readdirSync(join(tmp, '.claude', 'skills'))).toContain('impeccable');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('leaves unrelated legacy skill names alone', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-mig-legacy-'));
|
|
createFakeSkills(tmp, ['teach-impeccable'], ['.claude']);
|
|
|
|
expect(migrateUnprefixImpeccable(tmp)).toBe(0);
|
|
expect(readdirSync(join(tmp, '.claude', 'skills'))).toContain('teach-impeccable');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ─── Install/update from local universal bundle ──────────────────────────────
|
|
|
|
describe('skills install/update: local universal bundle e2e', () => {
|
|
test('root help advertises top-level skills commands', () => {
|
|
const output = run('--help');
|
|
|
|
expect(output).toContain('install Install impeccable skills');
|
|
expect(output).toContain('update Update skills to the latest version');
|
|
expect(output).toContain('impeccable skills <command> Legacy namespace; still supported.');
|
|
expect(output).not.toContain('skills install Install impeccable skills');
|
|
});
|
|
|
|
test('top-level install aliases the legacy skills install command', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-top-level-install-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-top-level-install-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
|
|
const output = run('install -y --providers=claude --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Installed impeccable into: .claude (project)');
|
|
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('install completion says /impeccable init runs in the agent chat, not the terminal (#472)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-install-472-msg-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-install-472-msg-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
|
|
const output = run('install -y --providers=claude --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain("type /impeccable init in your AI coding agent's chat (not in this terminal)");
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('`impeccable init` in the shell points at the agent chat instead of "Unknown command" (#472)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-init-472-'));
|
|
|
|
let error;
|
|
try {
|
|
run('init', { cwd: tmp, stdio: 'pipe' });
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
|
|
expect(error).toBeDefined();
|
|
expect(error.status).toBe(1);
|
|
const stderr = String(error.stderr);
|
|
expect(stderr).toContain("Type /impeccable init in your AI coding agent's chat");
|
|
expect(stderr).not.toContain('Unknown command');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('a real path named init still routes to detect, not the #472 guidance', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-init-path-472-'));
|
|
mkdirSync(join(tmp, 'init'), { recursive: true });
|
|
writeFileSync(join(tmp, 'init', 'page.html'), '<!doctype html><html><head><title>t</title></head><body><p>hello</p></body></html>\n');
|
|
|
|
// Detect exits 0 on a clean scan and 2 when findings surface; either way it
|
|
// must be the detector answering, not the init redirect. --json makes that
|
|
// positive: the detector always prints a JSON findings array.
|
|
let output = '';
|
|
try {
|
|
output = run('init --json', { cwd: tmp, stdio: 'pipe' });
|
|
} catch (e) {
|
|
output = `${e.stdout || ''}${e.stderr || ''}`;
|
|
}
|
|
|
|
expect(output.trim().startsWith('[')).toBe(true);
|
|
expect(output).not.toContain('is not a CLI command');
|
|
expect(output).not.toContain('Unknown command');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 60000);
|
|
|
|
test('formats detected harnesses as concise source-to-target rows', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-detect-lines-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-detect-lines-'));
|
|
const detections = [
|
|
{
|
|
provider: '.claude',
|
|
scope: 'user',
|
|
foundPath: join(home, '.claude'),
|
|
installRoot: home,
|
|
installPath: join(home, '.claude', 'skills'),
|
|
},
|
|
{
|
|
provider: '.agents',
|
|
scope: 'user',
|
|
foundPath: join(home, '.codex'),
|
|
installRoot: home,
|
|
installPath: join(home, '.agents', 'skills'),
|
|
},
|
|
{
|
|
provider: '.pi',
|
|
scope: 'user',
|
|
foundPath: join(home, '.pi'),
|
|
installRoot: home,
|
|
installPath: join(home, '.pi', 'agent', 'skills'),
|
|
},
|
|
];
|
|
|
|
const lines = formatInstallDetectionLines(tmp, detections, home);
|
|
expect(lines).toEqual([
|
|
'Detected harnesses:',
|
|
' Claude Code ~/.claude',
|
|
' Codex CLI ~/.codex',
|
|
' Pi Coding Agent ~/.pi',
|
|
]);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
test('installs provider-specific skills into a fresh project', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-install-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp);
|
|
|
|
const output = run('skills install -y --providers=claude,codex,cursor', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(output).toContain('Done!');
|
|
|
|
for (const provider of ['.claude', '.agents', '.cursor']) {
|
|
const skillDir = join(tmp, provider, 'skills', 'impeccable');
|
|
expect(existsSync(join(skillDir, 'SKILL.md'))).toBe(true);
|
|
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.local.json'))).toBe(true);
|
|
expect(existsSync(join(tmp, '.cursor', 'hooks.json'))).toBe(true);
|
|
expect(existsSync(join(tmp, '.codex', 'hooks.json'))).toBe(true);
|
|
// The CLI puts Codex's skill at `.agents/skills`, so the project-scope hook
|
|
// command must point there — not at the bundle's own `.codex/skills` path,
|
|
// which would resolve to a nonexistent file and silently no-op the hook.
|
|
const codexHooks = readFileSync(join(tmp, '.codex', 'hooks.json'), 'utf8');
|
|
expect(codexHooks).toContain('.agents/skills/impeccable/scripts/hook.mjs');
|
|
expect(codexHooks).not.toContain('.codex/skills/impeccable/scripts/hook.mjs');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('installs Antigravity skills into .agent/ with --providers=antigravity', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-antigravity-install-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.agent']);
|
|
|
|
const output = run('skills install -y --providers=antigravity --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(output).toContain('Done!');
|
|
|
|
const skillDir = join(tmp, '.agent', 'skills', 'impeccable');
|
|
expect(existsSync(join(skillDir, 'SKILL.md'))).toBe(true);
|
|
expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toContain('Local deterministic bundle for .agent.');
|
|
expect(existsSync(join(skillDir, 'scripts', 'context.mjs'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('updates stale Antigravity skills at .agent/ from the local bundle', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-antigravity-update-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.agent']);
|
|
|
|
const skillDir = join(tmp, '.agent', 'skills', 'impeccable');
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\nstale: true\n---\nOld content.\n');
|
|
|
|
const output = run('skills update -y --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(output).toContain('Updated');
|
|
|
|
const content = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
|
|
expect(content).not.toContain('stale: true');
|
|
expect(content).toContain('version: 9.9.9-local');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('interactive install explains home detections and can target the project root', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-project-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-project-'));
|
|
execSync('git init', { cwd: tmp });
|
|
for (const dir of ['.claude', '.codex', '.cursor', '.gemini']) {
|
|
mkdirSync(join(home, dir), { recursive: true });
|
|
}
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents', '.cursor', '.gemini']);
|
|
|
|
const output = run('skills install --no-hooks', {
|
|
cwd: tmp,
|
|
input: '\nproject\n\n',
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Detected harnesses:');
|
|
expect(output).toContain('Claude Code ~/.claude');
|
|
expect(output).toContain('~/.codex');
|
|
expect(output).toContain('Install target: [1] Detected only (claude, codex, cursor, gemini) [2] Customize [1]:');
|
|
for (const provider of ['.claude', '.agents', '.cursor', '.gemini']) {
|
|
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
|
|
}
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('interactive install can add providers beyond detected harnesses', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-add-more-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-add-more-'));
|
|
execSync('git init', { cwd: tmp });
|
|
mkdirSync(join(home, '.claude'), { recursive: true });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents']);
|
|
|
|
const output = run('skills install --no-hooks', {
|
|
cwd: tmp,
|
|
input: '2\nclaude,codex\nproject\n\n',
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Install target: [1] Detected only (claude) [2] Customize [1]:');
|
|
expect(output).toContain('Select harnesses (comma-separated:');
|
|
expect(output).toContain('Installed impeccable into: .claude, .agents (project)');
|
|
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(tmp, '.agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('interactive install defaults config-only home detections to project scope', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-config-only-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-config-only-'));
|
|
execSync('git init', { cwd: tmp });
|
|
for (const dir of ['.claude', '.codex', '.cursor', '.gemini']) {
|
|
mkdirSync(join(home, dir), { recursive: true });
|
|
}
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents', '.cursor', '.gemini']);
|
|
|
|
const output = run('skills install --no-hooks', {
|
|
cwd: tmp,
|
|
input: '\n\n\n',
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor, .gemini (project)');
|
|
for (const provider of ['.claude', '.agents', '.cursor', '.gemini']) {
|
|
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
|
|
}
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('interactive install defaults home detections with real skills to user scope', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-user-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-user-'));
|
|
execSync('git init', { cwd: tmp });
|
|
for (const dir of ['.claude', '.codex', '.cursor', '.gemini']) {
|
|
mkdirSync(join(home, dir), { recursive: true });
|
|
}
|
|
writeSkill(home, '.claude', 'existing-user-skill');
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents', '.cursor', '.gemini']);
|
|
|
|
const output = run('skills install --no-hooks', {
|
|
cwd: tmp,
|
|
input: '\n\n\n',
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor, .gemini (global)');
|
|
for (const provider of ['.claude', '.agents', '.cursor', '.gemini']) {
|
|
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
|
|
}
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('Codex system/runtime-only skills do not count as real user skills', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-codex-system-skills-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-codex-system-skills-'));
|
|
execSync('git init', { cwd: tmp });
|
|
mkdirSync(join(home, '.codex', 'skills', 'codex-primary-runtime'), { recursive: true });
|
|
mkdirSync(join(home, '.codex', 'skills', '.system', 'skill-creator'), { recursive: true });
|
|
writeFileSync(join(home, '.codex', 'skills', '.system', 'skill-creator', 'SKILL.md'), '---\nname: skill-creator\n---\n');
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.agents']);
|
|
|
|
const output = run('skills install --no-hooks', {
|
|
cwd: tmp,
|
|
input: '\n\n\n',
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Codex CLI');
|
|
expect(output).toContain('Installed impeccable into: .agents (project)');
|
|
expect(existsSync(join(tmp, '.agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(home, '.agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('interactive install with no detections asks for providers directly', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-interactive-none-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-interactive-none-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents']);
|
|
|
|
const output = run('skills install --no-hooks', {
|
|
cwd: tmp,
|
|
input: 'claude,codex\nproject\n\n',
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('No harnesses detected');
|
|
expect(output).toContain('Select harnesses (comma-separated:');
|
|
expect(output).toContain('Installed impeccable into: .claude, .agents (project)');
|
|
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(tmp, '.agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('--scope=global installs skills globally and project hooks point there', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-scope-user-hooks-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-scope-user-hooks-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.agents', '.cursor']);
|
|
|
|
const output = run('skills install -y --providers=claude,codex,cursor --scope=global', {
|
|
cwd: tmp,
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Installed impeccable into: .claude, .agents, .cursor (global)');
|
|
for (const provider of ['.claude', '.agents', '.cursor']) {
|
|
expect(existsSync(join(home, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
|
|
}
|
|
expect(readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8')).toContain(join(home, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs'));
|
|
expect(readFileSync(join(tmp, '.codex', 'hooks.json'), 'utf8')).toContain(join(home, '.agents', 'skills', 'impeccable', 'scripts', 'hook.mjs'));
|
|
expect(readFileSync(join(tmp, '.cursor', 'hooks.json'), 'utf8')).toContain(join(home, '.cursor', 'skills', 'impeccable', 'scripts', 'hook-before-edit.mjs'));
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
// Pi discovers global skills from ~/.pi/agent/skills/, not ~/.pi/skills/ (#327).
|
|
// Also covers the GLOBAL_HARNESS_HINTS detection: no --providers is passed, so
|
|
// the ~/.pi dir alone must route the install to Pi's agent skills path.
|
|
test('global install detects ~/.pi and writes Pi skills to ~/.pi/agent/skills', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-scope-user-pi-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-scope-user-pi-'));
|
|
execSync('git init', { cwd: tmp });
|
|
mkdirSync(join(home, '.pi'), { recursive: true });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.pi']);
|
|
|
|
const output = run('skills install -y --scope=global --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Installed impeccable into: .pi (global)');
|
|
expect(existsSync(join(home, '.pi', 'agent', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(home, '.pi', 'skills', 'impeccable'))).toBe(false);
|
|
expect(existsSync(join(tmp, '.pi', 'skills', 'impeccable', 'SKILL.md'))).toBe(false);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
// OpenCode reads global skills from its config directory, not ~/.opencode:
|
|
// $OPENCODE_CONFIG_DIR/skills, else $XDG_CONFIG_HOME/opencode/skills, else
|
|
// ~/.config/opencode/skills. Writing to ~/.opencode/skills produced an
|
|
// install `opencode debug skill` never saw (#406).
|
|
test('global install writes OpenCode skills to ~/.config/opencode/skills (#406)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-scope-user-oc-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-scope-user-oc-'));
|
|
execSync('git init', { cwd: tmp });
|
|
mkdirSync(join(home, '.opencode'), { recursive: true });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
|
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
delete env.OPENCODE_CONFIG_DIR;
|
|
delete env.XDG_CONFIG_HOME;
|
|
|
|
const output = run('skills install -y --scope=global --no-hooks', { cwd: tmp, env });
|
|
|
|
expect(output).toContain('Installed impeccable into: .opencode (global)');
|
|
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('OpenCode global dir honors OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME (#406)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-env-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-env-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
|
const baseEnv = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
delete baseEnv.OPENCODE_CONFIG_DIR;
|
|
delete baseEnv.XDG_CONFIG_HOME;
|
|
|
|
run('skills install -y --providers=opencode --scope=global --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...baseEnv, OPENCODE_CONFIG_DIR: join(home, 'occfg') },
|
|
});
|
|
expect(existsSync(join(home, 'occfg', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
|
|
run('skills install -y --providers=opencode --scope=global --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...baseEnv, XDG_CONFIG_HOME: join(home, 'xdg') },
|
|
});
|
|
expect(existsSync(join(home, 'xdg', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 20000);
|
|
|
|
test('global OpenCode install migrates a legacy ~/.opencode/skills copy, sparing siblings (#406)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-migrate-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-migrate-'));
|
|
execSync('git init', { cwd: tmp });
|
|
writeSkill(home, '.opencode', 'impeccable');
|
|
writeSkill(home, '.opencode', 'unrelated-skill');
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
|
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
delete env.OPENCODE_CONFIG_DIR;
|
|
delete env.XDG_CONFIG_HOME;
|
|
|
|
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
|
|
|
|
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
// The stranded legacy copy is gone; the sibling skill is untouched.
|
|
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
|
|
expect(existsSync(join(home, '.opencode', 'skills', 'unrelated-skill', 'SKILL.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('OpenCode migration never follows a symlinked legacy skills dir (#406)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-symlink-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-symlink-'));
|
|
execSync('git init', { cwd: tmp });
|
|
// Shared skill storage with ~/.opencode/skills symlinked at it. Deleting
|
|
// "the legacy copy" through the link would destroy the shared original.
|
|
writeSkill(join(home, '.config'), 'agents', 'impeccable');
|
|
mkdirSync(join(home, '.opencode'), { recursive: true });
|
|
symlinkSync(join(home, '.config', 'agents', 'skills'), join(home, '.opencode', 'skills'), 'dir');
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
|
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
delete env.OPENCODE_CONFIG_DIR;
|
|
delete env.XDG_CONFIG_HOME;
|
|
|
|
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
|
|
|
|
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
// The shared store behind the symlink is intact, link included.
|
|
expect(existsSync(join(home, '.config', 'agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(lstatSync(join(home, '.opencode', 'skills')).isSymbolicLink()).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('OpenCode migration leaves a home-rooted repo project install alone (#406)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-homerepo-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-homerepo-'));
|
|
execSync('git init', { cwd: tmp });
|
|
// The home dir IS a repo (dotfiles setup): .opencode/skills there is a
|
|
// live project-scope install, not a stranded pre-#406 global one.
|
|
execSync('git init', { cwd: home });
|
|
writeSkill(home, '.opencode', 'impeccable');
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
|
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
delete env.OPENCODE_CONFIG_DIR;
|
|
delete env.XDG_CONFIG_HOME;
|
|
|
|
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
|
|
|
|
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('global install detects OpenCode from ~/.config/opencode alone (#406)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-detect-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-detect-'));
|
|
execSync('git init', { cwd: tmp });
|
|
// No ~/.opencode at all; only the config dir marks OpenCode as present.
|
|
mkdirSync(join(home, '.config', 'opencode'), { recursive: true });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
|
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
delete env.OPENCODE_CONFIG_DIR;
|
|
delete env.XDG_CONFIG_HOME;
|
|
|
|
const output = run('skills install -y --scope=global --no-hooks', { cwd: tmp, env });
|
|
|
|
expect(output).toContain('Installed impeccable into: .opencode (global)');
|
|
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
// Project scope must stay at .pi/skills/ even when the git root IS the home
|
|
// dir (dotfiles repos), where scope can't be inferred from the path alone.
|
|
// An existing global install at ~/.pi/agent/skills must not swallow the
|
|
// project-scope request into its already-installed refresh path.
|
|
test('project-scope install keeps Pi skills in .pi/skills even for a home-rooted repo', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-home-rooted-project-pi-'));
|
|
execSync('git init', { cwd: home });
|
|
writeSkill(join(home, '.pi'), 'agent', 'impeccable');
|
|
const bundleRoot = createFakeUniversalBundle(home, ['.pi']);
|
|
|
|
const output = run('skills install -y --providers=pi --no-hooks', {
|
|
cwd: home,
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Installed impeccable into: .pi (project)');
|
|
expect(existsSync(join(home, '.pi', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
// The pre-existing global copy is untouched, not refreshed in place.
|
|
expect(readFileSync(join(home, '.pi', 'agent', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('name: impeccable');
|
|
expect(readFileSync(join(home, '.pi', 'agent', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).not.toContain('Local deterministic bundle');
|
|
|
|
// An unscoped update from the same root must refresh BOTH Pi trees, not
|
|
// just the first layout it finds.
|
|
run('skills update -y --no-hooks', {
|
|
cwd: home,
|
|
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(readFileSync(join(home, '.pi', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('Local deterministic bundle');
|
|
expect(readFileSync(join(home, '.pi', 'agent', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('Local deterministic bundle');
|
|
|
|
rmSync(home, { 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('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 });
|
|
const bundleRoot = createFakeUniversalBundle(tmp);
|
|
|
|
const output = run('skills install -y --providers=claude,codex,cursor --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(output).toContain('Done!');
|
|
expect(output).not.toContain('Installed hooks into');
|
|
for (const provider of ['.claude', '.agents', '.cursor']) {
|
|
expect(existsSync(join(tmp, provider, 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
}
|
|
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);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('updates stale copied skills from the local bundle', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-update-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
|
|
const skillDir = join(tmp, '.claude', 'skills', 'impeccable');
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\nstale: true\n---\nOld content.\n');
|
|
|
|
const output = run('skills update -y', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(output).toContain('Updated');
|
|
|
|
const content = readFileSync(join(skillDir, 'SKILL.md'), 'utf8');
|
|
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.local.json'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('skills update refreshes script-only bundle changes when SKILL.md is unchanged', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-script-only-update-'));
|
|
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 },
|
|
});
|
|
|
|
const scriptPath = join(tmp, '.claude', 'skills', 'impeccable', 'scripts', 'context.mjs');
|
|
writeFileSync(scriptPath, 'console.log("old broken script");\n');
|
|
|
|
const output = run('skills update -y --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Updated');
|
|
expect(readFileSync(scriptPath, 'utf8')).toBe('console.log("local bundle context");\n');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('plain install refreshes an already-installed stale skill', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-existing-install-refresh-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
|
|
const skillDir = join(tmp, '.claude', 'skills', 'impeccable');
|
|
mkdirSync(join(skillDir, 'scripts'), { recursive: true });
|
|
writeFileSync(
|
|
join(skillDir, 'SKILL.md'),
|
|
readFileSync(join(bundleRoot, '.claude', 'skills', 'impeccable', 'SKILL.md'), 'utf8')
|
|
);
|
|
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("old broken script");\n');
|
|
|
|
const output = run('skills install -y --providers=claude --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('already installed');
|
|
expect(output).toContain('Updated');
|
|
expect(readFileSync(join(skillDir, 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("local bundle context");\n');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('plain install only refreshes selected copied providers', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-existing-install-scope-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.cursor']);
|
|
|
|
for (const provider of ['.claude', '.cursor']) {
|
|
const skillDir = join(tmp, provider, 'skills', 'impeccable');
|
|
mkdirSync(join(skillDir, 'scripts'), { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: impeccable\nstale: ${provider}\n---\nOld content.\n`);
|
|
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), `console.log("old ${provider} script");\n`);
|
|
}
|
|
|
|
const output = run('skills install -y --providers=claude --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('already installed');
|
|
expect(output).toContain('Updated');
|
|
expect(readFileSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
|
|
expect(readFileSync(join(tmp, '.cursor', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('stale: .cursor');
|
|
expect(readFileSync(join(tmp, '.cursor', 'skills', 'impeccable', 'scripts', 'context.mjs'), 'utf8')).toBe('console.log("old .cursor script");\n');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('explicit --providers installs a missing provider without --force (#500)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-explicit-missing-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.cursor']);
|
|
|
|
// Seed an existing .claude install; .cursor has nothing yet.
|
|
const skillDir = join(tmp, '.claude', 'skills', 'impeccable');
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\nversion: 9.9.9-local\n---\nSeeded install.\n');
|
|
|
|
const output = run('skills install -y --providers=cursor --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('Installed impeccable into: .cursor');
|
|
expect(readFileSync(join(tmp, '.cursor', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
|
|
// The unselected .claude install is left alone.
|
|
expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toContain('Seeded install.');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('explicit --providers mixes per-target updates and fresh installs (#500)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-explicit-mixed-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude', '.cursor']);
|
|
|
|
// Stale .claude install; .cursor has nothing yet.
|
|
const skillDir = join(tmp, '.claude', 'skills', 'impeccable');
|
|
mkdirSync(join(skillDir, 'scripts'), { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\nstale: .claude\n---\nOld content.\n');
|
|
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("old script");\n');
|
|
|
|
const output = run('skills install -y --providers=claude,cursor', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
expect(output).toContain('already installed');
|
|
expect(output).toContain('Updated');
|
|
expect(output).toContain('Installed impeccable into: .cursor');
|
|
expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
|
|
expect(readFileSync(join(tmp, '.cursor', 'skills', 'impeccable', 'SKILL.md'), 'utf8')).toContain('version: 9.9.9-local');
|
|
// The freshly installed provider gets its hooks and agents too.
|
|
expect(existsSync(join(tmp, '.cursor', 'hooks.json'))).toBe(true);
|
|
expect(existsSync(join(tmp, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('skills update --no-hooks refreshes skills without touching malformed hook manifests', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-no-hooks-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.cursor']);
|
|
|
|
run('skills install -y --providers=cursor --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
mkdirSync(join(tmp, '.cursor'), { recursive: true });
|
|
writeFileSync(join(tmp, '.cursor', 'hooks.json'), '{ malformed');
|
|
|
|
const output = run('skills update -y --no-hooks', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
expect(output).toContain('Skills are up to date');
|
|
expect(readFileSync(join(tmp, '.cursor', 'hooks.json'), 'utf8')).toBe('{ malformed');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('skills update reports malformed hook manifests cleanly on the up-to-date path', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-bad-hooks-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.cursor']);
|
|
|
|
run('skills install -y --providers=cursor', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
writeFileSync(join(tmp, '.cursor', 'hooks.json'), '{ malformed');
|
|
|
|
expect(() => run('skills update -y', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
stdio: 'pipe',
|
|
})).toThrow();
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
|
|
test('--force reinstall over an old prefixed install lands on canonical impeccable', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-local-force-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
const prefixed = join(tmp, '.claude', 'skills', 'i-impeccable');
|
|
mkdirSync(prefixed, { recursive: true });
|
|
writeFileSync(join(prefixed, 'SKILL.md'), '---\nname: i-impeccable\n---\n');
|
|
|
|
run('skills install -y --force --providers=claude', {
|
|
cwd: tmp,
|
|
env: { ...process.env, IMPECCABLE_BUNDLE_PATH: bundleRoot },
|
|
});
|
|
|
|
const skills = readdirSync(join(tmp, '.claude', 'skills'));
|
|
expect(skills).toContain('impeccable');
|
|
expect(skills).not.toContain('i-impeccable');
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}, 15000);
|
|
});
|
|
|
|
describe('hook manifest merge helpers', () => {
|
|
test('mergeHookManifests refreshes fresh description and version while preserving third-party hooks', () => {
|
|
const merged = mergeHookManifests(
|
|
{
|
|
version: 0,
|
|
description: 'old description',
|
|
hooks: {
|
|
preToolUse: [
|
|
{ command: 'node third-party.mjs' },
|
|
{ command: 'node .cursor/skills/impeccable/scripts/hook-before-edit.mjs' },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
version: 1,
|
|
description: 'fresh description',
|
|
hooks: {
|
|
preToolUse: [
|
|
{ command: 'node .cursor/skills/impeccable/scripts/hook-before-edit.mjs' },
|
|
],
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(merged.version).toBe(1);
|
|
expect(merged.description).toBe('fresh description');
|
|
expect(merged.hooks.preToolUse.map((entry) => entry.command)).toEqual([
|
|
'node third-party.mjs',
|
|
'node .cursor/skills/impeccable/scripts/hook-before-edit.mjs',
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ─── Hook command path resolution (issue #399, part 1) ───────────────────────
|
|
// The bundled Claude manifest ships a ${CLAUDE_PROJECT_DIR}-relative command.
|
|
// That resolves per-project, so a user-level (~/.claude/settings.local.json)
|
|
// hook — which fires in EVERY project — must be rewritten to the resolved
|
|
// absolute skill path, or Node crashes on every PostToolUse/Stop in projects
|
|
// without a local skill copy. Project-level hooks keep ${CLAUDE_PROJECT_DIR}.
|
|
// Both are wrapped with a missing-file guard so a missing script exits 0.
|
|
|
|
// A bundle whose Claude manifest mirrors production: ${CLAUDE_PROJECT_DIR}-relative.
|
|
function createProjectDirBundle(root) {
|
|
const bundleRoot = join(root, 'projdir-bundle');
|
|
const skillDir = join(bundleRoot, '.claude', 'skills', 'impeccable', 'scripts');
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(bundleRoot, '.claude', 'skills', 'impeccable', 'SKILL.md'),
|
|
'---\nname: impeccable\nversion: 9.9.9-local\n---\nbundle\n');
|
|
mkdirSync(join(bundleRoot, '.claude'), { recursive: true });
|
|
writeFileSync(join(bundleRoot, '.claude', 'settings.json'), JSON.stringify({
|
|
hooks: {
|
|
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
|
|
{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' },
|
|
] }],
|
|
Stop: [{ hooks: [
|
|
{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' },
|
|
] }],
|
|
},
|
|
}, null, 2));
|
|
return bundleRoot;
|
|
}
|
|
|
|
function claudeHookCommands(manifestPath) {
|
|
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
return Object.values(parsed.hooks).flatMap(entries =>
|
|
entries.flatMap(entry => (entry.hooks || []).map(h => h.command)));
|
|
}
|
|
|
|
describe('copyProviderHooks: hook command path resolution (#399)', () => {
|
|
test('project-scope hook keeps ${CLAUDE_PROJECT_DIR} and adds a missing-file guard', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-project-'));
|
|
const bundleDir = createProjectDirBundle(tmp);
|
|
|
|
// skillRoot === root === a non-home project dir: keep the portable token.
|
|
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: tmp });
|
|
|
|
const commands = claudeHookCommands(join(tmp, '.claude', 'settings.local.json'));
|
|
expect(commands.length).toBeGreaterThan(0);
|
|
for (const command of commands) {
|
|
expect(command).toContain('${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs');
|
|
expect(command).not.toContain(tmp); // no absolute rewrite for project scope
|
|
expect(command).toContain('[ ! -f '); // guarded so a missing file exits 0
|
|
expect(command).not.toContain('|| true'); // must preserve node's exit code
|
|
}
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
// The user/global case (isHomeDir(root) true) is driven end-to-end through a
|
|
// child process below ('user-level update writes an absolute, guarded hook'),
|
|
// where HOME is set in the child's env so os.homedir() reflects it. It cannot
|
|
// be faked reliably in-process, so it is not unit-tested here.
|
|
|
|
test('project hook pointing at a global skill uses the absolute skill path', () => {
|
|
// --scope=global shape: manifest root is the project, skill lives in home.
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-split-'));
|
|
const skillHome = mkdtempSync(join(tmpdir(), 'imp-hook-skillroot-'));
|
|
const bundleDir = createProjectDirBundle(tmp);
|
|
|
|
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: skillHome });
|
|
|
|
const commands = claudeHookCommands(join(tmp, '.claude', 'settings.local.json'));
|
|
const absolute = join(skillHome, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
|
|
for (const command of commands) {
|
|
expect(command).toContain(absolute);
|
|
expect(command).not.toContain('${CLAUDE_PROJECT_DIR}');
|
|
expect(command).toContain('[ ! -f ');
|
|
}
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(skillHome, { recursive: true, force: true });
|
|
});
|
|
|
|
test('single-quotes an absolute install path that embeds $(...), and the guard is inert under /bin/sh (#476)', () => {
|
|
// A hook command is re-executed by the harness on every edit. JSON.stringify
|
|
// is not shell quoting: an install path containing $(...) inside double
|
|
// quotes would run on each fire. The absolute POSIX form must be
|
|
// single-quoted so the substitution stays inert.
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-split-'));
|
|
const skillHome = mkdtempSync(join(tmpdir(), 'imp-hook-$(touch pwned)-'));
|
|
const bundleDir = createProjectDirBundle(tmp);
|
|
|
|
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: skillHome });
|
|
|
|
const raw = readFileSync(join(tmp, '.claude', 'settings.local.json'), 'utf8');
|
|
// The path appears single-quoted, never double-quoted (which would leave
|
|
// the substitution live for /bin/sh).
|
|
expect(raw).toContain(`'${skillHome}`);
|
|
expect(raw).not.toContain(`"${skillHome}`);
|
|
|
|
const commands = claudeHookCommands(join(tmp, '.claude', 'settings.local.json'));
|
|
expect(commands.length).toBeGreaterThan(0);
|
|
// End-to-end: actually run each generated guard under /bin/sh from a clean
|
|
// cwd. The hook script does not exist (skillHome is empty), so `[ ! -f ... ]`
|
|
// short-circuits and node never runs — and crucially the single-quoted
|
|
// $(touch pwned) must not execute. Prove it: no `pwned` file appears and the
|
|
// guard exits 0.
|
|
if (process.platform !== 'win32') {
|
|
const runCwd = mkdtempSync(join(tmpdir(), 'imp-hook-run-'));
|
|
for (const command of commands) {
|
|
expect(command).toContain('[ ! -f ');
|
|
expect(command).not.toMatch(/"[^"]*\$\(touch pwned\)/);
|
|
execFileSync('/bin/sh', ['-c', command], { cwd: runCwd, stdio: 'ignore' });
|
|
}
|
|
expect(existsSync(join(runCwd, 'pwned'))).toBe(false);
|
|
rmSync(runCwd, { recursive: true, force: true });
|
|
}
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(skillHome, { recursive: true, force: true });
|
|
});
|
|
|
|
test('the Windows hook form keeps a usable double-quoted absolute path (#533)', () => {
|
|
// cmd.exe does no $(...) substitution but treats single quotes as literal,
|
|
// so the Windows command form must keep the absolute path double-quoted or
|
|
// a space in the install path would split the argument. copyProviderHooks
|
|
// branches on process.platform, so drive it as win32 in-process.
|
|
const original = process.platform;
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-win-'));
|
|
const skillHome = mkdtempSync(join(tmpdir(), 'imp-hook-win-home-'));
|
|
const bundleDir = createProjectDirBundle(tmp);
|
|
try {
|
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: skillHome });
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { value: original, configurable: true });
|
|
}
|
|
|
|
const absolute = join(skillHome, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
|
|
for (const command of claudeHookCommands(join(tmp, '.claude', 'settings.local.json'))) {
|
|
// Windows guard shape (node -e wrapper) with the absolute path double-quoted.
|
|
expect(command).toContain(`"${absolute}"`);
|
|
expect(command).not.toContain(`'${absolute}`);
|
|
expect(command).toContain('node -e');
|
|
}
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(skillHome, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ─── Update scope resolution (issue #399, part 2) ────────────────────────────
|
|
|
|
describe('skills update: names the resolved target and honors scope (#399)', () => {
|
|
test('user-level update writes an absolute, guarded hook to ~/.claude', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-user-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-update-user-home-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
|
|
// Seed a user-level install (skills only), then update it with hooks.
|
|
run('skills install -y --providers=claude --scope=global --no-hooks', { cwd: tmp, env });
|
|
expect(existsSync(join(home, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
|
|
|
const output = run('skills update -y --user', { cwd: tmp, env });
|
|
expect(output).toContain('user level'); // scope named explicitly
|
|
expect(output).toContain('~'); // resolved home path named, not a bare ".claude"
|
|
|
|
const settingsPath = join(home, '.claude', 'settings.local.json');
|
|
expect(existsSync(settingsPath)).toBe(true);
|
|
const raw = readFileSync(settingsPath, 'utf8');
|
|
expect(raw).toContain(join(home, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs'));
|
|
expect(raw).not.toContain('${CLAUDE_PROJECT_DIR}');
|
|
expect(raw).toContain('[ ! -f ');
|
|
// The project dir was never touched.
|
|
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(false);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 20000);
|
|
|
|
test('does not vendor impeccable into a repo that only tracks OTHER skills', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-vendor-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-update-vendor-home-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
|
|
// The repo tracks a first-party, NON-impeccable skill under .claude/skills.
|
|
writeSkill(tmp, '.claude', 'house-brand');
|
|
// A real user-level impeccable install exists.
|
|
run('skills install -y --providers=claude --scope=global --no-hooks', { cwd: tmp, env });
|
|
|
|
const output = run('skills update -y', { cwd: tmp, env });
|
|
// Targets the user level, not the project's unrelated .claude/skills.
|
|
expect(output).toContain('user level');
|
|
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(false);
|
|
expect(existsSync(join(tmp, '.claude', 'skills', 'house-brand'))).toBe(true);
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 20000);
|
|
|
|
test('--user with no user-level install reports the resolved user path', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-nouser-'));
|
|
const home = mkdtempSync(join(tmpdir(), 'imp-update-nouser-home-'));
|
|
execSync('git init', { cwd: tmp });
|
|
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
|
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
|
// Only a project install exists; --user must not fall through to it.
|
|
run('skills install -y --providers=claude --no-hooks', { cwd: tmp, env });
|
|
|
|
expect(() => run('skills update -y --user', { cwd: tmp, env, stdio: 'pipe' })).toThrow();
|
|
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
}, 20000);
|
|
});
|
|
|
|
// ─── Update fallback (remote direct download smoke) ──────────────────────────
|
|
|
|
describeRemote('skills update: refreshes from the production universal bundle', () => {
|
|
let tmp;
|
|
|
|
beforeAll(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), 'imp-test-update-'));
|
|
execSync('git init', { cwd: tmp });
|
|
|
|
// Stale impeccable skill that the update should overwrite with fresh,
|
|
// compiled content. v3.0 ships a single `impeccable` skill (with
|
|
// sub-commands), so it is the one the bundle refreshes.
|
|
const skillDir = join(tmp, '.claude', 'skills', 'impeccable');
|
|
mkdirSync(skillDir, { recursive: true });
|
|
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: impeccable\nstale: true\n---\nOld content.\n');
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('downloads the bundle and refreshes the impeccable skill', () => {
|
|
const output = run('skills update -y', { cwd: tmp });
|
|
expect(output).toContain('Updated');
|
|
|
|
// The skill now carries fresh, compiled content (no 'stale: true').
|
|
const content = readFileSync(join(tmp, '.claude', 'skills', 'impeccable', 'SKILL.md'), 'utf8');
|
|
expect(content).not.toContain('stale: true');
|
|
expect(content).toContain('name:');
|
|
}, 60000);
|
|
|
|
test('refreshed skill ships its compiled scripts directory', () => {
|
|
// The compiled variant bundles scripts/ (context loader, detector shim, ...).
|
|
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable', 'scripts'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── Full install remote smoke (downloads the production universal bundle) ───
|
|
|
|
describeRemote('skills install: production universal bundle download', () => {
|
|
let tmp;
|
|
|
|
beforeAll(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), 'imp-test-full-'));
|
|
execSync('git init', { cwd: tmp });
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
test('installs skills into a fresh project', () => {
|
|
const output = run('skills install -y', { cwd: tmp });
|
|
expect(output).toContain('Done!');
|
|
|
|
const hasSkills = ['.claude', '.cursor'].some(d => {
|
|
const dir = join(tmp, d, 'skills');
|
|
return existsSync(dir) && readdirSync(dir).length > 0;
|
|
});
|
|
expect(hasSkills).toBe(true);
|
|
}, 90000);
|
|
|
|
test('--force reinstall over an old prefixed install lands on canonical impeccable', () => {
|
|
// Seed a stale prefixed install, then reinstall. The migration should
|
|
// retire i-impeccable so we are left with the canonical name only.
|
|
const prefixed = join(tmp, '.claude', 'skills', 'i-impeccable');
|
|
mkdirSync(prefixed, { recursive: true });
|
|
writeFileSync(join(prefixed, 'SKILL.md'), '---\nname: i-impeccable\n---\n');
|
|
|
|
run('skills install -y --force', { cwd: tmp });
|
|
|
|
const skills = readdirSync(join(tmp, '.claude', 'skills'));
|
|
expect(skills).toContain('impeccable');
|
|
expect(skills).not.toContain('i-impeccable');
|
|
}, 90000);
|
|
});
|