@bomb.sh/tools
Version:
The internal dev, build, and lint CLI for Bombshell projects
194 lines (193 loc) • 7.07 kB
JavaScript
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