Skip to content

Commit 52fd524

Browse files
authored
feat(sync): link skills into .agents/skills (#69)
* test(sync): cover syncing into .agents/skills * feat(sync): link skills into .agents/skills Also removes links left in skills/ by earlier syncs, matched by name since their targets may no longer exist. * test(sync): avoid shadowing the fs symlink import
1 parent ee9dcfd commit 52fd524

4 files changed

Lines changed: 106 additions & 10 deletions

File tree

‎.changeset/sync-agents-skills.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@bomb.sh/tools': minor
3+
---
4+
5+
Changes `bsh sync` to link skills into `.agents/skills/` instead of `skills/`. Re-run `bsh sync` to move existing links; it removes the old `skills/` links and updates the `.gitignore` and `AGENTS.md` entries.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ If you'd like to use this package for your own projects, please consider forking
1616

1717
## Agent Skills
1818

19-
If you use an AI coding agent, run `pnpm bsh sync` to symlink this package's skill files into your project's `skills/` directory. Synced skills are automatically added to your `.gitignore`, and an index of them is maintained in your `AGENTS.md`. Claude Code users: add `@AGENTS.md` to your project's `CLAUDE.md`.
19+
If you use an AI coding agent, run `pnpm bsh sync` to symlink this package's skill files into your project's `.agents/skills/` directory. Synced skills are automatically added to your `.gitignore`, and an index of them is maintained in your `AGENTS.md`.

‎src/commands/sync.test.ts‎

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import { lstat, readlink, realpath, symlink } from 'node:fs/promises';
1+
import { lstat, readlink, realpath, symlink as fsSymlink } from 'node:fs/promises';
22
import { fileURLToPath } from 'node:url';
3-
import { describe, it, expect } from 'vitest';
3+
import { describe, it, expect, vi } from 'vitest';
44
import { createFixture, createMocks } from '../test-utils/index.ts';
5-
import { copySkills, findParentPackage, resolveSkillsSource, updateAgentsMd } from './sync.ts';
5+
import {
6+
copySkills,
7+
findParentPackage,
8+
resolveSkillsSource,
9+
sync,
10+
updateAgentsMd,
11+
} from './sync.ts';
612

713
describe('copySkills', () => {
814
it('symlinks each skill into the destination', async () => {
@@ -96,7 +102,7 @@ describe('copySkills', () => {
96102
},
97103
});
98104
// Left behind by an older sync that linked through the real path.
99-
await symlink(
105+
await fsSymlink(
100106
`${await realpath(fileURLToPath(new URL('store/skills/', fixture.root)))}/removed`,
101107
fileURLToPath(new URL('project/skills/removed', fixture.root)),
102108
);
@@ -127,7 +133,7 @@ describe('updateAgentsMd', () => {
127133
});
128134

129135
expect(await fixture.text('AGENTS.md')).toContain(
130-
'- **test** — [skills/test/SKILL.md](skills/test/SKILL.md) - Vitest test runner with colocated .test.ts files\n',
136+
'- **test** — [.agents/skills/test/SKILL.md](.agents/skills/test/SKILL.md) - Vitest test runner with colocated .test.ts files\n',
131137
);
132138
});
133139
});
@@ -180,3 +186,62 @@ describe('findParentPackage', () => {
180186
expect(await findParentPackage()).toBe(null);
181187
});
182188
});
189+
190+
describe('sync', () => {
191+
it('links skills into .agents/skills and replaces links from older syncs', async () => {
192+
const fixture = await createFixture({
193+
project: {
194+
'package.json': '{ "name": "my-app" }',
195+
'AGENTS.md': '# Project\n',
196+
'.gitignore': 'node_modules\n\n# bsh:skills\nskills/lifecycle/\n# /bsh:skills\n',
197+
// Left behind by an older version, now dangling.
198+
skills: {
199+
lifecycle: ({ symlink }) =>
200+
symlink('../node_modules/.pnpm/old-hash/node_modules/@bomb.sh/tools/skills/lifecycle'),
201+
'mine.md': 'user-owned',
202+
},
203+
},
204+
});
205+
// Resolve the real path so relative links survive a symlinked tmpdir (macOS).
206+
const project = await realpath(fileURLToPath(new URL('project/', fixture.root)));
207+
createMocks({ env: { INIT_CWD: project } });
208+
vi.spyOn(console, 'info').mockImplementation(() => {});
209+
210+
await sync({ args: [] });
211+
212+
const link = fileURLToPath(new URL('project/.agents/skills/lifecycle', fixture.root));
213+
expect((await lstat(link)).isSymbolicLink()).toBe(true);
214+
expect(await fixture.text('project/.agents/skills/lifecycle/SKILL.md')).toContain(
215+
'name: lifecycle',
216+
);
217+
218+
await expect(
219+
lstat(fileURLToPath(new URL('project/skills/lifecycle', fixture.root))),
220+
).rejects.toThrow();
221+
expect(await fixture.text('project/skills/mine.md')).toBe('user-owned');
222+
223+
const gitignore = await fixture.text('project/.gitignore');
224+
expect(gitignore).toContain('.agents/skills/lifecycle/');
225+
expect(gitignore).not.toMatch(/^skills\//m);
226+
227+
expect(await fixture.text('project/AGENTS.md')).toContain(
228+
'[.agents/skills/lifecycle/SKILL.md](.agents/skills/lifecycle/SKILL.md)',
229+
);
230+
});
231+
232+
it('removes the legacy skills directory once it is empty', async () => {
233+
const fixture = await createFixture({
234+
project: {
235+
'package.json': '{ "name": "my-app" }',
236+
skills: { lifecycle: ({ symlink }) => symlink('../gone/lifecycle') },
237+
},
238+
});
239+
const project = await realpath(fileURLToPath(new URL('project/', fixture.root)));
240+
createMocks({ env: { INIT_CWD: project } });
241+
vi.spyOn(console, 'info').mockImplementation(() => {});
242+
243+
await sync({ args: [] });
244+
245+
expect(await fixture.isDirectory('project/skills')).toBe(false);
246+
});
247+
});

‎src/commands/sync.ts‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ const SENTINEL_START = '<!-- bsh:skills -->';
1313
const SENTINEL_END = '<!-- /bsh:skills -->';
1414
const GITIGNORE_START = '# bsh:skills';
1515
const GITIGNORE_END = '# /bsh:skills';
16+
const SKILLS_DIR = '.agents/skills/';
17+
// Where syncs before `.agents/skills/` linked skills.
18+
const LEGACY_SKILLS_DIR = 'skills/';
1619

1720
export async function sync(_ctx: CommandContext): Promise<void> {
1821
const parentPkg = await findParentPackage();
@@ -29,11 +32,12 @@ export async function sync(_ctx: CommandContext): Promise<void> {
2932
return;
3033
}
3134

32-
const skills = await copySkills({ source, dest: new URL('skills/', root) });
35+
const skills = await copySkills({ source, dest: new URL(SKILLS_DIR, root) });
36+
await removeLegacyLinks({ dir: new URL(LEGACY_SKILLS_DIR, root), skills });
3337
await updateGitignore({ root, skills });
3438
await updateAgentsMd({ root, skills });
3539

36-
console.info(`Synced ${skills.length} skills to skills/`);
40+
console.info(`Synced ${skills.length} skills to ${SKILLS_DIR}`);
3741
}
3842

3943
/**
@@ -119,12 +123,33 @@ async function pruneStaleLinks(options: {
119123
}
120124
}
121125

126+
/**
127+
* Remove links from syncs that predate `.agents/skills/`. Match by name, not
128+
* target: after an upgrade those links point into a store path that may no
129+
* longer exist. Anything that isn't a symlink is left alone.
130+
*/
131+
async function removeLegacyLinks(options: { dir: URL; skills: SkillInfo[] }): Promise<void> {
132+
const { dir, skills } = options;
133+
if (!(await hfs.isDirectory(dir))) return;
134+
135+
const names = new Set(skills.map((s) => s.name));
136+
let remaining = 0;
137+
for await (const entry of hfs.list(dir)) {
138+
if (entry.isSymlink && names.has(entry.name)) {
139+
await rm(fileURLToPath(new URL(entry.name, dir)));
140+
} else {
141+
remaining++;
142+
}
143+
}
144+
if (remaining === 0) await hfs.delete(dir);
145+
}
146+
122147
async function updateGitignore(options: { root: URL; skills: SkillInfo[] }): Promise<void> {
123148
const { root, skills } = options;
124149
const gitignorePath = new URL('.gitignore', root);
125150
let content = (await hfs.text(gitignorePath)) ?? '';
126151

127-
const lines = skills.map((s) => `skills/${s.name}/`);
152+
const lines = skills.map((s) => `${SKILLS_DIR}${s.name}/`);
128153
const section = [GITIGNORE_START, ...lines, GITIGNORE_END].join('\n');
129154

130155
const startIdx = content.indexOf(GITIGNORE_START);
@@ -147,7 +172,8 @@ export async function updateAgentsMd(options: { root: URL; skills: SkillInfo[] }
147172

148173
const lines = skills.map((s) => {
149174
const desc = s.description.split(/\.(?:\s|$)/)[0]?.trim();
150-
return `- **${s.name}** — [skills/${s.name}/SKILL.md](skills/${s.name}/SKILL.md)${desc ? ` - ${desc}` : ''}`;
175+
const path = `${SKILLS_DIR}${s.name}/SKILL.md`;
176+
return `- **${s.name}** — [${path}](${path})${desc ? ` - ${desc}` : ''}`;
151177
});
152178

153179
const section = [

0 commit comments

Comments
 (0)