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,
  autoCompletePlugin,
} from "@crustjs/plugins";

const main = new Crust("my-cli")
  .meta({ description: "My CLI" })
  .use(versionPlugin("1.0.0"))
  .use(autoCompletePlugin({ 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
autoCompletePlugin()Suggests corrections for mistyped subcommands
updateNotifierPlugin()Checks npm for newer versions and displays an update notice

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(autoCompletePlugin())   // Runs second, catches routing errors
  .use(helpPlugin())           // Runs third, handles --help

A typical order is: version -> autocomplete -> 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 autocomplete) 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