/** * Unit coverage for the plugin subtree script-path rewrite (issue #523). * * The ./plugin subtree copies the dist/claude-code output, whose * {{scripts_path}} resolves to the project-relative * `.claude/skills/impeccable/scripts`. Run from the plugin cache, that path * points into the user's project: a plugin-only user gets MODULE_NOT_FOUND, * and a dual-install user silently runs the project's older skill copy. The * rewrite uses `${CLAUDE_SKILL_DIR}` only in SKILL.md; raw references keep * the explicit `` placeholder. It removes allowed-tools * and drops the node pre-approval: no frontmatter rule can bind approval to * the loaded plugin root, and an unbound wildcard would auto-approve any * same-shaped path anywhere on disk. */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { execFileSync } from 'node:child_process'; import { parseFrontmatter } from '../scripts/lib/utils.js'; import { rewritePluginMarkdown, rewritePluginAgentMarkdown, rewritePluginMarkdownTree, verifyPluginSkillRewrite, verifyPluginAgentRewrite, CLAUDE_PROJECT_SCRIPTS_PATH, AGENT_EMBED_FALLBACK, } from '../scripts/lib/plugin-paths.js'; describe('rewritePluginMarkdown', () => { test('rewrites a script instruction to the quoted CLAUDE_SKILL_DIR form', () => { const input = 'Run `node .claude/skills/impeccable/scripts/context.mjs` once per session.'; expect(rewritePluginMarkdown(input)).toBe( 'Run `node "${CLAUDE_SKILL_DIR}/scripts/context.mjs"` once per session.', ); }); test('rewrites every occurrence, quoting the script path but not the arguments', () => { const input = [ 'node .claude/skills/impeccable/scripts/live.mjs', 'node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done', ].join('\n'); const output = rewritePluginMarkdown(input); expect(output).not.toContain(CLAUDE_PROJECT_SCRIPTS_PATH); expect(output).toContain('node "${CLAUDE_SKILL_DIR}/scripts/live.mjs"'); expect(output).toContain('node "${CLAUDE_SKILL_DIR}/scripts/live-poll.mjs" --reply EVENT_ID done'); }); test('quotes the engine launcher path and leaves the verb outside the quotes', () => { const output = rewritePluginMarkdown( 'Run `/scripts/impeccable context` once, then `/scripts/impeccable.cmd doctor --json`; ' + 'already quoted: `"/scripts/impeccable" hooks on`.', ); expect(output).toContain('`"${CLAUDE_SKILL_DIR}/scripts/impeccable" context`'); expect(output).toContain('`"${CLAUDE_SKILL_DIR}/scripts/impeccable.cmd" doctor --json`'); expect(output).not.toContain('""${CLAUDE_SKILL_DIR}'); }); test('quotes commands already in the skill-base-dir form without double-quoting', () => { // SKILL.src.md's Setup step 1 carries the token form natively; a base // directory with spaces splits an unquoted path before node sees it. const input = 'Run `node /scripts/context.mjs` once per session. ' + 'Already quoted: `node "/scripts/detect.mjs"`.'; expect(rewritePluginMarkdown(input)).toBe( 'Run `node "${CLAUDE_SKILL_DIR}/scripts/context.mjs"` once per session. ' + 'Already quoted: `node "${CLAUDE_SKILL_DIR}/scripts/detect.mjs"`.', ); }); test('removes the entire allowed-tools frontmatter block', () => { const input = [ '---', 'name: impeccable', 'allowed-tools:', ' - Bash(npx impeccable *)', ' - Bash(.claude/skills/impeccable/scripts/impeccable *)', 'license: Apache 2.0', '---', '', 'Body text.', ].join('\n'); const output = rewritePluginMarkdown(input); expect(output).not.toMatch(/^allowed-tools:/m); expect(output).not.toContain('scripts/impeccable *'); expect(output).not.toContain('npx impeccable'); expect(output).toContain('license: Apache 2.0'); expect(output).toContain('Body text.'); }); test('drops the project-path fallback clause from Setup step 1', () => { const input = '1. Run `node /scripts/context.mjs` once per session, where `` is the ' + "loaded base directory the runtime reports for this skill; keep cwd at the user's project. " + 'That base directory resolves every `.claude/skills/impeccable/scripts/impeccable ` command in this skill ' + 'and its references, and `.claude/skills/impeccable/scripts` is the fallback only when the runtime ' + 'reports no base directory. Pass a named source file or route as `--target `.'; const output = rewritePluginMarkdown(input); expect(output).toContain( 'Every `"${CLAUDE_SKILL_DIR}/scripts/impeccable" ` command in this skill and its references resolves against that base directory.', ); // The naive rewrite would keep the fallback clause and name the token as // its own fallback for when there is no base directory to resolve it. expect(output).not.toContain('fallback'); expect(output).not.toContain(CLAUDE_PROJECT_SCRIPTS_PATH); }); test('leaves unrelated project-relative paths alone', () => { const input = 'State lives in `.impeccable/live/roots.json` and `.claude/settings.json`.'; expect(rewritePluginMarkdown(input)).toBe(input); }); }); describe('rewritePluginAgentMarkdown', () => { // The real source sentence shape: command, purpose clause, next sentence. const sourceStep = 'after every generation, run `node .claude/skills/impeccable/scripts/embed-prompt.mjs ' + '--prompt ""` so the prompt lives inside the image itself. The build thread ' + 'composes what you made.'; test('rewrites agent instructions to the quoted plugin-root variable form', () => { // A spawned agent never loads SKILL.md, so the token // Setup defines is unresolvable in its prompt. Claude Code substitutes // ${CLAUDE_PLUGIN_ROOT} inline in plugin agent content. const output = rewritePluginAgentMarkdown(sourceStep); expect(output).toContain( 'run `node "${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/embed-prompt.mjs" --prompt ""`', ); }); test('appends the sidecar fallback as its own sentence, not mid-sentence', () => { const output = rewritePluginAgentMarkdown(sourceStep); // The fallback follows the full embed sentence and precedes the next one. expect(output).toContain( `so the prompt lives inside the image itself.${AGENT_EMBED_FALLBACK} The build thread`, ); }); test('never emits the skill-base-dir token into an agent file', () => { const output = rewritePluginAgentMarkdown(sourceStep); expect(output).not.toContain(''); expect(output).not.toContain(CLAUDE_PROJECT_SCRIPTS_PATH); }); }); describe('verifyPluginAgentRewrite', () => { let root; beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-agent-verify-')); }); afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); const writeAgent = (contents) => { const p = path.join(root, 'agent.md'); fs.writeFileSync(p, contents); return p; }; const sourceStep = 'run `node .claude/skills/impeccable/scripts/embed-prompt.mjs --prompt "

"` ' + 'so the prompt lives inside the image itself. Next sentence.'; test('accepts a correctly rewritten agent file', () => { const p = writeAgent(rewritePluginAgentMarkdown(sourceStep)); expect(() => verifyPluginAgentRewrite(p)).not.toThrow(); }); test('fails when an unresolvable path form survives', () => { const p = writeAgent('run `node "/scripts/embed-prompt.mjs"` please.'); expect(() => verifyPluginAgentRewrite(p)).toThrow(/cannot\s+resolve/); }); test('fails when the embed instruction lost its fallback sentence', () => { // Simulate a source rewording that breaks the fallback anchor: the // sentence-splice regex no-ops when no period follows the command. const reworded = sourceStep.replace( ' so the prompt lives inside the image itself. Next sentence.', ' -- no closing period', ); const p = writeAgent(rewritePluginAgentMarkdown(reworded)); expect(() => verifyPluginAgentRewrite(p)).toThrow(/sidecar/); }); }); describe('rewritePluginMarkdownTree', () => { let root; beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-plugin-paths-')); }); afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); test('rewrites .md files recursively and leaves scripts untouched', () => { const write = (rel, contents) => { const abs = path.join(root, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, contents); }; write('SKILL.md', 'Run `node .claude/skills/impeccable/scripts/context.mjs`.'); write('reference/live.md', 'node .claude/skills/impeccable/scripts/live.mjs'); // hook-admin.mjs installs project-scoped hooks; its project path is correct. write( 'scripts/hook-admin.mjs', 'const cmd = \'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"\';', ); rewritePluginMarkdownTree(root); expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf-8')).toBe( 'Run `node "${CLAUDE_SKILL_DIR}/scripts/context.mjs"`.', ); expect(fs.readFileSync(path.join(root, 'reference/live.md'), 'utf-8')).toBe( 'node "/scripts/live.mjs"', ); expect(fs.readFileSync(path.join(root, 'scripts/hook-admin.mjs'), 'utf-8')).toContain( '${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs', ); }); test('raw references retain an explicit base-directory placeholder, including Windows commands', () => { const input = 'Run `.claude/skills/impeccable/scripts/impeccable context` or `/scripts/impeccable.cmd context`.'; const output = rewritePluginMarkdown(input, { isSkillEntrypoint: false }); expect(output).toBe('Run `"/scripts/impeccable" context` or `"/scripts/impeccable.cmd" context`.'); expect(output).not.toContain('${CLAUDE_SKILL_DIR}'); expect(rewritePluginMarkdown(output, { isSkillEntrypoint: false })).toBe(output); }); test('entrypoint and explicitly resolved reference commands work from a cache path with spaces', () => { const skillDir = path.join(root, 'plugin cache', 'skills', 'impeccable'); fs.mkdirSync(path.join(skillDir, 'scripts'), { recursive: true }); fs.writeFileSync(path.join(skillDir, 'scripts/probe.mjs'), 'console.log(process.argv[2]);'); const env = { ...process.env }; delete env.CLAUDE_SKILL_DIR; for (const isSkillEntrypoint of [true, false]) { const command = rewritePluginMarkdown('node .claude/skills/impeccable/scripts/probe.mjs resolved', { isSkillEntrypoint }) .replaceAll(isSkillEntrypoint ? '${CLAUDE_SKILL_DIR}' : '', skillDir); expect(execFileSync('sh', ['-c', command], { env, encoding: 'utf8' }).trim()).toBe('resolved'); } }); test('applies the agent rewrite when passed for an agents tree', () => { const agentsDir = path.join(root, 'agents'); fs.mkdirSync(agentsDir, { recursive: true }); fs.writeFileSync( path.join(agentsDir, 'impeccable-asset-producer.md'), 'run `node .claude/skills/impeccable/scripts/embed-prompt.mjs `', ); rewritePluginMarkdownTree(agentsDir, rewritePluginAgentMarkdown); expect(fs.readFileSync(path.join(agentsDir, 'impeccable-asset-producer.md'), 'utf-8')).toBe( 'run `node "${CLAUDE_PLUGIN_ROOT}/skills/impeccable/scripts/embed-prompt.mjs" `', ); }); test('is a no-op on a missing directory', () => { expect(() => rewritePluginMarkdownTree(path.join(root, 'does-not-exist'))).not.toThrow(); }); }); describe('verifyPluginSkillRewrite', () => { let root; beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-plugin-verify-')); }); afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); }); const writeSkill = (contents) => { const p = path.join(root, 'SKILL.md'); fs.writeFileSync(p, contents); return p; }; const goodSkill = [ '1. Run `/scripts/impeccable context` once per session, where `` is the ' + "loaded base directory the runtime reports for this skill; keep cwd at the user's project. " + 'That base directory resolves every `.claude/skills/impeccable/scripts/impeccable ` command in this skill ' + 'and its references, and `.claude/skills/impeccable/scripts` is the fallback only when the runtime ' + 'reports no base directory.', ].join('\n'); test('accepts a correctly rewritten SKILL.md', () => { const p = writeSkill(rewritePluginMarkdown(goodSkill)); expect(() => verifyPluginSkillRewrite(p)).not.toThrow(); expect(fs.readFileSync(p, 'utf-8')).not.toMatch(/^allowed-tools:/m); }); test('fails the build when the Setup fallback sentence no longer matched', () => { // Simulate SKILL.src.md rewording step 1: the sentence replacement // no-ops, so the plugin copy keeps the project path as its fallback. const reworded = goodSkill.replace('is the fallback only when', 'is used only when'); const p = writeSkill(rewritePluginMarkdown(reworded)); expect(() => verifyPluginSkillRewrite(p)).toThrow(/Setup step 1 fallback sentence/); }); test('fails the build when a launcher pre-approval survives the removal', () => { const p = writeSkill( rewritePluginMarkdown(goodSkill) + '\n - Bash(${CLAUDE_SKILL_DIR}/scripts/impeccable.cmd *)\n', ); expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher/); }); test('fails the build when allowed-tools frontmatter survives the removal', () => { const p = writeSkill([ '---', 'name: impeccable', 'allowed-tools:', ' - Bash(npx impeccable *)', 'license: Apache 2.0', '---', '', rewritePluginMarkdown(goodSkill), ].join('\n')); expect(() => verifyPluginSkillRewrite(p)).toThrow(/allowed-tools/); }); test('fails the build when a legacy node pre-approval survives', () => { // A copy that still carries a node pre-approval must fail the same way as // a surviving launcher line, even outside an allowed-tools block. const p = writeSkill( rewritePluginMarkdown(goodSkill) + '\n - Bash(node ${CLAUDE_SKILL_DIR}/scripts/*)\n', ); expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher or node script path/); }); test('fails the build when the project-relative scripts path survives at all', () => { // Simulate a path shape the replacements don't know: the rewritten copy // still names the project scripts directory somewhere new. const p = writeSkill( rewritePluginMarkdown(goodSkill) + '\nState lives next to `.claude/skills/impeccable/scripts` on disk.', ); expect(() => verifyPluginSkillRewrite(p)).toThrow(/still contains the project-relative scripts path/); }); test('fails the build when the skill-base-dir token survives the rewrite', () => { const p = writeSkill(rewritePluginMarkdown(goodSkill).replace('${CLAUDE_SKILL_DIR}', '')); expect(() => verifyPluginSkillRewrite(p)).toThrow(/still contains the token/); }); }); describe('SKILL.src.md frontmatter', () => { test('keeps allowed-tools in source for non-Claude providers (issue #736)', () => { const src = fs.readFileSync( path.join(import.meta.dirname, '../skill/SKILL.src.md'), 'utf-8', ); const { frontmatter } = parseFrontmatter(src); expect(frontmatter['allowed-tools']).toBeDefined(); }); }); describe('SKILL.src.md Setup step 1 authoring contract (issue #744)', () => { test('disambiguates the skill-base-dir token', () => { const setup = fs.readFileSync( path.join(import.meta.dirname, '../skill/SKILL.src.md'), 'utf-8', ).replace(/\r\n?/g, '\n'); const step1 = setup.match(/^1\. .+$/m)?.[0] ?? ''; expect(step1).toMatch(/skill folder, not a plugin root/); }); });