Skip to content

CLI Subcommands

When to use

Use this pattern when building a CLI tool with multiple commands and nested subcommands. The directory hierarchy mirrors the command-line interface: each command and subcommand gets its own directory with an index.js entry point. Shared utilities, SQL queries, and templates are co-located at the appropriate hierarchy level.

The pattern

Use Commander.js executableFile to delegate subcommands to separate files. Each command directory contains its own index.js that defines the command interface and either implements the action directly (leaf command) or delegates further (branch command).

Using subcommands

The directory structure becomes the command-line interface. Users invoke commands by name, and help is available at every level:

bash
# Root command (start is default)
myapp

# Explicit command
myapp build

# Nested subcommand
myapp db migrate apply

# Help at any level
myapp db --help
myapp db migrate --help

Leaf commands can accept options. This test command uses the sh tag for safe shell interpolation with an optional watch flag:

javascript
#!/usr/bin/env node
import { program } from "commander";
import { sh } from "sh-cmd-tag";

program.name("test");
program.description("run tests");
program.option("-w, --watch", "run in watch mode");
program.action(async function (options) {
  const command = sh`
    node --test
      ${options.watch ? "--watch" : undefined}
      ${testFiles}
  `;
  await command.run({ stdio: "inherit" });
});

program.parse();

Directory structure

Each command and subcommand maps to its own directory with an index.js entry point. Branch commands delegate to subdirectories; leaf commands implement the action directly:

cli/
├── index.js              # root CLI entry
├── start/
│   └── index.js          # start command (leaf)
├── build/
│   └── index.js          # build command (leaf)
├── deploy/
│   └── index.js          # deploy command (leaf)
├── test/
│   └── index.js          # test command (leaf)
└── db/
    ├── index.js          # db subcommand (branch)
    ├── backup/
    │   └── index.js      # db backup (leaf)
    ├── proxy/
    │   └── index.js      # db proxy (leaf)
    └── migrate/
        ├── index.js      # db migrate (branch)
        ├── migration.js  # shared utility
        ├── create-migration-table.sql
        ├── delete-migration.sql
        ├── get-migrations.sql
        ├── insert-migration.sql
        ├── templates/
        │   └── ...       # migration templates
        ├── create/
        │   └── index.js  # db migrate create (leaf)
        ├── apply/
        │   └── index.js  # db migrate apply (leaf)
        └── unapply/
            └── index.js  # db migrate unapply (leaf)

Defining a root CLI (branch command)

The root index.js defines top-level commands and delegates to subdirectories:

javascript
#!/usr/bin/env node
import { program } from "commander";

program.name("myapp");
program.description("App CLI");
program.version("0.0.1");
program.allowUnknownOption();
program.allowExcessArguments(true);

program.command("start", "start server", {
  isDefault: true,
  executableFile: "start/index.js",
});
program.command("db", "database commands", {
  executableFile: "db/index.js",
});
program.command("test", "run tests", {
  executableFile: "test/index.js",
});
program.command("build", "build for deployment", {
  executableFile: "build/index.js",
});
program.command("deploy", "deploy to production", {
  executableFile: "deploy/index.js",
});

program.parse();

Defining a nested branch command

Intermediate commands delegate further:

javascript
#!/usr/bin/env node
import { program } from "commander";

program.name("db");
program.description("database commands");

program.command("migrate", "migration commands", {
  executableFile: "migrate/index.js",
});
program.command("proxy", "start database proxy", {
  executableFile: "proxy/index.js",
});
program.command("backup", "backup database", {
  executableFile: "backup/index.js",
});

program.parse();

Defining a leaf command

Terminal commands implement program.action() with the actual logic. They import shared utilities from parent directories:

javascript
#!/usr/bin/env node
import { program } from "commander";
import { Migration } from "../migration.js";

program.name("apply");
program.description("apply pending migrations");
program.action(async function () {
  const migration = new Migration();
  await migration.apply();
});

program.parse();

Co-located resources

SQL queries and templates live alongside the commands that use them. This keeps related files together and makes it obvious where each resource is consumed:

migrate/
├── index.js
├── migration.js           # shared Migration class
├── create-migration-table.sql
├── delete-migration.sql
├── get-migrations.sql
├── insert-migration.sql
└── templates/
    └── migration.sql.ejs  # template for new migrations

Load SQL files with a caller-relative file utility:

javascript
import { file } from "@chriscalo/file";

const SQL = {
  createTable: file("./create-migration-table.sql"),
  getMigrations: file("./get-migrations.sql"),
  insert: file("./insert-migration.sql"),
  delete: file("./delete-migration.sql"),
};

Trade-offs

  • Deep nesting adds boilerplate. Each level needs its own index.js with Commander setup. For small CLIs with 2–3 commands, a single file with program.command(name) and inline actions is simpler.
  • executableFile spawns a child process. Each delegated command runs in a new Node.js process. This adds startup time (~50–100 ms per level) but isolates command dependencies.
  • Co-located SQL couples queries to commands. If multiple commands share queries, extract them to a shared directory. The co-location pattern works best when each command owns its queries.
  • Commander.js is a dependency. Alternatives like yargs, citty, or node:util.parseArgs exist. The executableFile pattern is Commander-specific, but the directory hierarchy concept works with any framework.