DEV Community

Alex Chen
Alex Chen

Posted on

Building a CLI Tool with Node.js (From Zero to npm)

Building a CLI Tool with Node.js (From Zero to npm)

CLI tools make you more productive. Here's how to build and publish your own.

Why Build a CLI?

I used to do the same 10 steps manually every time I started a new project:
1. Create directory
2. npm init
3. Install dependencies
4. Create folder structure
5. Add .gitignore
6. Set up ESLint
7. Configure TypeScript
8. Add test setup
9. Create README
10. Initialize git

Now: npx my-boilerplate new-project
Done in 3 seconds.
Enter fullscreen mode Exit fullscreen mode

Step 1: Project Setup

mkdir my-cli && cd my-cli
npm init -y

# Install CLI dependencies
npm install commander inquirer chalk ora figlet

# Dev dependencies
npm install -D typescript @types/node tsx
Enter fullscreen mode Exit fullscreen mode
// package.json  CLI-specific fields
{
  "name": "my-awesome-cli",
  "version": "1.0.0",
  "description": "My awesome CLI tool",
  "type": "module",           // Use ESM (modern)
  "bin": {
    "my-cli": "./dist/index.js"
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "keywords": ["cli", "tool"],
  "license": "MIT"
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Parse Arguments with Commander

#!/usr/bin/env node
// src/index.ts

import { Command } from 'commander';
import chalk from 'chalk';
import { createProject } from './commands/create.js';
import { initConfig } from './commands/init.js';

const program = new Command();

program
  .name('my-cli')
  .description('My awesome CLI tool')
  .version('1.0.0');

// Main command: create a new project
program
  .command('create <project-name>')
  .description('Create a new project')
  .option('-t, --template <type>', 'Template to use', 'typescript')
  .option('--no-git', 'Skip git initialization')
  .option('-d, --dest <path>', 'Destination directory', '.')
  .action((name, options) => {
    console.log(chalk.blue(`Creating project: ${name}`));
    console.log(chalk.gray(`Template: ${options.template}`));

    createProject(name, {
      template: options.template,
      git: options.git,
      dest: options.dest,
    });
  });

// Subcommand: initialize config
program
  .command('init')
  .description('Initialize configuration file')
  .option('-f, --force', 'Overwrite existing config')
  .action((options) => {
    initConfig({ force: options.force });
  });

program.parse();
Enter fullscreen mode Exit fullscreen mode

Step 3: Interactive Prompts with Inquirer

// src/commands/create.ts
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs/promises';
import path from 'path';

interface CreateOptions {
  template: string;
  git: boolean;
  dest: string;
}

export async function createProject(
  name: string, 
  options: CreateOptions
) {
  // Ask questions interactively
  const answers = await inquirer.prompt([
    {
      type: 'list',
      name: 'framework',
      message: 'Which framework?',
      choices: ['React', 'Vue', 'Svelte', 'Vanilla JS'],
      default: 'React',
    },
    {
      type: 'checkbox',
      name: 'features',
      message: 'Select features:',
      choices: [
        { name: 'TypeScript', value: 'ts', checked: true },
        { name: 'ESLint', value: 'eslint', checked: true },
        { name: 'Prettier', value: 'prettier' },
        { name: 'Testing (Jest)', value: 'jest' },
        { name: 'Tailwind CSS', value: 'tailwind' },
        { name: 'Docker support', value: 'docker' },
      ],
    },
    {
      type: 'confirm',
      name: 'installDeps',
      message: 'Install dependencies now?',
      default: true,
    },
  ]);

  const spinner = ora('Creating project...').start();

  try {
    const targetDir = path.join(options.dest, name);

    // Create directory
    await fs.mkdir(targetDir, { recursive: true });

    // Generate package.json
    const pkg = generatePackageJson(name, answers);
    await fs.writeFile(
      path.join(targetDir, 'package.json'),
      JSON.stringify(pkg, null, 2)
    );

    // Generate files based on selections
    if (answers.features.includes('ts')) {
      await writeTsConfig(targetDir);
    }
    if (answers.features.includes('eslint')) {
      await writeEslintConfig(targetDir);
    }
    if (answers.features.includes('tailwind')) {
      await writeTailwindConfig(targetDir);
    }

    spinner.succeed(chalk.green('Project created!'));

    console.log('\n' + chalk.bold('Next steps:'));
    console.log(`  cd ${name}`);
    if (answers.installDeps) {
      console.log(`  npm install`);
    }
    console.log(`  npm run dev\n`);

  } catch (error) {
    spinner.fail(chalk.red('Failed to create project'));
    throw error;
  }
}

function generatePackageJson(name: string, answers: any) {
  return {
    name,
    version: '0.1.0',
    private: true,
    scripts: {
      dev: answers.framework === 'Vanilla JS' ? 'serve .' : 'vite',
      build: 'vite build',
      preview: 'vite preview',
    },
    dependencies: {},
    devDependencies: {},
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Beautiful Output

// src/utils/display.ts
import chalk from 'chalk';
import figlet from 'figlet';

export function showBanner() {
  console.log(
    chalk.cyan(figlet.textSync('MY-CLI', { font: 'Standard' }))
  );
  console.log(chalk.gray('The awesome project scaffolder\n'));
}

export function success(message: string) {
  console.log(chalk.green(`✅ ${message}`));
}

export function error(message: string) {
  console.log(chalk.red(`❌ ${message}`));
}

export function warning(message: string) {
  console.log(chalk.yellow(`⚠️  ${message}`));
}

export function info(message: string) {
  console.log(chalk.blue(`ℹ️  ${message}`));
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Error Handling for CLIs

// src/utils/errors.ts
import chalk from 'chalk';

export class CliError extends Error {
  constructor(message: string, public code?: string) {
    super(message);
    this.name = 'CliError';
  }
}

export function handleFatalError(error: unknown): never {
  if (error instanceof CliError) {
    console.error(chalk.red(`\nError (${error.code}): ${error.message}`));
  } else if (error instanceof Error) {
    console.error(chalk.red(`\nUnexpected error: ${error.message}`));
    if (process.env.DEBUG) {
      console.error(error.stack);
    }
  } else {
    console.error(chalk.red('\nUnknown error occurred'));
  }

  process.exit(1);
}

// Wrap main function
process.on('uncaughtException', handleFatalError);
process.on('unhandledRejection', handleFatalError);
Enter fullscreen mode Exit fullscreen mode

Step 6: Make It Executable & Testable

# Link locally for testing (symlinks globally)
npm link

# Now you can run it anywhere!
my-cli create my-app

# When done testing:
npm unlink
Enter fullscreen mode Exit fullscreen mode

Step 7: Publish to npm

# Build
npm run build

# Test locally first
node dist/index.js --help
node dist/index.js create test-project

# Publish!
npm publish --access public

# Users can now run:
npx my-cli create my-app
Enter fullscreen mode Exit fullscreen mode

Pro Tips

# 1. Tab completion (for bash/zsh)
my-cli --completion=bash >> ~/.bashrc
source ~/.bashrc

# 2. Update notification
# Check npm registry on each run, notify if update available

# 3. Config files (~/.my-clirc or .myclirc.yml)
# Let users customize defaults

# 4. Plugins system
# Allow community to extend your CLI

# 5. Telemetry (opt-in!)
# Understand how people use your CLI
# Respect privacy — always opt-in, clearly explain what's collected
Enter fullscreen mode Exit fullscreen mode

Popular CLI Tools for Inspiration

Tool What It Does Stars Key Lesson
create-react-app React scaffolding 90K+ Convention over configuration
Vite Fast dev server/build 70K+ Speed matters
TurboRepo Monorepo management 25K+ DX is everything
Rustup Rust toolchain 5K+ Simple but powerful
GH GitHub official CLI 35K+ Official > third-party

What CLI tools do you use daily? Ever built one yourself?

Follow @armorbreak for more Node.js content.

Top comments (0)