Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/agents/src/commands/__tests__/install.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
141 changes: 78 additions & 63 deletions packages/agents/src/lib/__tests__/skill-transform.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand All @@ -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.');
Expand All @@ -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)',
Expand All @@ -88,15 +91,15 @@ 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']);
});

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',
Expand All @@ -108,16 +111,18 @@ 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/,
);
});

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/,
);
});
Expand All @@ -128,23 +133,23 @@ 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 () => {
// The heading slugs differently on each harness, so no single fragment addresses it. Checking ahead of the
// 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/,
);
});

it('throws on a broken include directive', async () => {
await writeSkill({ 'SKILL.md': '# Demo\n\n<!-- include: _partials/missing.md / -->\n' });

await expect(renderSkillDirectory(skillDir, 'demo', contentDir, context())).rejects.toThrow(
await expect(renderSkillDirectory(skillDir, 'demo', contentDir, buildContext())).rejects.toThrow(
DirectiveExpansionError,
);
});
Expand All @@ -155,7 +160,7 @@ describe(renderSkillDirectory, () => {
'reference/guide.md': '<!-- guidance-hook: glossary -->\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');
Expand All @@ -167,7 +172,10 @@ describe(renderSkillDirectory, () => {
'_partials/hook.md': '<!-- guidance-hook: implementation-preferences -->\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');
Expand All @@ -180,23 +188,23 @@ describe(renderSkillDirectory, () => {
'_partials/hook.md': '<!-- guidance-hook: preferences -->\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/,
);
});

it('rejects a malformed hook name', async () => {
await writeSkill({ 'SKILL.md': '# Demo\n\n<!-- guidance-hook: Mixed-Case -->\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 () => {
await writeSkill({ 'SKILL.md': '# Demo\n\n<!-- guidance-hook: impl -->\n\nProse.\n' });

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',
);

Expand All @@ -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',
);

Expand All @@ -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',
);

Expand All @@ -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',
);

Expand All @@ -250,18 +258,6 @@ describe(renderSkillDirectory, () => {

// region | Helpers

function context(overrides: Partial<SkillDeployContext> = {}): 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<RenderedSkillEntry>, relPath: string): string {
const entry = entries.find((candidate) => candidate.relPath === relPath);
Expand Down Expand Up @@ -298,59 +294,78 @@ 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<!-- guidance-hook: implementation-preferences -->\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<!-- guidance-hook: impl -->\n\nRows.\n', 'utf8');

const rendered = await renderSupportEntry(srcPath, '_data', contentDir, {
toolMapping: TOOL_MAPPING,
anchor: homeAnchor('.claude/skills'),
homeDir: '.claude',
harnessId: 'claude',
skillSigil: '/',
subagentSigil: '',
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' });
});

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<!-- guidance-hook: impl -->\n\nRows.\n', 'utf8');

const rendered = await renderSupportEntry(srcDir, '_data', contentDir, {
toolMapping: TOOL_MAPPING,
anchor: homeAnchor('.claude/skills'),
homeDir: '.claude',
harnessId: 'claude',
skillSigil: '/',
subagentSigil: '',
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 a deploy context targeting the Claude harness, with `overrides` applied over its defaults. */
function buildContext(overrides: Partial<SkillDeployContext> = {}): SkillDeployContext {
return {
toolMapping: TOOL_MAPPING,
anchor: homeAnchor('.claude/skills'),
homeDir: '.claude',
harnessId: 'claude',
skillSigil: '/',
subagentSigil: '',
...overrides,
};
}

// endregion | Helpers
12 changes: 12 additions & 0 deletions packages/agents/src/lib/__tests__/support-deploy.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Loading
Loading