← Back to articles

How to Build a CLI Tool with Node.js (2026)

CLI tools are everywhere — create-next-app, vercel, turbo. Building one is straightforward with modern Node.js. Here's the complete guide from zero to published npm package.

Project Setup

mkdir my-cli
cd my-cli
npm init -y
// package.json
{
  "name": "my-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "my-cli": "./bin/index.js"
  },
  "files": ["bin", "src"]
}

The Basics — A Simple CLI

#!/usr/bin/env node
// bin/index.js

const args = process.argv.slice(2)
const command = args[0]

switch (command) {
  case 'hello':
    console.log(`Hello, ${args[1] || 'World'}!`)
    break
  case 'version':
    console.log('1.0.0')
    break
  default:
    console.log('Usage: my-cli <command>')
    console.log('Commands: hello, version')
}
chmod +x bin/index.js
node bin/index.js hello Developer
# Hello, Developer!

Better Argument Parsing with Commander

npm install commander
#!/usr/bin/env node
// bin/index.js
import { program } from 'commander'

program
  .name('my-cli')
  .description('A CLI tool for awesome things')
  .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')
  .option('--typescript', 'Use TypeScript', false)
  .action((name, options) => {
    console.log(`Creating project: ${name}`)
    console.log(`Template: ${options.template}`)
    console.log(`TypeScript: ${options.typescript}`)
  })

program
  .command('deploy')
  .description('Deploy the project')
  .option('-e, --env <environment>', 'Target environment', 'production')
  .action((options) => {
    console.log(`Deploying to ${options.env}...`)
  })

program.parse()
my-cli init my-app --template react --typescript
my-cli deploy --env staging
my-cli --help

Interactive Prompts with @inquirer/prompts

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

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

  const template = await select({
    message: 'Choose a template:',
    choices: [
      { name: 'Next.js', value: 'nextjs' },
      { name: 'Remix', value: 'remix' },
      { name: 'Astro', value: 'astro' },
      { name: 'Hono API', value: 'hono' },
    ],
  })

  const features = await checkbox({
    message: 'Select features:',
    choices: [
      { name: 'TypeScript', value: 'typescript', checked: true },
      { name: 'Tailwind CSS', value: 'tailwind' },
      { name: 'ESLint', value: 'eslint' },
      { name: 'Testing', value: 'testing' },
    ],
  })

  const proceed = await confirm({
    message: `Create ${name} with ${template}?`,
  })

  if (proceed) {
    console.log('Creating project...')
    // Create project logic here
  }
}

Colored Output with chalk

npm install chalk
import chalk from 'chalk'

console.log(chalk.green('✓') + ' Project created successfully')
console.log(chalk.red('✗') + ' Build failed')
console.log(chalk.yellow('⚠') + ' Warning: deprecated dependency')
console.log(chalk.blue.bold('→') + ' Next steps:')
console.log(chalk.dim('  Run `npm install` to get started'))

Spinners with ora

npm install ora
import ora from 'ora'

const spinner = ora('Installing dependencies...').start()

try {
  await installDependencies()
  spinner.succeed('Dependencies installed')
} catch (error) {
  spinner.fail('Failed to install dependencies')
}

File System Operations

import { mkdir, writeFile, readFile, cp } from 'fs/promises'
import { join } from 'path'

async function createProject(name, template) {
  const projectDir = join(process.cwd(), name)

  // Create directory
  await mkdir(projectDir, { recursive: true })

  // Copy template files
  const templateDir = join(import.meta.dirname, '..', 'templates', template)
  await cp(templateDir, projectDir, { recursive: true })

  // Update package.json with project name
  const pkgPath = join(projectDir, 'package.json')
  const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
  pkg.name = name
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2))
}

Running Shell Commands

import { execSync, spawn } from 'child_process'

// Synchronous (simple)
execSync('npm install', { cwd: projectDir, stdio: 'inherit' })

// Async with streaming output
function runCommand(cmd, args, cwd) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, { cwd, stdio: 'inherit' })
    child.on('close', (code) => {
      code === 0 ? resolve() : reject(new Error(`Exit code: ${code}`))
    })
  })
}

await runCommand('npm', ['install'], projectDir)

Full Example: Project Scaffolding CLI

#!/usr/bin/env node
import { program } from 'commander'
import { input, select, confirm } from '@inquirer/prompts'
import chalk from 'chalk'
import ora from 'ora'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import { execSync } from 'child_process'

program
  .command('create')
  .description('Create a new project')
  .argument('[name]')
  .action(async (nameArg) => {
    const name = nameArg || await input({ message: 'Project name:' })
    const template = await select({
      message: 'Template:',
      choices: [
        { name: 'Next.js + Tailwind', value: 'nextjs' },
        { name: 'Hono API', value: 'hono' },
      ],
    })

    const dir = join(process.cwd(), name)

    const spinner = ora('Creating project...').start()
    await mkdir(dir, { recursive: true })
    await writeFile(join(dir, 'package.json'), JSON.stringify({
      name, version: '0.1.0', private: true,
    }, null, 2))
    spinner.succeed('Project created')

    const install = await confirm({ message: 'Install dependencies?' })
    if (install) {
      const s = ora('Installing...').start()
      execSync('npm install', { cwd: dir, stdio: 'pipe' })
      s.succeed('Dependencies installed')
    }

    console.log()
    console.log(chalk.green.bold('Done!') + ' Get started:')
    console.log(chalk.dim(`  cd ${name}`))
    console.log(chalk.dim('  npm run dev'))
  })

program.parse()

Publishing to npm

# Login to npm
npm login

# Test locally
npm link
my-cli create  # Should work!

# Publish
npm publish

# Users install with:
npx my-cli create
# or
npm install -g my-cli

FAQ

Should I use TypeScript?

Yes, for larger CLIs. Use tsx for development and tsup for building.

How do I handle errors gracefully?

Wrap main logic in try/catch. Show user-friendly messages. Exit with code 1 on errors.

How do I test a CLI?

Use execa to run your CLI as a subprocess in tests. Assert on stdout and exit codes.

Should I use Commander or yargs?

Commander for most CLIs. yargs if you need complex argument parsing. Both work well.

Bottom Line

Node.js CLI tools in 2026: Commander for argument parsing, @inquirer/prompts for interactivity, chalk for colors, ora for spinners. The whole stack is lightweight and well-maintained. Build, test locally with npm link, publish to npm. Total setup: 30 minutes to a working CLI.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.