#!/usr/bin/env npx tsx /** * Generate a Supabase seed migration that inlines every atom SKILL.md body into * agent_atom_registry.body. * * Why a generated migration (not a runtime/seed-script write): * - Vercel's build has no DB, so prebuild seeding can't write. * - Skill bodies must reach prod via the one deploy path the project trusts: * supabase/migrations applied on deploy. The SQL is generated, never hand-authored. * - It also fixes the "registry never seeded" case (the manual seed may never have * run in prod): the migration populates the rows on deploy. * * Determinism: * - Atoms are emitted sorted by id; bodies are dollar-quoted with a collision-proof * tag; `version` is derived from a committed content-hash manifest * (scripts/.skill-body-manifest.json) so it only bumps when a SKILL.md changes. * - A no-change run emits NOTHING (byte-identical repo). A change emits ONE new * timestamped migration (append-only: we never edit an existing migration). * * Usage: * npx tsx scripts/generate-skill-bodies.ts # emit a migration if skills changed * npx tsx scripts/generate-skill-bodies.ts --check # CI guard: exit 1 if a skill changed * # without a regenerated migration */ import { createHash } from 'node:crypto' import { readdir, writeFile } from 'node:fs/promises' import { existsSync, readFileSync } from 'node:fs' import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import { discoverAtoms, type DiscoveredAtom } from './lib/atom-discovery' const __filename = fileURLToPath(import.meta.url) const ROOT = dirname(dirname(__filename)) const MIGRATIONS_DIR = join(ROOT, 'supabase', 'migrations') const MANIFEST_PATH = join(ROOT, 'scripts', '.skill-body-manifest.json') const checkOnly = process.argv.includes('--check') interface ManifestEntry { hash: string version: number } type Manifest = Record function sha256(s: string): string { return createHash('sha256').update(s, 'utf8').digest('hex') } function loadManifest(): Manifest { if (!existsSync(MANIFEST_PATH)) return {} try { return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as Manifest } catch { return {} } } async function saveManifest(manifest: Manifest): Promise { // Sorted keys for stable diffs. const sorted: Manifest = {} for (const id of Object.keys(manifest).sort()) sorted[id] = manifest[id] await writeFile(MANIFEST_PATH, JSON.stringify(sorted, null, 2) + '\n', 'utf8') } /** Wrap a string in a dollar-quote tag guaranteed not to appear inside it. */ function dollarQuote(s: string): string { let tag = '$gb$' let n = 0 while (s.includes(tag)) { tag = `$gb${n}$` n++ } return `${tag}${s}${tag}` } /** Single-quoted SQL text literal (apostrophes doubled). For short, simple values. */ function sqlStr(s: string): string { return `'${s.replace(/'/g, "''")}'` } function sqlTextArray(arr: string[]): string { if (arr.length === 0) return `'{}'::text[]` return `ARRAY[${arr.map(sqlStr).join(', ')}]::text[]` } /** Next migration timestamp = max existing 14-digit prefix + 1 (guarantees ordering). */ async function nextMigrationTimestamp(): Promise { const files = await readdir(MIGRATIONS_DIR) // 14-digit timestamps (max ~1e14) are well within Number.MAX_SAFE_INTEGER (~9e15). let max = 0 for (const f of files) { const m = /^(\d{14})_/.exec(f) if (m) { const n = Number(m[1]) if (n > max) max = n } } return String(max + 1).padStart(14, '0') } function buildValuesRow(atom: DiscoveredAtom, version: number): string { const triggerJson = JSON.stringify(atom.trigger_signals ?? {}) return [ ' (', ` ${sqlStr(atom.id)},`, ` ${sqlStr(atom.tier)},`, ` ${sqlStr(atom.title)},`, ` ${dollarQuote(atom.description)},`, ` ${sqlTextArray(atom.sni_prefixes)},`, ` ${dollarQuote(triggerJson)}::jsonb,`, ` ${atom.estimated_tokens},`, ` ${sqlStr(atom.body_path)},`, ` ${dollarQuote(atom.body)},`, ` ${atom.parent_atom_id ? sqlStr(atom.parent_atom_id) : 'NULL'},`, ` ${version},`, ` ${atom.schema_version}`, ' )', ].join('\n') } export function buildMigrationSql(atoms: DiscoveredAtom[], versions: Record): string { const header = `-- AUTO-GENERATED by scripts/generate-skill-bodies.ts: DO NOT EDIT BY HAND. -- Regenerate with \`npm run skills:generate\` after editing any .claude/skills/**/SKILL.md. -- -- Seeds agent_atom_registry rows with their SKILL.md body so the MCP server and the -- in-app agent read skill content from the DB (works on Vercel, Docker, self-hosted). -- Idempotent: ON CONFLICT refreshes content fields but leaves is_active and -- mcp_exposed under manual control (they take column defaults on first insert). -- The WHERE guard skips rows whose registry version is newer than this seed's: -- two branches can each carry a full seed, and the one that applies last can -- no longer downgrade atoms the other already bumped. ` const values = atoms.map((a) => buildValuesRow(a, versions[a.id])).join(',\n') const insert = `INSERT INTO public.agent_atom_registry (id, tier, title, description, sni_prefixes, trigger_signals, estimated_tokens, body_path, body, parent_atom_id, version, schema_version) VALUES ${values} ON CONFLICT (id) DO UPDATE SET tier = EXCLUDED.tier, title = EXCLUDED.title, description = EXCLUDED.description, sni_prefixes = EXCLUDED.sni_prefixes, trigger_signals = EXCLUDED.trigger_signals, estimated_tokens = EXCLUDED.estimated_tokens, body_path = EXCLUDED.body_path, body = EXCLUDED.body, parent_atom_id = EXCLUDED.parent_atom_id, version = EXCLUDED.version, schema_version = EXCLUDED.schema_version, updated_at = now() WHERE public.agent_atom_registry.version <= EXCLUDED.version; ` return `${header}\n${insert}\nNOTIFY pgrst, 'reload schema';\n` } async function main() { const atoms = await discoverAtoms(ROOT) if (atoms.length === 0) { console.error('No atoms discovered under .claude/skills/: refusing to emit an empty seed.') process.exit(1) } // Dangling references/ links (mcp_optimization_plan P2-2): an atom body // pointing at a references/ file that does not exist ships a 404 to every // agent that follows it. Fails BOTH modes so a dangling pointer can never // reach the registry. Top-level atoms only: reference children live inside // references/ themselves, so relative links would double-resolve. const refLinkRe = /(?:\]\(|\b)\.?\/?(references\/[A-Za-z0-9._/-]+\.md)/g const danglingRefs: string[] = [] for (const atom of atoms) { if (atom.parent_atom_id) continue const baseDir = dirname(join(ROOT, atom.body_path)) const seen = new Set() for (const m of atom.body.matchAll(refLinkRe)) { const rel = m[1] if (seen.has(rel)) continue seen.add(rel) if (!existsSync(join(baseDir, rel))) danglingRefs.push(`${atom.id}: ${rel}`) } } if (danglingRefs.length > 0) { console.error('✗ dangling references/ link(s) in atom bodies: create the file or remove the pointer:') for (const d of danglingRefs) console.error(` ${d}`) process.exit(1) } const manifest = loadManifest() const onDisk = new Map(atoms.map((a) => [a.id, sha256(a.body)])) // Drift = added, changed, or removed atoms vs. the committed manifest. const added: string[] = [] const changed: string[] = [] for (const [id, hash] of onDisk) { const prev = manifest[id] if (!prev) added.push(id) else if (prev.hash !== hash) changed.push(id) } const removed = Object.keys(manifest).filter((id) => !onDisk.has(id)) const hasDrift = added.length > 0 || changed.length > 0 || removed.length > 0 if (checkOnly) { if (!hasDrift) { console.log(`✓ skill bodies up to date (${atoms.length} atoms).`) return } console.error('✗ skill bodies are STALE: a SKILL.md changed without regenerating the seed migration.') if (added.length) console.error(` added: ${added.join(', ')}`) if (changed.length) console.error(` changed: ${changed.join(', ')}`) if (removed.length) console.error(` removed: ${removed.join(', ')}`) console.error('\nRun `npm run skills:generate` and commit the emitted migration.') process.exit(1) } if (!hasDrift) { console.log(`✓ no skill changes (${atoms.length} atoms): nothing to generate.`) return } // Compute versions: bump only changed atoms; new atoms start at 1. const newManifest: Manifest = {} const versions: Record = {} for (const atom of atoms) { const hash = onDisk.get(atom.id)! const prev = manifest[atom.id] const version = !prev ? 1 : prev.hash === hash ? prev.version : prev.version + 1 versions[atom.id] = version newManifest[atom.id] = { hash, version } } const ts = await nextMigrationTimestamp() const fileName = `${ts}_seed_agent_atom_bodies.sql` const filePath = join(MIGRATIONS_DIR, fileName) await writeFile(filePath, buildMigrationSql(atoms, versions), 'utf8') await saveManifest(newManifest) console.log(`Wrote supabase/migrations/${fileName} (${atoms.length} atoms).`) if (added.length) console.log(` added: ${added.join(', ')}`) if (changed.length) console.log(` changed: ${changed.join(', ')}`) if (removed.length) console.log(` removed (dropped from manifest, row left as-is in DB): ${removed.join(', ')}`) } // Only run when invoked directly (not when imported by tests). if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main().catch((err) => { console.error(err) process.exit(1) }) }