Files
stanza/AGENTS.md
T
2026-05-22 18:37:24 -04:00

22 KiB

Stanza

Shadcn-style CLI for assembling modular full-stack TS monorepos. Currently ships init, add, remove, list, search against these categories: framework, styling, db, orm, auth, tooling, testing. swap + update verbs and more categories (api, ai, ui, payments, deploy, email, monorepo) are planned — the manifest already reserves the fields they'll need (modules[category][].version, regions). See the module registry for the module roadmap and TODO.md for active work.

Three things differentiate stanza from other scaffolders:

  1. Post-init stanza add works on existing projects (manifest-driven, peer-aware).
  2. Generated code is vendored verbatim — no @stanza/runtime dep.
  3. Open registry spec — third parties can host their own static JSON.

Layout

  • apps/cli/stanza-cli, node entrypoint at src/bin.ts (run via tsx in dev, built to dist/bin.mjs for publish via vp pack)
  • apps/web/@stanza/web, TanStack Start visual builder (Vite-native, no Vinxi)
  • packages/registry/ — shared schema, category/peer resolver, Zod manifest validator
  • packages/codemods/ — ts-morph helpers (idempotent + reversible)
  • packages/create-stanza/pnpm create stanza shim
  • scripts/ — repo-root maintainer helpers (not shipped): registry-build.ts emits static CDN JSON
  • registry/modules/<category>-<id>/ — first-party modules: module.ts + templates/ (modules don't ship codemod code; see Architecture rules)

In a generated project, a module's output lands per its category's home (in the canonical CATEGORIES array): auth/db/orm (home: package) install into an internal workspace package at packages/<dir>/ (named @<manifest.name>/<dir>, consumed via workspace:*; db + orm share packages/db/); framework/styling/testing (home: app) wire the app shell; tooling (home: repo) writes config at the repo root + scripts in the root package.json. categoryHome(id), PACKAGE_DIRS, and categoryLabel(id) are all derived from the array.

The repo runs on the Vite+ toolchain (vp) — one CLI for dev/build/test/lint/fmt/check/pack, all configured by the root vite.config.ts (test/lint/fmt/staged blocks). There is no turbo.json, .oxlintrc.json, .oxfmtrc.json, or per-package vitest.config.ts — that config now lives in vite.config.ts. vp run <script> runs a package.json script; vp run -r <task> fans out across the workspace (topological); vp run <pkg>#<task> targets one package. vp install/add/remove delegate to pnpm (the declared packageManager). vite/vitest are catalog-aliased to @voidzero-dev/vite-plus-core/@voidzero-dev/vite-plus-test in pnpm-workspace.yamlkeep those aliases; never add standalone vite/vitest. Source imports use vite-plus and vite-plus/test (template files under registry/modules/*/templates/ stay on stock vite/vitest — they target generated user projects). Git hooks are managed by vp config.vite-hooks/; CI bootstraps via voidzero-dev/setup-vp@v1.

  • tsx apps/cli/src/bin.ts <verb> — run the CLI directly in dev (no build step; use tsx watch for a watch loop)
  • vp run stanza-cli#build — build the publishable CLI via vp pack (wraps tsdown; config in apps/cli/tsdown.config.ts, referenced from apps/cli/vite.config.ts's pack block — externalizes npm deps, inlines workspace packages). Same for create-stanza. vp run -r build builds everything (both CLIs + the web app)
  • vp run registry:build — regenerate dist/registry/{index,modules/*}.json (script body is bun scripts/registry-build.ts; portable, tsx scripts/registry-build.ts works too)
  • vp run @stanza/web#dev — TanStack Start dev server. prebuild invokes apps/web/scripts/prepare-registry.sh which copies the built dist/registry/ into apps/web/public/registry/. Since dist/registry/ is gitignored, the script builds it first when it's missing (e.g. on Vercel's clean checkout) — preferring bun locally and falling back to pnpm exec tsx on node-only deploy targets. The deployed site ships this directory as static assets, so CLI and web consume the same JSON. public/registry/ is also registered as a Nitro serverAssets dir (vite.config.ts), so its contents are embedded into the server bundle at build time — SSR reads it via useStorage("assets:registry") (not the CDN), which is why it works on Vercel where public/ is absent from the function fs
  • vp check — format + lint + type-check across the repo (oxfmt + oxlint + type-aware tsgolint; lint.options.typeAware/typeCheck are on). vp check --fix autofixes formatting + fixable lint. vp lint / vp fmt run those passes alone. Prefer vp check for validation loops — it replaces the old pnpm lint/fmt:check/check-types trio
  • vp test — run the workspace test suites once (Vitest 4 via vite-plus/test; selection via test.projects in vite.config.ts). apps/web is intentionally excluded from projects (a vite-plus alpha bug breaks its plugin-heavy SSR config under the workspace loader); its suite passes standalone via cd apps/web && vp test. Re-add it to projects once the upstream loader bug is fixed
  • src/routeTree.gen.ts is generated by the web build (vp run @stanza/web#build) — run it before the first vp check if the file is missing
  • E2E smoke: seed $TMPDIR/x with stanza.json + apps/web/package.json, then tsx apps/cli/src/bin.ts add <slot> <module>
  • pnpm changeset (or vp exec changeset) — create a new changeset describing what changed (run after a substantive PR)
  • vp run release — build CLI/create-stanza and publish to npm (only runs in CI; locally use vp pack / pnpm pack to inspect tarballs)

Browser Automation

Use agent-browser for web automation. Run agent-browser --help for all commands.

Core workflow:

  1. agent-browser open <url> - Navigate to page
  2. agent-browser snapshot -i - Get interactive elements with refs (@e1, @e2)
  3. agent-browser click @e1 / fill @e2 "text" - Interact using refs
  4. Re-snapshot after page changes

Distributed Skill

  • skills/stanza-cli/SKILL.md is the installable agent skill for using Stanza through the npm-distributed CLI. Keep it accurate whenever CLI flags, command behavior, slot/add-on names, registry resolution, or published package names change; agents using the skill may not have access to this source repo and will rely on the public CLI contract alone.

Toolchain invariants

  • Node-only at runtime. The CLI source uses node APIs and is dev-run via tsx; the published binary is plain ESM JS (#!/usr/bin/env node). The only place bun appears is the shebang on root maintainer scripts (scripts/*.ts) for our own convenience — those scripts don't use any Bun.* APIs and run fine under tsx/node
  • Build pipeline: vp pack (which wraps tsdown) compiles each publishable package to ESM JS in dist/. The tsdown options live in each package's tsdown.config.ts and are passed through vite.config.ts's pack block. External npm deps are not bundled (users install them via the normal dep chain); workspace deps are inlined (we don't publish @stanza/registry and @stanza/codemods separately). Transitive runtime deps (ts-morph, zod) MUST be declared as direct dependencies of the publishable package or it will inline them into the bundle
  • Per-workspace dist/ paths: main/types in the source package.json still point at ./src/ so other workspaces resolve .ts directly during dev. The published tarball overrides via publishConfig to point at ./dist/<x>.mjs/.d.mts
  • Publishing: only stanza-cli and create-stanza ship to npm. @stanza/codemods + @stanza/registry are marked private: true (inlined into the CLI bundle by tsdown); @stanza/web is private (deployed as a Vercel site, not an npm package); the registry/modules/* packages are also private (they're registry data, not npm packages). Releases go through Changesets: drop a markdown file via pnpm changeset, push to main → the release workflow opens a "Version Packages" PR; merging that PR triggers the same workflow to run vp run release (build CLI + create-stanza, then changeset publish to npm). Requires NPM_TOKEN in repo secrets; provenance attestations are emitted via id-token: write + NPM_CONFIG_PROVENANCE=true
  • pnpm 10 + node-linker: isolated — each workspace MUST declare @types/node in its own devDeps and set types: ["node"] in tsconfig (auto-discovery doesn't reach into the isolated node_modules/@types)
  • TypeScript 6 — allowImportingTsExtensions: true + noEmit: true is set in tsconfig.json; the CLI/create-stanza emit JS via tsdown, the registry/codemods packages stay source-only and never emit
  • tsconfig.json excludes **/templates/** globally — template files target user projects, not this repo
  • Zod 4: use z.partialRecord(K, V) for finite-key partial records (z.record(z.enum, V) requires exhaustive keys)
  • TanStack Start: verbatimModuleSyntax: false in apps/web/tsconfig.json (server bundles leak otherwise); tanstackStart() MUST precede react() in vite plugins

Architecture rules

  • Modules are vendored: their templates land in the user's repo verbatim; no @stanza/runtime dep
  • Template distribution: scripts/registry-build.ts inlines each template file's contents into the per-module JSON's templates[].content field so HTTP-loaded manifests are self-contained. The runner prefers tpl.content and only reads from registry/modules/<x>/templates/ when it's absent (local dev). New templates need no build wiring — they're picked up automatically
  • Module logos: drop logo.svg (theme-agnostic) or logo-light.svg + logo-dark.svg (theme pair) in a module's directory. The registry build auto-detects and inlines as mod.logo (string or { light, dark }) — module authors don't declare anything in module.ts. First-party logos come from svgl.app. The web builder renders inline via dangerouslySetInnerHTML
  • Registry is data; CLI is the runtime: the per-module JSON ships templates (text), deps (strings), env (strings), scripts (strings), logos (SVG markup), and codemod invocations ({ id, args }). It does NOT ship codemod code. The catalog of generic codemods lives in packages/codemods/src/builtins/ and is exposed via the @stanza/codemods/builtins subpath export — each codemod is parameterized by TArgs and reusable across modules (wrap-root-layout serves both Clerk and any future provider-style auth/state library). The catalog is statically imported into the CLI binary at build time, so distribution shape (single binary, pnpm-isolated, npm-hoisted, npx, bun --compile) doesn't matter — implementations always travel with the runtime
  • Adding a generic codemod: drop <id>.ts under packages/codemods/src/builtins/, default-export a Codemod<TArgs>, and register it in packages/codemods/src/builtins/index.ts. Codemods that bake in module-specific identifiers don't belong — factor them into args
  • Third-party codemods: deferred. Third-party HTTP-loaded modules can use the existing catalog codemods (pass { id, args } from their manifest) but can't add new ones until we land a proper sandboxed-execution + signing model
  • apps/web previews are server-rendered: Shiki runs in apps/web/src/server/highlighter.ts (module-singleton, kept warm). The builder loader (createServerFn in apps/web/src/server/builder-state.ts) computes selected files from URL search params, pre-renders Shiki HTML for each, and ships Record<path, { light, dark }> to the client. shiki must NEVER be imported from a client component — verify after vp run @stanza/web#build by grepping .output/public/assets/*.js for the shiki runtime (createHighlighter/codeToHtml/loadWasm), which should be absent. A bare grep shiki now yields false positives: the MDX docs pages ship statically-rendered code blocks with class="shiki" + --shiki-* CSS vars, which is fine — only the runtime must stay server-side
  • Category taxonomy (framework | styling | db | orm | auth | tooling | testing, more planned). One unified concept: a Module carries a single category field (no kind/slot discriminator). A category has two orthogonal, explicit properties — cardinality ("one" single-choice / "many" coexisting) and home (app/repo/package). Both live on the Category entry in CATEGORIES; CategoryId, KNOWN_CATEGORIES, PEER_CATEGORIES, PACKAGE_DIRS all derive from it, so adding a category is a one-line edit ({ id, label, description, cardinality, home }). Constraint-bearing is emergent: a category is a peer candidate only if some module declares peers/match against it, and the resolver iterates PEER_CATEGORIES (the cardinality: "one" ids) only — so a many category like testing never participates in others' dispatch. A module can declare a one-way peers (e.g. { framework: [...] }) + framework-varying adapters regardless of its own cardinality. CATEGORIES order is topological (a category appears after everything it peers on; many categories last)
  • Manifest stores selections in one modules record keyed by CategoryId, each an array (Partial<Record<CategoryId, StanzaModuleRecord[]>>); cardinality: "one" categories are kept to ≤ 1 record by add/init rejecting a second pick (the runner write itself is uniform). Read via selectedOne(m, cat) / selectedAll(m, cat). Region ownership keys on module.id, so two modules writing the same file at disjoint dot-paths (vitest scripts.test vs playwright scripts.test:e2e) never conflict
  • Adapter keys encode peer choices (e.g., next+drizzle); the resolver picks the most specific match
  • Module-level install fields: dependencies, devDependencies, env, scripts, and consumesPackages are declared at the module level when shared across adapters. Adapter-level fields override per-key (env merges by name). This avoids re-declaring the same dependencies and env block in every adapter of a multi-framework module (cf. Better Auth — 6 adapters, but better-auth dep and the two env vars are declared once)
  • Install home routing: a module's templates/deps/scripts route by its category's home (the single decision point — categoryHome(id), read by both the CLI runner and the web's synthesizePackageJsons). home: package (auth/db/orm) → packages/<dir>/ workspace package (named @<manifest.name>/<dir>), templates scope: "package", app gets a workspace:* dep; home: repo (tooling) → scope: "repo" config at the repo root + deps/scripts in the root package.json; home: app (framework/styling/testing) → the active app. Package bootstrap files (the package's package.json + tsconfig.json and the host app's workspace dep) are system-owned — not tracked in regions. stanza remove's sweep (over PACKAGE_DIRS) deletes them when no claims remain under packages/<dir>/
  • Generated projects don't share a tsconfig base: every apps/*/tsconfig.json and packages/*/tsconfig.json is self-contained. The framework module ships the app's tsconfig; the runner's ensureSlotPackage writes a matching self-contained config when bootstrapping a slot package. tsconfig.json lives in the stanza repo only; do not emit it in generated trees
  • Cross-package wiring: when a module's source code imports from another internal package (e.g. better-auth's auth.ts reads db from the orm package), declare consumesPackages: ["db"] at the module level. The runner adds @<project>/db: workspace:* to this module's own package. Module-level (not adapter-level) because the source code is shared infrastructure — adapters vary in templates/codemods, not in what they import. Templates can reference other packages via {{<dir>PackageName}} substitution (e.g. {{dbPackageName}}@my-app/db) — substitution runs over both template bodies (when template: true) and codemod-invocation args string values
  • Region ownership in stanza.json is the source of truth for stanza remove. Two modules claiming the same region is a hard error (RegionConflictError). Note: regions are not yet sufficient for swap — that verb additionally needs old-adapter-region → new-adapter-region mapping; design pending
  • Codemod catalog ids are part of the public contract: once stanza is published, renaming a builtin codemod id breaks every third-party manifest that references it. Treat ids as you would npm package names — additions are free, renames require a deprecation cycle, removals require a manifest schema version bump
  • Module version field: every module manifest declares a version string, pinned into stanza.json at install time. The upcoming swap/update verbs read it; today it's stored for forward compatibility. Bump it on schema-affecting changes (template additions, dep upgrades) per semver
  • Declarative beats imperative: prefer templates/dependencies/env/scripts over imperative codemods; the runner applies declarative fields generically
  • Reserved manifest fields: modules[category][].version and regions are written today but only fully consumed by the upcoming swap/update verbs — do not drop them
  • Web app hosts the canonical registry: pnpm --filter @stanza/web build runs prebuild which builds the registry (if missing) and copies it into apps/web/public/registry/. The deployed Vercel output therefore ships index.json + modules/*.json at the same origin. The published CLI's DEFAULT_REGISTRY_URL points at this same URL, so users get a working CLI with zero configuration. Self-hosters and CI override via STANZA_REGISTRY (URL or filesystem path)

Module authoring

  • module.ts exports defineModule({...}) with a single category (one of KNOWN_CATEGORIES) and at least one adapter (use match: {} for "default / no peer"). Modules live in registry/modules/<category>-<id>/ (e.g. testing-vitest/). For many-cardinality categories, give co-existing modules disjoint scripts/file paths so their region claims don't collide
  • Templates go in templates/, referenced by src path. scope decides where dest lands:
    • "app" (default) → manifest.appDir (e.g. apps/web/)
    • "repo" → repo root
    • "package"packages/<dir>/ where <dir> is the active category's home.dir — only valid for categories with home.kind === "package" (auth, db, orm)
  • For auth/db/orm modules, default to scope: "package" for everything that can live inside the package boundary. Reach for scope: "app" only when a framework convention forces the file to sit at the app root (e.g. Next's middleware.ts, App Router API routes). App-scoped files should be thin shims that import from {{packageName}}; set template: true and the runner runs mustache substitution
  • For cross-package imports (e.g. auth reading db), declare consumesPackages: ["<dir>"] at the module level (not on each adapter). The runner adds the workspace dep on first apply. Write the import as {{<dir>PackageName}} (e.g. import { db } from "{{dbPackageName}}";)
  • Hoist shared install fields (dependencies, devDependencies, env, scripts) to the module level when they don't vary across adapters. Adapter-level values still work for true variations and override per-key. Example: Better Auth's dependencies: { "better-auth": "^1.6.11" } and the two env vars are declared once at module level; only the per-(framework, orm) templates and codemods sit in the adapter blocks
  • Framework modules MUST NOT ship a package.json.tpl — it collides with addPackageDependency. Let the runner merge deps into the host's package.json. The same rule applies to package-scoped modules: don't ship a packages/<dir>/package.json template — the runner's ensureSlotPackage bootstraps one and merges deps in
  • To invoke an imperative codemod from a module, add codemods: [{ id: "<catalog-id>", args: {...} }] to the adapter. Modules never ship code — if no catalog entry matches the need, design a new generic codemod with the right args. String values in args go through mustache substitution (same context as template bodies)
  • For codemods that operate on files inside a slot's package (e.g. extending the orm's schema barrel from the auth module), pass base: "package:<dir>" to the catalog codemod — re-export and append-to-file both honor it

Gotchas

  • Clack spinners (p.spinner()) don't auto-stop on promise rejection — wrap awaits in try/catch and call spinner.stop(...failed) in the catch
  • LSP diagnostics on template files (e.g. "Cannot find module 'react'") are noise; the global exclude works for tsc but tsserver still indexes them
  • The dev registry is found by walking up from import.meta.url looking for registry/modules/; STANZA_REGISTRY env var overrides (FS path or HTTP URL)
  • pnpm install says "Already up to date" when only workspace package.json files changed without lockfile-affecting bumps — use pnpm install --force to re-link
  • Oxlint/oxfmt now run through vp (bundled in vite-plus); their config lives in the lint/fmt blocks of vite.config.ts, not standalone rc files. Oxlint has most but not all ESLint plugin rules — prevent-abbreviations, react/jsx-uses-react, etc. don't exist; a bad rule name fails the lint run
  • vp check is type-aware (lint.options.typeAware/typeCheck: true), so it runs tsgolint type checks too. tsgolint does not support compilerOptions.baseUrl — if a tsconfig sets it, vite-plus silently skips type-aware checks. Genuinely-needed as assertions at untyped boundaries (CSS custom props in React.CSSProperties, Object.fromEntries → exhaustive Record, first-party SSR asset casts) carry a scoped // oxlint-disable-next-line typescript/no-unsafe-type-assertion; test files relax no-unsafe-type-assertion/no-floating-promises via the vite.config.ts test override