Crust logoCrust

Extensions

Add shared flags, commands, hooks, and generated files to your CLI.

Extensions add behavior across your CLI. Start with the built-ins for help, version output, color control, and command suggestions:

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();
$ my-cli --version
my-cli v0.2.0
$ my-cli deply
Unknown command "deply". Did you mean "deploy"?

Available commands: deploy
# exit 1

help() adds -h and --help and shows help when this actionless root is invoked. Registration order matters: noColor() configures color before output hooks, and version() handles --version before help() can handle the invocation.

Call .extend() on the root builder, not inside a command recipe. See built-in Extensions for the full list.

Write your own

Use an Extension when shared behavior needs flags, commands, or lifecycle hooks. For a helper used only inside actions, a plain function is enough.

This Extension adds a preview mode that skips the action:

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

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

const app = new Crust("deploy").extend(preview).action(({ stdout }) => stdout("deployed"));
await app.execute();
$ deploy
deployed
$ deploy --preview
nothing changed

Return ctx.handled() from preRun to stop before validation and the action. Calling it without returning it does not stop the invocation.

Give each Extension a namespaced id such as acme:preview. Registering the same id again replaces the earlier registration.

Builder methods other than .factory() return new frozen Extensions; there is no finalization step. Declare flags and Contexts before the hooks that read them. Collection methods append; setting a hook again replaces its callback.

For configurable Extensions, chain .factory():

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

export const banner = defineExtension(defineExtensionId("acme:banner")).factory(
	(extension, text: string = "hello") => extension.preRun((ctx) => ctx.stdout(text)),
);

const app = new Crust("my-cli").extend(banner("welcome")).action(() => {});
await app.execute();

Return a chain on the supplied Extension to keep its id. See ExtensionBuilder and ExtensionFactory for the full contracts.

Owned flags and commands

The preview flag above is available on every command. Set recursive: false on a flag definition to make it root-only.

An Extension can also install top-level commands:

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

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

export const diagnostics = defineExtension(defineExtensionId("acme:diagnostics")).add(doctor);

const app = new Crust("my-cli").extend(diagnostics);
await app.execute();
$ my-cli doctor
checking my-cli

Contributed commands work like commands added with .add(), including typed run() paths. @crustjs/mcp uses this pattern to install an mcp command that serves your commands as MCP tools.

Hooks

Use preRun for work before validation and the action, and postRun for work after the invocation settles:

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

export const outcomes = defineExtension(defineExtensionId("acme:outcomes"))
	.flags({ name: "skip", type: "boolean" })
	.preRun((ctx) => {
		if (ctx.flags.skip === true) return ctx.handled();
	})
	.postRun((ctx, outcome) => {
		ctx.stdout(`outcome: ${outcome.}`);
	});

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

const failed = new Crust("app").extend(outcomes).action(() => {
	throw new Error("boom");
});
console.log((await failed.run([])).stdout); // => "outcome: failed"
  • preRun follows .extend() order. Returning ctx.handled() skips later preRun hooks and the action.
  • postRun runs in reverse order, including after an Extension handles the invocation early or a failure once hooks start. Preparation, routing, and input-binding failures happen before hooks and skip it. Context cleanup runs afterwards.
  • onError handles terminal errors from .execute(); see Error handling.

Hook flags are pre-validation values, not schema outputs. Check values before using them. See ExtensionHooks for the full lifecycle.

Dependency injection

Extensions can install Contexts and use them from hooks:

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

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

export const logging = defineExtension(defineExtensionId("acme:logging"))
	.provide(logger()) 
	.use(logger) 
	.postRun(async ({ ctx }, outcome) => {
		(await ctx.).info(`outcome: ${outcome.status}`);
	});

const app = new Crust("my-cli").extend(logging).action(({ stdout }) => stdout("ready"));
await app.execute();
$ my-cli
ready
outcome: completed

.provide() installs Context instances across the command tree; .use() declares which factories the hooks need. An Extension that supplies and reads a Context needs both. To use an app-supplied Context instead, omit the Extension's .provide() and call .provide(logger()) on the app before .extend(logging).

A provided Context's documentation sections appear once on the root, not on every command.

A flag-owning Context requires successful validation. Use it in the action, or in postRun after validation has succeeded—not after an Extension handled the invocation early and skipped validation.

Required root metadata

An Extension that needs version can require it with defineExtension<"version">(id):

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

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

// 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"

TypeScript rejects .extend(stamp) unless the root declares version. Bun and Node builds (bun build, or crust build for those runtimes) skip this check, so run tsc --noEmit first. Deno-runtime crust build uses deno compile, which type-checks local modules by default.

If a factory argument can supply the value instead, build the factory without a metadata requirement and annotate it with overloads. Only calls that omit the argument then require root metadata. The overloads are type-only, so handle a missing value at runtime:

metadata-optional.ts
import {
	Crust,
	CrustError,
	defineExtension,
	defineExtensionId,
	type Extension,
	type ExtensionId,
} from "@crustjs/core";

type Stamp<MetaKeys extends "version"> = Extension<{}, [], [], [], MetaKeys>;

export interface StampFactory {
	(version: string): Stamp<never>; // an explicit value needs no root metadata
	(version?: string): Stamp<"version">; // an omitted value requires it
	readonly id: ExtensionId;
}

export const stamp: StampFactory = defineExtension(defineExtensionId("acme:stamp")).factory(
	(extension, version?: string) =>
		extension.preRun((ctx) => {
			const resolved = version ?? ctx.rootCommand.meta.version;
			if (resolved === undefined) throw new CrustError("DEFINITION", "acme:stamp needs a version");
			ctx.stdout(`version ${resolved}`);
		}),
);

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

version() uses this pattern.

Command sections

Add documentation with .sections(). command: [] targets the root; command: ["deploy"] would target a deploy command:

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.",
	},
]);

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

my-cli --help includes:

Environment:
  Set DEPLOY_TOKEN before running.

The callback receives a snapshot containing contributed commands and flags, but no Extension-contributed sections. See ExtensionSectionContribution for validation and audience rules.

Build hooks

A build hook returns files; Crust writes them. This one saves the frozen Command Snapshot as JSON:

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

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

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

await app.execute();

During crust build:

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

Put files under an Extension-specific directory such as acme-manifest/. Loose files at the root of .crust/artifacts/ are not copied into staged packages. Return file contents rather than writing into the build output yourself.

Hooks run in registration order. Section callbacks refresh after each hook, so later hooks can see sections based on earlier artifacts. For example, register skill() before man() to list packaged skills in the man page. See ExtensionBuildContext and BuildFile for output and path rules.

Section consumers

The built-in help, man, and skill Extensions render sections for different audiences. A custom generator can do the same with visibleSectionsFor():

web-docs.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(({ ,  }) => [
		`# ${[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(() => {}),
);

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

The generated web-docs/docs.md contains:

# my-cli deploy

## Safety

Run preview first.

## Screenshots

![preview](preview.png)

only: [webDocs] keeps the Screenshots section out of other consumers, such as help. Use except to exclude consumers instead; both accept Extension values or ids.

visibleSectionsFor(snapshot, id) selects visible commands and merges same-title sections for that consumer. For one command, use sectionsFor(sections, id). Both come from @crustjs/core/tooling.

On this page