Tutorial

How to Build CLI Subcommands

Structure a CLI with subcommands so each command has its own flags and help, and the top-level help lists them all.

On this page

  1. Introduction
  2. Define the commands
  3. Add per-command flags
  4. Generate per-command help
  5. Route by argv[2]
  6. Share global flags
  7. Test subcommands
  8. Common pitfalls
  9. Conclusion

Introduction

The hardest part of structure multi-command CLI is not writing the code — it is deciding what the code is allowed to reject. A subcommands setup that accepts everything is easy to ship and expensive to operate, because every malformed input becomes a production surprise. The discipline is to enumerate the shapes the system actually receives, encode them in subcommands, and let the library reject the rest loudly. This keeps the happy path fast and the failure path visible.

The cost of structure multi-command CLI without subcommands is paid in incidents, not in code. The code looks fine because the happy path works, and the happy path is what you demo. The failure path is what you operate, and the failure path is where subcommands earns its keep. Instrument the rejections, alert on the spikes, and let the metric tell you when the guard is misaligned with reality.

The sections below walk through the concrete decisions: where to put the subcommands check, how to keep it fast under load, what to do when the contract changes, and how to test the failure paths so they degrade on purpose instead of by accident.

Define the commands

Define the commands is the first decision because it determines what the rest of the subcommands pipeline can assume. A wrong choice here turns into structure multi-command CLI under load, and the invariant you thought subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Define the commands is the one most teams skip: drive subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

program.command('build').option('-w, --watch', 'watch').action(build);
program.command('deploy').option('-e, --env <n>', 'env').action(deploy);
program.parse();

The javascript above is the shape to ship for structure multi-command CLI. It is small enough to audit and specific enough that the failure behavior is obvious. Adapt the constants to your traffic and latency budget, and resist the urge to handle cases the system has not seen yet.

Add per-command flags

The mechanism behind Add per-command flags is straightforward in subcommands, but the consequences ripple. A wrong choice here turns into structure multi-command CLI under load, and the invariant you thought subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Add per-command flags is the one most teams skip: drive subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Generate per-command help

Generate per-command help is where most subcommands implementations leak performance. A wrong choice here turns into structure multi-command CLI under load, and the invariant you thought subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Generate per-command help is the one most teams skip: drive subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

test('routes to build', () => {
  parse(['build']);
  expect(build).toHaveBeenCalled();
});

Pin the behavior with this test. A subcommands setup that passes this test degrades on purpose instead of by accident, which is the difference between a managed failure and an incident.

Route by argv[2]

The hard part of Route by argv[2] is not the happy path — it is what happens when subcommands fails halfway. A wrong choice here turns into structure multi-command CLI under load, and the invariant you thought subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Route by argv[2] is the one most teams skip: drive subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Share global flags

Share global flags should be observable without a debugger. A wrong choice here turns into structure multi-command CLI under load, and the invariant you thought subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Share global flags is the one most teams skip: drive subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Test subcommands

Test subcommands is a trade, not a winner. A wrong choice here turns into structure multi-command CLI under load, and the invariant you thought subcommands guaranteed no longer holds. Measure the impact before committing, because the cost of a bad choice is paid in incidents, not in a code review.

The test for Test subcommands is the one most teams skip: drive subcommands to the failing condition and assert the system degrades gracefully. Write it with a fake clock for time-based logic, a stub for the failure path, and an assertion on the user-visible outcome. A test that passes by timing out is a hope, not a test.

Common pitfalls

The common pitfalls in structure multi-command CLI all reduce to one mistake: treating subcommands as a formality instead of a contract. The contract is the thing the rest of the system relies on, and a weak contract propagates weakness everywhere.

  • Skipping the subcommands boundary so structure multi-command CLI enters unvalidated.
  • Assuming subcommands is fast without measuring the tail latency under load.
  • Letting structure multi-command CLI fail silently instead of failing loudly with a metric.
  • Testing only the happy path so the subcommands failure surfaces in production.
  • Sharing mutable state across requests so one bad input poisons the next.

Conclusion

Do not conflate the conclusion with the implementation. They look similar from the outside but the failure modes differ: subcommands fails fast and the caller knows, while an unchecked path fails slow and the caller guesses. Choose the one whose failure mode your system can absorb, and document the choice at the call site.

Self-check: write a test that drives subcommands to the failing condition and assert the system degrades gracefully. Then structure multi-command CLI is a property you maintain, not a feature you hope for.

Sources: Commander subcommands, oclif

FAQ

Nested subcommands?
Yes, use command groups. git remote add is a nested command.
Shared flags?
Define on the parent. Child commands inherit them.

Related Examples