Crust
Immutable command builders, reusable definitions, and execution.
import {
Crust,
defineCommand,
defineContext,
defineFlag,
resolveArtifactDir,
type CommandConfig,
type CommandDefinition,
type CommandDefinitionBuilder,
type ContextConfig,
type RootCommandMeta,
} from "@crustjs/core";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.
Reusable command definitions
A sealed command declares consumed Context factories with .use(...factories): .use(logger) is demand (a factory), .provide(logger()) is supply (an instance). Like the other builder methods, .use() is variadic and accumulates across calls. Because declarations are type-only, .use() requires a statically known, non-empty factory tuple; an empty call or a widened array spread would declare nothing and fails typecheck.
Actions and setup receive a typed lazy bag and await values with await ctx.<name>; missing wiring is reported at .provide(), .add(), or .extend().
CommandDefinitionBuilder
// Simplified: the complete alias also carries tree, result, dependency, and provider state.
type CommandDefinitionBuilder<
Flags,
A,
Ctx,
Sibs extends string = never,
Sp extends string = LocalSpellingsOf<Flags>,
> = Crust<Flags, A, Ctx, Sibs, Sp, {}, {}, CollisionSpellings, void, {}, string, "recipe">;This is the configure-only Crust capability passed to a defineCommand() recipe. Its fluent methods preserve and refine argument, accumulated flag, and provided Context types. Each factory passed to .use() accumulates its value and transitive dependency closure into the action's typed ctx and into the sealed definition's declared dependencies.
Sibs accumulates the sibling command names and aliases registered through .add(), and Sp caches the known flag spellings accumulated through .flags() and .provide(). Both are defaulted parameters. Root-only methods remain visible in keyof, but their _types.caps: "app" this constraint makes them uncallable on recipes; .use() symmetrically requires _types.caps: "recipe".
CommandDefinition
interface CommandDefinition<
Name extends string = string,
Aliases extends readonly string[] = readonly string[],
Shape extends CommandShape = CommandShape,
Deps extends ContextMap = {},
> {
readonly name: Name;
as<const N extends string>(
name: N & CommandNameBrand<N>,
): CommandDefinition<N, Aliases, Shape, Deps>;
}A CommandDefinition is a frozen value carrying its name, static config, and configure recipe. .as(name) returns the same definition under a different name, so one definition can be added twice. Configured aliases travel with the renamed definition and must remain unique among siblings.
It has no command node or execution methods.
defineCommand(name, config?, recipe)
defineCommand<const Name extends string>(
name: (Name & CommandNameBrand<Name>),
recipe: (command: CommandDefinitionBuilder) => CommandDefinitionBuilder,
): CommandDefinition<Name>;
defineCommand<const Name extends string, const C extends CommandConfig>(
name: (Name & CommandNameBrand<Name>),
config: C & ValidateCommandConfig<Name, C>,
recipe: (command: CommandDefinitionBuilder) => CommandDefinitionBuilder,
): CommandDefinition<Name, AliasesOf<C>>;This sketch is simplified; the first defineCommand() overload starts the complete declaration.
Prop
Type
Creates an inert reusable definition under a required name. Static description, usage, aliases, and hidden belong in config; application version belongs only in the root Crust constructor:
const verbose = defineFlag("verbose", { type: "boolean" });
const logging = defineContext("logging", { flags: [verbose] }, ({ flags, stderr }) => ({
debug(message: string) {
if (flags.verbose) stderr(message);
},
}));
const auth = defineContext("auth", () => ({ user: "Ada" }));
const deploy = defineCommand("deploy", { description: "Deploy an application" }, (command) =>
command
.use(logging, auth)
.args({ name: "target", type: "string", required: true })
.action(async ({ args, ctx }) => {
(await ctx.logging).debug(`${(await ctx.auth).user}:${args.target}`);
}),
);The recipe executes once for each .add(), not when the definition is created, so configuration side effects repeat for every addition. Statically blank names and __proto__ fail typecheck, including a blank or reserved member of a name union even when another member is an open template such as `mode-${string}`. Broad string names are supported and checked when consumed, including equality to a carried alias; unresolved generics may require a validated parameter constraint.
Materialization verifies that the recipe returns its seeded builder and does not register nested Extensions. Declared Context availability is checked without running setup; factory and callback value contracts remain TypeScript-owned.
Constructor
class Crust<
Flags = {},
A extends ArgsDef = [],
Ctx = {},
Sibs extends string = never,
Sp extends string = LocalSpellingsOf<Flags>,
Tree = {},
CtxFlags = {},
CollisionSp = CollisionSpellings,
Result = void,
const Meta = {},
const Name extends string = string,
Caps extends "app" | "recipe" = "app",
>
new Crust(
name: (Name & CommandNameBrand<Name>),
meta?: Meta
// Overloads enforce local proof, allowed keys, and required metadata.
)This sketch is simplified; the first constructor overload starts the complete declaration.
Prop
Type
Creates a root command builder. Metadata is optional unless an installed Extension requires it. Meta retains inferred metadata through fluent calls; .extend() checks Extension-owned requirements.
Optional or widened metadata cannot promise a required field. Known extra root keys are rejected, including on pretyped objects. Generic wrappers must also rule out extra keys; M extends RootCommandMeta alone is insufficient.
Dynamic section facts are automatically checked at consumption; metadata value requirements remain type-owned.
Root metadata belongs in the constructor because aliases and hidden state apply only to subcommands. version is exposed on the root snapshot. sections accepts ordered plain-text entries and is normalized immediately.
Literal mistakes are also type-checked.
const app = new Crust("my-cli", {
description: "Manage issues",
version: "1.2.3",
usage: "my-cli <command>",
});Definition aliases must be non-empty, contain no whitespace, not start with -, differ from their canonical name, and not collide with sibling names or aliases. TypeScript reports known alias-shape violations at defineCommand() and sibling collisions at .add(); the actual grammar and destination are automatically checked too. .add(...definitions) accepts dynamic collections.
Repeated aliases within one command remain allowed.
Chaining semantics
Variadic .flags(), .args(), .provide(), .add(), and root-only .extend() accumulate across calls. .extend() deduplicates ExtensionIds last-registration-wins. TypeScript rejects statically known invalid definitions and collisions; automatic consumption checks cover observable names, spellings, positional layout, Context availability, and destination flag relations.
Dynamic Extension commands retain canonical last-write-wins during preparation. Existing static duplicate-context/command/owned-flag checks still protect earlier typed consumers; runtime replacement is not proof that opaque callbacks are compatible.
Conditional chains
Each call returns a builder whose type records what was added so far. A conditional can produce two different builder types. TypeScript may then fail to infer the inputs of the next variadic call. Crust rejects calls that would silently lose validation or value 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", () => (prod ? connect() : null));
c.provide(db(), cache()); // db resolves to Connection | nullIf the shape genuinely differs, write the full chain in each branch so .action() sees the honest union:
prod ? c.provide(db(), cache()) : c.provide(cache());With explicit input type arguments:
.provide(),.use(), and.add()remain available on a union when validation succeeds..flags(),.args(), and.extend()still reject unions of distinct builder types.
.args(...defs)
args<const NewA extends ArgsDef>(
...defs: NewA & AppendArgsChecks<A, NewA>,
): CrustThis sketch is simplified; the first .args() overload starts the complete declaration.
Defines positional arguments; repeated calls append in call order. .args(...defs) accepts dynamic collections, and an open previous layout stays open. Definitions may come from defineArg(name, def) or inline literals.
Duplicate names fail typecheck with FIX_DUPLICATE_ARG, Promise-returning custom parsers fail with FIX_ASYNC_PARSE, and only the final argument across all calls may be variadic.
const app = new Crust("copy").args(
{ name: "source", type: "path", required: true },
{ name: "destinations", type: "path", variadic: true },
);A definition can instead carry a Standard Schema. Schema-backed arguments receive the raw string, undefined when absent, or string[] when variadic. The schema owns coercion, defaults, requiredness, choices, and validation; it is mutually exclusive with type, default, required, choices, and parse.
const app = new Crust("serve").args({ name: "port", schema: z.coerce.number().int().min(1) });.flags(...defs)
flags<const Defs extends readonly NamedFlagDef[]>(
...defs: ValidateLocalFlagDefs<Defs, Sp>,
): CrustThis sketch is simplified; the first .flags() overload starts the complete declaration.
Defines local flags from named flag definitions: values from defineFlag(name, def) or inline literals carrying name. Helpers shallow-freeze owned copies of structural arrays; each consuming boundary validates them again. JSON/URL/schema payloads are not cloned or frozen.
Flag names omit the -- prefix.
const app = new Crust("serve").flags(
{ name: "verbose", type: "boolean", short: "v" },
{ name: "port", type: "number", default: 3000 },
{ name: "include", type: "string", multiple: true },
);Repeated .flags() calls accumulate local flags and their known spellings in Sp. TypeScript rejects known spelling collisions, repeated own aliases, invalid short lengths, and Promise-returning parsers. .flags(...defs) accepts dynamic collections, whose actual constraints are checked automatically.
Concrete builder types carry authoring capabilities; AnyCrust exposes completed apps only. Open namespaces stay conservative, while independently known fields retain their types. Parsers are not called during authoring and must return synchronously when invoked.
Flags declared here are local to this command. Context-owned flags are the propagation mechanism for descendant parsers; downstream code receives their derived Context capability. Boolean flags support generated --no-<spelling> negation for the canonical name and every long alias; noNegate: true rejects negation with a PARSE error and hides the generated label from help, completion, and man output.
Names and aliases beginning with no- are reserved.
Schema-backed flags require type: "string" | "boolean" only to declare whether the flag consumes a token. Their schema owns the value rules and receives the raw parsed value, including undefined when omitted even with multiple: true. Schema-backed flag details are part of the FlagDef reference.
const app = new Crust("serve").flags({
name: "port",
type: "string",
schema: z.coerce.number().int().min(1),
});.use(...factories)
use<const Fs extends readonly [AnyContextFactory, ...AnyContextFactory[]]>(
...factories: Fs,
): CrustThis sketch is simplified; the first .use() overload starts the complete declaration.
Declares the Context factories consumed by a definition recipe without providing instances. The variadic call requires a non-empty tuple, and repeated calls accumulate demands. The demands and their transitive dependencies are checked against available providers when the definition reaches .add().
.use() is recipe-only. Its capability-typed this parameter makes it unavailable on the root app.
.provide(...instances)
.provide(...instances) accepts literal and dynamic collections. Contexts must originate from defineContext(); attachment consumes private defining data rather than overwritten fields on structural copies. Runtime same-name replacement requires noncolliding owned flags; statically known duplicates remain rejected where they would endanger earlier consumers.
Applicable callback-value demands remain enforced by TypeScript, not runtime reflection.
provide<const Cs extends readonly AnyContextInstance[]>(
...instances: KnownContextInstances<Cs> & ProvideChecks<Sp, Cs>
& ValidateContextNames<Ctx, Cs> & ValidateContextDeps<Ctx, Cs>,
): CrustThis sketch is simplified; the first .provide() overload starts the complete declaration.
Attaches Contexts produced by invoking defineContext() factories. Contexts are inherited by later-added descendants and constructed lazily when their bag property is accessed. The return type merges every instance's owned flags into the builder's accumulated flag type.
import { defineContext, Crust } from "@crustjs/core";
const database = defineContext("database", ({ options }: { 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;
});Declared dependencies (Context uses, command .use(), Extension uses) are checked at composition. Dependencies may appear in either order within one .provide() call; across calls, dependency providers must precede their consumers. Dynamic collections use the same variadic methods.
Declared availability is checked without setup. .use() retains factory references rather than reflecting callback values. Invocation still checks missing providers, cycles, and lifecycle misuse.
A Context's top-level flags are installed as propagating flags on this builder and later-added descendants, without mutating the original definitions. Dependencies construct recursively on the first pull.
Cleanup registered with setup's defer(cleanup) and values implementing Symbol.dispose or Symbol.asyncDispose run in reverse registration order after post-run hooks; a returned value registers when its setup succeeds, so it disposes before that setup's deferred callbacks. Calling defer after setup has settled throws a DEFINITION error. Context names, dependencies, and owned-flag collisions are checked by TypeScript; dynamically assembled owned-flag collisions throw a DEFINITION error from .provide(). Lazy bag access retains runtime DEFINITION guards for missing providers on dynamically assembled paths, dependency cycles, and lifecycle misuse.
Dynamic same-name providers without owned-flag collisions use the last instance, while static duplicate names remain rejected. Overlapping owned flag names or aliases still throw when the instances share a Context name. Instances from .of() retain the factory's owned flags, so .of() does not bypass those collisions.
.extend(...extensions)
extend(...extensions: readonly Extension[]): CrustThis sketch is simplified; the first .extend() overload starts the complete declaration.
Registers application-wide Extensions. Calls accumulate in registration order, with duplicate ExtensionIds deduplicated last-registration-wins. Statically known contributed names, aliases, and owned flags must not collide.
.extend(...extensions) accepts dynamic collections; metadata and callback demands remain type-owned. .extend() is root-only: its _types.caps: "app" this constraint makes recipe calls fail typecheck.
Extensions must originate from defineExtension(). Core consumes immutable private defining data through structural copies; changing public contribution fields on a spread does not redefine that Extension. Attachment validates declared availability without constructing Contexts; contributed command recipes are checked when prepared.
import { help, version } from "@crustjs/extensions";
const app = new Crust("my-cli", { version: "1.2.3" }).extend(version(), help());Added definitions become ordinary command nodes before root Extensions prepare the application, so root Extensions apply to them. Extension-contributed definitions are materialized when the application prepares.
An Extension's provides instances and their owned flags are installed immediately across the root and existing descendants, preserving more-specific local providers on descendants. Later-added descendants inherit them too. Extension commands and declared flags are materialized during preparation.
Statically known definitions are compile-time checked; preparation also checks dynamic flag collisions, recipe behavior, and documentation sections.
Extensions describes custom Extension authoring.
.action(action)
action<R>(
action: (ctx: CrustCommandContext<A, Flags, Ctx>) => R,
): CrustThis sketch is simplified; the .action() declaration defines the complete signature.
Defines the Command Action. Its awaited return type is carried by the builder for typed run() calls. A later .action() call replaces both the action and its result type on the returned immutable builder.
The action runs after routing, parsing, preRun hooks, and validation; Contexts construct on demand.
const app = new Crust("greet")
.args({ name: "name", type: "string", required: true })
.action(({ args, flags, ctx, rawArgs, command, rootCommand, stdout, stderr }) => {
stdout(`Hello, ${args.name}!`);
});The context contains typed args, typed effective flags, the lazy typed ctx bag, rawArgs after --, the resolved command and application rootCommand snapshots, and injectable stdout(text) and stderr(text) callbacks from InvocationIO. CrustCommandContext extends InvocationIO; Context setups receive those same callbacks for the invocation.
.add(...definitions)
add<const Ds extends readonly CommandDefinition<any>[]>(
...definitions: Ds & ValidateCommandDefinitions<Ds, Sibs>,
): Crust<
Flags,
A,
Ctx,
Sibs | CommandDefinitionSpellings<Ds[number]>,
Sp
>This sketch is simplified; the first .add() overload starts the complete declaration.
Materializes inert definitions as fresh child commands, each under its carried name. .as(name) renames a definition, so the same definition can route under different names:
const app = new Crust("git").provide(logging()).provide(auth()).add(deploy, deploy.as("ship"));Inline one-off commands are added the same way:
const app = new Crust("my-cli").add(
defineCommand("up", (command) => command.action(({ stdout }) => stdout("up"))),
);Statically known sibling collisions fail at .add() with FIX_COMMAND_COLLISION; alias grammar fails at defineCommand() with FIX_ALIAS_SHAPE. .add(...definitions) accepts dynamic definitions. Materialization checks actual sibling spellings, declared availability, inherited Context-owned flag relations, and seeded-builder identity without running setup.
Pending Extension commands retain last-write-wins at preparation.
A definition can add other definitions. Context providers are inherited on the materialized command path; a nested definition accesses one by declaring its factory with .use() and awaiting the corresponding ctx property.
The typed run() shape of an added definition merges the Context-owned flags already provided on the parent path, matching the runtime parser: descendants accept inherited Context-owned flags, while parent-local flags stay excluded.
A later .provide() does not backfill an already-added definition. Extension provides reach
existing descendants, but a definition's .use() demands require those providers to be registered
before .add().
.command(name, recipe)
command<const N extends string, B extends CommandDefinitionBuilder>(
name: (N & CommandNameBrand<N>),
recipe: (command: CommandDefinitionBuilder) => B,
): CrustThis sketch is simplified; the first .command() overload starts the complete declaration.
Defines an app-local leaf subcommand inline, as root-only sugar for .add(defineCommand(name, recipe)):
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 builder is seeded with the Contexts and Context-owned flags accumulated on the builder so far at the call site, so the inline action's ctx is typed without .use() declarations. Contexts provided after the .command() call are not visible to it, matching the positional semantics of .provide(). Inline .use() demands that the call site does not satisfy fail typecheck with FIX_MISSING_DEPENDENCY.
.command() represents short, app-local leaf commands; defineCommand represents commands in separate files, reused commands, and packaged commands. .command() is root-only: its _types.caps: "app" this constraint makes recipe calls fail typecheck.
.snapshot()
snapshot(): Promise<CommandSnapshot>This sketch is simplified; the .snapshot() declaration defines the complete signature.
Prepares a frozen Command Snapshot for tooling such as man-page, skill, and build generators. It materializes Extension contributions and validates sections returned by Extension callbacks. Command-authored sections are validated when defined.
It does not call Command Actions.
import type { CommandSnapshot } from "@crustjs/core";
const snapshot: CommandSnapshot = await app.snapshot();Section consumption filters entries for the consumer first, then merges entries with the same exact, case-sensitive title. Merged bodies retain source order and are joined with a newline.
During artifact builds, section callbacks are re-evaluated after each build hook. Each later hook receives the refreshed frozen snapshot, while a hook's own outputs are absent from the snapshot passed to that hook.
The CommandSnapshot type is exported from @crustjs/core and re-exported from the @crustjs/core/tooling subpath; see the core module reference.
.run(path, input?, io?)
run(path, input?, io?): Promise<RunOutcome<R>>This sketch is simplified; the first .run() overload starts the complete declaration.
Invokes an application programmatically with a command path and structured input inferred from the application. Editors complete command names at each path level plus the selected command's argument and flag names. Required inputs, input value types, and the selected action's awaited return type are inferred by TypeScript.
const outcome = await app.run(["greet"], { args: { name: "Ada" }, flags: { loud: true } });
if (outcome.status === "failed") throw outcome.error;
if (outcome.status === "completed") {
console.log(outcome.stdout, outcome.result);
} else {
console.log(`Finished by ${outcome.by}`);
}Structured values bind directly against selected definitions without producing argv, then flow through schemas, Contexts, and the action. Observable value kinds and choices are checked automatically. Custom parsers and schemas receive their raw contract, usually strings, even when action output is a number, union, or object.
Paths are normalized; JSON/URL payloads retain identity. Scalar JSON inputs accept JSON primitives, structurally compatible named interfaces, and readonly arrays/tuples; repeated JSON inputs use mutable occurrence arrays. Types exclude Date, bigint, functions, and other non-JSON inputs; this is not an arbitrary unknown-value decoder or serialization pass.
undefined omits optional input. Empty multiple arrays mean zero occurrences and use a default if present.
Supplied positionals must form a prefix, including across defaulted positions. Required core variadics without defaults need nonempty tuples. Omission requires every definition alternative to permit it.
Conditional choices, primitive kinds, and occurrence layouts remain conservative; automatic checks do not manufacture a static proof. Independently known inputs remain strict. Narrow dynamic values before passing them to a known application, or use AnyCrust when paths and input are genuinely dynamic.
A known literal contract accepts valid literals, choice unions, and honestly narrowed strings, not broad strings, wrong primitives, missing required inputs, invalid paths, or fresh-object typo keys. Standard TypeScript structural assignability allows predeclared objects to carry extra keys. Core rejects supplied unknown argument or flag keys before action.
Keys with undefined values are treated as omitted. Assertions do not validate input. A genuinely dynamic AnyCrust view supports broad paths and structured input with RunOutcome<unknown>; it is a completed-app view, not authoring authority or an escape hatch for static callers.
Each declared own argument or flag value is read once during binding; name validation does not invoke its getter again.
Actual same-ID Extension replacement can remove canonical flag keys still retained by the builder's accumulated type. Automatic binding returns a failed outcome for a supplied retired key when no current definition supplies it, including affected descendant and provider-owned flags. Surviving or reintroduced keys remain valid.
This does not update an earlier action's assumptions about removed flags; in particular, omitting a removed defaulted flag can still give that action undefined.
The optional raw array reaches the Command Action unchanged as rawArgs, equivalent to tokens after a -- separator in terminal input:
const outcome = await app.run(["exec"], { args: { command: "lint" }, raw: ["--fix", "src"] });
if (outcome.status === "failed") throw outcome.error;Positional values may begin with - or equal a subcommand name or alias; the typed path alone selects the command. run() returns a failed outcome for unknown path segments even when the nearest resolved parent has an action. By contrast, argv routing can treat unmatched tokens as positionals; see Unknown commands.
A COMMAND_NOT_FOUND failure has details.input, details.available, details.commandPath, and details.parentCommand. available lists canonical names of non-hidden children only. ExtensionContext.argv contains only the path for run() invocations.
run() is quiet by default and resolves after cleanup to core RunOutcome: every branch carries readonly stdout and stderr strings. completed owns the awaited action result, finished owns the finishing Extension by, and failed owns the original escaping error, including non-Error values.
Preparation, binding, hooks, action, cancellation, and cleanup failures retain partial output. run() never presents errors, invokes onError, or changes process.exitCode. Authoring and attachment errors still throw at consumption, and snapshot() preparation still rejects.
Schema failures from every schema evaluated in one invocation aggregate into one CrustError with code VALIDATION; details.issues contains the normalized issue list.
When invocation and disposal both fail, the failed outcome contains a SuppressedError whose .error is the disposal failure and whose .suppressed is the invocation failure. One disposal failure after a successful invocation is retained unchanged. On the Node 22 fallback, multiple disposal failures aggregate in an AggregateError.
Captured callback payloads are joined with "\n", without a synthetic trailing newline. Multiline payloads are not split or trimmed. Separate streams do not promise combined chronological ordering or byte-perfect terminal transcripts.
Memory is proportional to emitted output.
Optional io.stdout/io.stderr sinks receive writes live, once each, after capture. Omitted channels stay quiet. A sink throw becomes an invocation failure, retaining the write that triggered it.
No callbacks or actions are rerun for capture.
Core threads invocation-owned IO through the ambient terminal seam: default prompt/progress output reaches captured stderr, with line buffering and non-TTY progress output. Prompt input remains separate. Explicit terminal streams take precedence.
Nested/concurrent invocations have isolated capture. Direct console/process writes, subprocesses, files, and explicit prompt streams are not intercepted. Terminal interaction goes through execute() or runInteractive.
Prepared invocations are cached once per immutable builder and shared by subsequent run(), execute(), and snapshot() calls on that builder. Command recipes, Extension commands and flags, and section callbacks therefore prepare once; a later fluent call returns a new builder with its own preparation.
Literal Extension commands and flags participate in typed paths and inputs. Recursive flags reach descendants; recursive: false flags stay root-only. Widened or dynamically assembled contributions retain conservative open namespaces, with unknown action results on unknowable paths.
Dynamic collections use .extend(...extensions) without a wrapper. Known command/flag collisions remain compile errors; supported dynamic canonical replacements retain last-write-wins at preparation.
.at(path)
at(path): CommandHandle<CommandShapeAt<Shape, Path>>Binds a typed command path into a reusable handle. handle.run(input?, io?) accepts exactly what run(path, input?, io?) accepts after the path, with the same inference, validation, and RunOutcome. Use it to let commands call other commands with checked input, to expose a CLI subtree as a typed library function, or to hand a pre-bound invoker to a test helper.
const remoteAdd = app.at(["remote", "add"]);
const outcome = await remoteAdd.run({ args: { name: "origin", url }, flags: { fetch: true } });
export { remoteAdd };at() validates the path eagerly against the prepared command tree and throws COMMAND_NOT_FOUND immediately, 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 is bound to the builder it was taken from. Because fluent calls return new builders, commands added after at() are not visible to an earlier handle; take the handle from the finished application. Handles do not narrow further — call at() on the application again for a sibling path.
.execute(options?)
execute(options?: {
argv?: string[];
io?: Partial<InvocationIO>;
}): Promise<number>This sketch is simplified; the .execute() declaration defines the complete signature.
Runs the terminal boundary. By default it parses process.argv.slice(2); options.argv overrides it, while options.io captures rendered output in tests. Explicit IO also captures default @crustjs/progress output and @crustjs/prompts output for the full invocation; it does not provide prompt input.
await app.execute();Invocation order
Both .execute() and .run() follow the same sequence. Steps 4 through 10 are skipped when routing or argv parsing fails.
- Prepare the command tree by applying Extensions, recipes, flags, and sections.
- Route the command path.
- Parse argv for
.execute(), or bind structured input for.run(). - Run Extension
preRunhooks in registration order; a hook that returnsctx.finish()ends the invocation here with afinishedoutcome. - Check required values and positional structure.
- Apply Standard Schemas and aggregate their issues.
- Construct declared Contexts when first pulled.
- Run the Command Action.
- Run Extension
postRunhooks in reverse registration order, with thecompleted,finished, orfailedoutcome. - Settle Context setup, then run deferred cleanup and dispose constructed Contexts in reverse registration order.
A failure in steps 4 through 8 still runs steps 9 and 10. See propagation boundaries for what each phase's failure becomes.
execute() resolves to the terminal exit code: 0 on success, 1 on failure, or 130 for cancellation. It still renders a failure once through Extension onError hooks ending in Core's renderer and sets process.exitCode on failure. Any Error whose name is "AbortError", including a DOMException, is cancellation.
It is offered to onError hooks and renders nothing when no hook claims it.
.action(action) defines behavior. .run(path, input, io) is the programmatic invocation entry
point, and .execute() is the terminal entry point. Command definitions are inert and expose
neither.
resolveArtifactDir(name)
function resolveArtifactDir(name: string): string;Returns the absolute path of an Extension artifact directory or a crust.include directory shipped with the running CLI. name is a top-level directory name ("skills", "templates"); anything with a path separator, ., .., or an empty string throws.
The artifact path is computed from how the CLI is running. The artifact directory is not probed, so a missing directory is reported by the caller, not here.
| Situation | Result |
|---|---|
Compiled executable (bun build --compile, deno compile) | <name> next to the executable: a platform package's bin/<name>, in .crust/<platform>/ or after install |
| Crust-built Node bundle | <name> next to 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 isolated build output directory: what earlier Extension build hooks wrote for that entry |
Source (bun run, node, deno run, source-linked command) | .crust/root/<name> under the nearest package.json above the real entrypoint (following process.argv[1] symlinks): the output of the last crust build |
import { readdirSync } from "node:fs";
const templates = readdirSync(resolveArtifactDir("templates"));crust build marks the Bun and Node bundles it produces by defining process.env.CRUST_INTERNAL_BUILD as the literal "1", so resolveArtifactDir() can tell a staged bundle from source; standalone Deno binaries are detected directly. CRUST_INTERNAL_BUILD is reserved: every process.env.CRUST_INTERNAL_BUILD read in your CLI is replaced at bundle time, regardless of the runtime environment. The same marker compiles the crust build snapshot protocol out of Bun and Node bundles: only the source entries crust build runs honor SNAPSHOT_PATH_ENV, while a finished bundle dispatches normally with that variable set. Deno binaries are not marked and keep the protocol.