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)
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
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();
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`));
}
};
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!`));
}
};
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(),
}))
);
}
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
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();
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"
}
}
# 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
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,
});
Have you built a CLI tool? What's your favorite tip?
Follow @armorbreak for more Node.js content.
Top comments (0)