DEV Community

Alex Chen
Alex Chen

Posted on

Building a CLI Tool with Node.js: The Complete Guide

Building a CLI Tool with Node.js: The Complete Guide

Command-line tools are the developer's best friend. Here's how to build your own.

Why Build a CLI?

  • Automate repetitive tasks
  • Share tools with your team
  • Package and distribute via npm
  • Learn Node.js internals

Project Setup

mkdir my-cli && cd my-cli
npm init -y
npm install commander inquirer chalk ora figlet

# package.json changes:
# "name": "my-cli",
# "bin": { "my-cli": "./bin/cli.js" },
# "type": "module" (optional, for ESM)
Enter fullscreen mode Exit fullscreen mode

Basic Structure

my-cli/
├── bin/
│   └── cli.js          # Entry point (shebang + execute)
├── src/
│   ├── commands/        # Each command in its own file
│   │   ├── init.js
│   │   ├── build.js
│   │   └── deploy.js
│   ├── utils/           # Shared utilities
│   │   ├── logger.js
│   │   ├── config.js
│   │   └── helpers.js
│   └── index.js         # Main program setup
├── package.json
└── README.md
Enter fullscreen mode Exit fullscreen mode

The Entry Point (bin/cli.js)

#!/usr/bin/env node

// Shebang above tells OS to run with Node.js

import { program } from 'commander';
import { version } from '../package.json' assert { type: 'json' };

program
  .name('my-cli')
  .description('My awesome CLI tool')
  .version(version)
  .parse();
Enter fullscreen mode Exit fullscreen mode

Adding Commands

// src/commands/init.js
import chalk from 'chalk';
import { execSync } from 'child_process';

export const init = {
  command: 'init [project-name]',
  description: 'Initialize a new project',
  action: (projectName) => {
    if (!projectName) {
      console.log(chalk.red('Error: Please provide a project name'));
      console.log(chalk.cyan('Usage: my-cli init my-project'));
      process.exit(1);
    }

    console.log(chalk.blue(`Creating project: ${projectName}...`));

    // Create directory
    execSync(`mkdir -p ${projectName}`, { stdio: 'inherit' });

    // Create files
    const fs = await import('fs');
    fs.writeFileSync(`${projectName}/package.json`, JSON.stringify({
      name: projectName,
      version: '1.0.0',
      scripts: { start: 'node index.js' }
    }, null, 2));

    fs.writeFileSync(`${projectName}/index.js`, `console.log('Hello from ${projectName}!');\n`);

    console.log(chalk.green('✅ Project created successfully!'));
    console.log(chalk.cyan(`\nNext steps:\n  cd ${projectName}\n  npm install\n  npm start\n`));
  }
};
Enter fullscreen mode Exit fullscreen mode

Interactive Prompts (with Inquirer)

// src/commands/create.js
import inquirer from 'inquirer';
import chalk from 'chalk';

export const create = {
  command: 'create',
  description: 'Create new resource interactively',
  action: async () => {
    console.log(chalk.bold('\n🚀 Create New Resource\n'));

    const answers = await inquirer.prompt([
      {
        type: 'list',
        name: 'type',
        message: 'What do you want to create?',
        choices: ['Component', 'Page', 'API Route', 'Service'],
        filter: input => input.toLowerCase().replace(/\s+/g, '-'),
      },
      {
        type: 'input',
        name: 'name',
        message: 'What is the name?',
        validate: input => input.length > 2 || 'Name must be at least 3 characters',
        filter: input => input.trim(),
      },
      {
        type: 'checkbox',
        name: 'features',
        message: 'Which features?',
        choices: [
          { name: 'TypeScript support', value: 'typescript', checked: true },
          { name: 'Include tests', value: 'tests', checked: true },
          { name: 'Add CSS module', value: 'css-module' },
          { name: 'Include documentation', value: 'docs' },
        ],
      },
      {
        type: 'confirm',
        name: 'installDeps',
        message: 'Install dependencies now?',
        default: true,
      },
    ]);

    // Generate files based on answers
    generateResource(answers.type, answers.name, answers.features);

    if (answers.installDeps) {
      installDependencies(answers.features);
    }

    console.log(chalk.green(`\n✅ ${answers.type} "${answers.name}" created!`));
  }
};
Enter fullscreen mode Exit fullscreen mode

Beautiful Output (Chalk + Ora + Figlet)

import chalk from 'chalk';
import ora from 'ora';
import figlet from 'figlet';

// Colored output
console.log(chalk.red('Error!'));
console.log(chalk.green('Success!'));
console.log(chalk.yellow('Warning'));
console.log(chalk.blue.bold('Info'));
console.log(chalk.cyan.underline('Link'));
console.log(chalk.rgb(123, 45, 67).bgWhite('Custom color'));

// Spinner for async operations
const spinner = ora('Installing dependencies...').start();

try {
  await installDeps();
  spinner.succeed(chalk.green('Dependencies installed!'));
} catch (error) {
  spinner.fail(chalk.red('Installation failed: ' + error.message));
}

// ASCII art banner
figlet('My CLI', (err, data) => {
  if (data) console.log(chalk.cyan(data));
});

// Table display
function displayTable(items) {
  console.table(
    items.map(item => ({
      Name: item.name,
      Status: item.active ? chalk.green('Active') : chalk.gray('Inactive'),
      Size: formatBytes(item.size),
      Modified: item.modifiedAt.toLocaleDateString(),
    }))
  );
}
Enter fullscreen mode Exit fullscreen mode

Configuration Files

// src/utils/config.js
import fs from 'fs';
import path from 'path';
import os from 'os';

const CONFIG_DIR = path.join(os.homedir(), '.my-cli');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');

const DEFAULTS = {
  theme: 'default',
  editor: 'code',
  defaultRegistry: 'npm',
  autoUpdate: true,
};

export function loadConfig() {
  try {
    if (fs.existsSync(CONFIG_FILE)) {
      return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) };
    }
  } catch {}
  return DEFAULTS;
}

export function saveConfig(config) {
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}

// Usage:
// my-cli config set editor vim
// my-cli config get theme
// my-cli config list
Enter fullscreen mode Exit fullscreen mode

Error Handling

// Graceful error handling pattern
class CLIError extends Error {
  constructor(message, code = 1) {
    super(message);
    this.code = code;
  }
}

async function main() {
  try {
    // Your CLI logic here
  } catch (error) {
    if (error instanceof CLIError) {
      console.error(chalk.red(`\n❌ ${error.message}`));
      process.exit(error.code);
    } else {
      console.error(chalk.red('\n💥 Unexpected error:'));
      console.error(error.stack);
      process.exit(1);
    }
  }
}

main();
Enter fullscreen mode Exit fullscreen mode

Publishing to npm

// package.json (final)
{
  "name": "@username/my-cli",
  "version": "1.0.0",
  "description": "My awesome CLI tool",
  "type": "module",
  "bin": {
    "my-cli": "./bin/cli.js"
  },
  "files": ["bin/", "src/"],
  "scripts": {
    "test": "jest"
  },
  "keywords": ["cli", "tool", "scaffold"],
  "license": "MIT",
  "engines": {
    "node": ">=18.0.0"
  }
}
Enter fullscreen mode Exit fullscreen mode
# Test locally first:
npm link                    # Create global symlink
my-cli --help               # Should work globally!

# Publish:
npm publish --access public

# Users can then:
npm install -g @username/my-cli
my-cli --help
Enter fullscreen mode Exit fullscreen mode

Pro Tips

// 1. Update notification (like npm does!)
import pkg from '../package.json' assert { type: 'json' };
import { checkForUpdate } from './utils/update.js';

async function checkVersion() {
  const update = await checkForUpdate(pkg.name, pkg.version);
  if (update) {
    console.log(chalk.yellow(`\n⚠️  Update available: ${pkg.version}${update.version}`));
    console.log(chalk.cyan(`Run: npm update -g ${pkg.name}\n`));
  }
}

// 2. Debug mode
if (process.env.DEBUG) {
  // Enable verbose logging
}

// 3. Auto-completion (for bash/zsh)
program.configureHelp({
  sortSubcommands: true,
  showGlobalOptions: true,
});
Enter fullscreen mode Exit fullscreen mode

Have you built a CLI tool? What's your favorite tip?

Follow @armorbreak for more Node.js content.

Top comments (0)