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 installIn 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.jsonThe 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 buildThe 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 returnsAPI reference
CLI flags
| Flag | Value | Description |
|---|---|---|
<name> (positional) | slug | Shorthand for --name <name>: the create-caisson my-app quickstart. An explicit --name always wins on conflict. |
--name <slug> | a-z0-9, kebab, max 64 chars | Project name; the output directory unless --out is set. |
--module <id@version> | repeatable | An exact @caisson-sh/<slug>@<version> pin. Split on the last @, so a scoped id keeps its leading @. |
--deploy <target> | railway | fly | vercel | Layers that deploy target's template directory on top of base. |
--framework <target> | next | Layers a wired Next.js App-Router starter demonstrating auth/tenancy/billing/jobs/email/ai-config wiring on the base substrate. |
--out <dir> | path | Output directory (defaults to the project name). |
--dry-run | flag | Print the file plan; write nothing. |
--help, -h | flag | Print 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 hookEvery 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).