diff --git a/.github/workflows/check-skills.yml b/.github/workflows/check-skills.yml deleted file mode 100644 index d1bc862..0000000 --- a/.github/workflows/check-skills.yml +++ /dev/null @@ -1,143 +0,0 @@ -# check-skills.yml — Drop this into your library repo's .github/workflows/ -# -# Checks for stale intent skills after a release and opens a review PR -# if any skills need attention. The PR body includes a prompt you can -# paste into Claude Code, Cursor, or any coding agent to update them. -# -# Triggers: new release published, or manual workflow_dispatch. -# -# Template variables (replaced by `intent setup`): -# @bomb.sh/tools — e.g. @tanstack/query or my-workspace workspace - -name: Check Skills - -on: - release: - types: [published] - workflow_dispatch: {} - -permissions: - contents: write - pull-requests: write - -jobs: - check: - name: Check for stale skills - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install intent - run: npm install -g @tanstack/intent - - - name: Check staleness - id: stale - run: | - OUTPUT=$(intent stale --json 2>&1) || true - echo "$OUTPUT" - - # Check if any skills need review - NEEDS_REVIEW=$(echo "$OUTPUT" | node -e " - const input = require('fs').readFileSync('/dev/stdin','utf8'); - try { - const reports = JSON.parse(input); - const stale = reports.flatMap(r => - r.skills.filter(s => s.needsReview).map(s => ({ library: r.library, skill: s.name, reasons: s.reasons })) - ); - if (stale.length > 0) { - console.log(JSON.stringify(stale)); - } - } catch {} - ") - - if [ -z "$NEEDS_REVIEW" ]; then - echo "has_stale=false" >> "$GITHUB_OUTPUT" - else - echo "has_stale=true" >> "$GITHUB_OUTPUT" - # Escape for multiline GH output - EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) - echo "stale_json<<$EOF" >> "$GITHUB_OUTPUT" - echo "$NEEDS_REVIEW" >> "$GITHUB_OUTPUT" - echo "$EOF" >> "$GITHUB_OUTPUT" - fi - - - name: Build summary - if: steps.stale.outputs.has_stale == 'true' - id: summary - run: | - node -e " - const stale = JSON.parse(process.env.STALE_JSON); - const lines = stale.map(s => - '- **' + s.skill + '** (' + s.library + '): ' + s.reasons.join(', ') - ); - const summary = lines.join('\n'); - - const prompt = [ - 'Review and update the following stale intent skills for @bomb.sh/tools:', - '', - ...stale.map(s => '- ' + s.skill + ': ' + s.reasons.join(', ')), - '', - 'For each stale skill:', - '1. Read the current SKILL.md file', - '2. Check what changed in the library since the skill was last updated', - '3. Update the skill content to reflect current APIs and behavior', - '4. Run \`npx @tanstack/intent validate\` to verify the updated skill', - ].join('\n'); - - // Write outputs - const fs = require('fs'); - const env = fs.readFileSync(process.env.GITHUB_OUTPUT, 'utf8'); - const eof = require('crypto').randomBytes(15).toString('base64'); - fs.appendFileSync(process.env.GITHUB_OUTPUT, - 'summary<<' + eof + '\n' + summary + '\n' + eof + '\n' + - 'prompt<<' + eof + '\n' + prompt + '\n' + eof + '\n' - ); - " - env: - STALE_JSON: ${{ steps.stale.outputs.stale_json }} - - - name: Open review PR - if: steps.stale.outputs.has_stale == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ github.event.release.tag_name || 'manual' }}" - BRANCH="skills/review-${VERSION}" - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" - git commit --allow-empty -m "chore: review stale skills for ${VERSION}" - git push origin "$BRANCH" - - gh pr create \ - --title "Review stale skills (${VERSION})" \ - --body "$(cat <<'PREOF' - ## Stale Skills Detected - - The following skills may need updates after the latest release: - - ${{ steps.summary.outputs.summary }} - - --- - - ### Update Prompt - - Paste this into your coding agent (Claude Code, Cursor, etc.): - - ~~~ - ${{ steps.summary.outputs.prompt }} - ~~~ - - PREOF - )" \ - --head "$BRANCH" \ - --base main diff --git a/.github/workflows/notify-intent.yml b/.github/workflows/notify-intent.yml deleted file mode 100644 index bd9a4b0..0000000 --- a/.github/workflows/notify-intent.yml +++ /dev/null @@ -1,51 +0,0 @@ -# notify-intent.yml — Drop this into your library repo's .github/workflows/ -# -# Fires a repository_dispatch event whenever docs or source files change -# on merge to main. This triggers the skill staleness check workflow. -# -# Requirements: -# - A fine-grained PAT with contents:write on this repository stored -# as the INTENT_NOTIFY_TOKEN repository secret. -# -# Template variables (replaced by `intent setup`): -# @bomb.sh/tools — e.g. @tanstack/query or my-workspace workspace -# docs/** — e.g. docs/** -# src/** — e.g. packages/query-core/src/** - -name: Trigger Skill Review - -on: - push: - branches: [main] - paths: - - "docs/**" - - "src/**" - -jobs: - notify: - name: Trigger Skill Review - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - name: Collect changed files - id: changes - run: | - FILES=$(git diff --name-only HEAD~1 HEAD | jq -R -s -c 'split("\n") | map(select(length > 0))') - echo "files=$FILES" >> "$GITHUB_OUTPUT" - - - name: Dispatch to intent repo - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.INTENT_NOTIFY_TOKEN }} - repository: ${{ github.repository }} - event-type: skill-check - client-payload: | - { - "package": "@bomb.sh/tools", - "sha": "${{ github.sha }}", - "changed_files": ${{ steps.changes.outputs.files }} - } diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml deleted file mode 100644 index 8c62379..0000000 --- a/.github/workflows/validate-skills.yml +++ /dev/null @@ -1,52 +0,0 @@ -# validate-skills.yml — Drop this into your library repo's .github/workflows/ -# -# Validates skill files on PRs that touch the skills/ directory. -# Ensures frontmatter is correct, names match paths, and files stay under -# the 500-line limit. - -name: Validate Skills - -on: - pull_request: - paths: - - "skills/**" - - "**/skills/**" - -jobs: - validate: - name: Validate skill files - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install intent CLI - run: npm install -g @tanstack/intent - - - name: Find and validate skills - run: | - # Find all directories containing SKILL.md files - SKILLS_DIR="" - if [ -d "skills" ]; then - SKILLS_DIR="skills" - elif [ -d "packages" ]; then - # Monorepo — find skills/ under packages - for dir in packages/*/skills; do - if [ -d "$dir" ]; then - echo "Validating $dir..." - intent validate "$dir" - fi - done - exit 0 - fi - - if [ -n "$SKILLS_DIR" ]; then - intent validate "$SKILLS_DIR" - else - echo "No skills/ directory found — skipping validation." - fi diff --git a/README.md b/README.md index 9edf6a9..2b0e35e 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,4 @@ If you'd like to use this package for your own projects, please consider forking ## Agent Skills -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`. +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`. diff --git a/package.json b/package.json index 819656a..3a4c0fe 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "bombshell", "cli", "internal", - "skills", - "tanstack-intent" + "skills" ], "homepage": "https://bomb.sh", "license": "MIT", @@ -70,12 +69,12 @@ "publint": "^0.3.18", "tinyexec": "^1.0.1", "tsdown": "^0.21.0-beta.2", + "ultramatter": "^0.0.4", "vitest": "^4.0.18", "vitest-ansi-serializer": "^0.2.1" }, "devDependencies": { "@changesets/cli": "^2.28.1", - "@tanstack/intent": "^0.0.23", "@types/node": "^22.13.14" }, "volta": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46799b4..3d0a442 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: tsdown: specifier: ^0.21.0-beta.2 version: 0.21.0-beta.2(@typescript/native-preview@7.0.0-dev.20260307.1)(oxc-resolver@11.19.1)(publint@0.3.18)(typescript@5.9.3) + ultramatter: + specifier: ^0.0.4 + version: 0.0.4 vitest: specifier: ^4.0.18 version: 4.0.18(@types/node@22.13.14)(jiti@2.6.1)(yaml@2.8.3) @@ -48,9 +51,6 @@ importers: '@changesets/cli': specifier: ^2.28.1 version: 2.28.1 - '@tanstack/intent': - specifier: ^0.0.23 - version: 0.0.23 '@types/node': specifier: ^22.13.14 version: 22.13.14 @@ -901,10 +901,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tanstack/intent@0.0.23': - resolution: {integrity: sha512-q5e0sh5e+xBOR5Z7eoZTBcXtakgTwucm2m0bNUkj7h4UagSgIDmPRYbtC7B81AF4FB5rW2OP1zgl7Jd9EH4qEw==} - hasBin: true - '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1564,6 +1560,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ultramatter@0.0.4: + resolution: {integrity: sha512-1f/hO3mR+/Hgue4eInOF/Qm/wzDqwhYha4DxM0hre9YIUyso3fE2XtrAU6B4njLqTC8CM49EZaYgsVSa+dXHGw==} + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} @@ -2315,11 +2314,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tanstack/intent@0.0.23': - dependencies: - cac: 6.7.14 - yaml: 2.8.3 - '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3002,6 +2996,8 @@ snapshots: typescript@5.9.3: {} + ultramatter@0.0.4: {} + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -3081,6 +3077,7 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - yaml@2.8.3: {} + yaml@2.8.3: + optional: true zod@4.3.6: {} diff --git a/src/bin.ts b/src/bin.ts index b1d8ebb..d56e54c 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -5,9 +5,10 @@ import { dev } from "./commands/dev.ts"; import { format } from "./commands/format.ts"; import { init } from "./commands/init.ts"; import { lint } from "./commands/lint.ts"; +import { sync } from "./commands/sync.ts"; import { test } from "./commands/test.ts"; -const commands = { build, dev, format, init, lint, test }; +const commands = { build, dev, format, init, lint, sync, test }; async function main() { const [command, ...args] = argv.slice(2); diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..b9d6cde --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,170 @@ +import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { cwd } from "node:process"; +import { pathToFileURL } from "node:url"; +import { parse } from "ultramatter"; +import type { CommandContext } from "../context.ts"; + +const SENTINEL_START = ""; +const SENTINEL_END = ""; + +export async function sync(_ctx: CommandContext): Promise { + const root = pathToFileURL(`${cwd()}/`); + + if (await isSelf(root)) { + console.info("Skipping sync — running inside @bomb.sh/tools"); + return; + } + + const source = new URL("node_modules/@bomb.sh/tools/skills/", root); + if (!(await exists(source))) { + console.error("@bomb.sh/tools is not installed. Run `pnpm add -D @bomb.sh/tools` first."); + 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/`); +} + +interface SkillInfo { + name: string; + description: string; +} + +async function copySkills(options: { source: URL; dest: URL }): Promise { + const { source, dest } = options; + const skills: SkillInfo[] = []; + const entries = await readdir(source, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name.startsWith("_")) continue; + + const srcDir = new URL(`${entry.name}/`, source); + const destDir = new URL(`${entry.name}/`, dest); + + await rm(destDir, { recursive: true, force: true }); + await copyDir({ source: srcDir, dest: destDir }); + + const skillMd = new URL("SKILL.md", destDir); + if (await exists(skillMd)) { + const content = await readFile(skillMd, "utf8"); + const frontmatter = parseFrontmatter(content); + if (frontmatter) { + skills.push(frontmatter); + } + } + } + + return skills; +} + +async function copyDir(options: { source: URL; dest: URL }): Promise { + const { source, dest } = options; + await mkdir(dest, { recursive: true }); + const entries = await readdir(source, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = new URL(entry.name, source); + const destPath = new URL(entry.name, dest); + + if (entry.isDirectory()) { + if (entry.name.startsWith("_")) continue; + await copyDir({ source: new URL(`${entry.name}/`, source), dest: new URL(`${entry.name}/`, dest) }); + } else { + await copyFile(srcPath, destPath); + } + } +} + +async function updateGitignore(options: { root: URL; skills: SkillInfo[] }): Promise { + const { root, skills } = options; + const gitignorePath = new URL(".gitignore", root); + let content = ""; + if (await exists(gitignorePath)) { + content = await readFile(gitignorePath, "utf8"); + } + + const missing: string[] = []; + for (const skill of skills) { + const entry = `skills/${skill.name}/`; + if (!content.includes(entry)) { + missing.push(entry); + } + } + + if (missing.length === 0) return; + + const suffix = content.endsWith("\n") || content === "" ? "" : "\n"; + const section = `${suffix}\n# @bomb.sh/tools skills (synced)\n${missing.join("\n")}\n`; + await writeFile(gitignorePath, content + section, "utf8"); +} + +async function updateAgentsMd(options: { root: URL; skills: SkillInfo[] }): Promise { + const { root, skills } = options; + const agentsPath = new URL("AGENTS.md", root); + let content = ""; + if (await exists(agentsPath)) { + content = await readFile(agentsPath, "utf8"); + } + + const lines = skills.map((s) => { + const desc = s.description.split(".")[0].trim(); + return `- **${s.name}** — [skills/${s.name}/SKILL.md](skills/${s.name}/SKILL.md) — ${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 writeFile(agentsPath, content, "utf8"); +} + +function parseFrontmatter(content: string): SkillInfo | undefined { + const { frontmatter } = parse(content); + 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 }; +} + +async function isSelf(root: URL): Promise { + const pkgPath = new URL("package.json", root); + if (!(await exists(pkgPath))) return false; + const content = await readFile(pkgPath, "utf8"); + const pkg = JSON.parse(content) as { name?: string }; + return pkg.name === "@bomb.sh/tools"; +} + +async function exists(url: URL): Promise { + try { + await readdir(url); + return true; + } catch { + try { + await readFile(url); + return true; + } catch { + return false; + } + } +}