DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Building a CLI Tool in Node.js That People Actually Want to Use

What Makes a Good CLI?

Most developer tools are CLIs. git, docker, npm, gh—all of them. A well-built CLI is invisible: it does what you expect, fails clearly, and stays out of the way.

Here's how to build one that meets that bar.

Setup with TypeScript

mkdir my-cli && cd my-cli
npm init -y
npm install commander chalk ora execa
npm install -D typescript @types/node ts-node tsup
Enter fullscreen mode Exit fullscreen mode
// package.json additions
{
  "bin": {
    "mycli": "./dist/index.js"
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs --dts",
    "dev": "ts-node src/index.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Commander: Argument Parsing

#!/usr/bin/env node
import { Command } from 'commander';
import { deploy } from './commands/deploy';
import { init } from './commands/init';

const program = new Command();

program
  .name('mycli')
  .description('Deploy and manage your applications')
  .version('1.0.0');

program
  .command('init')
  .description('Initialize a new project')
  .option('-t, --template <template>', 'project template', 'default')
  .option('--no-git', 'skip git initialization')
  .action(init);

program
  .command('deploy')
  .description('Deploy to production')
  .argument('<environment>', 'target environment (staging|production)')
  .option('-f, --force', 'skip confirmation prompts')
  .option('--dry-run', 'preview what would be deployed')
  .action(deploy);

program.parseAsync(process.argv);
Enter fullscreen mode Exit fullscreen mode

Output That Doesn't Suck

import chalk from 'chalk';

export const log = {
  info: (msg: string) => console.log(chalk.blue(''), msg),
  success: (msg: string) => console.log(chalk.green(''), msg),
  warning: (msg: string) => console.log(chalk.yellow(''), msg),
  error: (msg: string) => console.error(chalk.red(''), msg),

  // Structured output for scripts
  json: (data: unknown) => {
    if (process.env.CI || !process.stdout.isTTY) {
      console.log(JSON.stringify(data));
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

Spinners for Long Operations

import ora from 'ora';

export async function deploy(environment: string, options: { force: boolean }) {
  const spinner = ora('Connecting to deployment service...').start();

  try {
    spinner.text = 'Building application...';
    await buildApp();

    spinner.text = `Deploying to ${environment}...`;
    const result = await deployToEnvironment(environment);

    spinner.succeed(`Deployed successfully → ${result.url}`);
  } catch (error) {
    spinner.fail(`Deployment failed: ${error.message}`);
    process.exit(1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Interactive Prompts

import { input, confirm, select } from '@inquirer/prompts';

export async function init(options: { template: string; git: boolean }) {
  const projectName = await input({
    message: 'Project name:',
    default: 'my-project',
    validate: (value) => {
      if (!value.match(/^[a-z0-9-]+$/)) return 'Use lowercase letters, numbers, and hyphens only';
      return true;
    },
  });

  const template = await select({
    message: 'Select template:',
    choices: [
      { name: 'SaaS Starter (Next.js + Stripe + Auth)', value: 'saas' },
      { name: 'API Server (Express + Prisma)', value: 'api' },
      { name: 'CLI Tool (Commander + TypeScript)', value: 'cli' },
    ],
  });

  if (!options.force) {
    const confirmed = await confirm({
      message: `Create ${projectName} with ${template} template?`,
    });
    if (!confirmed) process.exit(0);
  }

  // ... create project
}
Enter fullscreen mode Exit fullscreen mode

Running Shell Commands

import { execa } from 'execa';

async function buildApp() {
  const { stdout, stderr, exitCode } = await execa('npm', ['run', 'build'], {
    cwd: process.cwd(),
    stderr: 'pipe',
    stdout: 'pipe',
  });

  if (exitCode !== 0) {
    throw new Error(`Build failed:\n${stderr}`);
  }

  return stdout;
}

// Stream output in real-time
async function streamBuild() {
  const proc = execa('npm', ['run', 'build']);
  proc.stdout?.pipe(process.stdout);
  proc.stderr?.pipe(process.stderr);
  await proc;
}
Enter fullscreen mode Exit fullscreen mode

Config Files

import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';

interface Config {
  apiUrl: string;
  token?: string;
  defaultEnvironment: string;
}

const CONFIG_PATH = join(process.env.HOME!, '.myclirc');

export function readConfig(): Partial<Config> {
  if (!existsSync(CONFIG_PATH)) return {};
  return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
}

export function writeConfig(config: Partial<Config>) {
  const existing = readConfig();
  writeFileSync(CONFIG_PATH, JSON.stringify({ ...existing, ...config }, null, 2));
}

// Usage
mycli config set token sk-abc123
mycli deploy production  // uses token from config
Enter fullscreen mode Exit fullscreen mode

Error Handling

// Top-level error handler
process.on('unhandledRejection', (error: Error) => {
  log.error(error.message);
  if (process.env.DEBUG) console.error(error.stack);
  process.exit(1);
});

// User-friendly errors
class CLIError extends Error {
  constructor(message: string, public exitCode = 1) {
    super(message);
  }
}

throw new CLIError('Authentication failed. Run `mycli login` first.');
Enter fullscreen mode Exit fullscreen mode

Publishing to npm

# Build
npm run build

# Test locally
npm link
mycli --version

# Publish
npm publish --access public

# Users install with:
npm install -g mycli
Enter fullscreen mode Exit fullscreen mode

The difference between a CLI people use and one they ignore is usually three things: fast startup, clear errors, and good help text. Nail those and you're 80% of the way there.


Need a complete CLI + SaaS backend? The Whoff Agents Ship Fast Skill Pack includes CLI patterns, API templates, and deployment scripts.

Top comments (0)