UNPKG

@bomb.sh/tools

Version:

The internal dev, build, and lint CLI for Bombshell projects

194 lines (193 loc) • 7.07 kB
import { relativeUrlPath, resolveLinkTarget } from "../utils.mjs"; import { findPackageJSON } from "node:module"; import { cwd, env, platform } from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { readlink, realpath, rm, symlink } from "node:fs/promises"; import { NodeHfs } from "@humanfs/node"; import { parse } from "yaml"; //#region src/commands/sync.ts const hfs = new NodeHfs(); 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/"; const LEGACY_SKILLS_DIR = "skills/"; async function sync(_ctx) { const parentPkg = await findParentPackage(); if (!parentPkg) { console.info("Skipping sync — no parent project found (running inside @bomb.sh/tools?)"); return; } const root = new URL("./", pathToFileURL(parentPkg)); const source = await resolveSkillsSource(root, new URL("../../skills/", import.meta.url)); if (!await hfs.isDirectory(source)) { console.error("Could not locate bundled skills directory."); return; } 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_DIR}`); } /** * Prefer linking through the project's `node_modules/@bomb.sh/tools` over * `import.meta.url`, which resolves to the real path. Under pnpm that is a * versioned `node_modules/.pnpm/<hash>/` directory, so links into it dangle * once a reinstall changes the hash. */ async function resolveSkillsSource(root, fallback) { const linked = new URL("node_modules/@bomb.sh/tools/skills/", root); return await hfs.isDirectory(linked) ? linked : fallback; } async function copySkills(options) { const { source, dest } = options; const skills = []; const keep = /* @__PURE__ */ new Set(); for await (const entry of hfs.list(source)) if (entry.isDirectory && !entry.name.startsWith("_")) keep.add(entry.name); await hfs.createDirectory(dest); await pruneStaleLinks({ dest, source, keep }); const linkType = platform === "win32" ? "junction" : "dir"; for (const name of keep) { const srcDir = new URL(`${name}/`, source); const linkPath = fileURLToPath(new URL(name, dest)); await rm(linkPath, { recursive: true, force: true }); await symlink(relativeUrlPath(dest, srcDir), linkPath, linkType); const content = await hfs.text(new URL("SKILL.md", srcDir)); if (content) { const frontmatter = parseFrontmatter(content); if (frontmatter) skills.push(frontmatter); } } return skills; } async function pruneStaleLinks(options) { const { dest, source, keep } = options; if (!await hfs.isDirectory(dest)) return; const sources = [source.href, `${pathToFileURL(await realpath(source)).href}/`]; for await (const entry of hfs.list(dest)) { if (!entry.isSymlink) continue; if (keep.has(entry.name)) continue; const linkPath = fileURLToPath(new URL(entry.name, dest)); try { const { href } = resolveLinkTarget(dest, await readlink(linkPath)); if (sources.some((s) => href.startsWith(s))) await hfs.deleteAll(linkPath); } catch {} } } /** * 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) { 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) { const { root, skills } = options; const gitignorePath = new URL(".gitignore", root); let content = await hfs.text(gitignorePath) ?? ""; const section = [ GITIGNORE_START, ...skills.map((s) => `${SKILLS_DIR}${s.name}/`), GITIGNORE_END ].join("\n"); const startIdx = content.indexOf(GITIGNORE_START); const endIdx = content.indexOf(GITIGNORE_END); if (startIdx !== -1 && endIdx !== -1) content = content.slice(0, startIdx) + section + content.slice(endIdx + 13); else if (skills.length > 0) { const suffix = content.endsWith("\n") || content === "" ? "" : "\n"; content = content + suffix + "\n" + section + "\n"; } await hfs.write(gitignorePath, content); } async function updateAgentsMd(options) { const { root, skills } = options; const agentsPath = new URL("AGENTS.md", root); let content = await hfs.text(agentsPath) ?? ""; const section = [ SENTINEL_START, "## @bomb.sh/tools Skills", "", "When working on these tasks, read the linked skill file for guidance:", "", ...skills.map((s) => { const desc = s.description.split(/\.(?:\s|$)/)[0]?.trim(); const path = `${SKILLS_DIR}${s.name}/SKILL.md`; return `- **${s.name}** — [${path}](${path})${desc ? ` - ${desc}` : ""}`; }), SENTINEL_END ].join("\n"); const startIdx = content.indexOf(SENTINEL_START); const endIdx = content.indexOf(SENTINEL_END); if (startIdx !== -1 && endIdx !== -1) content = content.slice(0, startIdx) + section + content.slice(endIdx + 20); else { const suffix = content.endsWith("\n") || content === "" ? "" : "\n"; content = content + suffix + "\n" + section + "\n"; } await hfs.write(agentsPath, content); } function parseFrontmatter(content) { const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content); if (!match) return void 0; const frontmatter = parse(match[1]); if (!frontmatter) return void 0; const name = frontmatter.name; const description = frontmatter.description?.trim().replaceAll(/\s+/g, " ") ?? ""; if (!name) return void 0; return { name, description }; } /** * Locate the consuming project's package.json. The project root must come * from where the command was invoked, never from this package's physical * location: under pnpm's isolated layout, import.meta.url resolves through * the node_modules symlink into node_modules/.pnpm/<hash>/, and walking up * from there lands in the store, not the user's project. INIT_CWD (set by * pnpm/npm to the directory the script was run from) is preferred because * package scripts may rewrite cwd. Returns null when no project is found or * when invoked inside @bomb.sh/tools itself. */ async function findParentPackage() { const candidate = findPackageJSON(pathToFileURL(`${env.INIT_CWD ?? cwd()}/`)); if (!candidate) return null; const text = await hfs.text(pathToFileURL(candidate)); if (!text) return null; try { if (JSON.parse(text).name === "@bomb.sh/tools") return null; } catch { return null; } return candidate; } //#endregion export { copySkills, findParentPackage, resolveSkillsSource, sync, updateAgentsMd }; //# sourceMappingURL=sync.mjs.map