Crust logoCrust

Crust

Immutable command builders, reusable definitions, and execution.

Crust builds an executable application root. defineCommand() builds inert reusable definitions that become commands only when added. Every fluent method returns a new builder; the original is unchanged.

Exact overloads and generic constraints live in command/crust.ts. This page documents behavior; hover types in your editor for the inferred shapes.

Reusable command definitions

defineCommand(name, config?, recipe)

src/cli.ts
import { Crust, defineCommand, defineContext, defineFlag } from "@crustjs/core";

const verbose = defineFlag("verbose", { type: "boolean" });
const logging = defineContext("logging")
	.flags(verbose)
	.setup(({ flags, stderr }) => ({
		debug(message: string) {
			if (flags.verbose) stderr(message);
		},
	}));
const auth = defineContext("auth").setup(() => ({ user: "Ada" }));

// Inert until added; `.use()` declares the Contexts this command needs.
const deploy = defineCommand("deploy", { description: "Deploy an application" }, (command) =>
	command
		.use(logging, auth)
		.args({ name: "target", type: "string", required: true })
		.action(async ({ args, ctx, stdout }) => {
			(await ctx.logging).debug(`deploying ${args.target}`);
			stdout(`${(await ctx.auth).user} deployed ${args.target}`);
		}),
);

defineCommand() returns a frozen CommandDefinition: a name, static config, and a recipe. It has no command node and no run() or execute(). The recipe receives a CommandDefinitionBuilder, the configure-only form of Crust, and runs once for each .add(), not when the definition is created. Side effects in a recipe therefore repeat for every addition.

Prop

Type

Application version belongs only in the root Crust constructor. Aliases must be non-empty, contain no whitespace, not start with -, and differ from the canonical name. Blank names and __proto__ fail typecheck.

.use(logging) declares demand for a factory; .provide(logging()) supplies an instance. A reusable definition sees only the Contexts it declares with .use(), and those demands are checked against the providers available where it is added.

definition.as(name) returns the same definition under another name, so one definition can be added twice. Its configured aliases travel with it and must stay unique among siblings.

Constructor

new Crust(name, meta?)

Prop

Type

Metadata is optional unless an installed Extension requires it; .extend() checks that requirement at compile time. TypeScript rejects extra root keys. version is exposed on the root snapshot. Aliases and hidden apply only to subcommands, so they are not root metadata.

src/cli.ts
import { help, version } from "@crustjs/extensions";

const app = new Crust("my-cli", { description: "Deploy tool", version: "1.2.3" })
	.provide(logging(), auth()) // providers must precede the definitions that use them
	.add(deploy, deploy.as("ship"))
	.command("status", (command) => command.action(({ stdout }) => stdout("ok")))
	.extend(version(), help());

Chaining semantics

.flags(), .args(), .provide(), .add(), and .extend() are variadic, accept dynamic collections, and accumulate across calls. TypeScript rejects statically known invalid definitions and collisions; dynamically assembled definitions are checked when the builder consumes them and throw a DEFINITION error.

Conditional chains

Each call returns a builder whose type records what was added so far, so a conditional can produce a union of builder types that the next call cannot infer from. Crust rejects such calls instead of silently losing types:

// Compile error: inputs cannot be inferred from this builder union.
(prod ? c.provide(db()) : c).provide(cache());

Keep the command shape static and put the condition in the Context value:

const db = defineContext("db").setup(() => (prod ? connect() : null));
c.provide(db(), cache()); // db resolves to Connection | null

If the shape genuinely differs, write the full chain in each branch:

prod ? c.provide(db(), cache()) : c.provide(cache());

.args(...defs)

Defines positional arguments in call order. Definitions come from defineArg(name, def) or inline literals; see ArgDef.

const copy = new Crust("copy").args(
  { name: "source", type: "path", required: true },
  { name: "destinations", type: "path", variadic: true },
);

Names must be unique, custom parse functions must be synchronous, and only the last argument across all calls may be variadic. A schema-backed argument receives the raw string (string[] when variadic, undefined when absent); its schema owns coercion, defaults, requiredness, and validation, so it cannot also set type, default, required, choices, or parse.

.flags(...defs)

Defines flags local to this command. Definitions come from defineFlag(name, def) or inline literals carrying name; names omit the -- prefix. See FlagDef.

const serve = new Crust("serve").flags(
  { name: "verbose", type: "boolean", short: "v" },
  { name: "port", type: "number", default: 3000 },
  { name: "include", type: "string", multiple: true },
);
  • Long names, aliases, and short spellings must not collide with other flags visible on the command.
  • One-character names and aliases accept one or two dashes: aliases: ["P"] accepts -P and --P. Help, completion, man, and skills output list both.
  • Boolean flags accept --no-<spelling> for the name and every long alias. noNegate: true rejects negation with a PARSE error and hides it from help, completion, and man output. Names and aliases beginning with no- are reserved.
  • Schema-backed flags still set type: "string" | "boolean" to declare whether a value token follows; the schema receives the raw value, including undefined when omitted.
  • Flags declared here do not reach subcommands. To share flags with descendants, give them to a Context and .provide() it.

.use(...factories)

Recipe-only. Declares the Context factories a reusable definition consumes, without providing instances. The call requires at least one factory; repeated calls accumulate. The demands and their transitive dependencies are checked when the definition reaches .add().

.provide(...instances)

Attaches Context instances created by invoking defineContext() factories. The Context is available to this command and to descendants added after this call, and is constructed lazily when its ctx property is first read.

const database = defineContext("database").setup((_input, options: { url: string }) => ({
  url: options.url,
  async [Symbol.asyncDispose]() {},
}));

const app = new Crust("my-cli")
  .provide(database({ url: "postgres://localhost/app" }))
  .action(async ({ ctx }) => (await ctx.database).url);
  • Flags declared with a Context's .flags() are installed on this command and later-added descendants. The builder's flag type includes them.
  • Its .sections() document only the providing command, after the command's own sections. Duplicate titles throw DEFINITION (duplicate-section) during preparation, not at .provide(). See Context-owned documentation.
  • Within one call, dependencies may appear in any order; across calls, providers must precede their consumers.
  • Names, dependencies, and owned-flag collisions are checked by TypeScript. Runtime composition checks reject missing declared providers and dynamic flag collisions with a DEFINITION error, without running setup. Pulling a Context also checks missing providers, dependency cycles, and lifecycle misuse.
  • A command can shadow an inherited Context. After .action(), a local replacement must satisfy the value type that action received.

Context lifecycle

  • Cleanup registered with setup's defer(cleanup), and returned values implementing Symbol.dispose or Symbol.asyncDispose, run in reverse registration order after postRun hooks, including when the action throws.
  • Call defer immediately after acquiring each resource so a later setup failure still releases it. Calling defer after setup has settled throws a DEFINITION error.
  • A returned disposable is disposed for you. Do not also close it with defer, await using, or in the action, and do not keep it after the invocation.
  • A value supplied through factory.of(value) is disposed by every invocation that pulls it. Use a fake without a disposer for repeated invocations.
  • Await dependencies during setup, before registering a defer callback that needs them, so they are still live when that callback runs.
  • Setup receives the invocation's signal. Pass it to cancellable initialization such as fetch(url, { signal }); do not reuse it for cleanup work that must finish.

See Contexts for worked examples.

.extend(...extensions)

Root-only. Registers application-wide Extensions in registration order. A repeated ExtensionId replaces the earlier registration.

import { help, version } from "@crustjs/extensions";

const app = new Crust("my-cli", { version: "1.2.3" }).extend(version(), help());

Extensions apply to every command, including definitions added before .extend(). Contexts an Extension provides reach the root and existing descendants while more-specific local providers win. Extension commands and flags are materialized when the application prepares. Extensions must come from defineExtension().

.action(action)

Defines the Command Action. It runs after routing, parsing, preRun hooks, and validation. Its awaited return value becomes the result of typed run() calls. A later .action() replaces both the action and its result type.

const greet = new Crust("greet")
  .args({ name: "name", type: "string", required: true })
  .action(({ args, stdout }) => stdout(`Hello, ${args.name}!`));

The action receives typed args and flags, the lazy ctx bag, rawArgs after --, a cancellation signal, the resolved command and rootCommand snapshots, and stdout(text) / stderr(text). See CrustCommandContext.

signal aborts on execute({ signal }), run(path, input, { signal }), or the first Ctrl-C during execute(). Pass it to fetch, timers, or child processes. When work throws the signal's reason, that error decides the exit path; see cancellation.

.add(...definitions)

Materializes definitions as fresh child commands under their carried names. See the root example, which adds deploy twice with .as().

Sibling names and aliases must not collide. A definition can itself add definitions; nested ones access inherited Contexts by declaring them with .use(). An added command accepts the Context-owned flags already provided on its parent path, but not the parent's local flags.

A later .provide() does not reach an already-added definition. Register a definition's providers before .add(). Extension-provided Contexts are the exception.

.command(name, recipe)

Sugar for .add(defineCommand(name, recipe)), for short inline commands. It works on the root and inside another command's recipe, so subcommands can nest inline. Use defineCommand() for commands in separate files and reused commands.

const app = new Crust("my-cli").provide(logging()).command("up", (command) =>
  command.flags({ name: "detach", type: "boolean" }).action(async ({ flags, ctx, stdout }) => {
    (await ctx.logging).debug(`detach: ${String(flags.detach)}`);
    stdout("up");
  }),
);

The recipe sees the Contexts and Context-owned flags available before the call, so its ctx is typed without .use(). Inside a recipe, those are the Contexts the parent declared with .use() or .provide(). Contexts provided after .command() are not visible to it.

.snapshot()

Resolves to a frozen CommandSnapshot for tooling such as help, man-page, skill, and build generators. It materializes Extension contributions and validates Extension sections, without calling Command Actions.

const snapshot = await app.snapshot();

Renderers filter sections for their audience, then merge entries with the same exact title in source order. During crust build, section callbacks are re-evaluated after each build hook, so later hooks see refreshed snapshots.

.run(path, input?, options?)

Invokes a command programmatically with a typed path and structured input, capturing its output.

src/cli.ts
const outcome = await app.run(["deploy"], { args: { target: "prod" }, flags: { verbose: true } });
if (outcome.status === "failed") throw outcome.error;
if (outcome.status === "completed") console.log(outcome.stdout); // => Ada deployed prod

Editors complete command names, argument and flag names, and the action's result type. Input rules:

  • Values bind directly against definitions without producing argv, then flow through parsers, schemas, Contexts, and the action. Custom parsers and schemas still receive their raw input, usually strings.
  • Supplied positionals must form a prefix. undefined means omitted. An empty multiple array means zero occurrences.
  • Structured input binding never reads process.env, so a flag's env fallback does not apply. Only .execute() uses flag environment fallbacks. Contexts such as @crustjs/env can still read the environment when pulled.
  • TypeScript rejects unknown argument, flag, and top-level keys, including in inputs held in variables. Keys set to undefined count as omitted. At runtime, unknown argument or flag keys return a failed outcome before the action runs.
  • raw reaches the action as rawArgs, like tokens after --.
  • The path alone selects the command. Unknown path segments return a COMMAND_NOT_FOUND failure, even when the nearest parent has an action.
  • For genuinely dynamic paths and input, use an AnyCrust view; it resolves to RunOutcome<unknown>.

run() resolves after cleanup to a RunOutcome, with captured output and a completed, handled, or failed status.

  • run() never renders errors, calls onError, or changes process.exitCode. Authoring errors still throw where the builder consumes them.
  • Captured writes are joined with "\n". stdout and stderr are captured separately, so their relative order is not preserved.
  • Default @crustjs/prompts and @crustjs/progress output is captured into stderr. Direct console or process writes, subprocesses, and files are not captured.
  • options.stdout / options.stderr also receive writes live. options.signal cancels the invocation. run() never installs a SIGINT handler.
  • Preparation runs once per builder and is shared by later run(), execute(), and snapshot() calls on it.

For terminal interaction in tests, use execute() or runInteractive.

.at(path)

Binds a typed command path into a reusable CommandHandle. handle.run(input?, options?) accepts exactly what run() accepts after the path.

src/cli.ts
const ship = app.at(["ship"]);
const shipped = await ship.run({ args: { target: "staging" } });
console.log(shipped.status); // => completed

at() validates the path immediately and throws COMMAND_NOT_FOUND with the same details as a failed run(). Aliases are accepted and handle.path keeps the caller's spelling. at([]) binds the root. A handle belongs to the builder it was taken from, so take it from the finished application.

.execute(options?)

Runs the terminal boundary: parses process.argv.slice(2) (or options.argv), renders failures, and resolves to the exit code. See ExecuteOptions.

src/cli.ts
const sync = new Crust("sync").action(async ({ signal, stdout }) => {
	const response = await fetch("https://example.com/export", { signal });
	stdout(await response.text());
});

await sync.execute(); // Ctrl-C → exit code 130, nothing rendered
  • Exit codes are 0 on success, 1 on failure, and 130 for cancellation. process.exitCode is set on failure.
  • A failure renders once, through Extension onError hooks and then Core's renderer. Cancellation is offered to onError hooks and renders nothing if none claims it. See propagation boundaries.
  • options.io captures rendered output in tests, including default prompt and progress output. It does not provide prompt input.
  • The first Ctrl-C aborts the invocation's signal with an AbortError. Work that honors the signal stops, then postRun hooks and Context cleanup run. A second Ctrl-C removes Core's listener and reaches the runtime's default handling unless another listener still owns the signal.

Invocation order

.execute() and .run() follow the same sequence. Routing or parse failures skip steps 4 through 10.

  1. Prepare the command tree: apply Extensions, recipes, flags, and sections.
  2. Route the command path.
  3. Parse argv (execute()) or bind structured input (run()).
  4. Run Extension preRun hooks in registration order; ctx.handled() ends the invocation with a handled outcome.
  5. Check required values and positional structure.
  6. Apply Standard Schemas and aggregate their issues.
  7. Construct Contexts as they are first pulled.
  8. Run the Command Action.
  9. Run Extension postRun hooks in reverse registration order with the outcome.
  10. Run deferred cleanup and dispose Contexts in reverse registration order.

A failure in steps 4 through 8 still runs steps 9 and 10.

.action() defines behavior. .run() is the programmatic entry point and .execute() is the terminal entry point. Command definitions expose neither.

resolveArtifactDir(name)

Returns the absolute path of an Extension artifact directory or a crust.include directory shipped with the running CLI.

src/cli.ts
import { readdirSync } from "node:fs";

import { resolveArtifactDir } from "@crustjs/core";

const templates = readdirSync(resolveArtifactDir("templates"));

name is one top-level directory name such as "skills" or "templates"; a path separator, ., .., or an empty string throws. The directory is not probed, so the caller reports a missing directory.

Artifact paths do not depend on npm or its launcher. For direct binary distribution, keep these directories next to the executable when archiving or installing it; copying only the executable leaves its data behind. Runtime packages use the different layout below.

Running asResult
Compiled executable (bun build --compile, deno compile, Node.js single executable application)<name> next to the executable
Crust-built runtime package bundle (Bun, Node, or Deno)<name> beside the bundle's bin/ directory: .crust/root/<name> in place, <installed root>/<name> after install
Snapshot preparation inside crust build<name> under the entry's build output directory, containing what earlier build hooks wrote
Source (bun run, node, deno run, source-linked command).crust/root/<name> under the nearest package.json above the real entrypoint: the output of the last crust build

crust build defines process.env.CRUST_INTERNAL_BUILD as "1" in the Bun and Node bundles it produces. The variable is reserved: every read of it in your CLI is replaced at bundle time. Deno runtime packages use a marker prepended to the bundled command instead, so they read no environment variable to find their artifacts and preserve the command's import.meta.main semantics.

On this page