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:
| Pattern | Use when | Notes |
|---|---|---|
.command(name, cb) | The subcommand is defined inline | Best default for small or nested command trees |
.sub(name) + .command(builder) | The subcommand lives in another file or should inherit parent flags | Best default for modular CLIs |
new Crust(name) + .command(builder) | The subcommand is intentionally standalone | Does 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 subcommandsHow 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.
Aliases
A command can declare alternative names — short or long forms that resolve to the same command node — via the aliases field on meta():
import { Crust } from "@crustjs/core";
import { helpPlugin } from "@crustjs/plugins";
const main = new Crust("my-cli").use(helpPlugin()).command("issue", (cmd) =>
cmd.meta({ description: "Manage issues", aliases: ["issues", "i"] }).command("list", (sub) =>
sub.meta({ description: "List open issues" }).run(() => {
console.log("open issues…");
}),
),
);
await main.execute();All three of the following invoke the same command:
my-cli issue list
my-cli issues list # via alias "issues"
my-cli i list # via alias "i"The canonical name is what appears in commandPath, error messages, and didYouMeanPlugin suggestions — the alias the user typed is not preserved. Help renders the canonical name with aliases inline:
COMMANDS:
issue (issues, i) Manage issuesConflict policy
Alias strings must not collide with the command's own canonical name, with any sibling's name, or with any sibling's alias. They must also be non-empty, contain no whitespace, and not start with -. Collisions throw a CrustError("DEFINITION", …) at registration time:
// ❌ Throws: alias "i" collides with sibling canonical name "i"
new Crust("my-cli")
.command("i", (cmd) => cmd.run(() => {}))
.command("issue", (cmd) => cmd.meta({ aliases: ["i"] }).run(() => {}));
// ^^^
// CrustError: Subcommand "issue" alias "i" collides with sibling
// canonical name "i"The same check runs during prepareCommandTree() for plugin-installed subcommands, so plugins can't sneak conflicting aliases past the policy.
Hidden Commands
Set hidden: true on a subcommand's meta() to omit it from the default --help listing:
const app = new Crust("my-cli")
.command("build", (cmd) => cmd.meta({ description: "Build the project" }).run(() => {}))
.command("__complete", (cmd) =>
cmd.meta({ hidden: true, description: "Internal completion entrypoint" }).run(() => {}),
);hidden affects help rendering only — the command remains directly invocable by name, alias resolution still works, and didYouMeanPlugin still suggests it. It is intended for internal/runtime commands like the __complete entrypoint used by shell-completion plugins. See the CommandMeta reference for details.
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 authContainer 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 containerWhen 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:
import { Crust } from "@crustjs/core";
export const app = new Crust("my-cli").flags({
verbose: { type: "boolean", short: "v", inherit: true },
});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
});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 + aliases
commandPath: ["my-cli"], // Path to the parent
parentCommand: main, // The parent command node
}If any sibling defines aliases, available is a flat list of canonical names followed by each canonical's aliases (in sibling insertion order). For example, issue with aliases: ["issues", "i"] and version produces available: ["issue", "issues", "i", "version"]. didYouMeanPlugin uses this list to match against alias spellings but always reports the canonical name as its suggestion.
The Did You Mean 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.