[READ-ONLY] Mirror of https://github.com/bombshell-dev/tools. Internal CLI to standardize tooling across all Bombshell projects
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(cli): add sync command (#24)

* feat(sync): remove tanstack intent

* feat(sync): add sync command

* chore(deps): install ultramatter

* ref(sync): symlink skills instead of copy

* fix(sync): remove outdated symlinks on sync

* ref(sync): update isSelf implementation

* ref(sync): use safeRead

* chore(sync): add changeset

authored by

Nate Moore and committed by
GitHub
(May 7, 2026, 8:59 PM EDT) fe061805 e3f58337

+227 -290
+5
.changeset/lovely-tables-boil.md
··· 1 + --- 2 + "@bomb.sh/tools": minor 3 + --- 4 + 5 + Add `sync` command to automatically symlink skills to the local project
-143
.github/workflows/check-skills.yml
··· 1 - # check-skills.yml — Drop this into your library repo's .github/workflows/ 2 - # 3 - # Checks for stale intent skills after a release and opens a review PR 4 - # if any skills need attention. The PR body includes a prompt you can 5 - # paste into Claude Code, Cursor, or any coding agent to update them. 6 - # 7 - # Triggers: new release published, or manual workflow_dispatch. 8 - # 9 - # Template variables (replaced by `intent setup`): 10 - # @bomb.sh/tools — e.g. @tanstack/query or my-workspace workspace 11 - 12 - name: Check Skills 13 - 14 - on: 15 - release: 16 - types: [published] 17 - workflow_dispatch: {} 18 - 19 - permissions: 20 - contents: write 21 - pull-requests: write 22 - 23 - jobs: 24 - check: 25 - name: Check for stale skills 26 - runs-on: ubuntu-latest 27 - steps: 28 - - name: Checkout 29 - uses: actions/checkout@v4 30 - with: 31 - fetch-depth: 0 32 - 33 - - name: Setup Node 34 - uses: actions/setup-node@v4 35 - with: 36 - node-version: 20 37 - 38 - - name: Install intent 39 - run: npm install -g @tanstack/intent 40 - 41 - - name: Check staleness 42 - id: stale 43 - run: | 44 - OUTPUT=$(intent stale --json 2>&1) || true 45 - echo "$OUTPUT" 46 - 47 - # Check if any skills need review 48 - NEEDS_REVIEW=$(echo "$OUTPUT" | node -e " 49 - const input = require('fs').readFileSync('/dev/stdin','utf8'); 50 - try { 51 - const reports = JSON.parse(input); 52 - const stale = reports.flatMap(r => 53 - r.skills.filter(s => s.needsReview).map(s => ({ library: r.library, skill: s.name, reasons: s.reasons })) 54 - ); 55 - if (stale.length > 0) { 56 - console.log(JSON.stringify(stale)); 57 - } 58 - } catch {} 59 - ") 60 - 61 - if [ -z "$NEEDS_REVIEW" ]; then 62 - echo "has_stale=false" >> "$GITHUB_OUTPUT" 63 - else 64 - echo "has_stale=true" >> "$GITHUB_OUTPUT" 65 - # Escape for multiline GH output 66 - EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) 67 - echo "stale_json<<$EOF" >> "$GITHUB_OUTPUT" 68 - echo "$NEEDS_REVIEW" >> "$GITHUB_OUTPUT" 69 - echo "$EOF" >> "$GITHUB_OUTPUT" 70 - fi 71 - 72 - - name: Build summary 73 - if: steps.stale.outputs.has_stale == 'true' 74 - id: summary 75 - run: | 76 - node -e " 77 - const stale = JSON.parse(process.env.STALE_JSON); 78 - const lines = stale.map(s => 79 - '- **' + s.skill + '** (' + s.library + '): ' + s.reasons.join(', ') 80 - ); 81 - const summary = lines.join('\n'); 82 - 83 - const prompt = [ 84 - 'Review and update the following stale intent skills for @bomb.sh/tools:', 85 - '', 86 - ...stale.map(s => '- ' + s.skill + ': ' + s.reasons.join(', ')), 87 - '', 88 - 'For each stale skill:', 89 - '1. Read the current SKILL.md file', 90 - '2. Check what changed in the library since the skill was last updated', 91 - '3. Update the skill content to reflect current APIs and behavior', 92 - '4. Run \`npx @tanstack/intent validate\` to verify the updated skill', 93 - ].join('\n'); 94 - 95 - // Write outputs 96 - const fs = require('fs'); 97 - const env = fs.readFileSync(process.env.GITHUB_OUTPUT, 'utf8'); 98 - const eof = require('crypto').randomBytes(15).toString('base64'); 99 - fs.appendFileSync(process.env.GITHUB_OUTPUT, 100 - 'summary<<' + eof + '\n' + summary + '\n' + eof + '\n' + 101 - 'prompt<<' + eof + '\n' + prompt + '\n' + eof + '\n' 102 - ); 103 - " 104 - env: 105 - STALE_JSON: ${{ steps.stale.outputs.stale_json }} 106 - 107 - - name: Open review PR 108 - if: steps.stale.outputs.has_stale == 'true' 109 - env: 110 - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 111 - run: | 112 - VERSION="${{ github.event.release.tag_name || 'manual' }}" 113 - BRANCH="skills/review-${VERSION}" 114 - 115 - git config user.name "github-actions[bot]" 116 - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 117 - git checkout -b "$BRANCH" 118 - git commit --allow-empty -m "chore: review stale skills for ${VERSION}" 119 - git push origin "$BRANCH" 120 - 121 - gh pr create \ 122 - --title "Review stale skills (${VERSION})" \ 123 - --body "$(cat <<'PREOF' 124 - ## Stale Skills Detected 125 - 126 - The following skills may need updates after the latest release: 127 - 128 - ${{ steps.summary.outputs.summary }} 129 - 130 - --- 131 - 132 - ### Update Prompt 133 - 134 - Paste this into your coding agent (Claude Code, Cursor, etc.): 135 - 136 - ~~~ 137 - ${{ steps.summary.outputs.prompt }} 138 - ~~~ 139 - 140 - PREOF 141 - )" \ 142 - --head "$BRANCH" \ 143 - --base main
-51
.github/workflows/notify-intent.yml
··· 1 - # notify-intent.yml — Drop this into your library repo's .github/workflows/ 2 - # 3 - # Fires a repository_dispatch event whenever docs or source files change 4 - # on merge to main. This triggers the skill staleness check workflow. 5 - # 6 - # Requirements: 7 - # - A fine-grained PAT with contents:write on this repository stored 8 - # as the INTENT_NOTIFY_TOKEN repository secret. 9 - # 10 - # Template variables (replaced by `intent setup`): 11 - # @bomb.sh/tools — e.g. @tanstack/query or my-workspace workspace 12 - # docs/** — e.g. docs/** 13 - # src/** — e.g. packages/query-core/src/** 14 - 15 - name: Trigger Skill Review 16 - 17 - on: 18 - push: 19 - branches: [main] 20 - paths: 21 - - "docs/**" 22 - - "src/**" 23 - 24 - jobs: 25 - notify: 26 - name: Trigger Skill Review 27 - runs-on: ubuntu-latest 28 - steps: 29 - - name: Checkout 30 - uses: actions/checkout@v4 31 - with: 32 - fetch-depth: 2 33 - 34 - - name: Collect changed files 35 - id: changes 36 - run: | 37 - FILES=$(git diff --name-only HEAD~1 HEAD | jq -R -s -c 'split("\n") | map(select(length > 0))') 38 - echo "files=$FILES" >> "$GITHUB_OUTPUT" 39 - 40 - - name: Dispatch to intent repo 41 - uses: peter-evans/repository-dispatch@v3 42 - with: 43 - token: ${{ secrets.INTENT_NOTIFY_TOKEN }} 44 - repository: ${{ github.repository }} 45 - event-type: skill-check 46 - client-payload: | 47 - { 48 - "package": "@bomb.sh/tools", 49 - "sha": "${{ github.sha }}", 50 - "changed_files": ${{ steps.changes.outputs.files }} 51 - }
-52
.github/workflows/validate-skills.yml
··· 1 - # validate-skills.yml — Drop this into your library repo's .github/workflows/ 2 - # 3 - # Validates skill files on PRs that touch the skills/ directory. 4 - # Ensures frontmatter is correct, names match paths, and files stay under 5 - # the 500-line limit. 6 - 7 - name: Validate Skills 8 - 9 - on: 10 - pull_request: 11 - paths: 12 - - "skills/**" 13 - - "**/skills/**" 14 - 15 - jobs: 16 - validate: 17 - name: Validate skill files 18 - runs-on: ubuntu-latest 19 - steps: 20 - - name: Checkout 21 - uses: actions/checkout@v4 22 - 23 - - name: Setup Node 24 - uses: actions/setup-node@v4 25 - with: 26 - node-version: 20 27 - 28 - - name: Install intent CLI 29 - run: npm install -g @tanstack/intent 30 - 31 - - name: Find and validate skills 32 - run: | 33 - # Find all directories containing SKILL.md files 34 - SKILLS_DIR="" 35 - if [ -d "skills" ]; then 36 - SKILLS_DIR="skills" 37 - elif [ -d "packages" ]; then 38 - # Monorepo — find skills/ under packages 39 - for dir in packages/*/skills; do 40 - if [ -d "$dir" ]; then 41 - echo "Validating $dir..." 42 - intent validate "$dir" 43 - fi 44 - done 45 - exit 0 46 - fi 47 - 48 - if [ -n "$SKILLS_DIR" ]; then 49 - intent validate "$SKILLS_DIR" 50 - else 51 - echo "No skills/ directory found — skipping validation." 52 - fi
+1 -1
README.md
··· 14 14 15 15 ## Agent Skills 16 16 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`.
+2 -3
package.json
··· 6 6 "bombshell", 7 7 "cli", 8 8 "internal", 9 - "skills", 10 - "tanstack-intent" 9 + "skills" 11 10 ], 12 11 "homepage": "https://bomb.sh", 13 12 "license": "MIT", ··· 71 70 "publint": "^0.3.18", 72 71 "tinyexec": "^1.1.1", 73 72 "tsdown": "^0.21.10", 73 + "ultramatter": "^0.0.4", 74 74 "vitest": "^4.1.2", 75 75 "vitest-ansi-serializer": "^0.2.1" 76 76 }, 77 77 "devDependencies": { 78 78 "@changesets/cli": "^2.30.0", 79 - "@tanstack/intent": "^0.0.27", 80 79 "@types/node": "^22.19.15" 81 80 }, 82 81 "volta": {
+8 -18
pnpm-lock.yaml
··· 38 38 tsdown: 39 39 specifier: ^0.21.10 40 40 version: 0.21.10(@typescript/native-preview@7.0.0-dev.20260427.1)(oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(publint@0.3.18)(typescript@5.9.3) 41 + ultramatter: 42 + specifier: ^0.0.4 43 + version: 0.0.4 41 44 vitest: 42 45 specifier: ^4.1.2 43 46 version: 4.1.2(@types/node@22.19.15)(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(yaml@2.8.3)) ··· 48 51 '@changesets/cli': 49 52 specifier: ^2.30.0 50 53 version: 2.30.0(@types/node@22.19.15) 51 - '@tanstack/intent': 52 - specifier: ^0.0.27 53 - version: 0.0.27 54 54 '@types/node': 55 55 specifier: ^22.19.15 56 56 version: 22.19.15 ··· 1047 1047 '@standard-schema/spec@1.1.0': 1048 1048 resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 1049 1049 1050 - '@tanstack/intent@0.0.27': 1051 - resolution: {integrity: sha512-R8U4uadLrp9uosW5G1J5s4RQtT7F0S2oqIfWX6OD7uObuM6vJLO0jZzw+Et7H3PFMnue3snjdzJYX5BmNV1RXA==} 1052 - hasBin: true 1053 - 1054 1050 '@tybys/wasm-util@0.10.1': 1055 1051 resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} 1056 1052 ··· 1189 1185 resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} 1190 1186 engines: {node: '>=8'} 1191 1187 1192 - cac@6.7.14: 1193 - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} 1194 - engines: {node: '>=8'} 1195 - 1196 1188 cac@7.0.0: 1197 1189 resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} 1198 1190 engines: {node: '>=20.19.0'} ··· 1701 1693 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 1702 1694 engines: {node: '>=14.17'} 1703 1695 hasBin: true 1696 + 1697 + ultramatter@0.0.4: 1698 + resolution: {integrity: sha512-1f/hO3mR+/Hgue4eInOF/Qm/wzDqwhYha4DxM0hre9YIUyso3fE2XtrAU6B4njLqTC8CM49EZaYgsVSa+dXHGw==} 1704 1699 1705 1700 unbash@3.0.0: 1706 1701 resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} ··· 2541 2536 2542 2537 '@standard-schema/spec@1.1.0': {} 2543 2538 2544 - '@tanstack/intent@0.0.27': 2545 - dependencies: 2546 - cac: 6.7.14 2547 - yaml: 2.8.3 2548 - 2549 2539 '@tybys/wasm-util@0.10.1': 2550 2540 dependencies: 2551 2541 tslib: 2.8.1 ··· 2671 2661 braces@3.0.3: 2672 2662 dependencies: 2673 2663 fill-range: 7.1.1 2674 - 2675 - cac@6.7.14: {} 2676 2664 2677 2665 cac@7.0.0: {} 2678 2666 ··· 3248 3236 3249 3237 typescript@5.9.3: 3250 3238 optional: true 3239 + 3240 + ultramatter@0.0.4: {} 3251 3241 3252 3242 unbash@3.0.0: {} 3253 3243
+23 -22
src/bin.ts
··· 1 1 #!/usr/bin/env node 2 - import { argv } from 'node:process'; 3 - import { build } from './commands/build.ts'; 4 - import { dev } from './commands/dev.ts'; 5 - import { format } from './commands/format.ts'; 6 - import { init } from './commands/init.ts'; 7 - import { lint } from './commands/lint.ts'; 8 - import { publintCommand as publint } from './commands/publint.ts'; 9 - import { test } from './commands/test.ts'; 2 + import { argv } from "node:process"; 3 + import { build } from "./commands/build.ts"; 4 + import { dev } from "./commands/dev.ts"; 5 + import { format } from "./commands/format.ts"; 6 + import { init } from "./commands/init.ts"; 7 + import { lint } from "./commands/lint.ts"; 8 + import { publintCommand as publint } from "./commands/publint.ts"; 9 + import { sync } from "./commands/sync.ts"; 10 + import { test } from "./commands/test.ts"; 10 11 11 - const commands = { build, dev, format, init, lint, publint, test }; 12 + const commands = { build, dev, format, init, lint, publint, sync, test }; 12 13 13 14 async function main() { 14 - const [command, ...args] = argv.slice(2); 15 + const [command, ...args] = argv.slice(2); 15 16 16 - if (!command) { 17 - console.log(`No command provided. Available commands: ${Object.keys(commands).join(', ')}\n`); 18 - return; 19 - } 17 + if (!command) { 18 + console.log(`No command provided. Available commands: ${Object.keys(commands).join(", ")}\n`); 19 + return; 20 + } 20 21 21 - const run = commands[command as keyof typeof commands]; 22 - if (!run) { 23 - console.log( 24 - `Unknown command: ${command}. Available commands: ${Object.keys(commands).join(', ')}`, 25 - ); 26 - return; 27 - } 22 + const run = commands[command as keyof typeof commands]; 23 + if (!run) { 24 + console.log( 25 + `Unknown command: ${command}. Available commands: ${Object.keys(commands).join(", ")}`, 26 + ); 27 + return; 28 + } 28 29 29 - await run({ args }); 30 + await run({ args }); 30 31 } 31 32 32 33 main();
+188
src/commands/sync.ts
··· 1 + import type { Dirent } from "node:fs"; 2 + import { mkdir, readdir, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; 3 + import { isAbsolute, relative, resolve } from "node:path"; 4 + import { cwd, platform } from "node:process"; 5 + import { fileURLToPath, pathToFileURL } from "node:url"; 6 + import { parse } from "ultramatter"; 7 + import type { CommandContext } from "../context.ts"; 8 + 9 + const SENTINEL_START = "<!-- bsh:skills -->"; 10 + const SENTINEL_END = "<!-- /bsh:skills -->"; 11 + const GITIGNORE_START = "# bsh:skills"; 12 + const GITIGNORE_END = "# /bsh:skills"; 13 + 14 + export async function sync(_ctx: CommandContext): Promise<void> { 15 + const root = pathToFileURL(`${cwd()}/`); 16 + 17 + if (await isSelf(root)) { 18 + console.info("Skipping sync — running inside @bomb.sh/tools"); 19 + return; 20 + } 21 + 22 + const source = new URL("node_modules/@bomb.sh/tools/skills/", root); 23 + if (!(await safeRead(source))) { 24 + console.error("@bomb.sh/tools is not installed. Run `pnpm add -D @bomb.sh/tools` first."); 25 + return; 26 + } 27 + 28 + const skills = await copySkills({ source, dest: new URL("skills/", root) }); 29 + await updateGitignore({ root, skills }); 30 + await updateAgentsMd({ root, skills }); 31 + 32 + console.info(`Synced ${skills.length} skills to skills/`); 33 + } 34 + 35 + interface SkillInfo { 36 + name: string; 37 + description: string; 38 + } 39 + 40 + async function copySkills(options: { source: URL; dest: URL }): Promise<SkillInfo[]> { 41 + const { source, dest } = options; 42 + const skills: SkillInfo[] = []; 43 + const entries = await readdir(source, { withFileTypes: true }); 44 + const keep = new Set<string>( 45 + entries.filter((e) => e.isDirectory() && !e.name.startsWith("_")).map((e) => e.name), 46 + ); 47 + 48 + await mkdir(dest, { recursive: true }); 49 + await pruneStaleLinks({ dest, source, keep }); 50 + 51 + const destDirPath = fileURLToPath(dest); 52 + const linkType = platform === "win32" ? "junction" : "dir"; 53 + 54 + for (const name of keep) { 55 + const srcDir = new URL(`${name}/`, source); 56 + const destDir = new URL(`${name}/`, dest); 57 + 58 + await rm(destDir, { recursive: true, force: true }); 59 + 60 + const target = relative(destDirPath, fileURLToPath(srcDir)); 61 + await symlink(target, fileURLToPath(destDir), linkType); 62 + 63 + const skillMd = new URL("SKILL.md", srcDir); 64 + 65 + const content = await safeRead(skillMd); 66 + if (content) { 67 + const frontmatter = parseFrontmatter(content); 68 + if (frontmatter) { 69 + skills.push(frontmatter); 70 + } 71 + } 72 + } 73 + 74 + return skills; 75 + } 76 + 77 + async function pruneStaleLinks(options: { 78 + dest: URL; 79 + source: URL; 80 + keep: Set<string>; 81 + }): Promise<void> { 82 + const { dest, source, keep } = options; 83 + const destPath = fileURLToPath(dest); 84 + const sourcePath = fileURLToPath(source); 85 + 86 + let entries: Dirent[]; 87 + try { 88 + entries = await readdir(dest, { withFileTypes: true }); 89 + } catch { 90 + return; 91 + } 92 + 93 + for (const entry of entries) { 94 + if (!entry.isSymbolicLink()) continue; 95 + if (keep.has(entry.name)) continue; 96 + 97 + const linkPath = fileURLToPath(new URL(entry.name, dest)); 98 + try { 99 + const target = await readlink(linkPath); 100 + const absTarget = isAbsolute(target) ? target : resolve(destPath, target); 101 + if (absTarget.startsWith(sourcePath)) { 102 + await rm(linkPath, { recursive: true, force: true }); 103 + } 104 + } catch { 105 + // ignore unreadable links 106 + } 107 + } 108 + } 109 + 110 + async function updateGitignore(options: { root: URL; skills: SkillInfo[] }): Promise<void> { 111 + const { root, skills } = options; 112 + const gitignorePath = new URL(".gitignore", root); 113 + let content = (await safeRead(gitignorePath)) ?? ""; 114 + 115 + const lines = skills.map((s) => `skills/${s.name}/`); 116 + const section = [GITIGNORE_START, ...lines, GITIGNORE_END].join("\n"); 117 + 118 + const startIdx = content.indexOf(GITIGNORE_START); 119 + const endIdx = content.indexOf(GITIGNORE_END); 120 + 121 + if (startIdx !== -1 && endIdx !== -1) { 122 + content = content.slice(0, startIdx) + section + content.slice(endIdx + GITIGNORE_END.length); 123 + } else if (skills.length > 0) { 124 + const suffix = content.endsWith("\n") || content === "" ? "" : "\n"; 125 + content = content + suffix + "\n" + section + "\n"; 126 + } 127 + 128 + await writeFile(gitignorePath, content, "utf8"); 129 + } 130 + 131 + async function updateAgentsMd(options: { root: URL; skills: SkillInfo[] }): Promise<void> { 132 + const { root, skills } = options; 133 + const agentsPath = new URL("AGENTS.md", root); 134 + let content = (await safeRead(agentsPath)) ?? ""; 135 + 136 + const lines = skills.map((s) => { 137 + const desc = s.description.split(".")[0]?.trim(); 138 + return `- **${s.name}** — [skills/${s.name}/SKILL.md](skills/${s.name}/SKILL.md)${desc ? ` - ${desc}` : ""}`; 139 + }); 140 + 141 + const section = [ 142 + SENTINEL_START, 143 + "## @bomb.sh/tools Skills", 144 + "", 145 + "When working on these tasks, read the linked skill file for guidance:", 146 + "", 147 + ...lines, 148 + SENTINEL_END, 149 + ].join("\n"); 150 + 151 + const startIdx = content.indexOf(SENTINEL_START); 152 + const endIdx = content.indexOf(SENTINEL_END); 153 + 154 + if (startIdx !== -1 && endIdx !== -1) { 155 + content = content.slice(0, startIdx) + section + content.slice(endIdx + SENTINEL_END.length); 156 + } else { 157 + const suffix = content.endsWith("\n") || content === "" ? "" : "\n"; 158 + content = content + suffix + "\n" + section + "\n"; 159 + } 160 + 161 + await writeFile(agentsPath, content, "utf8"); 162 + } 163 + 164 + function parseFrontmatter(content: string): SkillInfo | undefined { 165 + const { frontmatter } = parse(content); 166 + if (!frontmatter) return undefined; 167 + const name = frontmatter.name as string | undefined; 168 + const description = 169 + (frontmatter.description as string | undefined)?.trim().replaceAll(/\s+/g, " ") ?? ""; 170 + if (!name) return undefined; 171 + return { name, description }; 172 + } 173 + 174 + async function isSelf(root: URL): Promise<boolean> { 175 + const content = await safeRead(new URL("package.json", root)); 176 + if (!content) return false; 177 + const pkg = JSON.parse(content) as { name?: string }; 178 + return pkg.name === "@bomb.sh/tools"; 179 + } 180 + 181 + async function safeRead(url: URL): Promise<string | null> { 182 + try { 183 + return await readFile(url, "utf8"); 184 + } catch (err) { 185 + if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return null; 186 + throw err; 187 + } 188 + }