Commander.js
Complete Commander.js CLI framework guidance covering command structure, options, arguments, subcommands, action handlers, version management, and TypeScript integration. Use when: building CLI tools, parsing command-line arguments, implementing subcommands, handling options/flags, creating interactive CLIs, or migrating from other CLI frameworks. Keywords: Commander.js, CLI, command-line, arguments, options, flags, subcommands, action handlers, version, help text, TypeScript, yargs, meow, program, parseAsync, opts, args, variadic, required options, default values, custom help, error handling
npx skills add github:pantheon-org/tekhne --skill development--commanderjsComplete Commander.js framework guidance for building robust command-line interfaces with proper argument parsing, subcommands, options, and TypeScript support.
Mindset
Treat Commander.js as a declarative contract, not an argv reader. Model every input the CLI accepts as a .argument(), .option(), or subcommand before writing a single line of handler logic — the declaration is what generates --help output, coerces types, and produces Commander’s own validation errors for free. Never reach for process.argv[n] as a shortcut past that declaration: every value read directly from argv is a value Commander no longer validates, documents, or reports errors for consistently with the rest of the program. Keep action handlers thin. Parse into a fully typed options object first, then pass that single object into a service function — never scatter individual opts.foo, opts.bar properties across a call site, because a service that grows a fourth or fifth positional parameter loses the compiler’s ability to catch an argument-order mistake. Verify subcommand option scope before trusting program.opts() inside a subcommand action: each .command() owns its own option scope, and reading the parent program’s opts() from inside a child action silently returns the wrong values instead of failing loudly.
When to Use
Use this skill when building a new CLI tool with Commander.js, adding subcommands, options, or arguments to an existing Commander.js program, migrating a CLI from raw process.argv parsing or another framework (yargs, meow) onto Commander.js, or diagnosing an option/argument/action handler that is not behaving as declared (wrong scope, a missing await, argv leaking through, help text not matching actual parsing behavior).
When NOT to Use
Do not reach for this skill when the target CLI deliberately avoids third-party argument-parsing dependencies, when the runtime’s own built-in flag parser is the project’s stated convention, or when the task is building a terminal UI (menus, prompts, live-updating panes) rather than parsing arguments — a TUI is a different problem from argument parsing and needs different tooling.
How to Use
Read individual reference files for detailed guidance:
- Core Basics
- Options & Flags
- Commands & Structure
- Action Handlers
- TypeScript Setup
- Practices & Patterns
Each reference file contains:
- API documentation and usage patterns
- Practical code examples with TypeScript
- Common patterns and best practices
- Error handling and validation techniques
- Migration guides from other frameworks
Quick Start
import { Command } from 'commander';
const program = new Command();
program
.name('my-cli')
.description('CLI tool description')
.version('1.0.0');
program
.command('build')
.description('Build the project')
.option('-o, --output <path>', 'Output directory')
.option('-w, --watch', 'Watch for changes')
.action((options) => {
console.log('Building with options:', options);
});
await program.parseAsync(process.argv);
Navigation Workflow
- Start with core - Understand program setup and basic structure
- Add options - Define flags, required options, and defaults
- Structure commands - Create subcommands and command hierarchies
- Implement actions - Write action handlers with async/await
- Integrate TypeScript - Add proper typing and type safety
- Validate parsing - Test with
--helpand invalid inputs to verify argument parsing and error handling - Apply best practices - Error handling, validation, and testing
Common Patterns
Single Command CLI
program
.argument('<source>', 'Source file')
.argument('[destination]', 'Destination file')
.option('-f, --force', 'Force overwrite')
.action((source, destination, options) => {
// implementation
});
Multi-Command CLI
program
.command('init')
.description('Initialize project')
.action(() => { /* ... */ });
program
.command('deploy')
.description('Deploy application')
.option('-e, --env <name>', 'Environment')
.action((options) => { /* ... */ });
Modular Subcommands with Typed Options (Recommended)
// types/build-options.ts
export interface BuildOptions {
outDir: string;
minify: boolean;
watch: boolean;
}
// services/build-service.ts
import type { BuildOptions } from '../types/build-options';
export const buildProject = (options: BuildOptions): void => {
console.log('Building to:', options.outDir);
// All options available with type safety
};
// commands/build.ts
import { Command } from 'commander';
import { buildProject } from '../services/build-service';
import type { BuildOptions } from '../types/build-options';
export const buildCommand = new Command('build')
.description('Build project')
.option('-o, --out-dir <path>', 'Output directory', 'dist')
.option('-m, --minify', 'Minify output', false)
.option('-w, --watch', 'Watch mode', false)
.action((options: BuildOptions) => {
// Pass complete typed object to service
buildProject(options);
});
// index.ts
import { buildCommand } from './commands/build';
program.addCommand(buildCommand);
Do’s
✓ ALWAYS pass complete typed options objects to services (never individual properties)
✓ Define TypeScript interfaces for all options (e.g., BuildOptions, DeployOptions)
✓ Export commands as Command instances from subcommand modules
✓ Use .addCommand() to attach subcommands to parent Command
✓ Use parseAsync() for async action handlers
✓ Validate options in action handlers
✓ Provide clear descriptions for all commands/options
✓ Use TypeScript for type safety
✓ Handle errors gracefully with try/catch
✓ Use kebab-case for option names
✓ Provide sensible defaults for optional options
✓ Create barrel exports for commands (commands/index.ts)
✓ Test CLI with different argument combinations
✓ Use .exitOverride() for testing
✓ Document expected argument formats
Don’ts
✗ NEVER pass individual option properties to services (pass complete typed object)
✗ DON’T pass options piecemeal (e.g., service(opts.a, opts.b, opts.c))
✗ Don’t use parse() with async handlers (use parseAsync())
✗ Don’t ignore error handling in action handlers
✗ Don’t use camelCase in CLI flags (use kebab-case)
✗ Don’t forget to specify option argument types (<required> vs [optional])
✗ Don’t mix positional arguments with options ambiguously
✗ Don’t forget to call program.parse() or program.parseAsync()
✗ Don’t use global state in action handlers
✗ Don’t suppress built-in help text without good reason
✗ Don’t forget to version your CLI
✗ Don’t make all options required (use sensible defaults)
Anti-Patterns
NEVER access process.argv directly when Commander.js is available
- WHY: Commander.js handles argument parsing, validation, and help generation; bypassing it for any argument creates inconsistency in error handling and help output.
- BAD:
const url = process.argv[2]alongside Commander.js commands. - GOOD: Define all arguments as Commander.js options or arguments:
program.argument('<url>', 'Target URL').
NEVER use .action() callback without handling errors
- WHY: Unhandled rejections in async action callbacks crash the process without helpful error messages.
- BAD:
program.command('fetch').action(async (opts) => { await riskyOp(); }) - GOOD: Wrap in try/catch and call
program.error(err.message)for Commander-formatted error output.
NEVER add .parseAsync() without await
- WHY: Commander.js v8+ requires
await program.parseAsync()for async actions; without await, the process exits before async actions complete. - BAD:
program.parseAsync(process.argv)without await. - GOOD:
await program.parseAsync(process.argv)inside an async IIFE or main function.
NEVER define commands with positional arguments and options that share ambiguous prefixes
- WHY: Commander.js can misparse
--optionvalues as positional arguments when options are not consumed before positional parsing. - BAD:
program.argument('<file>').option('--format <fmt>')with ambiguous ordering in usage examples. - GOOD: Always place options before positional arguments in usage examples and validate input explicitly.
NEVER use program.opts() to read option values inside a subcommand
- WHY: Each subcommand has its own option scope; reading
program.opts()in a subcommand returns the parent options, not the subcommand options. - BAD: Reading
program.opts().verboseinside aprogram.command('deploy').action(). - GOOD: Use
command.opts()(the action’s first argument when using.action((opts) =>)) to access subcommand-specific options.
References
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
No eval scenarios available.
Action Handlers
Complete guide to implementing action handlers with async/await, error handling, and validation.
Basic Action Handler
program
.command('build')
.action(() => {
console.log('Building...');
});
Async Action Handlers
Always use parseAsync() with async handlers:
program
.command('deploy')
.action(async (options) => {
console.log('Deploying...');
await performDeployment();
console.log('Deployment complete');
});
// Must use parseAsync()
await program.parseAsync(process.argv);
Action Handler Parameters
Options Only
program
.option('-p, --port <number>', 'Port')
.action((options) => {
console.log('Port:', options.port);
});
Arguments Only
program
.command('greet')
.argument('<name>', 'Name')
.action((name) => {
console.log('Hello,', name);
});
Arguments and Options
program
.command('deploy')
.argument('<environment>', 'Environment')
.option('-b, --branch <name>', 'Branch')
.action((environment, options) => {
console.log('Deploying', options.branch, 'to', environment);
});
Multiple Arguments and Options
program
.command('copy')
.argument('<source>', 'Source')
.argument('<dest>', 'Destination')
.option('-f, --force', 'Force overwrite')
.action((source, dest, options) => {
console.log(`Copying ${source} to ${dest}`);
if (options.force) {
console.log('Force mode enabled');
}
});
Access to Command Object
program
.command('info')
.action((options, command) => {
console.log('Command name:', command.name());
console.log('Options:', options);
});
Error Handling
Try-Catch
program
.command('deploy')
.action(async (options) => {
try {
await performDeployment(options);
console.log('Deployment successful');
} catch (error) {
console.error('Deployment failed:', error.message);
process.exit(1);
}
});
Process Exit Codes
program
.command('test')
.action(async () => {
try {
const results = await runTests();
if (results.failed > 0) {
console.error(`${results.failed} tests failed`);
process.exit(1); // Exit with error code
}
console.log('All tests passed');
process.exit(0); // Exit successfully
} catch (error) {
console.error('Test execution failed:', error.message);
process.exit(2); // Different error code
}
});
Custom Error Handler
program
.configureOutput({
outputError: (str, write) => {
write(`ERROR: ${str}`);
}
})
.exitOverride((err) => {
if (err.code === 'commander.unknownOption') {
console.error('Unknown option provided');
process.exit(1);
}
throw err;
});
Validation
Validate Required Arguments
program
.command('connect')
.argument('<url>', 'Server URL')
.action((url) => {
try {
new URL(url); // Validate URL format
} catch {
console.error('Invalid URL format');
process.exit(1);
}
console.log('Connecting to:', url);
});
Validate Option Values
program
.command('serve')
.option('-p, --port <number>', 'Port', (value) => {
const port = parseInt(value, 10);
if (isNaN(port) || port < 1 || port > 65535) {
throw new Error('Port must be between 1 and 65535');
}
return port;
})
.action((options) => {
console.log('Starting server on port:', options.port);
});
Validate Option Combinations
program
.command('build')
.option('-w, --watch', 'Watch mode')
.option('-p, --production', 'Production build')
.action((options) => {
if (options.watch && options.production) {
console.error('Cannot use --watch with --production');
process.exit(1);
}
console.log('Building...');
});
Progress and Feedback
program
.command('install')
.action(async (options) => {
console.log('Installing dependencies...');
const packages = ['pkg1', 'pkg2', 'pkg3'];
for (let i = 0; i < packages.length; i++) {
console.log(`[${i + 1}/${packages.length}] Installing ${packages[i]}`);
await installPackage(packages[i]);
}
console.log('Installation complete!');
});
State Management
Avoid global state, use closures or pass state:
const createDeployCommand = (config: Config) => {
return new Command('deploy')
.action(async (options) => {
// Use config passed via closure
await deploy(config, options);
});
};
const config = loadConfig();
program.addCommand(createDeployCommand(config));
Conditional Logic
program
.command('deploy')
.argument('<environment>', 'Environment')
.option('-f, --force', 'Force deployment')
.option('-d, --dry-run', 'Dry run')
.action(async (environment, options) => {
if (options.dryRun) {
console.log('Dry run mode - simulating deployment');
return simulateDeployment(environment);
}
if (!options.force) {
const confirmed = await promptConfirm(
`Deploy to ${environment}?`
);
if (!confirmed) {
console.log('Deployment cancelled');
return;
}
}
await deploy(environment);
});
Parallel Operations
program
.command('build-all')
.action(async () => {
console.log('Building all targets...');
const builds = [
buildClient(),
buildServer(),
buildWorker(),
];
const results = await Promise.allSettled(builds);
const failed = results.filter(r => r.status === 'rejected');
if (failed.length > 0) {
console.error(`${failed.length} builds failed`);
process.exit(1);
}
console.log('All builds complete');
});
Complete Example
import { Command } from 'commander';
import { promises as fs } from 'fs';
const program = new Command();
program
.command('process')
.description('Process files')
.argument('<input>', 'Input file')
.argument('[output]', 'Output file')
.option('-f, --format <type>', 'Output format', 'json')
.option('-m, --minify', 'Minify output')
.option('-v, --verbose', 'Verbose logging')
.action(async (input, output, options) => {
try {
// Validation
if (options.verbose) {
console.log('Validating inputs...');
}
const validFormats = ['json', 'yaml', 'xml'];
if (!validFormats.includes(options.format)) {
throw new Error(
`Format must be one of: ${validFormats.join(', ')}`
);
}
// Check input exists
try {
await fs.access(input);
} catch {
throw new Error(`Input file not found: ${input}`);
}
// Process
if (options.verbose) {
console.log(`Reading ${input}...`);
}
const data = await fs.readFile(input, 'utf-8');
if (options.verbose) {
console.log('Processing data...');
}
let processed = processData(data, options.format);
if (options.minify) {
if (options.verbose) {
console.log('Minifying...');
}
processed = minify(processed);
}
// Output
const outputPath = output || `${input}.${options.format}`;
if (options.verbose) {
console.log(`Writing to ${outputPath}...`);
}
await fs.writeFile(outputPath, processed);
console.log(`Success: ${outputPath}`);
} catch (error) {
console.error('Error:', error.message);
if (options.verbose) {
console.error(error.stack);
}
process.exit(1);
}
});
await program.parseAsync(process.argv);
// Helper functions
const processData = (data: string, format: string): string => {
// Implementation
return data;
};
const minify = (data: string): string => {
// Implementation
return data;
};
Best Practices
- Always use async/await with
parseAsync() - Validate inputs early in action handler
- Use try-catch for error handling
- Provide meaningful error messages with context
- Use appropriate exit codes (0 = success, non-zero = error)
- Avoid global state - use closures or parameters
- Give user feedback during long operations
- Handle edge cases explicitly
- Log verbosely when requested with
-vflag - Fail fast - exit on validation errors
Commands & Subcommands
Complete guide to structuring commands and subcommands in Commander.js.
CRITICAL: Typed Options Pattern
Always pass options as typed objects to downstream services/modules.
Bad: Destructuring in Action
program
.command('build')
.option('-e, --env <name>', 'Environment')
.action(({ env }) => {
// ❌ BAD: Type information lost, unclear what's passed
buildProject(env);
});
Good: Typed Options Object
// types/build-options.ts
export interface BuildOptions {
env: string;
minify: boolean;
outDir: string;
}
// services/build-service.ts
export const buildProject = (options: BuildOptions): void => {
console.log('Building with:', options);
// Service knows exactly what it receives
};
// commands/build.ts
import { Command } from 'commander';
import { buildProject } from '../services/build-service';
import type { BuildOptions } from '../types/build-options';
export const buildCommand = new Command('build')
.description('Build the project')
.option('-e, --env <name>', 'Environment', 'development')
.option('-m, --minify', 'Minify output', false)
.option('-o, --out-dir <path>', 'Output directory', './dist')
.action((options: BuildOptions) => {
// ✅ GOOD: Full typed object passed to service
buildProject(options);
});
Why Typed Options?
- Type Safety: Compiler catches missing/incorrect options
- Clarity: Service signature shows all required inputs
- Testability: Easy to mock complete option objects
- Maintainability: Add options without changing signatures
- Documentation: Types document expected inputs
Basic Command
program
.command('build')
.description('Build the project')
.action(() => {
console.log('Building...');
});
Multiple Commands
program
.command('init')
.description('Initialize project')
.action(() => {
console.log('Initializing...');
});
program
.command('build')
.description('Build project')
.action(() => {
console.log('Building...');
});
program
.command('deploy')
.description('Deploy project')
.action(() => {
console.log('Deploying...');
});
await program.parseAsync(process.argv);
Command with Arguments
program
.command('deploy')
.description('Deploy to environment')
.argument('<environment>', 'Target environment')
.action((environment) => {
console.log('Deploying to:', environment);
});
// Usage: my-cli deploy production
Multiple Arguments
program
.command('copy')
.description('Copy files')
.argument('<source>', 'Source file')
.argument('<destination>', 'Destination file')
.action((source, destination) => {
console.log(`Copying ${source} to ${destination}`);
});
// Usage: my-cli copy src.txt dst.txt
Optional Arguments
program
.command('log')
.description('Show logs')
.argument('[lines]', 'Number of lines', '10')
.action((lines) => {
console.log(`Showing ${lines} lines`);
});
// Usage: my-cli log
// or: my-cli log 50
Variadic Arguments
program
.command('add')
.description('Add files')
.argument('<files...>', 'Files to add')
.action((files) => {
console.log('Adding files:', files);
});
// Usage: my-cli add file1.js file2.js file3.js
Command with Options
program
.command('build')
.description('Build project')
.option('-e, --env <name>', 'Environment', 'development')
.option('-w, --watch', 'Watch mode')
.option('-m, --minify', 'Minify output')
.action((options) => {
console.log('Building with options:', options);
});
// Usage: my-cli build --env production --minify
Modular Commands (Recommended Pattern)
Create subcommands as exported Command instances, with typed options passed to services:
Full Stack: Types → Service → Command
// types/build-options.ts
export interface BuildOptions {
env: string;
minify: boolean;
outDir: string;
}
// services/build-service.ts
import type { BuildOptions } from '../types/build-options';
export const buildProject = (options: BuildOptions): void => {
console.log('Building with:', options);
// Implementation receives fully typed options
};
// commands/build.ts
import { Command } from 'commander';
import { buildProject } from '../services/build-service';
import type { BuildOptions } from '../types/build-options';
export const buildCommand = new Command('build')
.description('Build project')
.option('-e, --env <name>', 'Environment', 'development')
.option('-m, --minify', 'Minify output', false)
.option('-o, --out-dir <path>', 'Output directory', './dist')
.action((options: BuildOptions) => {
// Pass complete typed options object to service
buildProject(options);
});
// types/deploy-options.ts
export interface DeployOptions {
branch: string;
force: boolean;
}
// services/deploy-service.ts
import type { DeployOptions } from '../types/deploy-options';
export const deployApp = (environment: string, options: DeployOptions): void => {
console.log(`Deploying ${options.branch} to ${environment}`);
// Service receives environment + typed options
};
// commands/deploy.ts
import { Command } from 'commander';
import { deployApp } from '../services/deploy-service';
import type { DeployOptions } from '../types/deploy-options';
export const deployCommand = new Command('deploy')
.description('Deploy application')
.argument('<environment>', 'Target environment')
.option('-b, --branch <name>', 'Branch', 'main')
.option('-f, --force', 'Force deployment', false)
.action((environment: string, options: DeployOptions) => {
// Pass all arguments + typed options to service
deployApp(environment, options);
});
// index.ts
import { Command } from 'commander';
import { buildCommand } from './commands/build';
import { deployCommand } from './commands/deploy';
const program = new Command();
program
.name('my-cli')
.version('1.0.0')
.addCommand(buildCommand)
.addCommand(deployCommand);
await program.parseAsync(process.argv);
Directory Structure with Service Layer
src/
├── types/
│ ├── build-options.ts # BuildOptions interface
│ ├── deploy-options.ts # DeployOptions interface
│ └── index.ts # Barrel exports
├── services/
│ ├── build-service.ts # buildProject(options: BuildOptions)
│ ├── deploy-service.ts # deployApp(env, options: DeployOptions)
│ └── index.ts # Barrel exports
├── commands/
│ ├── build.ts # Export buildCommand
│ ├── deploy.ts # Export deployCommand
│ └── index.ts # Barrel exports
└── index.ts # Main program
Barrel Exports for Commands
// commands/index.ts
export { buildCommand } from './build';
export { deployCommand } from './deploy';
export { testCommand } from './test';
// index.ts
import { Command } from 'commander';
import { buildCommand, deployCommand, testCommand } from './commands';
const program = new Command();
program
.name('my-cli')
.version('1.0.0')
.addCommand(buildCommand)
.addCommand(deployCommand)
.addCommand(testCommand);
await program.parseAsync(process.argv);
Subcommands with Modular Pattern + Typed Options
Subcommands follow the same pattern: types → services → commands:
// types/migrate-options.ts
export interface MigrateOptions {
rollback: boolean;
steps?: number;
}
// types/seed-options.ts
export interface SeedOptions {
clear: boolean;
}
// services/database-service.ts
import type { MigrateOptions } from '../types/migrate-options';
import type { SeedOptions } from '../types/seed-options';
export const runMigrations = (options: MigrateOptions): void => {
if (options.rollback) {
console.log('Rolling back migration');
} else {
console.log('Running migrations');
}
};
export const seedDatabase = (env: string, options: SeedOptions): void => {
console.log('Seeding database for:', env);
if (options.clear) {
console.log('Clearing existing data first');
}
};
// commands/db/migrate.ts
import { Command } from 'commander';
import { runMigrations } from '../../services/database-service';
import type { MigrateOptions } from '../../types/migrate-options';
export const migrateCommand = new Command('migrate')
.description('Run database migrations')
.option('--rollback', 'Rollback last migration', false)
.option('--steps <number>', 'Number of migrations')
.action((options: MigrateOptions) => {
runMigrations(options);
});
// commands/db/seed.ts
import { Command } from 'commander';
import { seedDatabase } from '../../services/database-service';
import type { SeedOptions } from '../../types/seed-options';
export const seedCommand = new Command('seed')
.description('Seed database')
.argument('[env]', 'Environment', 'development')
.option('--clear', 'Clear existing data', false)
.action((env: string, options: SeedOptions) => {
seedDatabase(env, options);
});
// commands/db/index.ts
import { Command } from 'commander';
import { migrateCommand } from './migrate';
import { seedCommand } from './seed';
export const dbCommand = new Command('db')
.description('Database operations')
.addCommand(migrateCommand)
.addCommand(seedCommand);
// index.ts
import { Command } from 'commander';
import { dbCommand } from './commands/db';
const program = new Command();
program
.name('my-cli')
.version('1.0.0')
.addCommand(dbCommand);
await program.parseAsync(process.argv);
// Usage: my-cli db migrate --rollback
// my-cli db seed production --clear
Inline Subcommands (Less Maintainable)
const db = program.command('db');
db.description('Database commands');
db.command('migrate')
.description('Run migrations')
.action(() => {
console.log('Running migrations...');
});
db.command('seed')
.description('Seed database')
.action(() => {
console.log('Seeding database...');
});
// Usage: my-cli db migrate
// my-cli db seed
Nested Subcommands
// commands/docker/container/start.ts
import { Command } from 'commander';
export const startCommand = new Command('start')
.description('Start container')
.argument('<name>', 'Container name')
.action((name) => {
console.log('Starting container:', name);
});
// commands/docker/container/stop.ts
import { Command } from 'commander';
export const stopCommand = new Command('stop')
.description('Stop container')
.argument('<name>', 'Container name')
.action((name) => {
console.log('Stopping container:', name);
});
// commands/docker/container/index.ts
import { Command } from 'commander';
import { startCommand } from './start';
import { stopCommand } from './stop';
export const containerCommand = new Command('container')
.description('Container operations')
.addCommand(startCommand)
.addCommand(stopCommand);
// commands/docker/index.ts
import { Command } from 'commander';
import { containerCommand } from './container';
export const dockerCommand = new Command('docker')
.description('Docker operations')
.addCommand(containerCommand);
// index.ts
import { Command } from 'commander';
import { dockerCommand } from './commands/docker';
const program = new Command();
program
.name('my-cli')
.version('1.0.0')
.addCommand(dockerCommand);
await program.parseAsync(process.argv);
// Usage: my-cli docker container start my-app
// my-cli docker container stop my-app
Command Aliases
export const installCommand = new Command('install')
.alias('i')
.description('Install dependencies')
.action(() => {
console.log('Installing...');
});
// Usage: my-cli install
// or: my-cli i
Default Command
export const serveCommand = new Command('serve')
.description('Start server')
.action(() => {
console.log('Starting server...');
});
program.addCommand(serveCommand, { isDefault: true });
// Usage: my-cli
// (runs serve command by default)
Hidden Commands
export const debugCommand = new Command('debug')
.description('Debug mode')
.hideCommand()
.action(() => {
console.log('Debug mode enabled');
});
// Hidden from help text
Command-Specific Help
export const deployCommand = new Command('deploy')
.description('Deploy application')
.argument('<environment>', 'Target environment')
.option('-d, --dry-run', 'Dry run')
.addHelpText('after', `
Examples:
$ my-cli deploy production
$ my-cli deploy staging --dry-run
`);
TypeScript Command Factory Pattern
// commands/build.ts
import { Command } from 'commander';
interface BuildOptions {
env: string;
minify: boolean;
watch: boolean;
}
export const buildCommand = new Command('build')
.description('Build project')
.option('-e, --env <name>', 'Environment', 'development')
.option('-m, --minify', 'Minify output', false)
.option('-w, --watch', 'Watch mode', false)
.action((options: BuildOptions) => {
console.log('Building with options:', options);
});
Complete Modular Example
// commands/build.ts
import { Command } from 'commander';
export const buildCommand = new Command('build')
.description('Build project')
.option('-e, --env <name>', 'Environment', 'development')
.option('-w, --watch', 'Watch mode')
.action((options) => {
console.log('Building for:', options.env);
});
// commands/deploy.ts
import { Command } from 'commander';
export const deployCommand = new Command('deploy')
.description('Deploy application')
.argument('<environment>', 'Target environment')
.option('-b, --branch <name>', 'Git branch', 'main')
.option('-d, --dry-run', 'Dry run')
.action((environment, options) => {
console.log(`Deploying ${options.branch} to ${environment}`);
if (options.dryRun) {
console.log('Dry run - no changes made');
}
});
// commands/db/migrate.ts
import { Command } from 'commander';
export const migrateCommand = new Command('migrate')
.description('Run migrations')
.option('--rollback', 'Rollback last migration')
.action((options) => {
if (options.rollback) {
console.log('Rolling back migration');
} else {
console.log('Running migrations');
}
});
// commands/db/seed.ts
import { Command } from 'commander';
export const seedCommand = new Command('seed')
.description('Seed database')
.argument('[env]', 'Environment', 'development')
.action((env) => {
console.log('Seeding database for:', env);
});
// commands/db/index.ts
import { Command } from 'commander';
import { migrateCommand } from './migrate';
import { seedCommand } from './seed';
export const dbCommand = new Command('db')
.description('Database operations')
.addCommand(migrateCommand)
.addCommand(seedCommand);
// commands/index.ts
export { buildCommand } from './build';
export { deployCommand } from './deploy';
export { dbCommand } from './db';
// index.ts
import { Command } from 'commander';
import { buildCommand, deployCommand, dbCommand } from './commands';
const program = new Command();
program
.name('project-cli')
.description('Project management CLI')
.version('1.0.0')
.addCommand(buildCommand)
.addCommand(deployCommand)
.addCommand(dbCommand);
await program.parseAsync(process.argv);
Directory Structure
src/
├── commands/
│ ├── build.ts # buildCommand export
│ ├── deploy.ts # deployCommand export
│ ├── db/
│ │ ├── migrate.ts # migrateCommand export
│ │ ├── seed.ts # seedCommand export
│ │ └── index.ts # dbCommand export
│ └── index.ts # Barrel exports all commands
└── index.ts # Main program + addCommand()
Best Practices
- Export Command instances from subcommand modules
- Use
.addCommand()to attach to parent Command - Create barrel exports in
commands/index.ts - One command per file (following single function per module pattern)
- Use descriptive command names that clearly indicate action
- Group related commands in subdirectories
- Provide clear descriptions for all commands
- Use TypeScript interfaces for typed options
- Document examples in help text for complex commands
- Validate arguments in action handlers
Why Modular Commands?
- Testability: Each command can be tested in isolation
- Maintainability: Easy to find and modify specific commands
- Reusability: Commands can be reused across different CLIs
- Separation of concerns: Business logic separated from CLI structure
- Barrel exports: Clean import statements in main program
- Type safety: Better TypeScript inference with exported types
Core Basics
Foundational Commander.js concepts and program setup.
Program Initialization
Basic Setup
import { Command } from 'commander';
const program = new Command();
program
.name('my-cli')
.description('Description of your CLI tool')
.version('1.0.0');
Loading Version from package.json (RECOMMENDED)
Always load version from package.json to keep it synchronized:
// index.ts or main CLI file
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
// ESM - Get package.json path
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJson = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf-8')
);
const program = new Command();
program
.name('my-cli')
.description('Description of your CLI tool')
.version(packageJson.version); // Load from package.json
With Bun (simpler import):
import { Command } from 'commander';
import packageJson from '../package.json' with { type: 'json' };
const program = new Command();
program
.name('my-cli')
.description('My CLI tool')
.version(packageJson.version); // Load from package.json
CommonJS (if not using ESM):
const { Command } = require('commander');
const packageJson = require('../package.json');
const program = new Command();
program
.name('my-cli')
.description('My CLI tool')
.version(packageJson.version);
Benefits:
- Single source of truth for version
- No manual version updates in multiple files
- Automatic sync with
npm versioncommands --versionflag always accurate
Parsing Arguments
Always use parseAsync() for async action handlers:
// Good - async support
await program.parseAsync(process.argv);
// Avoid - no async support
program.parse(process.argv);
Basic Structure
import { Command } from 'commander';
const program = new Command();
program
.name('my-tool')
.description('My CLI tool')
.version('1.0.0')
.option('-d, --debug', 'Enable debug mode')
.option('-c, --config <path>', 'Config file path')
.action((options) => {
console.log('Options:', options);
});
await program.parseAsync(process.argv);
Accessing Parsed Options
// Inside action handler - options passed as parameter
program
.option('-p, --port <number>', 'Port number', '3000')
.action((options) => {
console.log('Port:', options.port); // Camel-cased
});
// Outside action handler - use .opts()
await program.parseAsync(process.argv);
const options = program.opts();
console.log('Options:', options);
Help Text
Commander automatically generates help text:
my-cli --help
Customize help:
program
.addHelpText('before', 'Custom text before help')
.addHelpText('after', 'Custom text after help');
Version Information
program.version('1.0.0');
// Enables --version flag
Custom version flag:
program.version('1.0.0', '-v, --version', 'Display version');
Exit Handling
For testing, override default exit behavior:
program.exitOverride();
try {
await program.parseAsync(['node', 'test', '--invalid']);
} catch (err) {
// Handle error
}
Environment Variables
Access via process.env:
program
.option('-p, --port <number>', 'Port', process.env.PORT || '3000')
.action((options) => {
const port = parseInt(options.port);
});
Usage Pattern
program
.usage('[options] <command>');
Aliases
program
.alias('i'); // npm install -> npm i
Hook Lifecycle
program
.hook('preAction', (thisCommand, actionCommand) => {
console.log('Before action runs');
})
.hook('postAction', (thisCommand, actionCommand) => {
console.log('After action runs');
});
Complete Example
import { Command } from 'commander';
const program = new Command();
program
.name('deploy')
.description('Deploy application to production')
.version('1.0.0')
.option('-e, --env <name>', 'Environment', 'production')
.option('-d, --dry-run', 'Run without making changes')
.option('-v, --verbose', 'Verbose output')
.hook('preAction', () => {
console.log('Starting deployment...');
})
.action(async (options) => {
console.log('Deploying to:', options.env);
if (options.dryRun) {
console.log('Dry run mode - no changes made');
return;
}
// Deployment logic
});
await program.parseAsync(process.argv);
Options & Flags
Complete guide to defining and handling options in Commander.js.
Basic Options
program
.option('-d, --debug', 'Enable debug mode')
.option('-v, --verbose', 'Verbose output');
Options are accessed as camelCase:
.action((options) => {
if (options.debug) { /* ... */ }
if (options.verbose) { /* ... */ }
});
Options with Values
Required Value
program
.option('-p, --port <number>', 'Port number')
.option('-o, --output <path>', 'Output file');
// Usage: my-cli --port 3000 --output dist/
Optional Value
program
.option('-c, --config [path]', 'Config file (optional)');
// Usage: my-cli --config
// or: my-cli --config custom.json
Default Values
program
.option('-p, --port <number>', 'Port', '3000')
.option('-h, --host <address>', 'Host', 'localhost');
Option Types
String (default)
program
.option('-n, --name <value>', 'Name');
Number
import { Option } from 'commander';
program
.addOption(
new Option('-p, --port <number>', 'Port')
.argParser(parseInt)
);
Or with validation:
program
.option('-p, --port <number>', 'Port', (value) => {
const port = parseInt(value, 10);
if (isNaN(port) || port < 0 || port > 65535) {
throw new Error('Invalid port number');
}
return port;
});
Boolean
program
.option('-f, --force', 'Force overwrite')
.option('--no-color', 'Disable colors'); // Negative boolean
Multiple Values (Variadic)
program
.option('-i, --include <paths...>', 'Files to include');
// Usage: my-cli --include file1.js file2.js file3.js
Repeatable Options
program
.option('-v, --verbose', 'Verbose (repeatable)', (_, prev) => prev + 1, 0);
// Usage: my-cli -vvv
// Result: options.verbose === 3
Or collecting values:
program
.option('-e, --exclude <pattern>', 'Exclude pattern',
(value, prev) => prev.concat([value]), []);
// Usage: my-cli -e "*.test.js" -e "*.spec.js"
// Result: options.exclude === ['*.test.js', '*.spec.js']
Required Options
program
.requiredOption('-c, --config <path>', 'Config file (required)');
// Throws error if not provided
Option Conflicts
program
.option('-b, --build', 'Build project')
.option('-w, --watch', 'Watch mode')
.conflictsWith('watch'); // --build and --watch cannot be used together
Option Implies
program
.option('--production', 'Production mode')
.option('--minify', 'Minify output')
.addOption(
new Option('--production')
.implies({ minify: true })
);
Environment Variables as Defaults
program
.option('-p, --port <number>', 'Port', process.env.PORT || '3000')
.option('--api-key <key>', 'API Key', process.env.API_KEY);
Custom Option Processing
program
.option('-d, --date <date>', 'Date', (value) => {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new Error('Invalid date format');
}
return date;
});
Choices (Enum Values)
import { Option } from 'commander';
program
.addOption(
new Option('-l, --log-level <level>', 'Log level')
.choices(['debug', 'info', 'warn', 'error'])
.default('info')
);
Hidden Options
program
.addOption(
new Option('--internal', 'Internal use only')
.hideHelp()
);
Option Presets
import { Option } from 'commander';
program
.addOption(
new Option('-e, --env <name>', 'Environment')
.choices(['dev', 'staging', 'production'])
.default('dev')
.env('NODE_ENV')
);
Complete Example
import { Command, Option } from 'commander';
const program = new Command();
program
.name('build')
.description('Build project')
.requiredOption('-i, --input <path>', 'Input directory')
.option('-o, --output <path>', 'Output directory', 'dist')
.option('-f, --format <type>', 'Output format', (value) => {
const valid = ['esm', 'cjs', 'umd'];
if (!valid.includes(value)) {
throw new Error(`Format must be one of: ${valid.join(', ')}`);
}
return value;
}, 'esm')
.option('-m, --minify', 'Minify output')
.option('-s, --sourcemap', 'Generate sourcemaps')
.option('-w, --watch', 'Watch mode')
.addOption(
new Option('-l, --log-level <level>', 'Log level')
.choices(['debug', 'info', 'warn', 'error'])
.default('info')
)
.option('-e, --exclude <patterns...>', 'Exclude patterns')
.action(async (options) => {
console.log('Building with options:', options);
// Validate combinations
if (options.watch && options.minify) {
console.warn('Warning: minify is slow in watch mode');
}
// Implementation
});
await program.parseAsync(process.argv);
Best Practices
- Use kebab-case for option names:
--output-dirnot--outputDir - Provide defaults for optional options when sensible
- Validate option values in custom parsers
- Use
requiredOptioninstead of validating in action handlers - Use
choices()for enum values - Use descriptive names that clearly indicate purpose
- Group related options together in code
- Document expected formats in descriptions
Best Practices & Patterns
Common patterns, best practices, and anti-patterns for Commander.js applications.
Typed Options Pattern (REQUIRED)
Rule: Always Pass Complete Typed Options to Services
Commands should pass the entire options object (typed) to downstream services, never individual properties.
// ✅ GOOD: Pass complete typed options object
// types/build-options.ts
export interface BuildOptions {
outDir: string;
minify: boolean;
sourcemap: boolean;
watch: boolean;
}
// services/build-service.ts
import type { BuildOptions } from '../types/build-options';
export const buildProject = (options: BuildOptions): void => {
console.log('Building with options:', options);
if (options.watch) {
console.log('Starting watch mode...');
}
// All options available with type safety
};
// commands/build.ts
import { Command } from 'commander';
import { buildProject } from '../services/build-service';
import type { BuildOptions } from '../types/build-options';
export const buildCommand = new Command('build')
.option('-o, --out-dir <path>', 'Output directory', 'dist')
.option('-m, --minify', 'Minify output', false)
.option('-s, --sourcemap', 'Generate sourcemaps', false)
.option('-w, --watch', 'Watch mode', false)
.action((options: BuildOptions) => {
// Pass complete typed object
buildProject(options);
});
Anti-Pattern: Passing Individual Properties
// ❌ BAD: Passing individual options piecemeal
export const buildCommand = new Command('build')
.option('-o, --out-dir <path>', 'Output directory', 'dist')
.option('-m, --minify', 'Minify output', false)
.action((options) => {
// Loses type safety, hard to refactor
buildProject(options.outDir, options.minify);
});
// ❌ BAD: Service doesn't know what options are available
const buildProject = (outDir: string, minify: boolean) => {
// What about sourcemap? watch? Hard to discover
};
Benefits
Type Safety:
- Compile-time validation
- IDE autocomplete for all options
- Refactoring safety
Explicit Dependencies:
- Service signature documents what it needs
- No hidden dependencies
- Easy to test (mock the options object)
Maintainability:
- Add new options without changing service signatures
- Options interface is the single source of truth
Directory Structure for Typed Options
src/
├── types/
│ ├── build-options.ts
│ ├── deploy-options.ts
│ └── index.ts # Barrel export types
├── services/
│ ├── build-service.ts # Receives BuildOptions
│ ├── deploy-service.ts # Receives DeployOptions
│ └── index.ts
├── commands/
│ ├── build.ts # Passes BuildOptions
│ ├── deploy.ts # Passes DeployOptions
│ └── index.ts
└── index.ts
Testing with Typed Options
// build-service.test.ts
import { buildProject } from './build-service';
import type { BuildOptions } from '../types/build-options';
describe('buildProject', () => {
it('should build with minification', () => {
const options: BuildOptions = {
outDir: 'dist',
minify: true,
sourcemap: false,
watch: false,
};
// Easy to test - just pass typed object
buildProject(options);
// Assert expectations
});
});
CLI Design Principles
1. Clear Command Structure
// Good - Clear hierarchy
program
.command('db migrate')
.command('db seed')
.command('db reset')
// Avoid - Ambiguous naming
program
.command('run-migration')
.command('do-seed')
.command('database-clear')
2. Consistent Naming
// Good - Kebab-case for flags
program
.option('--output-dir <path>', 'Output directory')
.option('--dry-run', 'Dry run mode')
// Avoid - Mixed casing
program
.option('--outputDir <path>', 'Output directory')
.option('--dryRun', 'Dry run mode')
3. Sensible Defaults
// Good - Provide defaults
program
.option('-p, --port <number>', 'Port', '3000')
.option('-h, --host <address>', 'Host', 'localhost')
.option('--log-level <level>', 'Log level', 'info')
// Avoid - No defaults for optional options
program
.option('-p, --port <number>', 'Port')
.option('-h, --host <address>', 'Host')
Error Handling Patterns
Validation Errors
program
.command('deploy')
.argument('<env>', 'Environment')
.action((env) => {
const validEnvs = ['dev', 'staging', 'production'];
if (!validEnvs.includes(env)) {
console.error(
`Invalid environment: ${env}\n` +
`Valid values: ${validEnvs.join(', ')}`
);
process.exit(1);
}
deploy(env);
});
Operation Errors
program
.command('build')
.action(async () => {
try {
await build();
console.log('Build successful');
} catch (error) {
console.error('Build failed:', error.message);
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(1);
}
});
User-Friendly Messages
program
.command('connect')
.argument('<url>', 'Server URL')
.action(async (url) => {
try {
await connect(url);
} catch (error) {
if (error.code === 'ENOTFOUND') {
console.error(
`Unable to connect to ${url}\n` +
'Please check:\n' +
' - The URL is correct\n' +
' - You have internet connectivity\n' +
' - The server is running'
);
} else {
console.error('Connection failed:', error.message);
}
process.exit(1);
}
});
Configuration Patterns
Config File Loading
import { promises as fs } from 'fs';
const loadConfig = async (path: string) => {
try {
const content = await fs.readFile(path, 'utf-8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`Config file not found: ${path}`);
} else {
console.error(`Failed to load config: ${error.message}`);
}
process.exit(1);
}
};
program
.option('-c, --config <path>', 'Config file', 'config.json')
.action(async (options) => {
const config = await loadConfig(options.config);
console.log('Loaded config:', config);
});
Environment Variables
program
.option(
'-p, --port <number>',
'Port',
process.env.PORT || '3000'
)
.option(
'--api-key <key>',
'API Key',
process.env.API_KEY
)
.action((options) => {
if (!options.apiKey) {
console.error(
'API key required\n' +
'Provide via --api-key or API_KEY env var'
);
process.exit(1);
}
});
Testing Patterns
Setup for Testing
import { Command } from 'commander';
export const createProgram = (): Command => {
const program = new Command();
program
.name('my-cli')
.version('1.0.0')
.exitOverride(); // Throw instead of exit
return program;
};
Test Examples
import { describe, it, expect } from 'bun:test';
import { createProgram } from './cli';
describe('CLI', () => {
it('should parse options correctly', async () => {
const program = createProgram();
program
.option('-p, --port <number>', 'Port')
.action(() => {});
await program.parseAsync(['node', 'test', '--port', '8080']);
expect(program.opts().port).toBe('8080');
});
it('should handle missing required options', async () => {
const program = createProgram();
program
.requiredOption('-c, --config <path>', 'Config')
.action(() => {});
await expect(
program.parseAsync(['node', 'test'])
).rejects.toThrow();
});
});
Interactive Patterns
Confirmation Prompts
import * as readline from 'readline';
const confirm = (question: string): Promise<boolean> => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${question} (y/n): `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y');
});
});
};
program
.command('delete')
.argument('<file>', 'File to delete')
.option('-f, --force', 'Skip confirmation')
.action(async (file, options) => {
if (!options.force) {
const confirmed = await confirm(
`Delete ${file}?`
);
if (!confirmed) {
console.log('Cancelled');
return;
}
}
await deleteFile(file);
});
Progress Indication
program
.command('download')
.argument('<url>', 'URL to download')
.action(async (url) => {
console.log('Downloading...');
const total = await getContentLength(url);
let downloaded = 0;
const stream = await download(url);
stream.on('data', (chunk) => {
downloaded += chunk.length;
const percent = ((downloaded / total) * 100).toFixed(2);
process.stdout.write(`\rProgress: ${percent}%`);
});
await new Promise((resolve) => stream.on('end', resolve));
console.log('\nDownload complete');
});
Logging Patterns
Verbosity Levels
const createLogger = (verbose: boolean) => ({
debug: (msg: string) => {
if (verbose) console.log('[DEBUG]', msg);
},
info: (msg: string) => {
console.log('[INFO]', msg);
},
error: (msg: string) => {
console.error('[ERROR]', msg);
},
});
program
.option('-v, --verbose', 'Verbose output')
.action((options) => {
const log = createLogger(options.verbose);
log.debug('Starting process');
log.info('Processing...');
log.debug('Step 1 complete');
});
Performance Patterns
Lazy Loading Commands
// commands/build.ts
export const createBuildCommand = async () => {
const { build } = await import('./lib/build');
return new Command('build')
.action(async () => {
await build();
});
};
// index.ts
program
.command('build')
.action(async () => {
const buildCmd = await createBuildCommand();
await buildCmd.parseAsync(process.argv);
});
Version Management
✅ Load Version from package.json
Always load version from package.json to maintain single source of truth:
// index.ts
import { Command } from 'commander';
import packageJson from '../package.json' with { type: 'json' };
const program = new Command();
program
.name('my-cli')
.version(packageJson.version); // Single source of truth
Benefits:
- Automatic sync with
npm versioncommands - No manual version updates in code
- Consistent version across all commands
--versionflag always accurate
Node.js ESM (without import assertions):
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJson = JSON.parse(
readFileSync(join(__dirname, '../package.json'), 'utf-8')
);
program.version(packageJson.version);
❌ Hardcoded Version
// Avoid - requires manual updates
program.version('1.2.3');
// Problem: When you run `npm version patch`, code is out of sync
Anti-Patterns
❌ Global State
// Avoid
let globalConfig = {};
program
.command('build')
.action(() => {
globalConfig = loadConfig();
});
❌ Synchronous parse() with Async Actions
// Avoid
program
.action(async () => {
await doSomething();
});
program.parse(process.argv); // Should be parseAsync()
❌ No Error Handling
// Avoid
program
.command('deploy')
.action(async () => {
await deploy(); // What if this throws?
});
❌ Unclear Error Messages
// Avoid
if (!valid) {
console.error('Error');
process.exit(1);
}
// Prefer
if (!valid) {
console.error(
'Invalid configuration\n' +
'Expected format: { name: string, version: string }'
);
process.exit(1);
}
Complete Example
import { Command } from 'commander';
import { promises as fs } from 'fs';
const program = new Command();
program
.name('file-processor')
.description('Process files with various operations')
.version('1.0.0');
program
.command('convert')
.description('Convert file format')
.argument('<input>', 'Input file')
.argument('[output]', 'Output file')
.option('-f, --format <type>', 'Output format', 'json')
.option('-m, --minify', 'Minify output')
.option('-v, --verbose', 'Verbose output')
.option('--dry-run', 'Simulate without writing')
.action(async (input, output, options) => {
const log = createLogger(options.verbose);
try {
// Validation
log.debug('Validating inputs');
const validFormats = ['json', 'yaml', 'xml'];
if (!validFormats.includes(options.format)) {
throw new Error(
`Invalid format: ${options.format}\n` +
`Valid formats: ${validFormats.join(', ')}`
);
}
// Check input file
try {
await fs.access(input);
} catch {
throw new Error(`Input file not found: ${input}`);
}
// Process
log.info(`Reading ${input}`);
const data = await fs.readFile(input, 'utf-8');
log.debug('Converting format');
let converted = convert(data, options.format);
if (options.minify) {
log.debug('Minifying');
converted = minify(converted);
}
// Output
const outputPath = output || `${input}.${options.format}`;
if (options.dryRun) {
log.info('Dry run - no files written');
console.log(converted);
return;
}
log.info(`Writing to ${outputPath}`);
await fs.writeFile(outputPath, converted);
log.info('Conversion complete');
} catch (error) {
console.error('Error:', error.message);
if (options.verbose) {
console.error(error.stack);
}
process.exit(1);
}
});
await program.parseAsync(process.argv);
// Helpers
const createLogger = (verbose: boolean) => ({
debug: (msg: string) => {
if (verbose) console.log('[DEBUG]', msg);
},
info: (msg: string) => {
console.log('[INFO]', msg);
},
});
const convert = (data: string, format: string): string => {
// Implementation
return data;
};
const minify = (data: string): string => {
// Implementation
return data;
};
TypeScript Integration
Complete guide to using Commander.js with TypeScript for type-safe CLI applications.
Basic Setup
import { Command } from 'commander';
const program = new Command();
program
.name('my-cli')
.description('TypeScript CLI')
.version('1.0.0');
await program.parseAsync(process.argv);
Typed Options
interface BuildOptions {
env: string;
watch: boolean;
minify: boolean;
sourcemap: boolean;
}
program
.command('build')
.option('-e, --env <name>', 'Environment', 'development')
.option('-w, --watch', 'Watch mode', false)
.option('-m, --minify', 'Minify output', false)
.option('-s, --sourcemap', 'Generate sourcemaps', false)
.action((options: BuildOptions) => {
console.log('Environment:', options.env);
if (options.watch) {
console.log('Watch mode enabled');
}
});
Service Layer Pattern with Typed Options (REQUIRED)
Always pass typed options objects to downstream services. Never access options piecemeal.
Full Stack Architecture
types/ → Define option interfaces
services/ → Business logic (receives typed options)
commands/ → Commander wiring (passes typed options to services)
Complete Example
// types/deploy-options.ts
export interface DeployOptions {
env: string;
branch: string;
dryRun: boolean;
force: boolean;
}
// types/index.ts
export type { DeployOptions } from './deploy-options';
// services/deployment-service.ts
import type { DeployOptions } from '../types/deploy-options';
export const deployApplication = (options: DeployOptions): void => {
// Service receives complete typed options object
console.log('Deploying:', {
env: options.env,
branch: options.branch,
dryRun: options.dryRun,
force: options.force,
});
if (options.dryRun) {
console.log('[DRY RUN] Would deploy to:', options.env);
return;
}
// Actual deployment logic
};
// services/index.ts
export { deployApplication } from './deployment-service';
// commands/deploy.ts
import { Command } from 'commander';
import { deployApplication } from '../services/deployment-service';
import type { DeployOptions } from '../types/deploy-options';
export const deployCommand = new Command('deploy')
.description('Deploy application')
.option('-e, --env <name>', 'Environment', 'staging')
.option('-b, --branch <name>', 'Branch', 'main')
.option('-d, --dry-run', 'Dry run', false)
.option('-f, --force', 'Force deployment', false)
.action((options: DeployOptions) => {
// Pass complete typed options to service
deployApplication(options);
});
// commands/index.ts
export { deployCommand } from './deploy';
// index.ts
import { Command } from 'commander';
import { deployCommand } from './commands';
const program = new Command();
program.addCommand(deployCommand);
await program.parseAsync(process.argv);
Why This Pattern?
Type Safety:
- Compile-time validation of option usage
- IDE autocomplete for all options
- Refactoring safety (rename options confidently)
Explicit Dependencies:
- Service signature shows exactly what it needs
- No hidden dependencies on commander context
- Easy to test (mock the options object)
Single Responsibility:
- Commands handle parsing only
- Services handle business logic only
- Types define the contract between them
Anti-Pattern: Direct Access
// ❌ BAD: Accessing options piecemeal
export const deployCommand = new Command('deploy')
.option('-e, --env <name>')
.option('-b, --branch <name>')
.action((options) => {
// Passing individual values - loses type safety
deployApplication(options.env, options.branch);
});
// ❌ BAD: Untyped service signature
const deployApplication = (env: any, branch: any) => {
// What other options are available? Unknown!
};
Correct Pattern: Typed Options Object
// ✅ GOOD: Pass complete typed options
export const deployCommand = new Command('deploy')
.option('-e, --env <name>', 'Environment')
.option('-b, --branch <name>', 'Branch')
.option('-d, --dry-run', 'Dry run', false)
.action((options: DeployOptions) => {
// Pass complete object with all options
deployApplication(options);
});
// ✅ GOOD: Typed service signature
const deployApplication = (options: DeployOptions): void => {
// All available options are explicit and typed
};
Type-Safe Option Parsing (Legacy Pattern - Use Service Layer Instead)
import { Command, OptionValues } from 'commander';
interface DeployOptions extends OptionValues {
env: string;
branch: string;
dryRun: boolean;
force: boolean;
}
const parseOptions = (options: OptionValues): DeployOptions => {
return {
env: options.env as string,
branch: options.branch as string,
dryRun: options.dryRun as boolean,
force: options.force as boolean,
};
};
program
.command('deploy')
.option('-e, --env <name>', 'Environment')
.option('-b, --branch <name>', 'Branch', 'main')
.option('-d, --dry-run', 'Dry run')
.option('-f, --force', 'Force')
.action((opts: OptionValues) => {
const options = parseOptions(opts);
deploy(options);
});
Custom Option Types
interface ServerOptions {
port: number;
host: string;
ssl: boolean;
}
program
.command('serve')
.option('-p, --port <number>', 'Port', (value) => {
const port = parseInt(value, 10);
if (isNaN(port)) {
throw new Error('Port must be a number');
}
return port;
}, 3000)
.option('-h, --host <address>', 'Host', 'localhost')
.option('--ssl', 'Enable SSL', false)
.action((options: ServerOptions) => {
console.log('Starting server:', options);
});
Typed Arguments
program
.command('greet')
.argument('<name>', 'Person to greet')
.argument('[greeting]', 'Greeting message', 'Hello')
.action((name: string, greeting: string) => {
console.log(`${greeting}, ${name}!`);
});
Enum Types with Choices
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogOptions {
level: LogLevel;
format: 'text' | 'json';
}
import { Option } from 'commander';
program
.command('log')
.addOption(
new Option('-l, --level <level>', 'Log level')
.choices(['debug', 'info', 'warn', 'error'] as const)
.default('info')
)
.addOption(
new Option('-f, --format <format>', 'Output format')
.choices(['text', 'json'] as const)
.default('text')
)
.action((options: LogOptions) => {
console.log('Logging at level:', options.level);
});
Type Guards
interface Options extends OptionValues {
config?: string;
env: string;
}
const hasConfig = (options: Options): options is Options & { config: string } => {
return options.config !== undefined;
};
program
.option('-c, --config [path]', 'Config file')
.option('-e, --env <name>', 'Environment', 'development')
.action((opts: OptionValues) => {
const options = opts as Options;
if (hasConfig(options)) {
console.log('Using config:', options.config);
}
console.log('Environment:', options.env);
});
Generic Command Builder
interface CommandConfig<T> {
name: string;
description: string;
options: Record<string, any>;
action: (options: T) => Promise<void> | void;
}
const createTypedCommand = <T>(config: CommandConfig<T>): Command => {
const cmd = new Command(config.name);
cmd.description(config.description);
for (const [key, value] of Object.entries(config.options)) {
cmd.option(key, value);
}
cmd.action(config.action);
return cmd;
};
// Usage
interface BuildOptions {
env: string;
minify: boolean;
}
const buildCommand = createTypedCommand<BuildOptions>({
name: 'build',
description: 'Build project',
options: {
'-e, --env <name>': 'Environment',
'-m, --minify': 'Minify output',
},
action: async (options) => {
console.log('Building:', options);
},
});
program.addCommand(buildCommand);
Zod Validation
import { z } from 'zod';
const BuildOptionsSchema = z.object({
env: z.enum(['development', 'staging', 'production']),
port: z.number().int().min(1).max(65535),
watch: z.boolean(),
verbose: z.boolean().optional(),
});
type BuildOptions = z.infer<typeof BuildOptionsSchema>;
program
.command('build')
.option('-e, --env <name>', 'Environment')
.option('-p, --port <number>', 'Port', parseInt)
.option('-w, --watch', 'Watch mode')
.option('-v, --verbose', 'Verbose output')
.action((opts: unknown) => {
try {
const options = BuildOptionsSchema.parse(opts);
console.log('Valid options:', options);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Invalid options:');
error.errors.forEach(err => {
console.error(` ${err.path.join('.')}: ${err.message}`);
});
process.exit(1);
}
throw error;
}
});
Modular Commands with Types
// types/options.ts
export interface BuildOptions {
env: string;
minify: boolean;
sourcemap: boolean;
}
export interface DeployOptions {
environment: string;
branch: string;
dryRun: boolean;
}
// commands/build.ts
import { Command } from 'commander';
import type { BuildOptions } from '../types/options';
export const createBuildCommand = (): Command => {
return new Command('build')
.description('Build project')
.option('-e, --env <name>', 'Environment', 'development')
.option('-m, --minify', 'Minify output', false)
.option('-s, --sourcemap', 'Generate sourcemaps', false)
.action(async (options: BuildOptions) => {
console.log('Building with options:', options);
});
};
// commands/deploy.ts
import { Command } from 'commander';
import type { DeployOptions } from '../types/options';
export const createDeployCommand = (): Command => {
return new Command('deploy')
.description('Deploy application')
.argument('<environment>', 'Target environment')
.option('-b, --branch <name>', 'Branch', 'main')
.option('-d, --dry-run', 'Dry run', false)
.action(async (environment: string, options: Omit<DeployOptions, 'environment'>) => {
const fullOptions: DeployOptions = { environment, ...options };
console.log('Deploying:', fullOptions);
});
};
// index.ts
import { Command } from 'commander';
import { createBuildCommand } from './commands/build';
import { createDeployCommand } from './commands/deploy';
import packageJson from '../package.json' with { type: 'json' };
const program = new Command();
program
.name('my-cli')
.version(packageJson.version); // Load version from package.json
program.addCommand(createBuildCommand());
program.addCommand(createDeployCommand());
await program.parseAsync(process.argv);
Type-Safe Config Loading
import { z } from 'zod';
import { promises as fs } from 'fs';
const ConfigSchema = z.object({
env: z.string(),
port: z.number(),
database: z.object({
host: z.string(),
port: z.number(),
name: z.string(),
}),
});
type Config = z.infer<typeof ConfigSchema>;
const loadConfig = async (path: string): Promise<Config> => {
const content = await fs.readFile(path, 'utf-8');
const data = JSON.parse(content);
return ConfigSchema.parse(data);
};
program
.command('start')
.option('-c, --config <path>', 'Config file', 'config.json')
.action(async (options: { config: string }) => {
try {
const config = await loadConfig(options.config);
console.log('Starting with config:', config);
} catch (error) {
console.error('Failed to load config:', error.message);
process.exit(1);
}
});
Complete TypeScript Example
import { Command, Option, OptionValues } from 'commander';
import { z } from 'zod';
// Schema definition
const DeployOptionsSchema = z.object({
environment: z.enum(['dev', 'staging', 'production']),
branch: z.string(),
dryRun: z.boolean(),
force: z.boolean(),
verbose: z.boolean().optional(),
});
type DeployOptions = z.infer<typeof DeployOptionsSchema>;
// Command builder
const createDeployCommand = (): Command => {
return new Command('deploy')
.description('Deploy application')
.argument('<environment>', 'Target environment')
.option('-b, --branch <name>', 'Branch to deploy', 'main')
.option('-d, --dry-run', 'Simulate deployment', false)
.option('-f, --force', 'Force deployment', false)
.option('-v, --verbose', 'Verbose output', false)
.action(async (environment: string, opts: OptionValues) => {
try {
const options = DeployOptionsSchema.parse({
environment,
...opts,
});
await deploy(options);
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Invalid options:');
error.errors.forEach(err => {
console.error(` ${err.path.join('.')}: ${err.message}`);
});
process.exit(1);
}
throw error;
}
});
};
// Business logic
const deploy = async (options: DeployOptions): Promise<void> => {
if (options.verbose) {
console.log('Deploy options:', options);
}
if (options.dryRun) {
console.log('Dry run - simulating deployment');
return;
}
console.log(`Deploying ${options.branch} to ${options.environment}`);
// Deployment logic
};
// Program setup
const program = new Command();
program
.name('deploy-cli')
.description('Deployment CLI tool')
.version('1.0.0');
program.addCommand(createDeployCommand());
await program.parseAsync(process.argv);
Best Practices
- Define interfaces for all option types
- Use Zod for runtime validation
- Create type guards for conditional logic
- Use enums for fixed choices
- Modularize commands with typed exports
- Validate early in action handlers
- Use const assertions for choices arrays
- Type action parameters explicitly
- Use generics for reusable command builders
- Separate types into dedicated files