Skip to content

Web Development · Developer Tools

Building CLI Tools with TypeScript in 2026: A Practical Guide

Command-line tools are underrated as developer products, and TypeScript makes them maintainable. Parsing, prompts, output, errors, and distribution.

Anurag Verma

Anurag Verma

7 min read

Building CLI Tools with TypeScript in 2026

Sponsored

Share

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:

  1. 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.

  2. Use tsx for development, compile for distribution. tsx skips 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.

Frequently asked questions

commander, yargs, or oclif?
Commander for most things: a small API, minimal dependencies, and it handles subcommands well, which is the shape of most CLIs. Yargs when you have a lot of options with interdependent validation, because its declarative style expresses that more cleanly. Oclif when the CLI is becoming a product rather than a script, since it ships plugins, hooks and auto-updating that you would otherwise build, at the cost of being much heavier.
Why is my Node CLI slow to start?
Almost always because the top of the file imports everything the tool might need, so even running --help loads the entire dependency tree. Move expensive imports inside the action function that actually needs them. Node caches modules, so a command that uses one repeatedly pays the cost once. If startup is a hard requirement after that, the runtime is the limit, and Deno or a Rust hot path with a thin wrapper is the next step.
How should a CLI handle errors?
Distinguish expected failures from crashes. An expected failure, a missing config file, a bad argument, an unreachable API, should print a short message saying what to do about it and exit with a non-zero code. An unexpected one should not dump a raw stack trace at the user; catch at the top level, print something readable, and put the stack behind a --verbose or debug flag for when you need it.
What's the simplest way to distribute an internal tool?
npx against a published package, if it can be public. Add a bin entry to package.json, publish once, and colleagues run it with no install step and always get the current version. For code that cannot be public, a private registry like Verdaccio or GitHub Packages works the same way. A tarball is the fallback, and the cost is that nobody ever updates it.
Do I need a build step at all?
In development, no: tsx runs TypeScript directly and removes the compile-and-run loop. For what you ship, yes. Publishing compiled JavaScript means users are not paying to transpile your source on every invocation, and it removes TypeScript from their runtime requirements entirely. Keeping tsx for local work and tsc for the published artifact gets both.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored