create-caisson

create-caisson

Scaffold a tailored codebase from the versioned module registry, by hand, or driven by your AI agent through the auth-gated MCP server.

What it does

@caisson/cli is the generator. You pick a bundle and the modules you want; it assembles a tailored codebase by pulling versioned module sources from the Caisson registry. Bundles are compositions of the same audited base: the generator composes, it never forks.

bunx @caisson-sh/cli@latest my-app
cd my-app
bun install

The first prompt asks what to generate — a licensed build, a free sample, or a demo. Then you name the project and select modules. A bundle is chosen with the --edition <id> flag rather than a wizard question; passing one pre-checks its current members in the module list, and you can still add or remove from there. The base substrate — kernel, auth, tenancy-rls, ui, billing, jobs, email, ai-config, mcp-server, registry-schema, observability, rate-limit, ds-manifest — plus the generator tooling cli, migrate, and license-verify, is always free to install, independent of any entitlement. Paid modules such as credits are not part of it; they are entitlement-gated.

The registry contract

Every module the CLI emits is pulled from a versioned registry, not copied from a moving template. A generation resolves the module versions it used and pins them directly into the generated package.json dependencies (no separate lockfile) so the same inputs reproduce the same tree. The CLI and the buyer's AI agent read from the one registry: there is no second, drifting source.

Pass --dry-run to see exactly what a selection resolves to before anything is written:

$ bunx @caisson-sh/cli@latest my-app --edition compliance --dry-run
create-caisson: dry-run 11 files for "my-app" (compliance edition)
  .github/workflows/ci.yml
  .gitignore
  .npmrc
  AGENTS.md
  README.md
  eslint.config.js
  package.json
  src/__golden__/evidence.json
  src/__golden__/smoke.json
  src/golden.test.ts
  tsconfig.json

The bundle's modules arrive as pinned dependencies in that package.json rather than as copied source, which is why the scaffold itself is small.

Without --dry-run the same command writes the tree and prints the next steps:

$ bunx @caisson-sh/cli@latest my-app --edition compliance

create-caisson: generated "my-app" my-app

Next steps:
  1. cd my-app
  2. Add your Caisson license key to .npmrc (see README.md)
  3. bun install   # or: npm install
  4. bun run build

The contract it upholds: a generation is reproducible (exact module versions pinned into package.json, never a range) and metered (codegen-credits are integer units, never floats; a run debits an exact count, or it fails closed before writing). Metering applies to the hosted buyer-MCP path; the local binary above generates for free.

Drive it from your agent

The shipped MCP server is auth-gated. Your Claude Code or Cursor agent authenticates, reads the same registry, and runs the same generation the CLI does, so the agent can scaffold and reconfigure the codebase without you leaving the editor. The generate tool call returns the generation's id (stable across same-key retries), not the file contents.

// Illustrative: your agent calls the auth-gated MCP server, which resolves modules from the
// versioned registry, meters the run, and writes the generated tree to `target`.
const result = await caisson.callTool("generate", {
  edition: "compliance",
  modules: ["audit-worm", "field-crypto"],
  target: "./my-app",
});

// result.generationId — the canonical generation audit row id, stable across same-key retries

API reference

CLI flags

FlagValueDescription
<name> (positional)slugShorthand for --name <name>: the advertised bunx create-caisson my-app quickstart. An explicit --name always wins on conflict.
--name <slug>a-z0-9, kebab, max 64 charsProject name; the output directory unless --out is set.
--module <id@version>repeatableAn exact @caisson/<slug>@<version> pin. Split on the last @, so a scoped id keeps its leading @.
--edition <e>compliance | ai-production | local-first | agentic-dev | provenance | everythingA bundle, exactly these six ids. Alone it auto-selects the bundle's current modules (override with --module). The retired ids ai-kit / local-ai / agent-dev are rejected outright.
--deploy <target>railway | fly | vercelLayers that deploy target's template directory on top of base (+ bundle).
--framework <target>nextLayers a wired Next.js App-Router starter demonstrating auth/tenancy/billing/jobs/email/ai-config wiring on the base substrate.
--sample <id>e.g. eu-ai-act-sampleA free, Apache-2.0 evaluation sample. No --module/--edition, no license key. Mutually exclusive with --demo.
--demoflagFull-catalog generation with every commercial module replaced by a watermarked stub. No license key, never for production.
--out <dir>pathOutput directory (defaults to the project name).
--dry-runflagPrint the file plan; write nothing.
--help, -hflagPrint usage.

Run with no flags in a terminal and create-caisson prompts for whatever's missing, licensed build vs. free sample vs. full-catalog demo, project name, modules. Any flag you did pass is never re-prompted; a non-TTY invocation (CI, piped stdin) fails closed on a missing required field instead of hanging on a prompt.

The registry index schema

@caisson/registry-schema owns the index the CLI, the buyer MCP, and the docs all read against. Every module id and version is checked before it reaches a path or a subprocess argument: a bad id never gets far enough to matter:

function assertKnownModule(
  index: RegistryIndex,
  id: string,
): asserts id is ModuleId;
function assertKnownVersion(
  index: RegistryIndex,
  id: string,
  version: string,
): void;
function loadRegistryIndexFromFile(path: string): RegistryIndex;

assertKnownModule re-checks the @caisson/<slug> shape and membership in the index; assertKnownVersion additionally requires that exact version among the module's published versions. Both throw a plain Error on a miss, synchronously, before any generation work starts. loadRegistryIndexFromFile is the one sanctioned read path (RegistryIndex.parse underneath); a malformed or tampered index file throws rather than yielding a half-typed object.

interface RegistryIndex {
  schemaVersion: 1;
  modules: {
    id: string; // "@caisson/<slug>"
    latest: string; // semver
    versions: {
      version: string; // semver
      manifest: ModuleManifest;
      publishedAt: string; // ISO 8601
      gateAttestation: string; // "<ci-run-id>@<commit-sha>" — provenance, not the access control
    }[];
  }[];
}

The index is built by a CI-only writer, never hand-appended: a version's presence here is what "published" means. A generation resolves against this index once and pins the exact versions it used into package.json; nothing re-resolves against latest after that.

Generation and metering

@caisson/cli validates a raw selection against the registry allowlist, then materializes it: in-memory, no disk write yet:

function validateSelection(index: RegistryIndex, raw: unknown): Selection;

function generate(
  index: RegistryIndex,
  raw: unknown,
  engine?: GeneratorEngine, // defaults to templatesEngine
): { selection: Selection; files: GeneratedFileSet };

Selection is a .strict() Zod object, a lowercase slug projectName, an optional bundle edition, modules: { id, version }[] (at least one, no duplicate ids), and optional deployTarget / framework. validateSelection parses it, then runs assertKnownModule / assertKnownVersion on every module: an unknown id or a stale version throws before the engine is ever invoked.

The local create-caisson binary calls generate plus a disk FileSetWriter directly and stops there: it generates for free; the license key gates the package install, not the generation. The hosted buyer MCP instead drives the metered path:

function runGeneration(
  tx: TenantExecutor,
  deps: GenerationDeps,
  raw: unknown,
  meter: MeterInput,
): Promise<GenerationOutcome>;

interface MeterInput {
  accountId: string;
  idempotencyKey: string; // caller-minted UUID; a retry with the same key debits once
  amount?: number; // integer credits, default 1
}

interface GenerationOutcome {
  selection: Selection;
  files: GeneratedFileSet;
  balance: number;
  idempotent: boolean;
  generationId: string; // the canonical generation audit row id, stable across retries
}

runGeneration runs in one transaction: validate + compose the file set (bundle member pins, migration assembly, read-only), debit before writing anything (a short balance throws before any file touches disk), write via the injected FileSetWriter, then record one append-only audit row keyed on (accountId, idempotencyKey). A retried call with the same key debits once and resolves to the same generationId instead of a duplicate row.

createFileSetWriter(raw?: unknown) — Zod-validated to { overwrite?: boolean }, default overwrite: false — is the disk writer: every path is checked for null bytes, absolute paths, and .. segments before anything is written, and the whole set lands via a sibling temp directory + atomic rename; a failed write leaves no partial tree.

MCP tool schemas

The three base tools every authenticated buyer sees, regardless of entitlement:

// list_modules — no args
type ListModulesResult = { modules: string[] }; // the caller's owned entitlement slugs, sorted

// describe_module
type DescribeModuleArgs = { name: string }; // 1-128 chars
type DescribeModuleResult = { name: string; summary: string };
// throws EntitlementError (403) if the caller doesn't own `name`

// generate
type GenerateArgs = {
  projectName: string; // ^[a-z0-9][a-z0-9-]*$, max 64 chars
  edition?: "compliance" | "ai-kit" | "local-ai" | "agent-dev";
  modules: { id: string; version: string }[]; // 1-100 entries
  idempotencyKey?: string; // UUID; minted server-side when omitted, reused verbatim on a retry
};
type GenerateResult = { generationId: string };

Note generate's edition is the legacy compliance | ai-kit | local-ai | agent-dev vocabulary, disjoint from the CLI's --edition flag, which accepts only the six current bundle ids (compliance, ai-production, local-first, agentic-dev, provenance, everything) and rejects the three retired legacy names outright. Omit edition and pass modules directly if you're targeting a bundle the legacy MCP vocabulary doesn't cover.

Every generate call is allowlist-checked (id and version) against the same registry index the CLI validates against, before any entitlement check runs. Ownership is then checked against the caller's entitlements expanded through their bundle/module purchases, request a module outside that expanded set and the call throws EntitlementError (403) naming every id it isn't entitled to. A tool the caller isn't entitled to is invisible: it's excluded from list_tools, and calling it directly 404s exactly like a name that doesn't exist. A retired tool name instead answers 410 with a reason. When the host wires a rate limiter, every call is throttled per account before it reaches a handler (429).