Crust logoCrust

Extensions

Install and author app-wide reusable capabilities.

Use built-in Extensions when behavior should apply across the whole command tree.

cli.ts
import { Crust, defineCommand } from "@crustjs/core";
import { didYouMean, help, noColor, version } from "@crustjs/extensions";

const deploy = defineCommand("deploy", { description: "Deploy the app" }, (command) =>
	command.action(() => {}),
);

export const app = new Crust("my-cli", { version: "0.2.0" })
	.add(deploy)
	.extend(noColor(), version(), help(), didYouMean());

await app.execute();

help() adds -h and --help, version() adds root -v and --version, and didYouMean() handles unknown command errors. preRun hooks follow .extend() order, so noColor() comes before output hooks to set their color mode, and version() comes before help() because help finishes an actionless root.

$ my-cli
my-cli

Usage:
  my-cli <command> [options]

Commands:
  deploy     Deploy the app

Options:
  --color, --no-color          Enable colored output
  -v, --version                Show version number
  -h, --help                   Show help
$ my-cli --version
my-cli v0.2.0

$ my-cli deply
Unknown command "deply". Did you mean "deploy"?

Available commands: deploy
# exit 1

See the Extensions module for the other built-ins. Call .extend() on the root builder, not inside a defineCommand() recipe.

Write your own

preview.ts
export const preview = defineExtension(defineExtensionId("acme:preview"), {
	flags: [{ name: "preview", type: "boolean", description: "Show the plan" }],
	hooks: {
		preRun(ctx) {
			if (ctx.flags.preview !== true) return;
			ctx.stdout("nothing changed");
			return ctx.finish();
		},
	},
});

const previewApp = new Crust("deploy").extend(preview).action(() => "deployed");
const previewOutcome = await previewApp.run([], { flags: { preview: true } });
console.log(previewOutcome.status, previewOutcome.stdout); // => finished nothing changed

Import a function when shared code only needs values from a Command Action. Write an Extension when that code needs to own an app-wide flag or run around invocations; ctx.finish() ends this preview without running the action. The id is a namespaced string such as acme:preview, and registering the same id twice keeps the last registration.

Use defineExtension(id, factory) instead when callers need to configure it. See Extension and ExtensionFactory for the full contribution shapes.

Owned flags and commands

diagnostics.ts
const doctor = defineCommand("doctor", (command) =>
	command.action(({ rootCommand, stdout }) => stdout(`checking ${rootCommand.meta.name}`)),
);

export const diagnostics = defineExtension(defineExtensionId("acme:diagnostics"), {
	commands: [doctor],
});

const diagnosticsApp = new Crust("my-cli").extend(diagnostics);
console.log((await diagnosticsApp.run(["doctor"])).stdout); // => checking my-cli

Own a flag when every command installed with the Extension should accept it. Own a definition when installing the Extension should also add a top-level command; these commands are available in typed run() paths.

Extension flags are recursive by default. Set recursive: false on an inline flag definition when only the root should accept it, and see ExtensionFlagDef for the flag contract.

Hooks see pre-validation values, not schema outputs. A false-capable recursive value makes even defaulted flags possibly absent on descendants; optional schema multiple can produce a scalar or an array. Fixed tuples with one known name per definition retain precise hook types. Dynamic arrays, conditional collections, and uncertain names expose raw flag values that require narrowing: an absent definition leaves its name available to other Extensions.

Hooks

hooks.ts
import { Crust, defineExtension, defineExtensionId } from "@crustjs/core";

export const outcomes = defineExtension(defineExtensionId("acme:outcomes"), {
	flags: [{ name: "finish", type: "boolean" }],
	hooks: {
		preRun(ctx) {
			if (ctx.flags.finish === true) return ctx.finish();
		},
		postRun(ctx, outcome) {
			ctx.stdout(`outcome: ${outcome.status}`);
		},
	},
});

const completed = new Crust("app").extend(outcomes).action(() => {});
console.log((await completed.run([])).stdout); // => "outcome: completed"
console.log((await completed.run([], { flags: { finish: true } })).stdout);
// => "outcome: finished"

const failed = new Crust("app").extend(outcomes).action(() => {
	throw new Error("boom");
});
console.log((await failed.run([])).stdout); // => "outcome: failed"

Use hooks for app-wide work around every invocation, not work local to one Command Action. preRun runs before validation and the action, while postRun runs after the invocation settles and receives its completed, finished, or failed outcome.

The Command Action is skipped when preRun returns ctx.finish(), but postRun still runs. See ExtensionHooks for ordering, error handling, and the full hook context.

Dependency injection

providers.ts
import { Crust, defineContext, defineExtension, defineExtensionId } from "@crustjs/core";

const logger = defineContext("logger", ({ stdout }) => ({ info: stdout }));

export const logging = defineExtension(defineExtensionId("acme:logging"), {
	provides: [logger()],
});

const app = new Crust("my-cli").extend(logging).action(async ({ ctx }) => {
	(await ctx.logger).info("ready");
});

console.log((await app.run([])).stdout); // => "ready"

Use provides when installing the Extension should also install a Context across the command tree. If a hook consumes a Context supplied by the app instead, declare its factory in uses and pull it from the hook's lazy ctx bag.

Required root metadata

metadata.ts
import { Crust, defineExtension, defineExtensionId } from "@crustjs/core";

export const stamp = defineExtension<"version">()(defineExtensionId("acme:stamp"), {
	hooks: {
		preRun(ctx) {
			ctx.stdout(`version ${ctx.rootCommand.meta.version}`);
		},
	},
});

// new Crust("my-cli").extend(stamp);
// Type error: the root metadata does not guarantee "version".

const app = new Crust("my-cli", { version: "1.2.3" }).extend(stamp).action(() => {});
console.log((await app.run([])).stdout); // => "version 1.2.3"

Use the curried form when an Extension needs root metadata such as version. .extend() then rejects a root that does not declare the field, and hooks receive it as required.

Neither bun build nor crust build runs a TypeScript check. Run tsc --noEmit before building so missing metadata fails during development.

Command sections

sections.ts
import { Crust, defineExtension, defineExtensionId } from "@crustjs/core";
import { help } from "@crustjs/extensions";

export const guidance = defineExtension(defineExtensionId("acme:guidance"), {
	sections: () => [
		{
			command: [],
			title: "Environment",
			body: "Set DEPLOY_TOKEN before running.",
		},
	],
});

export const app = new Crust("my-cli").extend(guidance, help()).action(() => {});

Use sections when installing an Extension should add help text to a command. Here command: [] targets the root, and my-cli --help includes:

Environment:
  Set DEPLOY_TOKEN before running.

The sections callback receives the snapshot as authored, before any Extension's sections are applied, so it sees Extension-contributed commands and flags but not other Extensions' sections. Invalid entries (blank or multi-line title, blank body, empty audience, both only and except, or a command path that is not a canonical command) fail preparation with a DEFINITION error.

See the help Extension for rendering and ExtensionSectionContribution for targeting and audiences.

Section consumers

A consumer is any Extension identity that reads sections. help renders the current command's sections after Options, man collects the whole tree into one page, and skill writes them into per-command markdown files. Authors scope a section with only or except, passing either the Extension value or its id.

consumer.ts
import { Crust, defineCommand, defineExtension, defineExtensionId } from "@crustjs/core";
import { visibleSectionsFor } from "@crustjs/core/tooling";

const WEB_DOCS = defineExtensionId("acme:web-docs");

export const webDocs = defineExtension(WEB_DOCS, {
	build({ snapshot }) {
		const lines = visibleSectionsFor(snapshot, WEB_DOCS).flatMap(({ path, sections }) => [
			`# ${[snapshot.meta.name, ...path].join(" ")}`,
			...sections.map((s) => `## ${s.title}\n${s.body}`),
		]);
		return [{ path: "web-docs/docs.md", content: lines.join("\n\n") }];
	},
});

const deploy = defineCommand(
	"deploy",
	{
		sections: [
			{ title: "Safety", body: "Run preview first." },
			{ title: "Screenshots", body: "![preview](preview.png)", only: [webDocs] },
		],
	},
	(command) => command.action(() => {}),
);

export const app = new Crust("my-cli").add(deploy).extend(webDocs);

visibleSectionsFor(snapshot, id) walks listed commands in name order and returns each section-bearing command as { path, sections }, where path is [] for the root. It skips hidden commands and their subtrees, and applies the audience filter and same-title merge for id. Use sectionsFor(sections, id) for a single command. Both live in @crustjs/core/tooling.

Build hooks

build.ts
import { Crust, type BuildArtifacts, defineExtension, defineExtensionId } from "@crustjs/core";

export const manifest = defineExtension(defineExtensionId("acme:manifest"), {
	build({ snapshot }) {
		return [
			{ path: "acme-manifest/manifest.json", content: JSON.stringify(snapshot) },
		] satisfies BuildArtifacts;
	},
});

export const app = new Crust("my-cli").extend(manifest).action(() => {});

await app.execute();

Use a build hook when an Extension must generate files alongside the CLI. It receives the frozen Command Snapshot and returns the files to ship, each a path relative to the output directory plus its string or Uint8Array content. Core validates every path, rejects a path that another file already claims, whether equal (compared case-insensitively, since the tree may land on a case-insensitive filesystem) or nested under it (a file and a directory cannot share a name), writes the files, and records exactly what it wrote; the hook itself touches no disk.

Preparing Command Snapshot for my-cli...
  acme:manifest  1 file  acme-manifest/manifest.json

Hooks run in .extend() registration order, and section callbacks are re-evaluated after each hook's files are written, so a later hook's snapshot reflects what earlier hooks produced: register skill() before man() for the man page to list packaged skills. During this step resolveArtifactDir(name) points at the build output directory rather than .crust/root/, which the build has already wiped. The context carries no output directory: render into memory, or write to your own temporary directory and read the results back; the output directory is owned by the build tooling, which is what keeps the Build Report exact.

For package staging, give each build hook a unique top-level directory, as web-docs/docs.md above does: only top-level directories under .crust/artifacts/ are copied into the packages, so a loose top-level file is recorded in the manifest but never shipped. The Build Report is exact about one thing, the files hooks returned and the build wrote under .crust/artifacts/. It is not an inventory of every packaged file (launchers, binaries, crust.include directories, README.md, LICENSE are staged separately) and not a sandbox: files a hook writes elsewhere on its own are outside the report. See ExtensionBuildContext, BuildFile, and BuildArtifacts for the contracts.

On this page