Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/sync-agents-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bomb.sh/tools': minor
---

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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ If you'd like to use this package for your own projects, please consider forking

## Agent Skills

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`.
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`.
71 changes: 68 additions & 3 deletions src/commands/sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { lstat, readlink, realpath, symlink } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { createFixture, createMocks } from '../test-utils/index.ts';
import { copySkills, findParentPackage, resolveSkillsSource, updateAgentsMd } from './sync.ts';
import {
copySkills,
findParentPackage,
resolveSkillsSource,
sync,
updateAgentsMd,
} from './sync.ts';

describe('copySkills', () => {
it('symlinks each skill into the destination', async () => {
Expand Down Expand Up @@ -127,7 +133,7 @@ describe('updateAgentsMd', () => {
});

expect(await fixture.text('AGENTS.md')).toContain(
'- **test** — [skills/test/SKILL.md](skills/test/SKILL.md) - Vitest test runner with colocated .test.ts files\n',
'- **test** — [.agents/skills/test/SKILL.md](.agents/skills/test/SKILL.md) - Vitest test runner with colocated .test.ts files\n',
);
});
});
Expand Down Expand Up @@ -180,3 +186,62 @@ describe('findParentPackage', () => {
expect(await findParentPackage()).toBe(null);
});
});

describe('sync', () => {
it('links skills into .agents/skills and replaces links from older syncs', async () => {
const fixture = await createFixture({
project: {
'package.json': '{ "name": "my-app" }',
'AGENTS.md': '# Project\n',
'.gitignore': 'node_modules\n\n# bsh:skills\nskills/lifecycle/\n# /bsh:skills\n',
// Left behind by an older version, now dangling.
skills: {
lifecycle: ({ symlink }) =>
symlink('../node_modules/.pnpm/old-hash/node_modules/@bomb.sh/tools/skills/lifecycle'),
'mine.md': 'user-owned',
},
},
});
// Resolve the real path so relative links survive a symlinked tmpdir (macOS).
const project = await realpath(fileURLToPath(new URL('project/', fixture.root)));
createMocks({ env: { INIT_CWD: project } });
vi.spyOn(console, 'info').mockImplementation(() => {});

await sync({ args: [] });

const link = fileURLToPath(new URL('project/.agents/skills/lifecycle', fixture.root));
expect((await lstat(link)).isSymbolicLink()).toBe(true);
expect(await fixture.text('project/.agents/skills/lifecycle/SKILL.md')).toContain(
'name: lifecycle',
);

await expect(
lstat(fileURLToPath(new URL('project/skills/lifecycle', fixture.root))),
).rejects.toThrow();
expect(await fixture.text('project/skills/mine.md')).toBe('user-owned');

const gitignore = await fixture.text('project/.gitignore');
expect(gitignore).toContain('.agents/skills/lifecycle/');
expect(gitignore).not.toMatch(/^skills\//m);

expect(await fixture.text('project/AGENTS.md')).toContain(
'[.agents/skills/lifecycle/SKILL.md](.agents/skills/lifecycle/SKILL.md)',
);
});

it('removes the legacy skills directory once it is empty', async () => {
const fixture = await createFixture({
project: {
'package.json': '{ "name": "my-app" }',
skills: { lifecycle: ({ symlink }) => symlink('../gone/lifecycle') },
},
});
const project = await realpath(fileURLToPath(new URL('project/', fixture.root)));
createMocks({ env: { INIT_CWD: project } });
vi.spyOn(console, 'info').mockImplementation(() => {});

await sync({ args: [] });

expect(await fixture.isDirectory('project/skills')).toBe(false);
});
});
34 changes: 30 additions & 4 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const SENTINEL_START = '<!-- bsh:skills -->';
const SENTINEL_END = '<!-- /bsh:skills -->';
const GITIGNORE_START = '# bsh:skills';
const GITIGNORE_END = '# /bsh:skills';
const SKILLS_DIR = '.agents/skills/';
// Where syncs before `.agents/skills/` linked skills.
const LEGACY_SKILLS_DIR = 'skills/';

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

const skills = await copySkills({ source, dest: new URL('skills/', root) });
const skills = await copySkills({ source, dest: new URL(SKILLS_DIR, root) });
await removeLegacyLinks({ dir: new URL(LEGACY_SKILLS_DIR, root), skills });
await updateGitignore({ root, skills });
await updateAgentsMd({ root, skills });

console.info(`Synced ${skills.length} skills to skills/`);
console.info(`Synced ${skills.length} skills to ${SKILLS_DIR}`);
}

/**
Expand Down Expand Up @@ -119,12 +123,33 @@ async function pruneStaleLinks(options: {
}
}

/**
* Remove links from syncs that predate `.agents/skills/`. Match by name, not
* target: after an upgrade those links point into a store path that may no
* longer exist. Anything that isn't a symlink is left alone.
*/
async function removeLegacyLinks(options: { dir: URL; skills: SkillInfo[] }): Promise<void> {
const { dir, skills } = options;
if (!(await hfs.isDirectory(dir))) return;

const names = new Set(skills.map((s) => s.name));
let remaining = 0;
for await (const entry of hfs.list(dir)) {
if (entry.isSymlink && names.has(entry.name)) {
await rm(fileURLToPath(new URL(entry.name, dir)));
} else {
remaining++;
}
}
if (remaining === 0) await hfs.delete(dir);
}

async function updateGitignore(options: { root: URL; skills: SkillInfo[] }): Promise<void> {
const { root, skills } = options;
const gitignorePath = new URL('.gitignore', root);
let content = (await hfs.text(gitignorePath)) ?? '';

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

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

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

const section = [
Expand Down
Loading