MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Building CLI Tools with Node.js

Create professional CLI tools: argument parsing, interactive prompts, colors, and npm publishing.

Node.jsCLIJavaScriptTools

By MinhVo

Introduction

Command-line interfaces remain the backbone of developer workflows. From build tools and linters to deployment scripts and database migrations, CLI tools automate the repetitive tasks that developers perform dozens of times per day. Node.js has become one of the most popular platforms for building CLI tools, thanks to its rich ecosystem, cross-platform compatibility, and the ability to distribute tools instantly via npm.

Building a CLI tool that developers love requires more than just parsing arguments. A professional CLI provides clear help text, colorful output, interactive prompts, progress indicators, sensible error messages, and graceful handling of interrupts. It should feel native to the terminal, respecting conventions like exit codes, stderr for errors, and stdout for data. The difference between a script that "works" and a tool that developers reach for daily is the attention to these details.

In this guide, we will build a professional-grade CLI tool from scratch using Node.js. We will cover argument parsing with Commander.js, interactive prompts with Inquirer.js, colorful output with Chalk, progress indicators with Ora, proper error handling, testing strategies, and publishing to npm. By the end, you will have a complete mental model for building CLI tools that feel polished and production-ready.

Developer working in terminal

Understanding CLI Tools: Core Concepts

Anatomy of a CLI Tool

Every CLI tool follows the same fundamental pattern: the user invokes a command with arguments and options, the tool processes those inputs, performs some action, and produces output. The command is the entry point (e.g., git, npm, docker). Subcommands are verbs that describe actions (git commit, npm install, docker run). Arguments are positional parameters (docker run myimage). Options are named flags that modify behavior (--verbose, --output file.json, -v).

A well-designed CLI follows the Unix philosophy: do one thing well, compose with other tools via pipes, and use text as the universal interface. Your tool should read from stdin when appropriate, write to stdout for data output, and write to stdout for status messages. Exit codes should follow convention: 0 for success, 1 for general errors, and 2 for misuse.

The Node.js CLI Ecosystem

Node.js offers a mature ecosystem of libraries for building CLIs. The most important ones cover argument parsing (Commander.js, Yargs, Meow), interactive prompts (Inquirer.js, Prompts, Enquirer), terminal colors and styling (Chalk, Picocolors, Colorette), progress indicators (Ora, Listr, cli-progress), table formatting (cli-table3, Table), and file system utilities (fs-extra, globby, fast-glob). Each library solves a specific problem well, and combining them creates a professional experience.

Shebang and Entry Points

A Node.js CLI tool needs a shebang line at the top of its entry file so the operating system knows to execute it with Node.js. The standard shebang #!/usr/bin/env node works across Linux, macOS, and modern Windows. The package.json bin field maps command names to entry files, allowing npm to create the appropriate symlinks during installation.

Architecture and Design Patterns

Command Pattern

Organize your CLI around commands, where each command is a self-contained module with its own argument parsing, validation, and execution logic. This pattern scales well as your tool grows:

src/
  commands/
    init.ts      # mytool init
    build.ts     # mytool build
    deploy.ts    # mytool deploy
  utils/
    logger.ts    # Shared logging utilities
    config.ts    # Configuration loading
  index.ts       # Entry point and command registration

Each command module exports a function that receives parsed arguments and options, performs its work, and returns or throws. The entry point registers all commands with the argument parser and delegates execution.

Configuration Layering

Professional CLI tools support configuration from multiple sources, with a clear precedence order. Command-line arguments override environment variables, which override project-level config files, which override user-level config files, which override defaults. This layering gives users flexibility: they can set defaults in their shell profile, override per-project in a config file, and override per-invocation with flags.

Error Handling Strategy

CLI tools should handle errors at three levels: validation errors (bad input, show usage and exit with code 2), runtime errors (operation failed, show error message and exit with code 1), and unexpected errors (bugs, show stack trace in debug mode, friendly message otherwise). Never let an unhandled exception print a raw stack trace to the user.

Step-by-Step Implementation

Project Setup

Initialize the project with TypeScript support and install the essential CLI libraries:

mkdir my-cli-tool && cd my-cli-tool
npm init -y
npm install commander inquirer chalk ora cli-table3
npm install -D typescript @types/node @types/inquirer
npx tsc --init

Configure package.json with the bin field and type module:

{
  "name": "my-cli-tool",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "mytool": "./dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts"
  }
}

Argument Parsing with Commander.js

Commander.js provides a declarative API for defining commands, arguments, and options with automatic help generation:

#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
 
const program = new Command();
 
program
  .name('mytool')
  .description('A professional CLI tool for project scaffolding')
  .version('1.0.0');
 
program
  .command('init')
  .description('Initialize a new project')
  .argument('<name>', 'Project name')
  .option('-t, --template <template>', 'Template to use', 'default')
  .option('--no-git', 'Skip git initialization')
  .option('-v, --verbose', 'Enable verbose output')
  .action(async (name: string, options: {
    template: string;
    git: boolean;
    verbose?: boolean;
  }) => {
    try {
      await initProject(name, options);
    } catch (error: any) {
      console.error(chalk.red(`Error: ${error.message}`));
      process.exit(1);
    }
  });
 
program
  .command('build')
  .description('Build the current project')
  .option('-w, --watch', 'Watch for changes')
  .option('-o, --output <dir>', 'Output directory', 'dist')
  .action(async (options: { watch: boolean; output: string }) => {
    try {
      await buildProject(options);
    } catch (error: any) {
      console.error(chalk.red(`Error: ${error.message}`));
      process.exit(1);
    }
  });
 
program.parse();

Interactive Prompts with Inquirer.js

Interactive prompts guide users through complex workflows step by step. Inquirer.js supports input, confirm, list, checkbox, and password prompt types:

import inquirer from 'inquirer';
 
interface ProjectOptions {
  name: string;
  template: string;
  features: string[];
  packageManager: 'npm' | 'yarn' | 'pnpm';
  gitInit: boolean;
}
 
async function promptForOptions(name: string): Promise<ProjectOptions> {
  const answers = await inquirer.prompt([
    {
      type: 'list',
      name: 'template',
      message: 'Select a project template:',
      choices: [
        { name: 'TypeScript + Express API', value: 'express-ts' },
        { name: 'React + Vite', value: 'react-vite' },
        { name: 'Next.js Full-Stack', value: 'nextjs' },
        { name: 'CLI Tool', value: 'cli' },
      ],
    },
    {
      type: 'checkbox',
      name: 'features',
      message: 'Select additional features:',
      choices: [
        { name: 'ESLint + Prettier', value: 'linting', checked: true },
        { name: 'Docker configuration', value: 'docker' },
        { name: 'CI/CD pipeline', value: 'cicd' },
        { name: 'Testing setup (Vitest)', value: 'testing' },
      ],
    },
    {
      type: 'list',
      name: 'packageManager',
      message: 'Package manager:',
      choices: ['npm', 'yarn', 'pnpm'],
    },
    {
      type: 'confirm',
      name: 'gitInit',
      message: 'Initialize git repository?',
      default: true,
    },
  ]);
 
  return { name, ...answers };
}

Colorful Output and Progress

Use Chalk for colored output and Ora for spinners to create a polished terminal experience:

import chalk from 'chalk';
import ora from 'ora';
 
async function initProject(name: string, options: InitOptions) {
  console.log(chalk.bold.cyan(`\n🚀 Creating project: ${name}\n`));
 
  const spinner = ora('Scaffolding project files...').start();
 
  try {
    await scaffoldFiles(name, options.template);
    spinner.succeed(chalk.green('Project files created'));
 
    if (options.features.includes('linting')) {
      spinner.start('Configuring ESLint + Prettier...');
      await setupLinting(name);
      spinner.succeed(chalk.green('Linting configured'));
    }
 
    if (options.features.includes('docker')) {
      spinner.start('Generating Docker configuration...');
      await setupDocker(name);
      spinner.succeed(chalk.green('Docker configuration created'));
    }
 
    if (options.gitInit !== false) {
      spinner.start('Initializing git repository...');
      await execa('git', ['init'], { cwd: name });
      spinner.succeed(chalk.green('Git repository initialized'));
    }
 
    console.log(chalk.bold.green(`\n✅ Project ${name} created successfully!\n`));
    console.log(chalk.dim('Next steps:'));
    console.log(chalk.dim(`  cd ${name}`));
    console.log(chalk.dim('  npm install'));
    console.log(chalk.dim('  npm run dev\n'));
  } catch (error) {
    spinner.fail(chalk.red('Failed to create project'));
    throw error;
  }
}

Table Output

Display structured data using cli-table3 for comparison views and data summaries:

import Table from 'cli-table3';
 
function displayProjectSummary(project: ProjectInfo) {
  const table = new Table({
    head: [chalk.cyan('Property'), chalk.cyan('Value')],
    colWidths: [25, 50],
  });
 
  table.push(
    ['Name', project.name],
    ['Template', project.template],
    ['Features', project.features.join(', ')],
    ['Package Manager', project.packageManager],
    ['Node Version', project.nodeVersion],
    ['Files Created', project.fileCount.toString()],
    ['Dependencies', project.dependencyCount.toString()],
  );
 
  console.log(table.toString());
}

Terminal CLI tool output with colors and formatting

Real-World Use Cases

Project Scaffolding Tools

Tools like create-react-app, create-next-app, and npm init are the most common type of CLI tool. They ask users a series of questions about their desired setup, generate a project skeleton with all configuration files, install dependencies, and optionally initialize a git repository. The key to a good scaffolding tool is providing sensible defaults while allowing full customization through flags.

Build and Dev Tools

Build tools like Vite, Webpack, and esbuild are primarily CLI tools. They parse configuration files, watch for file changes, transform code, and output optimized bundles. These tools benefit from progress bars (showing build progress), colored error messages (highlighting the exact line with a syntax error), and structured output (showing asset sizes and warnings in a table).

Deployment CLIs

Deployment tools like the Vercel CLI, Netlify CLI, and AWS CLI handle authentication, project linking, build verification, and deployment. They typically use interactive prompts for initial setup (linking to a project, selecting an environment) and then support non-interactive mode for CI/CD pipelines. The best deployment CLIs provide real-time streaming of build logs and deployment status.

Database Migration Tools

Database migration CLIs like Prisma Migrate, Knex CLI, and Flyway manage schema changes across environments. They track which migrations have been applied, generate new migration files from schema diffs, apply migrations in order, and roll back on failure. Interactive prompts help developers confirm destructive operations like dropping columns.

Best Practices for Production

  1. Respect stdout vs stderr convention: Write data output to stdout and status/error messages to stderr. This allows users to pipe your tool's output to other commands (mytool list | grep prod) without status messages polluting the data stream. Use console.log for data and console.error for messages.

  2. Provide a --json flag: For any command that outputs structured data, support --json for machine-readable output. This enables integration with other tools, scripts, and CI/CD pipelines. When --json is active, suppress all non-JSON output including spinners and progress bars.

  3. Use proper exit codes: Exit with 0 on success, 1 on operational failure (e.g., network error, file not found), and 2 on usage error (e.g., missing required argument, invalid option). Shell scripts and CI pipelines depend on these conventions to make decisions.

  4. Handle SIGINT gracefully: When the user presses Ctrl+C, clean up any temporary files, cancel in-progress operations, and exit cleanly. Never print a stack trace on SIGINT. Register a handler with process.on('SIGINT', ...) and perform cleanup before calling process.exit(130) (the conventional exit code for SIGINT).

  5. Support both interactive and non-interactive modes: Detect whether stdin is a TTY using process.stdin.isTTY. If it is, show interactive prompts. If not (piped input, CI environment), require all inputs via flags and arguments. This dual mode makes your tool usable in both human and automated contexts.

  6. Provide --help for every command: Commander.js generates help automatically, but you should enhance it with examples. Add a .addHelpText('after', ...) block to each command showing common usage patterns. Developers often reach for --help before reading documentation.

  7. Cache aggressively: If your tool makes network requests (fetching templates, checking for updates), cache responses with appropriate TTLs. Use envPaths to determine the correct cache directory per platform (~/.cache/mytool on Linux, ~/Library/Caches/mytool on macOS).

  8. Version your config files: Include a version field in your configuration file format. When you release a new version that changes the config schema, provide automatic migration or clear error messages explaining what needs to change.

Common Pitfalls and Solutions

PitfallImpactSolution
Not handling Windows pathsTool breaks on Windows due to path separator differencesUse path.join() and path.resolve() everywhere; never hardcode /
Blocking the event loop with sync I/OCLI feels sluggish, especially with large file operationsUse fs.promises and async/await for all I/O operations
Not validating argumentsCryptic errors when users provide unexpected inputValidate all inputs early with clear error messages; use Zod or Joi for complex validation
Printing too much output by defaultUsers cannot find important information in verbose outputUse a --verbose flag; default output should be concise with key information only
Forgetting to set executable permissionnpx or global install fails with permission errorAdd chmod +x in a prepare script or ensure the bin file has correct permissions
Hardcoding environment-specific valuesTool fails in CI or different OS environmentsUse envPaths, os.homedir(), and process.env for all environment-dependent values

Performance Optimization

CLI tools should start fast. Users invoke CLIs frequently and expect sub-second startup times. The biggest performance bottleneck in Node.js CLIs is module loading. Large dependencies like inquirer and chalk add significant startup time.

Use dynamic import() to load heavy dependencies only when they are needed. If the user runs mytool --help, you do not need to load Inquirer.js. If they run mytool build, you do not need to load the interactive prompt module. This lazy loading pattern can reduce startup time by 50-70%.

// Only load inquirer when actually prompting
async function promptUser() {
  const inquirer = await import('inquirer');
  return inquirer.default.prompt([...]);
}

For the fastest possible startup, consider compiling your TypeScript CLI to a single JavaScript file using esbuild or SWC. This eliminates the need for a runtime TypeScript compiler and reduces the number of files that Node.js must load.

Comparison with Alternatives

FeatureNode.js + CommanderPython + ClickGo + CobraRust + Clap
Startup time~100-200ms~50-100ms~5-10ms~1-5ms
Binary distributionRequires Node.js runtimeRequires Python runtimeSingle binarySingle binary
EcosystemExcellent (npm)Excellent (PyPI)GoodGrowing
Development speedVery fastFastModerateSlower
Cross-platformExcellentGoodExcellentExcellent
Memory usageModerateModerateLowVery low
Best forDev tools, web ecosystemData tools, scriptingInfrastructure, DockerPerformance-critical CLIs

Node.js is the best choice when your tool is part of the JavaScript/TypeScript ecosystem, needs to run in environments where Node.js is already installed, or requires rapid development with a rich library ecosystem. For standalone binary distribution or performance-critical tools, Go or Rust are better choices.

Advanced Patterns

Plugin System

Design your CLI with a plugin architecture that allows third parties to extend it:

interface Plugin {
  name: string;
  commands: CommandDefinition[];
  hooks?: {
    beforeCommand?: (ctx: Context) => Promise<void>;
    afterCommand?: (ctx: Context) => Promise<void>;
  };
}
 
async function loadPlugins(): Promise<Plugin[]> {
  const pluginDirs = await globby(['mytool-plugin-*/package.json'], {
    cwd: path.join(os.homedir(), '.mytool', 'plugins'),
  });
  return Promise.all(pluginDirs.map(dir => import(dir)));
}

Auto-Update Checking

Check for new versions periodically and notify the user without blocking:

import updateNotifier from 'update-notifier';
import pkg from '../package.json' assert { type: 'json' };
 
const notifier = updateNotifier({
  pkg,
  updateCheckInterval: 1000 * 60 * 60 * 24, // Check once per day
});
 
// Shows update notification after command completes
notifier.notify();

Shell Completion

Provide tab completion for bash, zsh, and fish shells:

program
  .command('completion')
  .description('Generate shell completion script')
  .argument('[shell]', 'Shell type (bash, zsh, fish)')
  .action((shell) => {
    const detectedShell = shell || detectShell();
    console.log(program.outputCompletionScript());
  });

Testing Strategies

Unit Testing Commands

Test command logic in isolation by mocking the filesystem and external services:

import { vi, describe, it, expect, beforeEach } from 'vitest';
 
describe('init command', () => {
  beforeEach(() => {
    vi.mock('fs/promises');
    vi.mock('ora');
  });
 
  it('should create project directory', async () => {
    const mkdirSpy = vi.spyOn(fs, 'mkdir');
    await initProject('test-project', { template: 'default', git: false });
    expect(mkdirSpy).toHaveBeenCalledWith('test-project', { recursive: true });
  });
});

Integration Testing with execa

Test the actual CLI binary end-to-end:

import { execa } from 'execa';
 
describe('CLI integration', () => {
  it('should show help text', async () => {
    const { stdout } = await execa('node', ['dist/index.js', '--help']);
    expect(stdout).toContain('Usage:');
    expect(stdout).toContain('Commands:');
  });
 
  it('should exit with code 2 on missing argument', async () => {
    try {
      await execa('node', ['dist/index.js', 'init']);
    } catch (error: any) {
      expect(error.exitCode).toBe(1);
    }
  });
});

Future Outlook

The Node.js CLI ecosystem is evolving toward faster runtimes and simpler tooling. Bun and Deno offer significantly faster startup times than Node.js, making them attractive for CLI tools where every millisecond matters. The node:test runner is reducing the need for external testing frameworks. TypeScript-first CLIs are becoming the norm, with tsx enabling direct TypeScript execution during development.

The trend toward standalone binary distribution (via tools like pkg, nexe, or Bun's --compile flag) is closing the gap with Go and Rust CLIs. Developers can now distribute Node.js CLIs as single executables without requiring users to have Node.js installed.

AI-powered CLI tools are an emerging category. Tools like GitHub Copilot CLI and Warp terminal use LLMs to translate natural language descriptions into shell commands. This trend will expand, with CLI tools increasingly offering conversational interfaces alongside traditional command structures.

Conclusion

Building CLI tools with Node.js is a productive and rewarding endeavor. The key takeaways from this guide are:

  1. Use Commander.js for argument parsing — it provides declarative command definitions, automatic help generation, and a mature API that handles edge cases
  2. Layer your configuration — support flags, environment variables, config files, and defaults with clear precedence
  3. Make output beautiful but parsable — use Chalk for colors in interactive mode, but always support --json for machine consumption
  4. Handle errors at every level — validate inputs early, catch runtime errors gracefully, and never expose stack traces to users
  5. Optimize for startup time — lazy-load heavy dependencies, compile to single files, and keep the hot path lean
  6. Test both unit and integration — mock at the unit level for speed, but always test the actual binary end-to-end

Start with a simple command that does one thing well, then expand your CLI incrementally. The Node.js ecosystem provides all the building blocks you need to create tools that developers reach for every day.