Crust logoCrust

Subcommands

Build nested command trees with automatic routing.

Subcommands let you organize your CLI into a tree of nested commands, like git commit or docker compose up.

Pick a Pattern

Use the pattern that matches how the subcommand is defined:

PatternUse whenNotes
.command(name, cb)The subcommand is defined inlineBest default for small or nested command trees
.sub(name) + .command(builder)The subcommand lives in another file or should inherit parent flagsBest default for modular CLIs
new Crust(name) + .command(builder)The subcommand is intentionally standaloneDoes not inherit parent flags

Inline Subcommands

Use .command(name, cb) when the subcommand is defined in place:

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

const main = new Crust("my-cli")
  .meta({ description: "My CLI tool" })
  .use(helpPlugin())
  .command("build", (cmd) =>
    cmd
      .meta({ description: "Build the project" })
      .flags({ minify: { type: "boolean", default: true } })
      .run(({ flags }) => {
        console.log(`Building... (minify: ${flags.minify})`);
      })
  )
  .command("dev", (cmd) =>
    cmd
      .meta({ description: "Start dev server" })
      .flags({ port: { type: "number", default: 3000, short: "p" } })
      .run(({ flags }) => {
        console.log(`Dev server on port ${flags.port}`);
      })
  );

await main.execute();
my-cli build              # Runs the build command
my-cli dev --port 8080    # Runs the dev command
my-cli --help             # Shows help with available subcommands

How Routing Works

When Crust receives argv, it walks the subcommand tree before parsing flags or arguments. Each token is checked against subcommand names — if it matches, Crust recurses into that subcommand. If no match is found, the token is treated as a positional arg (when the command has .run()) or throws a COMMAND_NOT_FOUND error (when it doesn't).

This means subcommand names are consumed before flag parsing, so my-cli build --minify first resolves build, then parses --minify against the build command's flags. See the resolveCommand() reference for the detailed step-by-step algorithm.

Nested Subcommands

Subcommands can be nested arbitrarily deep:

const main = new Crust("my-cli")
  .command("create", (create) =>
    create
      .meta({ description: "Create resources" })
      .command("component", (cmd) =>
        cmd
          .meta({ description: "Create a component" })
          .args([{ name: "name", type: "string", required: true }])
          .run(({ args }) => {
            console.log(`Creating component: ${args.name}`);
          })
      )
      .command("plugin", (cmd) =>
        cmd
          .meta({ description: "Create a plugin" })
          .args([{ name: "name", type: "string", required: true }])
          .run(({ args }) => {
            console.log(`Creating plugin: ${args.name}`);
          })
      )
  );
my-cli create component Button
my-cli create plugin auth

Container Commands

A command with subcommands but no .run() handler is a container command. It exists purely to group subcommands:

const main = new Crust("my-cli")
  .meta({ description: "My CLI tool" })
  .command("build", (cmd) => cmd.run(() => { /* ... */ }))
  .command("dev", (cmd) => cmd.run(() => { /* ... */ }));
  // No .run() on main — this is a container

When a user runs a container command directly (e.g., my-cli with no subcommand), the help plugin automatically shows help with the available subcommands.

Commands with Both .run() and Subcommands

A command can have both .run() and subcommands. In this case:

  • If the first argument matches a subcommand name, it routes to that subcommand
  • Otherwise, the arguments are treated as positionals for the parent command
const main = new Crust("my-cli")
  .args([{ name: "file", type: "string" }])
  .command("build", (cmd) => cmd.run(() => { /* ... */ }))
  .command("dev", (cmd) => cmd.run(() => { /* ... */ }))
  .run(({ args }) => {
    // Runs when no subcommand matches
    console.log(`Processing file: ${args.file}`);
  });
my-cli build        # Routes to build subcommand
my-cli readme.md    # Runs parent command with file="readme.md"

File-Splitting Pattern

Use .sub() when the subcommand lives in another file or should inherit parent inherit: true flags:

shared.ts
import { Crust } from "@crustjs/core";

export const app = new Crust("my-cli")
  .flags({ verbose: { type: "boolean", short: "v", inherit: true } });
commands/deploy.ts
import { app } from "../shared.ts";

export const deployCmd = app.sub("deploy")
  .meta({ description: "Deploy to production" })
  .flags({ env: { type: "string", required: true } })
  .run(({ flags }) => {
    flags.verbose; // boolean | undefined — inherited and typed!
    flags.env;     // string
  });
cli.ts
import { app } from "./shared.ts";
import { deployCmd } from "./commands/deploy.ts";
import { helpPlugin } from "@crustjs/plugins";

await app
  .use(helpPlugin())
  .command(deployCmd)
  .execute();

.sub() creates a child builder pre-typed with the parent's inheritable flags. See the .sub() reference for details.

Standalone Builders

.command(builder) also accepts a standalone builder created with new Crust(name):

const app = new Crust("my-cli").flags({
  verbose: { type: "boolean", inherit: true },
});

const standalone = new Crust("doctor").run(() => {
  console.log("doctor");
});

await app.command(standalone).execute();

This works, but standalone is isolated from app, so it does not inherit app's inherit: true flags. If you want inherited flags or parent-aware typing, use .sub(name) instead:

const deploy = app.sub("deploy").run(({ flags }) => {
  flags.verbose; // boolean | undefined
});

await app.command(deploy).execute();

Unknown Subcommands

When a user types an unknown subcommand on a container command (no .run()), Crust throws a COMMAND_NOT_FOUND error with structured details:

{
  input: "buld",              // What the user typed
  available: ["build", "dev"], // Valid subcommands
  commandPath: ["my-cli"],     // Path to the parent
  parentCommand: main,         // The parent command node
}

The Autocomplete Plugin catches these errors and provides "Did you mean?" suggestions.

Command Path

Crust tracks the full command path during routing. This is used by the help plugin to generate correct usage text:

$ my-cli create component --help
my-cli create component - Create a component

USAGE:
  my-cli create component <name> [options]

The path ["my-cli", "create", "component"] is available in the CommandRoute returned by resolveCommand.

On this page