Skip to content

How the generator works

The path from a folder in registry/ to a repo on your disk: loader, resolver, mergers, compiler, generator, and the file in packages/core that owns each step.

8 min read

PagesHow the generator works

There is no template repo behind this. A selection goes through five stages, each one a file in packages/core/src, and a whole repo comes out the other end. The same selection gives the same bytes every time.

The stages, in order:

registry/  ->  loader  ->  resolver  ->  mergers  ->  compiler  ->  generator  ->  your repo
              registry.ts  resolver.ts  merge.ts    compile.ts    generate.ts

Each section names the real file and function, so you can read the source alongside this page.

1. The registry is the product

registry/ holds every plugin. A plugin is a folder: a stack, a battery or a design. The generator has no idea what your battery is called. It reads the folder, sorts it, and merges whatever it finds.

registry/<stacks|batteries|designs>/<id>/
  manifest.yaml            required
  template/                copied into the generated repo
  agent/
    rules/*.md             path-scoped conventions
    skills/*.md            slash commands
    agents/*.md            subagents
    hooks/*.md             hook definition plus its inline script
    solutions/*.md         long-form docs, also published at /cookbook
  design.yaml              designs only: the token set
  DESIGN.md                designs only

Today that is 1 stack, 26 batteries and 31 designs, plus 3 presets.

loadRegistry(root) in packages/core/src/registry.ts walks that tree and turns it into the typed Registry in types.ts. It parses the manifests and reads markdown frontmatter through parseFrontmatter from markdown.ts. It sorts arrays by id and normalises text (BOM stripped, CRLF folded). It is tolerant on purpose. A broken manifest is kept, not dropped, so validateRegistry in schema.ts can report every problem in one pass instead of dying on the first one.

toIndex(registry) makes the RegistryIndex: manifests, per-plugin counts, presets and the tested pairs, with no template bodies and no absolute paths. That is what the browser gets, which is how the builder re-resolves your picks on every click with no round trip.

packages/core/src/resolver.ts takes a raw Selection and returns a ResolvedPlan. It is the only stage that says no. It also runs in the browser, so it has no filesystem, no clock and no randomness.

resolve(selection, index) runs these passes:

  • Auto-add from requires. Better Auth needs an ORM, a database and email, so picking it adds Drizzle, Neon and Resend. Each addition comes with an auto-added explanation and a sentence saying why, which the builder shows inline. The pass runs until nothing changes, and a cycle is reported as requires-cycle instead of looping.
  • Stack compatibility, from each manifest's compatibleStacks.
  • Declared conflicts. A conflicts entry names a plugin. Supabase Auth conflicts with Neon, so you get conflict, not a repo with two databases.
  • One choice per category, reported as category-conflict.
  • The admin panel needs an auth provider: admin-needs-auth.
  • The tested set. Covered below.
  • Recommendations. recommends never adds anything. It emits a warn.

The plan carries the final selection, the plugin ids in a fixed order (stack, then batteries by category then id, then design), every explanation, the six counts behind the counters, and ok. When ok is false, generate refuses to run rather than write a repo that can't boot.

isSelectable(batteryId, selection, index) is the same machinery pointed sideways. It resolves your selection with and without a battery and reports the first blocking reason that battery adds. That is where the greyed-out options and their reasons come from. Nothing in the UI decides what is legal on its own.

3. The mergers assemble the files

packages/core/src/merge.ts holds the ways two plugins can add to the same output.

Token substitution. templateVars(selection) builds the token table and substitute(text, vars) applies it to every template file, every contributes.scripts value and every markdown body.

Tokenbunpnpmnpm
{{pm}}bunpnpmnpm
{{pmx}}bunxpnpm execnpx
{{pmDlx}}bunxpnpm dlxnpx
{{pmRun}}bun runpnpm runnpm run
{{pmAdd}}bun addpnpm addnpm install
{{tsRun}}buntsxtsx
{{tsServer}}bun --conditions=react-servertsx --conditions=react-servertsx --conditions=react-server

{{pmx}} runs a binary the repo already installed (prisma, drizzle-kit, tsx). {{pmDlx}} fetches a one-off tool the repo doesn't depend on (neonctl, a CLI pinned @latest). They differ under pnpm on purpose: pnpm dlx prisma ignores node_modules and downloads the latest release.

Plus {{projectName}}, {{design}}, {{mode}} and the link tokens. A plugin that writes npm run dev or npx prisma in a doc fails the template lint, because that string is wrong for two thirds of users. The docs you are reading use the same tokens: the tab strip above each command is substitute running three times.

JSON deep merge. deepMergeJson(base, patch) owns package.json, tsconfig.json, .mcp.json and .claude/settings.json. Keys are sorted on the way out, and arrays are concatenated with primitives deduped, so two plugins adding dependencies give one stable file.

Markdown sections. appendMarkdownSection(base, heading, body) builds CLAUDE.md a section at a time.

Slot fill. findSlots and fillSlots handle injection. Everything else is a plain file copy with collision checks: two plugins writing the same path raises CollisionError, not a last-writer race.

Slots

A stack template can leave a named hole:

{/* @slot providers */}

// @slot providers in TypeScript and <!-- @slot providers --> in markdown work the same way. A battery fills one by name from its manifest:

contributes:
  slots:
    providers: "template/slots/provider.tsx"

Two rules keep this predictable. A slot a battery fills but nothing declares fails bun run validate (slot-missing). It is never a silent skip. And an unfilled slot line is deleted with its blank line, so a stack template reads cleanly with zero batteries.

4. The compiler writes each agent target

The agent layer is written once in a neutral format (the frontmatter shapes in docs/architecture/CONTRACT.md). compileAgentLayer in packages/core/src/compile.ts writes it out for each target you picked. Claude Code is always one.

TargetOutput
Claude CodeCLAUDE.md, .claude/rules/<id>.md with paths:, .claude/agents/, .claude/skills/<name>/SKILL.md, .claude/hooks/*, .claude/settings.json, .mcp.json
CodexRoot AGENTS.md plus a nested AGENTS.md per rule path prefix, skills in .agents/skills/<name>/SKILL.md
Cursor.cursor/rules/<id>.mdc with globs:, plus one skill-<name>.mdc per skill that loads on request

Path scoping is what gets translated. Claude Code reads a paths: list in the rule's frontmatter, and a rule without one loads every session. Cursor reads globs:. Codex has no globs. So the compiler takes the literal prefix of each rule's paths and writes a nested AGENTS.md in that folder. Each one is sized so the whole chain Codex reads fits its 32 KiB limit.

Hooks are wired in .claude/settings.json only. Each command runs its script from $CLAUDE_PROJECT_DIR with your package manager (bun, or tsx from node_modules for pnpm and npm), so a guard still fires after the agent runs cd. See the agentic layer for what Codex and Cursor do with them.

5. The generator writes the repo

generate(plan, registry) in packages/core/src/generate.ts puts it together. Order matters. Templates land first so slot markers exist to be filled. The merged files go on top. The content hash is taken last, over everything except agentic.config.json, which then carries it.

Along the way it builds the files no single plugin owns:

  • package.json with the base scripts (dev, build, start, typecheck, lint, lint:fix, format, test, test:e2e, verify, verify:hooks) plus each battery's own.
  • .env.example from every battery's declared env vars.
  • docs/onboard.md from their onboarding steps, sorted by order.
  • CLAUDE.md, a short index that points at docs/onboard.md first.
  • docs/solutions/ seeded with each plugin's solution docs.
  • docs/plans/ with a README and a plan template.
  • scripts/verify.ts and scripts/verify-hooks.ts, filled in for your picks.

Three small modules finish the job:

  • hash.ts (hashFiles): sha256 over the sorted path and content pairs.
  • write.ts (writeRepo): writes to a folder, for the CLI and the matrix.
  • zip.ts (zipRepo): builds the zip for the web download.

The tested set

The resolver refuses any battery pair that is not in registry/tested.yaml, a sorted list of pairs. A set that exactly matches a preset skips the pair check, because the matrix runs every preset whole. Everything else is checked pair by pair, and a missing pair is an untested error, not a warning.

So the promise is per pair. Pick three batteries and each of the three pairs has passed the matrix. That exact trio may never have run together.

Only bun run matrix writes that file. For each combination it generates the repo outside this monorepo, installs it, typechecks it, lints it and runs its unit tests. With --build it also runs next build with no env set, boots the app and loads every static route. --update-tested and --add-tested need --build, and they write back only the pairs that passed. Edit the file by hand and you claim a check that never happened. The resolver believes it completely, and someone gets a repo that doesn't boot.

Two lanes go further than booting:

  • --db gives each combo a fresh database on local Postgres. It runs the repo's own migrations and seeds (each seed twice, to prove it is safe to rerun), builds with that env and loads every route against the live database.
  • --browser then runs the repo's own Playwright specs against that server: sign-up and sign-in, the app shell, /pricing and checkout with no provider keys, and the admin pages. A spec that has no environment to run in skips with a reason, and the lane prints every reason.

With either lane the matrix adds 8 lane combos, which between them cover every auth, ORM, database and payments battery. No Docker: Neon runs through the repo's own db:proxy and Supabase Auth through its GoTrue and PostgREST binaries.

shell

bun run matrix --db --browser --preset indie-saas

If the pair you want is greyed out, it isn't certified yet. From a clone of the generator (it runs on Bun), run the matrix for that battery's pairs and commit the result:

shell

bun run matrix --battery <id> --pairs --build --add-tested

Leave out --pairs and no pair runs, so nothing new gets certified.

Determinism, and why it replaces sync

The output has no timestamps, no random ids and no absolute paths. Keys are sorted before they are written, and files are sorted by path. agentic.config.json in the generated repo records the full selection, the plugin ids, the counts and the sha256 of the output.

That file is the V1 answer to "how do I get updates". There is no sync. Months later you regenerate from the same agentic.config.json on a newer registry and diff it against your repo. You see exactly what changed and take what you want. bun run snapshot in this repo generates every preset twice in one process and fails if the hashes differ, so the property that makes this work is itself tested.

Read it yourself

FileWhat it owns
packages/core/src/types.tsEvery shared type. Never redefined elsewhere.
packages/core/src/registry.tsloadRegistry, toIndex
packages/core/src/schema.tsvalidateManifest, validateRegistry
packages/core/src/resolver.tsresolve, isSelectable, defaultSelection, selectionFromPreset
packages/core/src/merge.tssubstitute, deepMergeJson, appendMarkdownSection, fillSlots
packages/core/src/compile.tscompileAgentLayer, hookCommand
packages/core/src/generate.tsgenerate
packages/core/src/markdown.tsparseFrontmatter, serializeFrontmatter, extractScriptBlock
packages/core/src/hash.ts · write.ts · zip.tshashFiles, writeRepo, zipRepo

Next: the agentic layer, which is what all of this exists to install.