Skip to main content
TutorialsTools & Productivity

Building a CLI Tool with Node.js: From Zero to NPM

Create a professional CLI tool with argument parsing, colorful output, progress indicators, and publish it to NPM for the world to use.

Prince Mbeah Essilfie

Prince Mbeah Essilfie

May 30, 2026 at 06:03 AM

9 min read 2186 views
Building a CLI Tool with Node.js: From Zero to NPM

Why Build a CLI?

CLIs are the most underrated way to boost productivity. Instead of remembering complex commands, wrap them in a tool tailored to your workflow.

Project Setup

mkdir my-cli && cd my-cli
pnpm init
pnpm add commander chalk ora
pnpm add -D typescript @types/node tsx
// package.json
{
  "name": "my-cli",
  "version": "1.0.0",
  "bin": {
    "mycli": "./dist/index.js"
  },
  "type": "module"
}

Basic Command Structure

#!/usr/bin/env node
// src/index.ts
import { Command } from 'commander'
import chalk from 'chalk'

const program = new Command()

program
  .name('mycli')
  .description('My awesome CLI tool')
  .version('1.0.0')

program
  .command('init')
  .description('Initialize a new project')
  .argument('[name]', 'Project name', 'my-project')
  .option('-t, --template <template>', 'Template to use', 'default')
  .action(async (name, options) => {
    console.log(chalk.blue(`Creating project: ${name}`))
    console.log(chalk.gray(`Template: ${options.template}`))
    await createProject(name, options.template)
    console.log(chalk.green('✓ Done!'))
  })

program.parse()

Progress Indicators

import ora from 'ora'

async function createProject(name: string, template: string) {
  const spinner = ora('Creating project structure...').start()

  await mkdir(name)
  spinner.text = 'Installing dependencies...'

  await installDeps(name)
  spinner.text = 'Configuring...'

  await configure(name, template)
  spinner.succeed('Project created successfully!')
}
Good CLI UX means: progress feedback, clear error messages, and --help that actually helps. Treat your CLI like a UI.

Interactive Prompts

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

async function interactiveInit() {
  const name = await input({
    message: 'Project name:',
    default: 'my-app',
  })

  const template = await select({
    message: 'Choose a template:',
    choices: [
      { name: 'Minimal', value: 'minimal' },
      { name: 'Full-stack (Nuxt)', value: 'nuxt' },
      { name: 'API only', value: 'api' },
    ],
  })

  const useDocker = await confirm({
    message: 'Add Docker setup?',
    default: true,
  })

  return { name, template, useDocker }
}

Error Handling

program
  .command('deploy')
  .action(async () => {
    try {
      await deploy()
    } catch (error) {
      console.error(chalk.red('✗ Deploy failed:'))
      console.error(chalk.gray((error as Error).message))
      process.exit(1)
    }
  })

Publishing to NPM

# Build
pnpm tsc

# Test locally
npm link
mycli --help

# Publish
npm login
npm publish

Tips

  • Always provide --help and --version
  • Use exit codes (0 = success, 1 = error)
  • Support piping (stdin/stdout)
  • Add shell completions for power users
  • Write a good README with examples

A well-crafted CLI is a gift to your future self and your team.

TypeScriptNode.js
Prince Mbeah Essilfie

Written by Prince Mbeah Essilfie

Fullstack Software Developer

Read next

The Complete Guide to Vue 3 Composables
12 min read

The Complete Guide to Vue 3 Composables

Understand the power of Vue 3's Composition API through practical, real-world composable patterns that you can use today.

Prince Mbeah EssilfiePrince Mbeah Essilfie
1.1K