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-sh/cli is the generator. You pick the modules you want; it assembles a tailored codebase from the versioned module registry. Every module is Apache-2.0 and installs from the public npm registry, so a generated project needs no token and no registry configuration.

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

In a terminal the wizard asks for the project name, then the modules.

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 your 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 --package @caisson-sh/cli@latest create-caisson my-app --module @caisson-sh/kernel@<version> --dry-run
create-caisson: dry-run — 9 files for "my-app"
  .github/workflows/ci.yml
  .gitignore
  .oxlintrc.json
  AGENTS.md
  README.md
  package.json
  src/__golden__/smoke.json
  src/golden.test.ts
  tsconfig.json

The selected 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 --package @caisson-sh/cli@latest create-caisson my-app --module @caisson-sh/kernel@<version>

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

Next steps:
  1. cd my-app
  2. bun install   # or: npm install
  3. bun run build

The contract it upholds: a generation is reproducible. Exact module versions are pinned into package.json, never a range.

Drive it from your agent

The shipped MCP server is auth-gated. Your Claude Code or Cursor agent authenticates, reads the same registry, and asks for the same generation the CLI does, so the agent can scaffold and reconfigure the codebase without you leaving the editor. The generate tool validates the selection and hands it to your host, which returns the generation's id.

// Illustrative: your agent calls the auth-gated MCP server, which validates every module id and
// version against the registry, then hands the selection to the host's onGenerate hook.
const result = await caisson.callTool("generate", {
  projectName: "my-app",
  modules: [{ id: "@caisson-sh/audit-worm", version: "<version>" }],
});

// result.generationId — whatever id the host's onGenerate hook returns

API reference

CLI flags

FlagValueDescription
<name> (positional)slugShorthand for --name <name>: the 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-sh/<slug>@<version> pin. Split on the last @, so a scoped id keeps its leading @.
--deploy <target>railway | fly | vercelLayers that deploy target's template directory on top of base.
--framework <target>nextLayers a wired Next.js App-Router starter demonstrating auth/tenancy/billing/jobs/email/ai-config wiring on the base substrate.
--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: project name and 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-sh/registry-schema owns the index the CLI and the MCP server 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-sh/<slug> shape and membership in the index; assertKnownVersion additionally requires that exact version among the module's catalog 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-sh/<slug>"
    latest: string; // semver
    versions: {
      version: string; // semver
      manifest: ModuleManifest;
      publishedAt?: string; // ISO 8601; absent from the CLI's bundled catalog
      gateAttestation?: string; // "<ci-run-id>@<commit-sha>"; absent from the CLI's bundled catalog
    }[];
  }[];
}

The CLI's catalog is derived when the CLI is built: every published @caisson-sh/* package, at the version in its package.json, with the description, dependencies, and stability from its manifest. It is never hand-edited. A generation resolves against this catalog once and pins the exact versions it used into package.json; nothing re-resolves against latest after that.

Generation

@caisson-sh/cli validates a raw selection against the module catalog, 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, 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 create-caisson binary calls generate plus a disk FileSetWriter directly.

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 caller sees:

// list_modules — no args
type ListModulesResult = { modules: string[] }; // every catalog module id, sorted

// describe_module
type DescribeModuleArgs = { name: string }; // 1-128 chars
type DescribeModuleResult = { name: string; latest: string; summary: string };
// throws NotFoundError (404) for an id that is not in the catalog

// generate
type GenerateArgs = {
  projectName: string; // ^[a-z0-9][a-z0-9-]*$, max 64 chars
  modules: { id: string; version: string }[]; // 1-100 entries
};
type GenerateResult = { generationId: string }; // returned by the host's onGenerate hook

Every generate call is allowlist-checked (id and version) against the same registry index the CLI validates against before it reaches the host. An unknown tool name 404s; 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).