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.