#!/usr/bin/env npx tsx /** * Seed / sync the agent_atom_registry table from SKILL.md files on disk. * * DEV / MANUAL path only. It writes the registry directly with the service role, * which needs DB access: so it is NOT part of prebuild. Production gets skill * bodies via the generated seed migration (scripts/generate-skill-bodies.ts), * which ships in supabase/migrations and runs on deploy. * * Discovery + frontmatter parsing live in scripts/lib/atom-discovery.ts so this * and the generator can never drift on which skills count as atoms. * * Usage: * npx tsx scripts/seed-agent-atom-registry.ts # apply * npx tsx scripts/seed-agent-atom-registry.ts --dry # print plan only */ import { config } from 'dotenv' config({ path: '.env.local' }) import { createClient } from '@supabase/supabase-js' import { fileURLToPath } from 'node:url' import { dirname, relative, join } from 'node:path' import { discoverAtoms } from './lib/atom-discovery' const __filename = fileURLToPath(import.meta.url) const ROOT = dirname(dirname(__filename)) const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY if (!supabaseUrl || !serviceRoleKey) { console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') process.exit(1) } const supabase = createClient(supabaseUrl, serviceRoleKey) const dryRun = process.argv.includes('--dry') async function main() { console.log(`Scanning ${relative(process.cwd(), join(ROOT, '.claude', 'skills'))}`) // Developer-audience references stay out of the registry, like the generator. const atoms = (await discoverAtoms(ROOT)).filter((a) => a.audience === 'agent') if (atoms.length === 0) { console.log('No atoms discovered.') return } // Map discovery → registry rows. We intentionally OMIT is_active so that a // manual kill-switch flip survives a re-seed (the column default applies on // first insert; ON CONFLICT leaves it untouched). `body` is inlined so runtime // reads from the DB rather than disk. First-party exposure is also preserved; // community exposure and publication dates are owned by reviewed frontmatter. const rows = atoms.map((a) => ({ id: a.id, tier: a.tier, title: a.title, description: a.description, sni_prefixes: a.sni_prefixes, trigger_signals: a.trigger_signals, estimated_tokens: a.estimated_tokens, body_path: a.body_path, body: a.body, parent_atom_id: a.parent_atom_id, version: a.frontmatter_version, schema_version: a.schema_version, ...(a.tier === 'community' ? { mcp_exposed: a.mcp_exposed === true, reviewed_at: a.reviewed_at } : {}), })) console.log(`\nFound ${rows.length} atoms:\n`) for (const r of rows) { console.log(` [${r.tier.padEnd(10)}] ${r.id.padEnd(40)} ${r.estimated_tokens.toString().padStart(6)} tokens`) } if (dryRun) { console.log('\n--dry: skipping write.') return } console.log('\nUpserting...') // PostgREST fills missing properties in a mixed batch with defaults/nulls. // Keep equal column sets together so first-party exposure is not overwritten. for (const batch of [rows.filter((row) => row.tier !== 'community'), rows.filter((row) => row.tier === 'community')]) { if (!batch.length) continue const { error } = await supabase.from('agent_atom_registry').upsert(batch, { onConflict: 'id' }) if (error) { console.error('Upsert failed:', error) process.exit(1) } } console.log(`Upserted ${rows.length} rows into agent_atom_registry.`) } main().catch((err) => { console.error(err) process.exit(1) })