Skip to content

Commit 47e2634

Browse files
committed
feat(sync): add sync command
1 parent 89051e2 commit 47e2634

3 files changed

Lines changed: 173 additions & 2 deletions

File tree

README.md

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

1515
## Agent Skills
1616

17-
If you use an AI coding agent, run `pnpm add -D @bomb.sh/tools` then have your agent run `pnpm dlx @tanstack/intent@latest install` to load project-specific skills for `@bomb.sh/tools`.
17+
If you use an AI coding agent, run `pnpm bsh sync` to copy skill files into your project's `skills/` directory. Claude Code users: add `@AGENTS.md` to your project's `CLAUDE.md`.

src/bin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import { dev } from "./commands/dev.ts";
55
import { format } from "./commands/format.ts";
66
import { init } from "./commands/init.ts";
77
import { lint } from "./commands/lint.ts";
8+
import { sync } from "./commands/sync.ts";
89
import { test } from "./commands/test.ts";
910

10-
const commands = { build, dev, format, init, lint, test };
11+
const commands = { build, dev, format, init, lint, sync, test };
1112

1213
async function main() {
1314
const [command, ...args] = argv.slice(2);

src/commands/sync.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2+
import { cwd } from "node:process";
3+
import { pathToFileURL } from "node:url";
4+
import { parse } from "ultramatter";
5+
import type { CommandContext } from "../context.ts";
6+
7+
const SENTINEL_START = "<!-- bsh:skills -->";
8+
const SENTINEL_END = "<!-- /bsh:skills -->";
9+
10+
export async function sync(_ctx: CommandContext): Promise<void> {
11+
const root = pathToFileURL(`${cwd()}/`);
12+
13+
if (await isSelf(root)) {
14+
console.info("Skipping sync — running inside @bomb.sh/tools");
15+
return;
16+
}
17+
18+
const source = new URL("node_modules/@bomb.sh/tools/skills/", root);
19+
if (!(await exists(source))) {
20+
console.error("@bomb.sh/tools is not installed. Run `pnpm add -D @bomb.sh/tools` first.");
21+
return;
22+
}
23+
24+
const skills = await copySkills({ source, dest: new URL("skills/", root) });
25+
await updateGitignore({ root, skills });
26+
await updateAgentsMd({ root, skills });
27+
28+
console.info(`Synced ${skills.length} skills to skills/`);
29+
}
30+
31+
interface SkillInfo {
32+
name: string;
33+
description: string;
34+
}
35+
36+
async function copySkills(options: { source: URL; dest: URL }): Promise<SkillInfo[]> {
37+
const { source, dest } = options;
38+
const skills: SkillInfo[] = [];
39+
const entries = await readdir(source, { withFileTypes: true });
40+
41+
for (const entry of entries) {
42+
if (!entry.isDirectory()) continue;
43+
if (entry.name.startsWith("_")) continue;
44+
45+
const srcDir = new URL(`${entry.name}/`, source);
46+
const destDir = new URL(`${entry.name}/`, dest);
47+
48+
await rm(destDir, { recursive: true, force: true });
49+
await copyDir({ source: srcDir, dest: destDir });
50+
51+
const skillMd = new URL("SKILL.md", destDir);
52+
if (await exists(skillMd)) {
53+
const content = await readFile(skillMd, "utf8");
54+
const frontmatter = parseFrontmatter(content);
55+
if (frontmatter) {
56+
skills.push(frontmatter);
57+
}
58+
}
59+
}
60+
61+
return skills;
62+
}
63+
64+
async function copyDir(options: { source: URL; dest: URL }): Promise<void> {
65+
const { source, dest } = options;
66+
await mkdir(dest, { recursive: true });
67+
const entries = await readdir(source, { withFileTypes: true });
68+
69+
for (const entry of entries) {
70+
const srcPath = new URL(entry.name, source);
71+
const destPath = new URL(entry.name, dest);
72+
73+
if (entry.isDirectory()) {
74+
if (entry.name.startsWith("_")) continue;
75+
await copyDir({ source: new URL(`${entry.name}/`, source), dest: new URL(`${entry.name}/`, dest) });
76+
} else {
77+
await copyFile(srcPath, destPath);
78+
}
79+
}
80+
}
81+
82+
async function updateGitignore(options: { root: URL; skills: SkillInfo[] }): Promise<void> {
83+
const { root, skills } = options;
84+
const gitignorePath = new URL(".gitignore", root);
85+
let content = "";
86+
if (await exists(gitignorePath)) {
87+
content = await readFile(gitignorePath, "utf8");
88+
}
89+
90+
const missing: string[] = [];
91+
for (const skill of skills) {
92+
const entry = `skills/${skill.name}/`;
93+
if (!content.includes(entry)) {
94+
missing.push(entry);
95+
}
96+
}
97+
98+
if (missing.length === 0) return;
99+
100+
const suffix = content.endsWith("\n") || content === "" ? "" : "\n";
101+
const section = `${suffix}\n# @bomb.sh/tools skills (synced)\n${missing.join("\n")}\n`;
102+
await writeFile(gitignorePath, content + section, "utf8");
103+
}
104+
105+
async function updateAgentsMd(options: { root: URL; skills: SkillInfo[] }): Promise<void> {
106+
const { root, skills } = options;
107+
const agentsPath = new URL("AGENTS.md", root);
108+
let content = "";
109+
if (await exists(agentsPath)) {
110+
content = await readFile(agentsPath, "utf8");
111+
}
112+
113+
const lines = skills.map((s) => {
114+
const desc = s.description.split(".")[0].trim();
115+
return `- **${s.name}** — [skills/${s.name}/SKILL.md](skills/${s.name}/SKILL.md) — ${desc}`;
116+
});
117+
118+
const section = [
119+
SENTINEL_START,
120+
"## @bomb.sh/tools Skills",
121+
"",
122+
"When working on these tasks, read the linked skill file for guidance:",
123+
"",
124+
...lines,
125+
SENTINEL_END,
126+
].join("\n");
127+
128+
const startIdx = content.indexOf(SENTINEL_START);
129+
const endIdx = content.indexOf(SENTINEL_END);
130+
131+
if (startIdx !== -1 && endIdx !== -1) {
132+
content = content.slice(0, startIdx) + section + content.slice(endIdx + SENTINEL_END.length);
133+
} else {
134+
const suffix = content.endsWith("\n") || content === "" ? "" : "\n";
135+
content = content + suffix + "\n" + section + "\n";
136+
}
137+
138+
await writeFile(agentsPath, content, "utf8");
139+
}
140+
141+
function parseFrontmatter(content: string): SkillInfo | undefined {
142+
const { frontmatter } = parse(content);
143+
if (!frontmatter) return undefined;
144+
const name = frontmatter.name as string | undefined;
145+
const description = (frontmatter.description as string | undefined)?.trim().replaceAll(/\s+/g, " ") ?? "";
146+
if (!name) return undefined;
147+
return { name, description };
148+
}
149+
150+
async function isSelf(root: URL): Promise<boolean> {
151+
const pkgPath = new URL("package.json", root);
152+
if (!(await exists(pkgPath))) return false;
153+
const content = await readFile(pkgPath, "utf8");
154+
const pkg = JSON.parse(content) as { name?: string };
155+
return pkg.name === "@bomb.sh/tools";
156+
}
157+
158+
async function exists(url: URL): Promise<boolean> {
159+
try {
160+
await readdir(url);
161+
return true;
162+
} catch {
163+
try {
164+
await readFile(url);
165+
return true;
166+
} catch {
167+
return false;
168+
}
169+
}
170+
}

0 commit comments

Comments
 (0)