clipanion
Clipanion is the typed command-line framework behind Yarn. You model each command as a class, declare positional arguments and flags with Option helpers, and register one or more command paths with a Cli instance. It generates parsing rules and formatted help, supports nested and overlapping commands, can pass unknown trailing arguments to another program, and integrates with Typanion for runtime validation and coercion. The design is especially good for a TypeScript CLI with many subcommands, but it is more framework-like than a small argv parser.
Clipanion is an excellent fit for a large TypeScript CLI whose command grammar is genuinely complicated, particularly if nested paths and option proxying matter. For a small new tool, its release-candidate latest tag, class ceremony, and quiet maintenance make Commander, cac, or Node's built-in parser easier recommendations.
Use it if
- You are building a TypeScript CLI with nested commands such as workspace list or config set and want each command isolated in a class
- You need unusual argument shapes such as options repeated into arrays, counters, required rests, or transparent proxy arguments without a double-dash separator
- You want generated command help, version handling, typed option properties, and runtime validation from the same command definitions
- You need commands to call other commands while overriding stdout, environment values, or application-specific context
- You want a tiny parser for one command: Clipanion asks you to define classes, static paths, option properties, and a CLI registration step where getopts or util.parseArgs would be easier
- You require a fully stable current release: npm marks 4.0.0-rc.4 as latest, so the default install is still a release candidate rather than a final 4.0.0
- You want an actively moving dependency: the last npm release and repository push were both in September 2024, despite the package still receiving millions of transitive downloads
- You expect automatic numeric conversion: Option.String returns strings unless you add a Typanion validator that explicitly coerces and checks the value
- You need an ecosystem of middleware and plugins: Clipanion has a focused API and is proven by Yarn, but Commander and yargs have much broader third-party examples and integrations
Setup reality
Installation is one package command and there are no native builds, credentials, or configuration files. The setup cost is structural instead. Each action becomes a Command subclass with an async execute method, option declarations are class fields, and multiple actions need static paths plus registration in a Cli instance. TypeScript is where the API makes the most sense; JavaScript works, but gives up much of the inferred-option benefit. The current npm latest tag points to 4.0.0-rc.4, so an unpinned install opts into a release candidate. The manifest also installs Typanion as a dependency and declares it as a peer, despite the README tagline claiming no runtime dependencies. Strings are not converted to numbers on their own, optional positionals need an explicit required: false setting, and all commands are hidden from the global help list until they define static usage metadata. Built-in help and version commands are not magic either: create a Cli and register Builtins.HelpCommand and Builtins.VersionCommand. Use this.context.stdout and stderr instead of console if you want composition and testable output. runExit sets process.exitCode but deliberately does not terminate the process, so leftover handles can keep a CLI alive. Option.Rest and Option.Proxy are order-dependent and mutually exclusive, which matters when refactoring base command classes.
Patterns
Run one command with inferred process argumentsrun-single-command
import {Command, Option, runExit} from 'clipanion';
class GreetCommand extends Command {
name = Option.String();
async execute() {
this.context.stdout.write(`Hello ${this.name}!\n`);
}
}
await runExit(GreetCommand);runExit reads process.argv, sets process.exitCode, and does not call process.exit, so open timers or sockets can still keep the program alive.
Register nested commands and an aliasregister-subcommands
import {Cli, Command} from 'clipanion';
class WorkspaceListCommand extends Command {
static paths = [['workspace', 'list'], ['ws', 'ls']];
async execute() {
this.context.stdout.write('app\nweb\n');
}
}
const cli = new Cli({binaryName: 'acme', binaryVersion: '1.0.0'});
cli.register(WorkspaceListCommand);
await cli.runExit(process.argv.slice(2));A command may have several paths, and each path is an array of literal tokens rather than one space-separated string.
Handle invocation with no command pathdefine-default-command
class StatusCommand extends Command {
static paths = [Command.Default, ['status']];
async execute() {
this.context.stdout.write('ready\n');
}
}Command.Default is an empty path and can coexist with named paths, allowing both acme and acme status to select this class.
Declare required strings and boolean aliasesparse-flags
class DeployCommand extends Command {
static paths = [['deploy']];
target = Option.String('--target', {required: true});
dryRun = Option.Boolean('-n,--dry-run', false);
async execute() {
this.context.stdout.write(`${this.target}: ${this.dryRun ? 'plan' : 'apply'}\n`);
}
}Comma-separated descriptors create aliases. A required named option is different from a required positional and produces a parser error when absent.
Collect a repeated option into an arraycollect-repeated-options
class NotifyCommand extends Command {
static paths = [['notify']];
emails = Option.Array('--email', []);
async execute() {
for (const email of this.emails)
this.context.stdout.write(`${email}\n`);
}
}Option.Array collects repeated occurrences such as --email a@example.com --email b@example.com; the empty initial value avoids undefined when none are passed.
Accept any number of positional filesparse-rest-arguments
class HashCommand extends Command {
static paths = [['hash']];
files = Option.Rest({required: 1});
async execute() {
this.context.stdout.write(`${this.files.length} files\n`);
}
}Rest is order-dependent, must follow earlier positional fields in the class layout, and cannot be combined with Option.Proxy.
Forward trailing arguments to another programproxy-child-arguments
import {spawn} from 'node:child_process';
class RunCommand extends Command {
static paths = [['run']];
tool = Option.String();
args = Option.Proxy();
async execute() {
const child = spawn(this.tool, this.args, {stdio: 'inherit'});
return await new Promise<number>((resolve, reject) => {
child.on('error', reject);
child.on('exit', code => resolve(code ?? 1));
});
}
}Proxy stops Clipanion parsing once reached, so child flags can pass through without --. Validate or allowlist executable names when input is untrusted.
Count repeated verbosity flagscount-verbosity
class BuildCommand extends Command {
static paths = [['build']];
verbosity = Option.Counter('-v,--verbose', 0);
async execute() {
this.context.stdout.write(`level=${this.verbosity}\n`);
}
}Short booleans and counters can be batched, so -vvv produces 3; passing --no-verbose resets the counter to zero.
Coerce and validate a port with Typanionvalidate-number-option
import * as t from 'typanion';
const isPort = t.applyCascade(t.isNumber(), [
t.isInteger(),
t.isInInclusiveRange(1, 65535),
]);
class ServeCommand extends Command {
port = Option.String('--port', '3000', {validator: isPort});
async execute() {
this.context.stdout.write(`port=${this.port}\n`);
}
}Option.String does not infer numbers from text. The validator both checks and coerces the value, and TypeScript infers the resulting number type.
Publish usage text and built-in helpadd-generated-help
import {Builtins, Cli, Command} from 'clipanion';
class CleanCommand extends Command {
static paths = [['clean']];
static usage = Command.Usage({
category: 'Workspace',
description: 'Remove generated files',
examples: [['Clean the workspace', '$0 clean']],
});
async execute() {}
}
const cli = new Cli({binaryName: 'acme'});
cli.register(CleanCommand);
cli.register(Builtins.HelpCommand);Commands without static usage are hidden from the global help listing, and the help command must be registered when constructing Cli manually.
Show a clean user-facing errorreport-usage-error
import {Command, UsageError} from 'clipanion';
class RemoveCommand extends Command {
force = Option.Boolean('--force', false);
async execute() {
if (!this.force)
throw new UsageError('Pass --force to confirm removal');
}
}UsageError prints the message and command usage without an internal stack trace. A normal Error is treated as an unexpected failure.
Pass typed application context into commandsinject-command-context
import type {BaseContext} from 'clipanion';
type AppContext = BaseContext & {cwd: string};
class PwdCommand extends Command<AppContext> {
async execute() {
this.context.stdout.write(`${this.context.cwd}\n`);
}
}
const cli = new Cli<AppContext>({binaryName: 'acme'});
cli.register(PwdCommand);
await cli.runExit(process.argv.slice(2), {cwd: process.cwd()});Custom contexts extend BaseContext. Clipanion fills the standard streams and environment, while every required custom key must be supplied to run or runExit.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| commander | npm | Choose it for the most familiar command and option API, wide adoption, and abundant integration examples |
| yargs | npm | Choose it for mature command builders, middleware, completion support, and detailed validation of conventional CLIs |
| cac | npm | Choose it for a smaller, simpler CLI builder when class-based commands and advanced argument grammars are unnecessary |