Web Development · Developer Tools
Building CLI Tools with TypeScript in 2026: A Practical Guide
Command-line tools are underrated as developer products. TypeScript makes them maintainable. Here's the full picture: argument parsing, interactive prompts, output formatting, and distribution.
Anurag Verma
7 min read
Sponsored
The best developer tools are often command-line tools. git, docker, curl, jq — they’re composable, scriptable, fast, and they stay out of the way. If you’re building something that developers will use, a CLI is often more appropriate than a web UI.
TypeScript makes CLI development significantly better than plain JavaScript. Types catch argument mismatches early, your editor gives you autocomplete on CLI options, and the resulting code is easier to maintain when the tool grows beyond what you planned.
This covers the full process: parsing arguments, interactive prompts, formatted output, error handling, and distribution.
Choosing a Foundation
You have three options for argument parsing in 2026. Each occupies a different point on the complexity-vs-power spectrum.
commander.js is the most widely used. Good for tools with subcommands (like git or docker), straightforward API, and minimal dependencies.
yargs has a more declarative style and built-in help generation. Better choice if your tool has many options with complex validation.
@oclif/core (from Salesforce) is a full framework: plugins, hooks, auto-updating, and TypeScript-first. Right for tools that become products (like a company’s own dev CLI). Heavier than commander but ships a lot of infrastructure you’d otherwise build yourself.
For a typical internal tool or open-source CLI, start with commander.
Project Setup
mkdir my-cli && cd my-cli
npm init -y
npm install commander chalk ora inquirer
npm install -D typescript @types/node tsx
npx tsc --init
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Update package.json:
{
"type": "module",
"bin": {
"mycli": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}
Basic Argument Parsing with Commander
// src/index.ts
#!/usr/bin/env node
import { Command } from 'commander';
import { generateCommand } from './commands/generate.js';
import { deployCommand } from './commands/deploy.js';
const program = new Command();
program
.name('mycli')
.description('A CLI for managing your projects')
.version('1.0.0');
program.addCommand(generateCommand);
program.addCommand(deployCommand);
program.parse();
// src/commands/generate.ts
import { Command } from 'commander';
import chalk from 'chalk';
interface GenerateOptions {
type: 'component' | 'page' | 'api';
typescript: boolean;
output: string;
}
export const generateCommand = new Command('generate')
.alias('g')
.description('Generate project files')
.argument('<name>', 'name of the thing to generate')
.option('-t, --type <type>', 'type to generate (component, page, api)', 'component')
.option('--typescript', 'generate TypeScript files', true)
.option('-o, --output <dir>', 'output directory', './src')
.action((name: string, options: GenerateOptions) => {
console.log(chalk.blue(`Generating ${options.type}: ${name}`));
console.log(`Output directory: ${options.output}`);
// ... actual generation logic
});
The typed GenerateOptions interface means TypeScript catches mistakes if you add an option but forget to update the type, or if you try to access a property that doesn’t exist.
Interactive Prompts with Inquirer
For operations where you need user input at runtime, inquirer handles prompts cleanly:
import inquirer from 'inquirer';
import chalk from 'chalk';
interface ProjectConfig {
name: string;
type: 'web' | 'api' | 'library';
features: string[];
useTypeScript: boolean;
packageManager: 'npm' | 'pnpm' | 'yarn';
}
async function promptProjectSetup(): Promise<ProjectConfig> {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Project name:',
validate: (input: string) => {
if (/^[a-z0-9-]+$/.test(input)) return true;
return 'Project name must be lowercase letters, numbers, and hyphens only';
},
},
{
type: 'list',
name: 'type',
message: 'Project type:',
choices: [
{ name: 'Web Application', value: 'web' },
{ name: 'API Server', value: 'api' },
{ name: 'Library / Package', value: 'library' },
],
},
{
type: 'checkbox',
name: 'features',
message: 'Features to include:',
choices: [
{ name: 'Testing (Vitest)', value: 'testing' },
{ name: 'Linting (ESLint)', value: 'linting' },
{ name: 'Formatting (Prettier)', value: 'formatting' },
{ name: 'Docker configuration', value: 'docker' },
],
},
{
type: 'confirm',
name: 'useTypeScript',
message: 'Use TypeScript?',
default: true,
},
{
type: 'list',
name: 'packageManager',
message: 'Package manager:',
choices: ['npm', 'pnpm', 'yarn'],
default: 'pnpm',
},
]);
return answers as ProjectConfig;
}
Inquirer handles arrow key navigation, multi-select, and input validation. The result is a typed object you can pass to your scaffolding logic.
Progress Indicators with Ora
Long-running operations need feedback. ora provides clean spinners:
import ora from 'ora';
import chalk from 'chalk';
async function deployWithProgress(projectName: string) {
const spinner = ora('Preparing deployment...').start();
try {
// Build step
spinner.text = 'Building project...';
await buildProject(projectName);
// Upload step
spinner.text = 'Uploading files...';
await uploadFiles(projectName);
// Verify step
spinner.text = 'Verifying deployment...';
const url = await verifyDeployment(projectName);
spinner.succeed(chalk.green(`Deployed successfully!`));
console.log(chalk.blue(` Live at: ${url}`));
} catch (error) {
spinner.fail(chalk.red('Deployment failed'));
throw error;
}
}
For multi-step operations where you want to show completed steps, not just a single rotating spinner:
import { createSpinner } from 'nanospinner';
const steps = [
{ text: 'Installing dependencies', fn: installDeps },
{ text: 'Running build', fn: runBuild },
{ text: 'Running tests', fn: runTests },
{ text: 'Creating release', fn: createRelease },
];
for (const step of steps) {
const spinner = createSpinner(step.text).start();
try {
await step.fn();
spinner.success({ text: step.text });
} catch (err) {
spinner.error({ text: step.text });
process.exit(1);
}
}
Structured Output for Tables and Lists
When displaying tabular data, don’t use console.log with manual spacing. Use cli-table3 or build a simple formatter:
import Table from 'cli-table3';
import chalk from 'chalk';
interface Deployment {
name: string;
status: 'running' | 'stopped' | 'error';
url: string;
deployedAt: Date;
}
function printDeployments(deployments: Deployment[]) {
const table = new Table({
head: ['Name', 'Status', 'URL', 'Deployed'],
style: { head: ['cyan'] },
});
for (const dep of deployments) {
const statusColored =
dep.status === 'running'
? chalk.green(dep.status)
: dep.status === 'error'
? chalk.red(dep.status)
: chalk.yellow(dep.status);
table.push([
dep.name,
statusColored,
dep.url,
dep.deployedAt.toLocaleDateString(),
]);
}
console.log(table.toString());
}
Error Handling
CLIs crash in ways that confuse users. The goal is showing useful error messages and exiting cleanly.
// src/utils/errors.ts
export class CliError extends Error {
constructor(
message: string,
public readonly exitCode: number = 1,
public readonly hint?: string
) {
super(message);
this.name = 'CliError';
}
}
// src/index.ts
import chalk from 'chalk';
async function main() {
try {
await program.parseAsync();
} catch (error) {
if (error instanceof CliError) {
console.error(chalk.red(`Error: ${error.message}`));
if (error.hint) {
console.error(chalk.gray(` Hint: ${error.hint}`));
}
process.exit(error.exitCode);
}
// Unexpected errors
console.error(chalk.red('Unexpected error:'), error);
process.exit(1);
}
}
main();
Then in your commands:
if (!fs.existsSync(configPath)) {
throw new CliError(
`Config file not found: ${configPath}`,
1,
`Run "mycli init" to create one, or pass --config to specify a different path`
);
}
Reading Config Files
Most non-trivial CLIs need config files. A simple pattern using cosmiconfig (which follows the standard convention of checking package.json, .myapprc, .myapp.config.js, etc.):
import { cosmiconfig } from 'cosmiconfig';
interface MyCliConfig {
output: string;
typescript: boolean;
plugins: string[];
}
const defaultConfig: MyCliConfig = {
output: './dist',
typescript: true,
plugins: [],
};
async function loadConfig(cwd: string): Promise<MyCliConfig> {
const explorer = cosmiconfig('mycli');
const result = await explorer.search(cwd);
if (!result) {
return defaultConfig;
}
return { ...defaultConfig, ...result.config };
}
Distribution
Local development:
npm link # makes 'mycli' available globally from this directory
Publishing to npm:
Add to package.json:
{
"files": ["dist", "README.md"],
"engines": { "node": ">=18" }
}
Then:
npm run build
npm publish
Without npm — npx-runnable: Publish once. Users can run it without installing:
npx mycli generate MyComponent
For internal tools: Publish to a private npm registry (Verdaccio, GitHub Packages) or distribute as a tarball.
A Note on Startup Time
Node.js CLIs can feel slow if they import heavy dependencies at startup. Two tricks help:
-
Lazy imports. Import expensive modules inside the command action function, not at the top of the file. Node.js caches modules, so repeated calls in the same process are fast.
-
Use
tsxfor development, compile for distribution.tsxskips the compilation step during development. For the distributed version, compile to JavaScript so users don’t pay the TypeScript compilation overhead.
The framework matters too. If startup time is a hard requirement, look at Deno (which caches and compiles to native) or writing the hot path in Rust with a thin TypeScript wrapper.
A well-built CLI is one of the most reusable things you can build. It composes with shell scripts, CI pipelines, and other tools in ways a web UI never does. TypeScript makes the developer experience good enough that the initial setup cost pays off quickly.
Sponsored
More from this category
More from Web Development
The JavaScript Temporal API: Finally a Date Object That Works
NestJS in 2026: The Enterprise Node.js Framework Most Teams Overlook
Test-Driven Development With AI Coding Assistants: Does TDD Still Make Sense in 2026?
Sponsored
The dispatch
Working notes from
the studio.
A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored