Files
stanza/AGENTS.md
T

14 KiB

Stanza

Shadcn-style CLI that assembles modular full-stack TS monorepos. Two unusual properties: (1) generated code is vendored verbatim — no @stanza/runtime dep — and (2) stanza add works on existing projects, not just at init. Verbs today: init, add, remove, list, search (swap + update planned — manifest already reserves modules[category][].version and regions). The canonical category list lives in packages/schema/src/category.ts (CATEGORIES); module roadmap in registry.mdx.

Layout

  • apps/cli/stanza-cli, entry src/bin.ts (publishable, ESM via tsdown)
  • apps/web/@withstanza/web, TanStack Start visual builder (private, deployed to Vercel)
  • packages/schema/@withstanza/schema: the stanza.json + module schemas, contract types, CATEGORIES list, package-manager + registry-config schemas (publishable; also inlined into CLI bundle). The single Zod source of truth — StanzaManifestSchema backs the published JSON Schema
  • packages/registry/@withstanza/registry: peer/adapter resolver + package.json/env/README/template synthesis (private; inlined into CLI bundle). Depends on @withstanza/schema + @withstanza/utils
  • packages/codemods/ — ts-morph helpers, idempotent + reversible (private; inlined)
  • packages/utils/@withstanza/utils: path-safety (safeRelativePath) + env-file (appendEnvVar) helpers shared by schema/registry/codemods (private; inlined)
  • packages/create-stanza/pnpm create stanza shim (publishable)
  • registry/modules/<category>-<id>/ — first-party modules: module.ts + templates/ (private; data, not code)
  • scripts/compile-registry.ts — standalone static-registry build (not part of any package; imports @withstanza/schema). Run via jiti; exports compileRegistry() for in-process callers
  • skills/stanza-cli/SKILL.md — agent skill for the published CLI. Update whenever the public CLI contract changes; agents using this skill won't have the source repo

Commands

The repo runs on the Vite+ toolchain (vp). All config lives in the root vite.config.ts — there is no turbo.json, .oxlintrc.json, .oxfmtrc.json, or per-package vitest.config.ts. vite/vitest are catalog-aliased to @voidzero-dev/vite-plus-core / @voidzero-dev/vite-plus-test in pnpm-workspace.yamlnever add standalone vite/vitest. Source imports use vite-plus / vite-plus/test; template files under registry/modules/*/templates/ stay on stock vite/vitest (they target user projects).

  • tsx apps/cli/src/bin.ts <verb> — run CLI in dev (no build step; tsx watch for a loop)
  • vp check — format + lint + type-check (oxfmt + oxlint + type-aware tsgolint). Use this for validation loops. --fix autofixes
  • vp test — Vitest 4 via vite-plus/test; project selection via test.projects in root config
  • vp run -r build — build everything (vp pack per package, wraps tsdown)
  • vp run @withstanza/web#dev — TanStack Start dev server. Its dev/build scripts first run the compile-registry vp task (defined under run.tasks in apps/web/vite.config.ts) → jiti scripts/compile-registry.ts apps/web/public/registry, emitting apps/web/public/registry/{index.json,modules/*.json}. The manifest JSON Schema is no longer a build artifact — it's served on request by the /schema.json route (apps/web/src/routes/schema[.]json.ts)
  • src/routeTree.gen.ts is generated by vp run @withstanza/web#build — run it before the first vp check if missing
  • E2E smoke: build the registry (jiti scripts/compile-registry.ts $TMPDIR/reg → writes $TMPDIR/reg/index.json + $TMPDIR/reg/modules/*.json directly under the out dir, no registry/ wrapper), seed $TMPDIR/x with stanza.json + apps/web/package.json, then STANZA_REGISTRY=$TMPDIR/reg/index.json tsx apps/cli/src/bin.ts add <category> <module>. The CLI reads a built registry main file (full path/URL, any filename) — there is no source-tree loader; STANZA_REGISTRY unset hits the production registry
  • pnpm changeset — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. stanza-cli, create-stanza, and @withstanza/schema ship to npm; every other workspace is private

Use agent-browser for web automation (agent-browser --help); prefer it over built-in browser tools.

Toolchain invariants

  • Node-only at runtime. CLI is dev-run via tsx, published as plain ESM JS (#!/usr/bin/env node). Bun shebangs on scripts/*.ts are convenience only — those scripts use no Bun.* APIs
  • Build pipeline: vp pack → tsdown → ESM dist/. Source main/types point at ./src/ for dev resolution across workspaces; publishConfig overrides for the tarball. External npm deps are NOT bundled; workspace deps ARE inlined — transitive runtime deps (ts-morph, zod) MUST be direct dependencies of the publishable package
  • pnpm 10 + node-linker: isolated — every workspace MUST declare @types/node in its own devDeps and set types: ["node"] in tsconfig
  • TypeScript 6allowImportingTsExtensions: true + noEmit: true repo-wide; tsconfig.json excludes **/templates/** (those target user projects)
  • Zod 4: use z.partialRecord(K, V) for finite-key partial records
  • TanStack Start: verbatimModuleSyntax: false in apps/web/tsconfig.json; tanstackStart() MUST precede react() in vite plugins

Architecture rules

  • Modules are vendored, not runtime-linked. Templates land in the user's repo verbatim
  • Registry is data; CLI is the runtime. Per-module JSON ships templates (text), deps, env, scripts, logos (SVG markup), and codemod invocations ({ id, args }) — never codemod code. The catalog of generic codemods lives in packages/codemods/src/builtins/ and is statically imported into the CLI bundle at build time, so distribution shape (single binary, pnpm-isolated, npx, etc.) doesn't matter
  • Adding a generic codemod: drop <id>.ts under builtins/, default-export a Codemod<TArgs>, register in builtins/index.ts. Module-specific identifiers don't belong — factor them into args
  • Codemod catalog ids are part of the public contract. Once published, renaming an id breaks third-party manifests. Treat ids like npm package names — additions are free, renames need a deprecation cycle, removals need a manifest schema version bump
  • Third-party codemods are deferred until a sandboxed-execution + signing model lands. Third-party modules can invoke existing catalog ids but can't add new ones
  • Template distribution: scripts/compile-registry.ts (the standalone registry build) inlines each template file's contents into templates[].content so HTTP-loaded manifests are self-contained. The runner prefers tpl.content and only reads from templates/ when absent (local dev). New templates need no build wiring
  • Categories carry orthogonal cardinality (one / many) and home (app / repo / package); both are declared on each entry in CATEGORIES. Adding a category is a one-line edit there — KNOWN_CATEGORIES, PEER_CATEGORIES, PACKAGE_DIRS, and categoryHome()/categoryLabel() all derive. Peer-resolution iterates PEER_CATEGORIES (the one-cardinality ids) only — a many category never participates in others' dispatch. CATEGORIES order is topological (each category appears after everything it peers on; many categories last)
  • Manifest layout: selections live in one modules record keyed by CategoryId, each an array of StanzaModuleRecord. Records carry optional apps?: string[]required for home: app, optional for home: package (omitted means "ship app-scoped shims into every app"; an explicit list restricts), forbidden for home: repo. The app id doubles as the workspace package suffix (id: "web"@<project>/web, regardless of dir). cardinality: "one" enforcement is per-app for home: app categories (selectedOne(m, cat, appId)), per-project otherwise
  • Install home routing: categoryHome(id) is the single decision point, read by both the CLI runner and the web's synthesizePackageJsons. home: packagepackages/<dir>/ workspace package named @<manifest.name>/<dir> (auto-bootstrapped by ensureSlotPackage); every consuming app gets a workspace:* dep. home: repo → repo-root config + scripts in root package.json. home: app → the targeted app
  • Generated projects don't share a tsconfig base. Every apps/*/tsconfig.json and packages/*/tsconfig.json is self-contained. The Stanza repo's root tsconfig.json exists only for this repo — never emit one into generated trees
  • Cross-package wiring: declare consumesPackages: ["<dir>"] at the module level (not per-adapter) when source imports another internal package. Reference other packages in templates / codemod args via {{packages.<dir>.name}} (e.g. {{packages.db.name}}@my-app/db). Full template context: { project, app, package, packages }; app.* is rebound per target app in the apply loop. renderTemplate lives in @withstanza/registry so CLI apply and web preview produce byte-identical output
  • Region ownership in stanza.json is the source of truth for stanza remove. Two modules claiming the same region throws RegionConflictError. Regions key on module.id, so disjoint dot-paths (vitest scripts.test vs playwright scripts.test:e2e) coexist. Package bootstrap files (package.json, tsconfig.json, workspace deps) are system-owned, not tracked in regions — stanza remove's sweep over PACKAGE_DIRS cleans them when no claims remain. Regions are not yet sufficient for swap — that verb needs adapter-region remapping; design pending
  • Reserved manifest fields: modules[category][].version and regions are written today but only fully consumed by upcoming swap/update. Don't drop them
  • Web previews are SSR. Shiki runs in highlighter.server.ts; the client-safe Preview type lives in highlighter.ts. Never import shiki from a client component. After vp run @withstanza/web#build, grep .output/public/assets/*.js for the runtime (createHighlighter/codeToHtml/loadWasm) — must be absent. A bare grep shiki yields false positives because MDX docs ship statically-rendered code blocks with class="shiki" + CSS vars (fine)
  • Web hosts the canonical registry. The compile-registry task writes the registry into apps/web/public/registry/; the deployed site serves it at the published CLI's default DEFAULT_REGISTRY_URL. STANZA_REGISTRY (URL or FS path) overrides for self-hosters / CI. public/registry/ is also a Nitro serverAssets dir (vite.config.ts) — SSR reads via useStorage("assets:registry") (see registry-base.server.ts), which is why Vercel works where public/ is absent from the function fs. The deployed site also serves the manifest JSON Schema at /schema.json (route apps/web/src/routes/schema[.]json.ts), compiled from @withstanza/schema's StanzaManifestSchema on request rather than emitted to public/ — this is the URL MANIFEST_SCHEMA_URL / a manifest's $schema resolves to
  • Third-party registries: namespaces declared under stanza.json#registries. Modules addressed as <category> @<ns>/<id>. telemetry.captureModule redacts non-@stanza ids to <redacted>; stderr still prints full ids (CI log forwarders will see them)

Module authoring

  • Modules live in registry/modules/<category>-<id>/. module.ts exports defineModule({...}) with one category and ≥1 adapter (match: {} for "default / no peer"). For many-cardinality categories, give co-existing modules disjoint scripts keys / file paths so region claims don't collide
  • Template scope: "app" (default) → each targeted app's dir; "repo" → repo root; "package"packages/<dir>/ (only valid for home: package categories). For package-home modules, default to scope: "package" for everything that fits inside the package; reach for "app" only when framework convention forces it (e.g. Next proxy.ts). App-scoped files should be thin shims that import from {{package.name}} with template: true
  • Hoist shared install fields (dependencies, devDependencies, env, scripts, consumesPackages) to the module level. Adapter-level values still override per-key (env merges by name)
  • Never ship package.json or tsconfig.json templates — they collide with the runner's ensureSlotPackage / addPackageDependency. Framework modules must not ship package.json.tpl; package-home modules must not ship packages/<dir>/package.json
  • Codemod invocations: codemods: [{ id, args }] on the adapter. String values in args go through mustache (same template context). For codemods operating on files inside a slot's package, pass base: "package:<dir>" (honored by re-export and append-to-file)
  • Adapter match keys encode peer choices (e.g. next+drizzle); the resolver picks the most specific match. A module can declare a one-way peers (e.g. { framework: [...] }) and framework-varying adapters regardless of its own cardinality
  • Logos: drop logo.svg (theme-agnostic) or logo-light.svg + logo-dark.svg (theme pair) in the module dir. The registry build auto-detects and inlines as mod.logo. First-party logos come from svgl.app
  • version field: declare on every manifest; pinned into stanza.json at install. Bump per semver on schema-affecting changes (templates, dep upgrades). Consumed by upcoming swap/update