import { readlink, realpath, rm, symlink } from 'node:fs/promises'; import { findPackageJSON } from 'node:module'; import { cwd, env, platform } from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { NodeHfs } from '@humanfs/node'; import { parse } from 'yaml'; import type { CommandContext } from '../context.ts'; import { relativeUrlPath, resolveLinkTarget } from '../utils.ts'; const hfs = new NodeHfs(); const SENTINEL_START = ''; const SENTINEL_END = ''; const GITIGNORE_START = '# bsh:skills'; const GITIGNORE_END = '# /bsh:skills'; export async function sync(_ctx: CommandContext): Promise { 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/', root) }); await updateGitignore({ root, skills }); await updateAgentsMd({ root, skills }); console.info(`Synced ${skills.length} skills to skills/`); } /** * 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//` directory, so links into it dangle * once a reinstall changes the hash. */ export async function resolveSkillsSource(root: URL, fallback: URL): Promise { const linked = new URL('node_modules/@bomb.sh/tools/skills/', root); return (await hfs.isDirectory(linked)) ? linked : fallback; } interface SkillInfo { name: string; description: string; } export async function copySkills(options: { source: URL; dest: URL }): Promise { const { source, dest } = options; const skills: SkillInfo[] = []; const keep = 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); // Use a path without a trailing slash. macOS rejects a trailing-slash link // path with ENOENT, and `rm` on a trailing-slash directory symlink follows // the link and deletes the source rather than unlinking the symlink itself. const linkPath = fileURLToPath(new URL(name, dest)); await rm(linkPath, { recursive: true, force: true }); const target = relativeUrlPath(dest, srcDir); await symlink(target, 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: { dest: URL; source: URL; keep: Set; }): Promise { const { dest, source, keep } = options; if (!(await hfs.isDirectory(dest))) return; // Links from older syncs may target the real path rather than `source`. 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 { // ignore unreadable links } } } async function updateGitignore(options: { root: URL; skills: SkillInfo[] }): Promise { 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 section = [GITIGNORE_START, ...lines, 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 + GITIGNORE_END.length); } else if (skills.length > 0) { const suffix = content.endsWith('\n') || content === '' ? '' : '\n'; content = content + suffix + '\n' + section + '\n'; } await hfs.write(gitignorePath, content); } export async function updateAgentsMd(options: { root: URL; skills: SkillInfo[] }): Promise { const { root, skills } = options; const agentsPath = new URL('AGENTS.md', root); let content = (await hfs.text(agentsPath)) ?? ''; 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 section = [ SENTINEL_START, '## @bomb.sh/tools Skills', '', 'When working on these tasks, read the linked skill file for guidance:', '', ...lines, 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 + SENTINEL_END.length); } else { const suffix = content.endsWith('\n') || content === '' ? '' : '\n'; content = content + suffix + '\n' + section + '\n'; } await hfs.write(agentsPath, content); } function parseFrontmatter(content: string): SkillInfo | undefined { const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content); if (!match) return undefined; const frontmatter = parse(match[1]!) as Record | null; if (!frontmatter) return undefined; const name = frontmatter.name as string | undefined; const description = (frontmatter.description as string | undefined)?.trim().replaceAll(/\s+/g, ' ') ?? ''; if (!name) return undefined; 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//, 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. */ export async function findParentPackage(): Promise { const startDir = env.INIT_CWD ?? cwd(); const candidate = findPackageJSON(pathToFileURL(`${startDir}/`)); if (!candidate) return null; const text = await hfs.text(pathToFileURL(candidate)); if (!text) return null; try { const pkg = JSON.parse(text) as { name?: string }; if (pkg.name === '@bomb.sh/tools') return null; } catch { return null; } return candidate; }