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.
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
// 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"
}
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();
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: {},
};
}
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}`));
}
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);
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
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
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
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)