Crust logoCrust

Plugins

Extend your CLI with the middleware-based plugin system.

Crust's plugin system lets you extend CLI behavior through two phases: setup (one-time initialization) and middleware (per-execution interception).

Using Plugins

Register plugins with .use():

import { Crust } from "@crustjs/core";
import { helpPlugin, versionPlugin, didYouMeanPlugin } from "@crustjs/plugins";

const main = new Crust("my-cli")
  .meta({ description: "My CLI" })
  .use(versionPlugin("1.0.0"))
  .use(didYouMeanPlugin({ mode: "help" }))
  .use(helpPlugin())
  .run(() => {
    console.log("Running!");
  });

await main.execute();

Crust ships with these official plugins:

PluginDescription
helpPlugin()Adds --help / -h and auto-generates help text
versionPlugin()Adds --version / -v to the root command
didYouMeanPlugin()Suggests corrections for mistyped subcommands
updateNotifierPlugin()Checks npm for newer versions and displays an update notice
completionPlugin()Generates bash/zsh/fish tab-completion scripts

Plugin Order

Plugins execute in the order they're registered. Each middleware wraps the next in a chain, so the first plugin registered is the outermost wrapper:

new Crust("my-cli")
  .use(versionPlugin("1.0.0")) // Runs first, can intercept early
  .use(didYouMeanPlugin()) // Runs second, catches routing errors
  .use(helpPlugin()); // Runs third, handles --help

A typical order is: version -> did-you-mean -> help.

Plugin Lifecycle

Setup Phase

The setup function runs once during CLI initialization, before any command parsing. It's used to register flags or perform one-time configuration:

const myPlugin: CrustPlugin = {
  name: "my-plugin",
  setup(context, actions) {
    // context.rootCommand — the root command node
    // context.argv — raw argv
    // context.state — plugin state store

    // Inject a flag into the root command
    actions.addFlag(context.rootCommand, "debug", {
      type: "boolean",
      description: "Enable debug mode",
    });
  },
};

Available setup actions:

ActionDescription
addFlag(command, name, def)Inject a flag into a command's effective flags (overrides if name exists)
addSubCommand(parent, name, command)Inject a subcommand (skipped if user already defined one with the same name)

Injecting Subcommands

Plugins can register subcommands during setup. If the user already defined a subcommand with the same name, the plugin's injection is skipped — user definitions always take priority.

Both addFlag overrides and addSubCommand skips are silent at runtime but produce warnings during crust build pre-compile validation, so you can catch unintentional conflicts before shipping.

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

const myPlugin: CrustPlugin = {
  name: "my-plugin",
  setup(context, actions) {
    const statusNode = createCommandNode("status");
    statusNode.meta.description = "Show status";
    statusNode.run = () => {
      console.log("Status: OK");
    };

    // Inject "status" as a subcommand of the root command
    actions.addSubCommand(context.rootCommand, "status", statusNode);
  },
};

Middleware Phase

The middleware function runs for each CLI invocation. It receives a context and a next function to pass control to the next middleware (or the command itself):

const myPlugin: CrustPlugin = {
  name: "my-plugin",
  middleware(context, next) {
    // context.route — resolved command route (or null)
    // context.input — parsed args/flags (or null)
    // context.rootCommand — the root command node
    // context.argv — raw argv
    // context.state — plugin state store

    if (context.input?.flags.debug) {
      console.log("[debug] Route:", context.route?.commandPath);
    }

    // Pass control to the next middleware or command execution
    return next();
  },
};

Always call next() unless you intentionally want to prevent the command from running (e.g., when the help plugin intercepts --help).

Writing a Custom Plugin

A CrustPlugin is a plain object with optional name, setup, and middleware:

import type { CrustPlugin } from "@crustjs/core";

function timingPlugin(): CrustPlugin {
  return {
    name: "timing",
    async middleware(context, next) {
      const start = performance.now();
      await next();
      const duration = (performance.now() - start).toFixed(2);
      console.log(`Completed in ${duration}ms`);
    },
  };
}

Use it like any other plugin:

new Crust("my-cli").use(timingPlugin()).use(helpPlugin());

Plugin State

Plugins can share state via context.state, a key-value store available in both setup and middleware phases:

function authPlugin(): CrustPlugin {
  return {
    name: "auth",
    setup(context) {
      context.state.set("auth.token", process.env.API_TOKEN);
    },
    async middleware(context, next) {
      const token = context.state.get<string>("auth.token");
      if (!token) {
        console.error("No API token found");
        process.exitCode = 1;
        return;
      }
      await next();
    },
  };
}

The state API:

MethodDescription
state.get<T>(key)Get a value (returns T | undefined)
state.set(key, value)Set a value
state.has(key)Check if a key exists
state.delete(key)Delete a key

Middleware Context

The full middleware context:

interface MiddlewareContext {
  readonly argv: readonly string[]; // Raw argv
  readonly rootCommand: CommandNode; // Root command node
  readonly state: PluginState; // Plugin state store
  route: CommandRoute | null; // Resolved route (null if routing failed)
  input: ParseResult | null; // Parsed input (null if parsing failed)
}

route and input may be null if routing or parsing failed — the error is re-thrown inside the middleware chain, allowing error-handling plugins (like didYouMeanPlugin) to catch it.

Plugin Collection

Plugins are collected from the entire command tree during .execute(). Root plugins come first, then depth-first through subcommands. This means you can register plugins on specific subcommands if needed, though most plugins are registered on the root command.

On this page