From 964419a23ea424695909557085a2867221b33064 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 01:16:46 -0700 Subject: [PATCH 1/3] agents|refactor: Render skill Markdown from a single link-anchor path Relative Markdown links in a skill's files resolve against one caller-supplied path rather than a directory name and a file name composed at render time, which is what lets a flat support file share the same render. --- packages/agents/src/lib/skill-transform.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/agents/src/lib/skill-transform.ts b/packages/agents/src/lib/skill-transform.ts index bc2d1441..6d7204fb 100644 --- a/packages/agents/src/lib/skill-transform.ts +++ b/packages/agents/src/lib/skill-transform.ts @@ -139,7 +139,7 @@ async function collectEntries( out.push({ kind: 'markdown', relPath, - content: await renderMarkdown(srcPath, relPath, slug, contentRoot, context), + content: await renderMarkdown(srcPath, `${slug}/${relPath}`, contentRoot, context), }); } else { out.push({ kind: 'asset', relPath, srcPath }); @@ -160,11 +160,13 @@ async function collectEntries( * In-body anchors are validated on the filled text, ahead of every rewrite: an anchor-only target is never rewritten, * so the verdict holds for every harness and a heading carrying a `{tool:NAME}` token is correctly unaddressable. * Validating after the fill is what lets a collision between host and bound guidance be caught at all. + * + * `fileRelPath` is the file's own path relative to the deployed skills directory, which is what a relative Markdown + * link resolves against before the context's anchor maps the result to its deployed path. */ async function renderMarkdown( srcPath: string, - relPath: string, - slug: string, + fileRelPath: string, contentRoot: string, context: SkillDeployContext, ): Promise { @@ -174,7 +176,7 @@ async function renderMarkdown( assertFilledAnchorsResolve(filled, contextLabel); const toolRewritten = rewriteToolNames(filled.content, toolMapping, contextLabel); const invocationRewritten = rewriteInvocationTokens(toolRewritten, { skillSigil, subagentSigil }, contextLabel); - const pathRewritten = rewriteMarkdownPaths(invocationRewritten, `${slug}/${relPath}`, anchor); + const pathRewritten = rewriteMarkdownPaths(invocationRewritten, fileRelPath, anchor); return rewriteTemplateVariables(pathRewritten, homeDir, harnessId); } From 2d5b23b72ac32f710b2ee85e25b2ea6db0125c09 Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 01:25:51 -0700 Subject: [PATCH 2/3] agents|fix: Rewrite links and tokens in a Markdown file support entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Markdown file placed directly under a package's `skills/` directory now deploys with its relative links, invocation tokens, and template variables resolved, matching what a file inside a support directory receives. Such a file carrying a `{rulebook:…}` token now fails the run, the same way one inside a support directory does. --- .../commands/__tests__/install.unit.test.ts | 14 ++++ .../__tests__/skill-transform.unit.test.ts | 66 +++++++++++-------- .../lib/__tests__/support-deploy.unit.test.ts | 12 ++++ packages/agents/src/lib/skill-transform.ts | 26 +++----- 4 files changed, 75 insertions(+), 43 deletions(-) diff --git a/packages/agents/src/commands/__tests__/install.unit.test.ts b/packages/agents/src/commands/__tests__/install.unit.test.ts index 2900b66d..91f6e51d 100644 --- a/packages/agents/src/commands/__tests__/install.unit.test.ts +++ b/packages/agents/src/commands/__tests__/install.unit.test.ts @@ -383,6 +383,20 @@ describe(installCommand, () => { expect(existsSync(path.join(claudeHome, 'skills', 'flat-note.md'))).toBe(true); }); + it('rewrites its links, invocation tokens, and template variables on the way to the harness home', async () => { + const claudeHome = await setupClaudeHome(); + await writeFlatSkill( + '# Flat note\n\nSee [the table](_data/table.md), run {skill:commit}, then `{harness_home_dir}/scripts/x.sh`.\n', + ); + + await installCommand(makeOptions(), tempDir, contentDir); + + const installed = await readFile(path.join(claudeHome, 'skills', 'flat-note.md'), 'utf8'); + expect(installed).toContain('[the table](~/.claude/skills/_data/table.md)'); + expect(installed).toContain('run /commit,'); + expect(installed).toContain('`~/.claude/scripts/x.sh`'); + }); + it('fails the run when its anchor names no heading', async () => { await setupClaudeHome(); await writeFlatSkill('# Flat note\n\nSee [the events](#lifecycle-events).\n'); diff --git a/packages/agents/src/lib/__tests__/skill-transform.unit.test.ts b/packages/agents/src/lib/__tests__/skill-transform.unit.test.ts index 4c6d9960..b2e63e87 100644 --- a/packages/agents/src/lib/__tests__/skill-transform.unit.test.ts +++ b/packages/agents/src/lib/__tests__/skill-transform.unit.test.ts @@ -298,53 +298,51 @@ describe(renderSupportEntry, () => { await rm(contentDir, { recursive: true, force: true }); }); - it('strips a declared guidance hook from a flat Markdown support entry', async () => { - const srcPath = path.join(skillsDir, '_data', 'table.md'); - await mkdir(path.dirname(srcPath), { recursive: true }); + it('strips a declared guidance hook from a Markdown file support entry', async () => { + const srcPath = path.join(skillsDir, 'table.md'); await writeFile(srcPath, '# Table\n\n\n\nRows.\n', 'utf8'); - const rendered = await renderSupportEntry(srcPath, '_data', contentDir, { - toolMapping: TOOL_MAPPING, - anchor: homeAnchor('.claude/skills'), - homeDir: '.claude', - harnessId: 'claude', - skillSigil: '/', - subagentSigil: '', - }); + const rendered = await renderSupportEntry(srcPath, 'table.md', contentDir, buildContext()); expect(rendered).toEqual({ kind: 'markdown', content: '# Table\n\n\nRows.\n' }); }); it("strips a support entry's hook even when the caller carries a binding for it", async () => { - const srcPath = path.join(skillsDir, '_data', 'table.md'); - await mkdir(path.dirname(srcPath), { recursive: true }); + const srcPath = path.join(skillsDir, 'table.md'); await writeFile(srcPath, '# Table\n\n\n\nRows.\n', 'utf8'); - const rendered = await renderSupportEntry(srcPath, '_data', contentDir, { - toolMapping: TOOL_MAPPING, - anchor: homeAnchor('.claude/skills'), - homeDir: '.claude', - harnessId: 'claude', - skillSigil: '/', - subagentSigil: '', + const rendered = await renderSupportEntry(srcPath, 'table.md', contentDir, { + ...buildContext(), guidanceHookFills: new Map([['impl', [{ slug: 'layout', body: 'Bound guidance.\n' }]]]), }); expect(rendered).toEqual({ kind: 'markdown', content: '# Table\n\n\nRows.\n' }); }); + it('rewrites links, invocation tokens, and template variables in a Markdown file support entry', async () => { + const srcPath = path.join(skillsDir, 'glossary.md'); + await writeFile( + srcPath, + 'See [the table](_data/table.md), run {skill:commit} on {harness_id}, then `{harness_home_dir}/scripts/x.sh`.\n', + 'utf8', + ); + + const rendered = await renderSupportEntry(srcPath, 'glossary.md', contentDir, buildContext()); + + expect(rendered).toEqual({ + kind: 'markdown', + content: + 'See [the table](~/.claude/skills/_data/table.md), run /commit on claude, then `~/.claude/scripts/x.sh`.\n', + }); + }); + it("strips a hook in a support directory's entries, the route that renders through the skill transform", async () => { const srcDir = path.join(skillsDir, '_data'); await mkdir(srcDir, { recursive: true }); await writeFile(path.join(srcDir, 'table.md'), '# Table\n\n\n\nRows.\n', 'utf8'); const rendered = await renderSupportEntry(srcDir, '_data', contentDir, { - toolMapping: TOOL_MAPPING, - anchor: homeAnchor('.claude/skills'), - homeDir: '.claude', - harnessId: 'claude', - skillSigil: '/', - subagentSigil: '', + ...buildContext(), guidanceHookFills: new Map([['impl', [{ slug: 'layout', body: 'Bound guidance.\n' }]]]), }); @@ -353,4 +351,20 @@ describe(renderSupportEntry, () => { entries: [{ kind: 'markdown', relPath: 'table.md', content: '# Table\n\n\nRows.\n' }], }); }); + + // region | Helpers + + /** Builds the deploy context these cases share: the Claude harness's sigils, home directory, and link anchor. */ + function buildContext(): SkillDeployContext { + return { + toolMapping: TOOL_MAPPING, + anchor: homeAnchor('.claude/skills'), + homeDir: '.claude', + harnessId: 'claude', + skillSigil: '/', + subagentSigil: '', + }; + } + + // endregion | Helpers }); diff --git a/packages/agents/src/lib/__tests__/support-deploy.unit.test.ts b/packages/agents/src/lib/__tests__/support-deploy.unit.test.ts index b7b9ab20..c9754e52 100644 --- a/packages/agents/src/lib/__tests__/support-deploy.unit.test.ts +++ b/packages/agents/src/lib/__tests__/support-deploy.unit.test.ts @@ -44,6 +44,18 @@ describe('source support delivery', () => { ); }); + it('renders a Markdown file support entry, anchoring its links in the source namespace', async () => { + await writeSupportFile('glossary.md', 'See [the house style](_data/house-style.md), then run {skill:commit}.\n'); + await writeSupportFile('_data/house-style.md', '# House style\n'); + + const entries = await renderSourceSupport(sourceDir, context()); + + const glossary = entries.find((entry) => entry.relPath === 'glossary.md'); + expect(glossary?.kind === 'markdown' && glossary.content).toBe( + 'See [the house style](~/.claude/skills/_sources/org/_data/house-style.md), then run /commit.\n', + ); + }); + it('rewrites tool placeholders and template variables in support content', async () => { await writeSupportFile('_data/tools.md', 'Use {tool:Read}; run `{harness_home_dir}/scripts/x.sh`.\n'); diff --git a/packages/agents/src/lib/skill-transform.ts b/packages/agents/src/lib/skill-transform.ts index 6d7204fb..a3c6350e 100644 --- a/packages/agents/src/lib/skill-transform.ts +++ b/packages/agents/src/lib/skill-transform.ts @@ -1,15 +1,9 @@ import { readdir, stat } from 'node:fs/promises'; import path from 'node:path'; -import { assertAnchorsResolve } from './anchor-resolution.ts'; import { expandIncludes } from './directive-expander.ts'; import { isTestDirectory } from './fs-helpers.ts'; -import { - assertFilledAnchorsResolve, - fillGuidanceHooks, - type GuidanceHookFills, - stripGuidanceHooks, -} from './guidance-hooks.ts'; +import { assertFilledAnchorsResolve, fillGuidanceHooks, type GuidanceHookFills } from './guidance-hooks.ts'; import { rewriteInvocationTokens } from './invocation-tokens.ts'; import { type ResolveLinkAnchor, rewriteMarkdownPaths, rewriteTemplateVariables } from './path-rewriter.ts'; import { rewriteToolNames } from './tool-name-rewriter.ts'; @@ -79,19 +73,20 @@ export async function renderSkillDirectory( } /** - * Renders one `skills/` support entry the way an install materializes it: a directory through the whole skill - * transform, a Markdown file through include expansion, the guidance-hook strip, the anchor gate, and the tool-name - * rewrite, and anything else not at all, since it is copied byte-for-byte and has nothing to check. + * Renders one `skills/` support entry the way an install materializes it: every Markdown file through the whole skill + * transform, whether it sits in a support directory or directly under `skills/`, and anything else not at all, since + * it is copied byte-for-byte and has nothing to check. Shape decides how an entry is walked, never which rewrites + * apply to the Markdown it holds. * * A support entry never fills a hook, whichever route it takes, so any fills the caller carries are dropped here. A * support entry is reached by a link rather than inlined, and guidance behind a link is the thing the hook mechanism * exists to route around. * * Shared by the installer, which writes what comes back, and by `validate`, which discards it. Rendering is where a - * defect surfaces, so the pass that checks a support entry and the pass that ships it have to run the same one — when - * they did not, `validate` rejected a shape the installer copies without complaint. + * defect surfaces, so the pass that checks a support entry and the pass that ships it run the same one. * - * `destName` is the entry's deployed directory name, which anchors link rewriting for a directory entry. + * `destName` is the entry's deployed name, which anchors link rewriting: the directory a directory entry's files sit + * in, and the file's own name for a Markdown file entry. */ export async function renderSupportEntry( srcPath: string, @@ -106,10 +101,7 @@ export async function renderSupportEntry( if (!srcPath.endsWith('.md')) { return { kind: 'verbatim' }; } - const sourceLabel = path.relative(contentRoot, srcPath).split(path.sep).join('/'); - const expanded = stripGuidanceHooks(await expandIncludes(srcPath, contentRoot), sourceLabel); - assertAnchorsResolve(expanded, sourceLabel); - return { kind: 'markdown', content: rewriteToolNames(expanded, unbound.toolMapping, sourceLabel) }; + return { kind: 'markdown', content: await renderMarkdown(srcPath, destName, contentRoot, unbound) }; } // region | Helpers From 1baef6802429694f1ba4f56abaf9516087c14e3c Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Mon, 10 Aug 2026 01:48:59 -0700 Subject: [PATCH 3/3] agents|tests: Share one deploy-context builder in the transform tests Both suites in the skill-transform tests build their deploy context from a single module-scoped `buildContext`, so a new field on the context needs one edit rather than two. --- .../__tests__/skill-transform.unit.test.ts | 113 +++++++++--------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/packages/agents/src/lib/__tests__/skill-transform.unit.test.ts b/packages/agents/src/lib/__tests__/skill-transform.unit.test.ts index b2e63e87..461f037c 100644 --- a/packages/agents/src/lib/__tests__/skill-transform.unit.test.ts +++ b/packages/agents/src/lib/__tests__/skill-transform.unit.test.ts @@ -39,7 +39,7 @@ describe(renderSkillDirectory, () => { '_partials/frag.md': 'Shared fragment.\n', }); - const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, context()); + const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext()); const content = markdownContent(entries, 'SKILL.md'); expect(content).toContain('Shared fragment.'); @@ -57,14 +57,17 @@ describe(renderSkillDirectory, () => { '_partials/frag.md': 'Then invoke {skill:capture-event}.\n', }); - const claude = markdownContent(await renderSkillDirectory(skillDir, 'demo', contentDir, context()), 'SKILL.md'); + const claude = markdownContent( + await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext()), + 'SKILL.md', + ); expect(claude).toContain('Dispatch code-reviewer.'); expect(claude).toContain('Then invoke /capture-event.'); expect(claude).not.toContain('{skill:'); expect(claude).not.toContain('{subagent:'); const rovo = markdownContent( - await renderSkillDirectory(skillDir, 'demo', contentDir, context({ skillSigil: '!' })), + await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext({ skillSigil: '!' })), 'SKILL.md', ); expect(rovo).toContain('Then invoke !capture-event.'); @@ -73,7 +76,7 @@ describe(renderSkillDirectory, () => { it('rewrites a bare-relative link in a nested .md against the skill slug and prefix', async () => { await writeSkill({ 'SKILL.md': '# Demo\n', 'reference/guide.md': 'See [the data](../data/table.csv).\n' }); - const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, context()); + const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext()); expect(markdownContent(entries, 'reference/guide.md')).toContain( '[the data](~/.claude/skills/demo/data/table.csv)', @@ -88,7 +91,7 @@ describe(renderSkillDirectory, () => { 'reference/__tests__/nested.md': '# Nested\n', }); - const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, context()); + const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext()); expect(entries.map((entry) => entry.relPath)).toEqual(['SKILL.md']); }); @@ -96,7 +99,7 @@ describe(renderSkillDirectory, () => { it('returns non-.md files as assets pointing at the source path', async () => { await writeSkill({ 'SKILL.md': '# Demo\n', 'data/table.csv': 'a,b\n1,2\n' }); - const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, context()); + const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext()); expect(entries.find((entry) => entry.relPath === 'data/table.csv')).toEqual({ kind: 'asset', @@ -108,8 +111,10 @@ describe(renderSkillDirectory, () => { it('throws a file/line-anchored error for an unmapped tool placeholder', async () => { await writeSkill({ 'SKILL.md': '# Demo\n\nUse {tool:Bash}.\n' }); - await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).rejects.toThrow(ToolNameRewriteError); - await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).rejects.toThrow( + await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).rejects.toThrow( + ToolNameRewriteError, + ); + await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).rejects.toThrow( /skills\/demo\/SKILL\.md:3/, ); }); @@ -117,7 +122,7 @@ describe(renderSkillDirectory, () => { it('throws a source-labelled error for an anchor naming no heading in the same file', async () => { await writeSkill({ 'SKILL.md': '# Demo\n\nSee [the events](#lifecycle-events).\n' }); - await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).rejects.toThrow( + await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).rejects.toThrow( /skills\/demo\/SKILL\.md carries 1 unresolvable anchor link target/, ); }); @@ -128,7 +133,7 @@ describe(renderSkillDirectory, () => { '_partials/events.md': '## Lifecycle events\n', }); - await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).resolves.toBeDefined(); + await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).resolves.toBeDefined(); }); it('rejects an anchor to the rendered slug of a heading carrying a tool placeholder', async () => { @@ -136,7 +141,7 @@ describe(renderSkillDirectory, () => { // rewrite is what makes that unauthorable rather than live on one harness and dead on the other. await writeSkill({ 'SKILL.md': '# Demo\n\n## {tool:Read} return parsing\n\n[x](#read-return-parsing)\n' }); - await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).rejects.toThrow( + await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).rejects.toThrow( /#read-return-parsing -- names no heading/, ); }); @@ -144,7 +149,7 @@ describe(renderSkillDirectory, () => { it('throws on a broken include directive', async () => { await writeSkill({ 'SKILL.md': '# Demo\n\n\n' }); - await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).rejects.toThrow( + await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).rejects.toThrow( DirectiveExpansionError, ); }); @@ -155,7 +160,7 @@ describe(renderSkillDirectory, () => { 'reference/guide.md': '\nGuide.\n', }); - const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, context()); + const entries = await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext()); expect(markdownContent(entries, 'SKILL.md')).toBe('# Demo\n\n\nProse.\n'); expect(markdownContent(entries, 'reference/guide.md')).toBe('Guide.\n'); @@ -167,7 +172,10 @@ describe(renderSkillDirectory, () => { '_partials/hook.md': '\n', }); - const content = markdownContent(await renderSkillDirectory(skillDir, 'demo', contentDir, context()), 'SKILL.md'); + const content = markdownContent( + await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext()), + 'SKILL.md', + ); expect(content).not.toContain('guidance-hook'); expect(content).toBe('# Demo\n\n\nProse.\n'); @@ -180,7 +188,7 @@ describe(renderSkillDirectory, () => { '_partials/hook.md': '\n', }); - await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).rejects.toThrow( + await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).rejects.toThrow( /skills\/demo\/SKILL\.md:5 name="preferences" firstDeclaredAt=3 reason=duplicate-hook/, ); }); @@ -188,7 +196,7 @@ describe(renderSkillDirectory, () => { it('rejects a malformed hook name', async () => { await writeSkill({ 'SKILL.md': '# Demo\n\n\n' }); - await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).rejects.toThrow(GuidanceHookError); + await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).rejects.toThrow(GuidanceHookError); }); it('fills a declared guidance hook with the guidance bound to it', async () => { @@ -196,7 +204,7 @@ describe(renderSkillDirectory, () => { const fills = new Map([['impl', [{ slug: 'layout', body: '# Layout\n\nGroup source by role.\n' }]]]); const content = markdownContent( - await renderSkillDirectory(skillDir, 'demo', contentDir, context({ guidanceHookFills: fills })), + await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext({ guidanceHookFills: fills })), 'SKILL.md', ); @@ -213,7 +221,7 @@ describe(renderSkillDirectory, () => { ['impl', [{ slug: 'layout', body: 'See [naming](~/.claude/skills/_data/naming.md) under `~/.claude`.\n' }]], ]); const content = markdownContent( - await renderSkillDirectory(skillDir, 'demo', contentDir, context({ guidanceHookFills: fills })), + await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext({ guidanceHookFills: fills })), 'SKILL.md', ); @@ -228,7 +236,7 @@ describe(renderSkillDirectory, () => { const fills = new Map([['impl', [{ slug: 'layout', body: 'Bound guidance.\n' }]]]); const content = markdownContent( - await renderSkillDirectory(skillDir, 'demo', contentDir, context({ guidanceHookFills: fills })), + await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext({ guidanceHookFills: fills })), 'SKILL.md', ); @@ -240,7 +248,7 @@ describe(renderSkillDirectory, () => { const fills = new Map([['impl', [{ slug: 'layout', body: 'Bound guidance.\n' }]]]); const content = markdownContent( - await renderSkillDirectory(skillDir, 'demo', contentDir, context({ guidanceHookFills: fills })), + await renderSkillDirectory(skillDir, 'demo', contentDir, buildContext({ guidanceHookFills: fills })), 'SKILL.md', ); @@ -250,18 +258,6 @@ describe(renderSkillDirectory, () => { // region | Helpers - function context(overrides: Partial = {}): SkillDeployContext { - return { - toolMapping: TOOL_MAPPING, - anchor: homeAnchor('.claude/skills'), - homeDir: '.claude', - harnessId: 'claude', - skillSigil: '/', - subagentSigil: '', - ...overrides, - }; - } - /** Returns the transformed content of the markdown entry at relPath, failing if it is absent or an asset. */ function markdownContent(entries: ReadonlyArray, relPath: string): string { const entry = entries.find((candidate) => candidate.relPath === relPath); @@ -311,10 +307,12 @@ describe(renderSupportEntry, () => { const srcPath = path.join(skillsDir, 'table.md'); await writeFile(srcPath, '# Table\n\n\n\nRows.\n', 'utf8'); - const rendered = await renderSupportEntry(srcPath, 'table.md', contentDir, { - ...buildContext(), - guidanceHookFills: new Map([['impl', [{ slug: 'layout', body: 'Bound guidance.\n' }]]]), - }); + const rendered = await renderSupportEntry( + srcPath, + 'table.md', + contentDir, + buildContext({ guidanceHookFills: new Map([['impl', [{ slug: 'layout', body: 'Bound guidance.\n' }]]]) }), + ); expect(rendered).toEqual({ kind: 'markdown', content: '# Table\n\n\nRows.\n' }); }); @@ -341,30 +339,33 @@ describe(renderSupportEntry, () => { await mkdir(srcDir, { recursive: true }); await writeFile(path.join(srcDir, 'table.md'), '# Table\n\n\n\nRows.\n', 'utf8'); - const rendered = await renderSupportEntry(srcDir, '_data', contentDir, { - ...buildContext(), - guidanceHookFills: new Map([['impl', [{ slug: 'layout', body: 'Bound guidance.\n' }]]]), - }); + const rendered = await renderSupportEntry( + srcDir, + '_data', + contentDir, + buildContext({ guidanceHookFills: new Map([['impl', [{ slug: 'layout', body: 'Bound guidance.\n' }]]]) }), + ); expect(rendered).toEqual({ kind: 'directory', entries: [{ kind: 'markdown', relPath: 'table.md', content: '# Table\n\n\nRows.\n' }], }); }); - - // region | Helpers - - /** Builds the deploy context these cases share: the Claude harness's sigils, home directory, and link anchor. */ - function buildContext(): SkillDeployContext { - return { - toolMapping: TOOL_MAPPING, - anchor: homeAnchor('.claude/skills'), - homeDir: '.claude', - harnessId: 'claude', - skillSigil: '/', - subagentSigil: '', - }; - } - - // endregion | Helpers }); + +// region | Helpers + +/** Builds a deploy context targeting the Claude harness, with `overrides` applied over its defaults. */ +function buildContext(overrides: Partial = {}): SkillDeployContext { + return { + toolMapping: TOOL_MAPPING, + anchor: homeAnchor('.claude/skills'), + homeDir: '.claude', + harnessId: 'claude', + skillSigil: '/', + subagentSigil: '', + ...overrides, + }; +} + +// endregion | Helpers