Cli App

Why CLI Apps Matter

Command-line interfaces provide scriptable, automatable tools for developers and power users. Production CLI apps require argument parsing, validation, help text, interactive prompts, progress feedback, and proper error handling.

Core Benefits:

  • Automation: Script workflows, integrate with CI/CD pipelines
  • Developer tools: Build scaffolding, code generation, deployment tools
  • System administration: Manage infrastructure, databases, services
  • Batch processing: Process files, data transformation, ETL pipelines
  • Cross-platform: Node.js CLIs run on Windows, macOS, Linux

Problem: Parsing command-line arguments manually with process.argv is tedious, error-prone, and provides no help text, validation, or user-friendly error messages.

Solution: CLI frameworks (Commander.js, Yargs, Oclif) handle argument parsing, validation, help generation, and provide interactive prompts (Inquirer), progress bars (cli-progress), and colored output (Chalk).

Standard Library First: process.argv

Node.js provides process.argv array containing command-line arguments.

The Manual Parsing Problem

Parsing process.argv requires manual string manipulation and validation.

Basic script (add.ts):

#!/usr/bin/env node
// => Shebang for direct execution (./add.ts)
// => Requires execute permission: chmod +x add.ts

const args = process.argv.slice(2);
// => process.argv: ["node", "add.ts", "arg1", "arg2", ...]
// => Slice(2): Skip "node" and script path
// => args: ["arg1", "arg2", ...]

console.log("Arguments:", args);
// => Print received arguments
// => Debugging: See what was passed

if (args.length < 2) {
  // => Validate argument count
  console.error("Error: Expected 2 numbers");
  console.error("Usage: add <num1> <num2>");
  process.exit(1);
  // => Exit with error code 1 (failure)
  // => Shell scripts can detect failure
}

const num1 = parseFloat(args[0]);
// => Parse first argument as number
// => parseFloat: Handles decimals
const num2 = parseFloat(args[1]);

if (isNaN(num1) || isNaN(num2)) {
  // => Validate parsed numbers
  // => NaN: Not a Number (invalid input)
  console.error("Error: Arguments must be numbers");
  process.exit(1);
}

const result = num1 + num2;
// => Perform calculation
console.log(result);
// => Output result to stdout
// => Can be captured: result=$(./add.ts 5 10)

Usage:

chmod +x add.ts
# => Make script executable
./add.ts 5 10
# => Output: 15

./add.ts abc 10
# => Error: Arguments must be numbers

./add.ts 5
# => Error: Expected 2 numbers

Density: 25 code lines, 32 annotation lines = 1.28 density (within 1.0-2.25 target)

Flags and Options Parsing

Manually parsing flags (–verbose, -v) is complex.

Pattern:

const args = process.argv.slice(2);
// => Raw arguments

const flags: Record<string, boolean> = {};
const positional: string[] = [];
// => Separate flags from positional arguments

for (let i = 0; i < args.length; i++) {
  const arg = args[i];

  if (arg.startsWith("--")) {
    // => Long flag: --verbose
    const flag = arg.slice(2);
    // => Extract flag name ("verbose")
    flags[flag] = true;
  } else if (arg.startsWith("-")) {
    // => Short flag: -v
    const flag = arg.slice(1);
    flags[flag] = true;
  } else {
    // => Positional argument
    positional.push(arg);
  }
}

console.log("Flags:", flags);
// => { verbose: true, v: true }
console.log("Positional:", positional);
// => ["file.txt"]

Usage:

./script.ts --verbose -d file.txt
# => Flags: { verbose: true, d: true }
# => Positional: ["file.txt"]

Density: 21 code lines, 20 annotation lines = 0.95 density (within 1.0-2.25 target)

Limitations of manual parsing for production:

  • No help generation: Must manually write –help text
  • No validation: Must manually validate types, ranges, required options
  • Complex flags: –option=value, combined short flags (-vvv) require custom logic
  • No subcommands: git-style commands (cli deploy prod) require routing logic
  • Error messages: Generic errors, no suggestion for typos
  • No auto-completion: Shell completion requires separate scripts

When manual parsing suffices:

  • Very simple scripts (1-2 positional arguments)
  • Internal tools with known usage patterns
  • Learning exercise to understand CLI fundamentals

Production Framework: Commander.js

Commander.js provides declarative CLI framework with automatic help generation.

Installation and Basic Usage

npm install commander
# => Commander.js: CLI framework
npm install @types/node
# => Node.js type definitions (process, etc.)

Basic CLI (cli.ts):

#!/usr/bin/env node
import { Command } from "commander";
// => Import Commander

const program = new Command();
// => Create program instance
// => Represents CLI application

program
  .name("mycli")
  // => CLI name (shown in help)
  .description("CLI tool for demonstration")
  // => Description (shown in help)
  .version("1.0.0");
// => Version (shown with --version flag)
// => Automatically adds -V, --version flag

program
  .command("add")
  // => Define subcommand
  // => Usage: mycli add <numbers...>
  .description("Add numbers")
  // => Subcommand description
  .argument("<numbers...>", "Numbers to add")
  // => Variadic argument (1+ values)
  // => <numbers...>: Required, multiple values
  .action((numbers: string[]) => {
    // => Action handler for subcommand
    // => numbers: Array of string arguments
    const sum = numbers.map(parseFloat).reduce((a, b) => a + b, 0);
    // => Parse strings to numbers and sum
    console.log(`Sum: ${sum}`);
  });

program
  .command("greet")
  .description("Greet a person")
  .argument("<name>", "Person to greet")
  // => Required argument
  .option("-l, --loud", "Greet loudly", false)
  // => Optional flag
  // => -l, --loud: Short and long form
  // => Default: false
  .action((name: string, options: { loud: boolean }) => {
    // => Action receives argument and options
    const message = `Hello, ${name}!`;
    console.log(options.loud ? message.toUpperCase() : message);
    // => Conditional based on flag
  });

program.parse(process.argv);
// => Parse arguments and execute matching command
// => Automatically handles --help, --version

Usage:

./cli.ts --help
# => Output:
# Usage: mycli [options] [command]
# CLI tool for demonstration
# Options:
#   -V, --version   output the version number
#   -h, --help      display help for command
# Commands:
#   add <numbers...>  Add numbers
#   greet <name>      Greet a person

./cli.ts add 5 10 15
# => Sum: 30

./cli.ts greet Alice --loud
# => HELLO, ALICE!

./cli.ts greet Bob
# => Hello, Bob!

Density: 31 code lines, 42 annotation lines = 1.35 density (within 1.0-2.25 target)

Options with Validation

Commander supports typed options with validation.

Pattern:

import { Command } from "commander";

const program = new Command();

program
  .command("deploy")
  .description("Deploy application")
  .requiredOption("-e, --env <environment>", "Environment (dev/staging/prod)")
  // => Required option (must be provided)
  // => <environment>: Takes value
  .option("-r, --replicas <count>", "Number of replicas", "3")
  // => Optional with default
  // => Default: "3" (string)
  .option("-v, --verbose", "Verbose output", false)
  // => Boolean flag (no value)
  .action((options: { env: string; replicas: string; verbose: boolean }) => {
    // Validate environment
    if (!["dev", "staging", "prod"].includes(options.env)) {
      // => Custom validation
      console.error("Error: Invalid environment (must be dev, staging, or prod)");
      process.exit(1);
    }

    const replicas = parseInt(options.replicas, 10);
    // => Parse string to number

    if (isNaN(replicas) || replicas < 1 || replicas > 10) {
      console.error("Error: Replicas must be between 1 and 10");
      process.exit(1);
    }

    if (options.verbose) {
      console.log(`Deploying to ${options.env} with ${replicas} replicas`);
    }

    // Deployment logic here
    console.log("Deployment started...");
  });

program.parse();

Usage:

./cli.ts deploy --env prod --replicas 5 --verbose
# => Deploying to prod with 5 replicas
# => Deployment started...

./cli.ts deploy
# => Error: required option '-e, --env <environment>' not specified

./cli.ts deploy --env invalid
# => Error: Invalid environment (must be dev, staging, or prod)

Density: 25 code lines, 28 annotation lines = 1.12 density (within 1.0-2.25 target)

Production Framework: Yargs

Yargs provides fluent API with advanced validation and middleware.

Installation and Basic Usage

npm install yargs
npm install @types/yargs
# => Yargs with TypeScript types

Basic Yargs CLI:

#!/usr/bin/env node
import yargs from "yargs/yargs";
// => Import yargs factory
import { hideBin } from "yargs/helpers";
// => hideBin: Remove node and script path from argv

yargs(hideBin(process.argv))
  // => Initialize yargs with cleaned arguments
  .command(
    "serve [port]",
    // => Command with optional argument
    // => [port]: Optional (square brackets)
    "Start the server",
    (yargs) => {
      // => Builder function: Define options
      return yargs.positional("port", {
        // => Configure positional argument
        describe: "Port to bind on",
        default: 3000,
        // => Default value
        type: "number",
        // => Type validation (must be number)
      });
    },
    (argv) => {
      // => Handler function: Execute command
      console.log(`Server starting on port ${argv.port}`);
      // => argv.port: Typed as number
    },
  )
  .option("verbose", {
    alias: "v",
    // => Short alias (-v)
    type: "boolean",
    // => Boolean flag
    description: "Run with verbose logging",
  })
  .demandCommand(1, "You need at least one command")
  // => Require at least 1 command
  // => Shows error if no command provided
  .help()
  // => Add --help flag
  .parse();
// => Parse and execute

Usage:

./cli.ts serve
# => Server starting on port 3000

./cli.ts serve 8080 --verbose
# => Server starting on port 8080
# => (with verbose flag set)

Density: 25 code lines, 28 annotation lines = 1.12 density (within 1.0-2.25 target)

Advanced Validation

Yargs provides rich validation options.

Pattern:

import yargs from "yargs/yargs";
import { hideBin } from "yargs/helpers";

yargs(hideBin(process.argv))
  .command(
    "user create <username>",
    "Create a user",
    (yargs) => {
      return yargs
        .positional("username", {
          type: "string",
          describe: "Username",
        })
        .option("email", {
          type: "string",
          demandOption: true,
          // => Required option (must be provided)
          describe: "User email",
        })
        .option("age", {
          type: "number",
          describe: "User age",
          coerce: (age: number) => {
            // => Custom transformation
            if (age < 0 || age > 150) {
              throw new Error("Age must be between 0 and 150");
              // => Validation error (Yargs shows nice message)
            }
            return age;
          },
        })
        .option("role", {
          type: "string",
          choices: ["admin", "user", "guest"],
          // => Restrict to allowed values
          default: "user",
        });
    },
    (argv) => {
      console.log("Creating user:", argv);
      // => argv: Typed object with all options
    },
  )
  .help()
  .parse();

Usage:

./cli.ts user create alice --email alice@example.com --age 30 --role admin
# => Creating user: { username: 'alice', email: 'alice@example.com', age: 30, role: 'admin' }

./cli.ts user create bob --email bob@example.com --role invalid
# => Error: Invalid values:
#   Argument: role, Given: "invalid", Choices: "admin", "user", "guest"

./cli.ts user create charlie --email charlie@example.com --age 200
# => Error: Age must be between 0 and 150

Density: 26 code lines, 26 annotation lines = 1.00 density (within 1.0-2.25 target)

Production Framework: Oclif

Oclif (Open CLI Framework) provides plugin architecture and TypeScript-first design.

Installation and Project Setup

npx oclif generate mycli
# => Generate new Oclif project
# => Includes TypeScript, testing, CI/CD setup

cd mycli
npm install
# => Install dependencies

Command structure (src/commands/hello.ts):

import { Command, Flags } from "@oclif/core";
// => Import Oclif base classes

export default class Hello extends Command {
  // => Command class (one file per command)
  static description = "Say hello";
  // => Description for help text

  static examples = [
    "<%= config.bin %> <%= command.id %> --name Alice",
    // => Example usage (shown in help)
  ];

  static flags = {
    // => Define flags
    name: Flags.string({
      char: "n",
      // => Short flag: -n
      description: "Name to greet",
      required: true,
      // => Required flag
    }),
    loud: Flags.boolean({
      char: "l",
      description: "Greet loudly",
      default: false,
    }),
  };

  async run(): Promise<void> {
    // => Command execution method
    const { flags } = await this.parse(Hello);
    // => Parse flags with type safety
    // => flags: { name: string, loud: boolean }

    const message = `Hello, ${flags.name}!`;

    if (flags.loud) {
      this.log(message.toUpperCase());
      // => this.log: Oclif logging (supports testing)
    } else {
      this.log(message);
    }
  }
}

Usage:

./bin/run hello --name Alice
# => Hello, Alice!

./bin/run hello --name Bob --loud
# => HELLO, BOB!

./bin/run hello --help
# => Shows help with description, examples, flags

Density: 25 code lines, 28 annotation lines = 1.12 density (within 1.0-2.25 target)

Multi-Command CLI

Oclif supports nested commands with folder structure.

Structure:

src/commands/
├── user/
│   ├── create.ts    # user create
│   ├── delete.ts    # user delete
│   └── list.ts      # user list

src/commands/user/create.ts:

import { Command, Flags } from "@oclif/core";

export default class UserCreate extends Command {
  static description = "Create a new user";

  static flags = {
    email: Flags.string({
      char: "e",
      description: "User email",
      required: true,
    }),
    role: Flags.string({
      char: "r",
      description: "User role",
      options: ["admin", "user", "guest"],
      // => Restrict to choices (like Yargs)
      default: "user",
    }),
  };

  async run(): Promise<void> {
    const { flags } = await this.parse(UserCreate);

    this.log(`Creating user with email: ${flags.email}, role: ${flags.role}`);
    // => Actual user creation logic here
  }
}

Usage:

./bin/run user create --email alice@example.com --role admin
# => Creating user with email: alice@example.com, role: admin

Interactive CLIs: Inquirer

Inquirer provides interactive prompts for user input.

Installation and Basic Usage

npm install inquirer
npm install @types/inquirer
# => Inquirer with TypeScript types

Interactive prompts:

import inquirer from "inquirer";

async function deploy() {
  // => Async function for prompts
  const answers = await inquirer.prompt([
    // => Prompt questions
    {
      type: "list",
      // => Multiple choice (arrow keys to select)
      name: "environment",
      message: "Select deployment environment:",
      choices: ["dev", "staging", "prod"],
      // => Available options
    },
    {
      type: "number",
      // => Number input with validation
      name: "replicas",
      message: "How many replicas?",
      default: 3,
      // => Default value (shown in parentheses)
      validate: (value: number) => {
        // => Custom validation function
        if (value < 1 || value > 10) {
          return "Replicas must be between 1 and 10";
          // => Validation error message
        }
        return true;
        // => Valid input
      },
    },
    {
      type: "confirm",
      // => Yes/no confirmation
      name: "confirmed",
      message: "Are you sure you want to deploy?",
      default: false,
      // => Default: No (safer for destructive operations)
    },
  ]);

  if (!answers.confirmed) {
    // => User chose "No"
    console.log("Deployment cancelled");
    return;
  }

  console.log(`Deploying to ${answers.environment} with ${answers.replicas} replicas`);
  // => Proceed with deployment
}

deploy();

Interactive session:

? Select deployment environment: (Use arrow keys)
❯ dev
  staging
  prod

? How many replicas? (3) 5

? Are you sure you want to deploy? (y/N) y

Deploying to dev with 5 replicas

Density: 31 code lines, 32 annotation lines = 1.03 density (within 1.0-2.25 target)

Password Input

Secure password prompts (masked input).

Pattern:

import inquirer from "inquirer";

async function login() {
  const answers = await inquirer.prompt([
    {
      type: "input",
      // => Text input
      name: "username",
      message: "Username:",
    },
    {
      type: "password",
      // => Masked password input
      name: "password",
      message: "Password:",
      mask: "*",
      // => Show asterisks instead of characters
    },
  ]);

  console.log(`Logging in as ${answers.username}`);
  // => Password not logged (security)
}

Output Formatting: Chalk

Chalk provides colored and styled terminal output.

Installation and Basic Usage

npm install chalk
# => Chalk: Terminal colors (ESM only in v5+)

Colored output:

import chalk from "chalk";

console.log(chalk.green("✓ Success: Deployment complete"));
// => Green text with checkmark
// => Visual feedback for success

console.log(chalk.red("✗ Error: Connection failed"));
// => Red text with X
// => Visual feedback for errors

console.log(chalk.yellow("⚠ Warning: Using development mode"));
// => Yellow text with warning symbol
// => Visual feedback for warnings

console.log(chalk.blue("ℹ Info: Starting server on port 3000"));
// => Blue text with info symbol

// Styled text
console.log(chalk.bold("Important message"));
// => Bold text
console.log(chalk.italic("Emphasized text"));
// => Italic text
console.log(chalk.underline("Underlined text"));
// => Underlined text

// Combined styles
console.log(chalk.bold.red("Critical error"));
// => Bold red text

// Background colors
console.log(chalk.bgGreen.black(" PASS "));
// => Black text on green background
console.log(chalk.bgRed.white(" FAIL "));
// => White text on red background

Density: 17 code lines, 24 annotation lines = 1.41 density (within 1.0-2.25 target)

Progress Feedback: cli-progress

Display progress bars for long-running operations.

Installation and Basic Usage

npm install cli-progress
npm install @types/cli-progress
# => cli-progress with TypeScript types

Progress bar:

import cliProgress from "cli-progress";

async function processFiles(files: string[]) {
  // => Simulate file processing
  const bar = new cliProgress.SingleBar(
    {
      // => Create progress bar
      format: "Progress |{bar}| {percentage}% | {value}/{total} files",
      // => Customize format
      // => {bar}: Progress bar visual
      // => {percentage}: Completion percentage
      // => {value}/{total}: Current/total count
    },
    cliProgress.Presets.shades_classic,
    // => Preset style (shades_classic, rect, legacy)
  );

  bar.start(files.length, 0);
  // => Start progress bar
  // => Total: files.length
  // => Initial value: 0

  for (let i = 0; i < files.length; i++) {
    // => Process each file
    await processFile(files[i]);
    // => Simulate async processing

    bar.update(i + 1);
    // => Update progress
    // => Increment by 1 for each file
  }

  bar.stop();
  // => Stop and clear progress bar
  console.log("Processing complete");
}

async function processFile(file: string): Promise<void> {
  // => Simulate file processing delay
  await new Promise((resolve) => setTimeout(resolve, 100));
}

processFiles(["file1.txt", "file2.txt", "file3.txt"]);

Output:

Progress |████████████░░░░░░░░| 60% | 3/5 files

Density: 26 code lines, 30 annotation lines = 1.15 density (within 1.0-2.25 target)

CLI Framework Progression Diagram

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
%% All colors are color-blind friendly and meet WCAG AA contrast standards

graph TB
    A[process.argv] -->|Declarative API| B[Commander.js]
    A -->|Advanced validation| C[Yargs]
    A -->|Plugin architecture| D[Oclif]

    A:::standard
    B:::framework
    C:::framework
    D:::production

    classDef standard fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef framework fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
    classDef production fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px

    subgraph Standard[" Standard Node.js "]
        A
    end

    subgraph Frameworks[" CLI Frameworks "]
        B
        C
        D
    end

    style Standard fill:#F0F0F0,stroke:#CC78BC,stroke-width:3px
    style Frameworks fill:#F0F0F0,stroke:#029E73,stroke-width:3px

Production Best Practices

Error Handling

Provide helpful error messages and exit codes.

program.command("deploy").action(async (options) => {
  try {
    await deploy(options);
    process.exit(0);
    // => Exit success
  } catch (error) {
    console.error(chalk.red(`Error: ${error.message}`));
    if (options.verbose) {
      console.error(error.stack);
      // => Stack trace in verbose mode
    }
    process.exit(1);
    // => Exit failure (non-zero code)
  }
});

Help Text

Provide examples and descriptions.

program
  .command("deploy")
  .description("Deploy application to cloud")
  .addHelpText(
    "after",
    `
Examples:
  $ mycli deploy --env prod --replicas 5
  $ mycli deploy --env staging
  `,
  );

Configuration Files

Support configuration files (.clirc, config.json).

import fs from "fs/promises";
import path from "path";

async function loadConfig(): Promise<any> {
  const configPath = path.join(process.cwd(), ".clirc.json");

  try {
    const content = await fs.readFile(configPath, "utf-8");
    return JSON.parse(content);
  } catch (error) {
    return {};
    // => Return empty config if file doesn't exist
  }
}

const config = await loadConfig();
// => Merge with CLI arguments (CLI takes precedence)

Trade-offs and When to Use Each

process.argv

Use when:

  • Very simple scripts (1-2 arguments)
  • Learning CLI fundamentals
  • No need for help text or validation

Avoid when:

  • Multiple commands or complex flags
  • User-facing tools (need good UX)
  • Team projects (standardized CLI framework better)

Commander.js

Use when:

  • Standard CLI needs (subcommands, options)
  • Want declarative, readable code
  • Good balance of features vs simplicity

Avoid when:

  • Need advanced validation (Yargs better)
  • Building plugin-based CLI (Oclif better)

Yargs

Use when:

  • Need advanced validation (types, choices, coercion)
  • Complex option parsing
  • Middleware for preprocessing

Avoid when:

  • Simple CLIs (Commander simpler)
  • TypeScript-first design (Oclif better)

Oclif

Use when:

  • Building large, plugin-based CLIs
  • TypeScript-first development
  • Need auto-generated help, testing utilities
  • Professional CLI tools (Heroku, Salesforce use Oclif)

Avoid when:

  • Simple one-off scripts (overhead)
  • Team unfamiliar with Oclif conventions

Common Pitfalls

Pitfall 1: No Exit Codes

Problem: Scripts always exit 0 (success) even on errors.

Solution: Exit with non-zero on errors.

try {
  await run();
  process.exit(0);
} catch (error) {
  console.error(error);
  process.exit(1);
}

Pitfall 2: Unhandled Promises

Problem: Async errors crash process without message.

Solution: Handle promise rejections.

process.on("unhandledRejection", (error) => {
  console.error("Unhandled rejection:", error);
  process.exit(1);
});

Pitfall 3: No Progress Feedback

Problem: Long operations with no feedback (looks frozen).

Solution: Use progress bars or spinners.

import ora from "ora";

const spinner = ora("Processing...").start();
await longOperation();
spinner.succeed("Processing complete");

Summary

CLI applications require argument parsing, validation, help generation, and user feedback. Commander.js provides declarative API, Yargs adds advanced validation, Oclif provides plugin architecture. Inquirer enables interactive prompts, Chalk adds colors, cli-progress shows progress.

Progression path:

  1. Learn with process.argv: Understand CLI fundamentals
  2. Use Commander.js: Standard CLI development
  3. Add Inquirer: Interactive prompts
  4. Use Oclif: Large, plugin-based CLIs

Production checklist:

  • ✅ Argument parsing (framework-based, not manual)
  • ✅ Help text (–help flag with examples)
  • ✅ Validation (types, required options, choices)
  • ✅ Error handling (try/catch, exit codes)
  • ✅ Progress feedback (spinners, progress bars)
  • ✅ Colored output (success green, errors red)
  • ✅ Interactive prompts (confirmations for destructive operations)
  • ✅ Configuration files (.clirc for defaults)

Choose framework based on complexity: Commander.js for standard CLIs, Yargs for advanced validation, Oclif for plugin-based architecture.

Last updated