|
| 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