js

How to Build a Professional CLI Tool with TypeScript and Commander.js

Learn how to create a powerful, user-friendly command-line interface using TypeScript, Commander.js, and best UX practices.

How to Build a Professional CLI Tool with TypeScript and Commander.js

I’ve been thinking about command-line tools a lot lately. Every time I reach for a tool to scaffold a project, run a database migration, or deploy code, I’m reminded how much a well-built CLI can transform a workflow. It’s not just about typing commands—it’s about creating an experience that feels intuitive, reliable, and even enjoyable. So I decided to build one properly, and I want to show you how to do the same.

Have you ever wondered why some CLI tools feel effortless while others frustrate you at every step?

Let’s start with the foundation. A good CLI begins with clear structure. Create a new directory and initialize your project.

mkdir my-cli-tool
cd my-cli-tool
npm init -y

Next, install what you’ll need. We’re using TypeScript for type safety, Commander.js for handling commands, and Ora for those smooth loading spinners.

npm install commander inquirer ora chalk boxen update-notifier
npm install --save-dev @types/node typescript ts-node @types/inquirer

Now, configure TypeScript. Create a tsconfig.json file at your project root.

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true
  }
}

How should you organize your files? I prefer a layout that separates commands, utilities, and core logic.

my-cli-tool/
├── src/
│   ├── commands/
│   ├── utils/
│   ├── index.ts
│   └── cli.ts
├── tests/
└── package.json

The entry point is crucial. This is where your CLI comes to life. Create src/cli.ts.

#!/usr/bin/env node

import { Command } from 'commander';
import chalk from 'chalk';
import updateNotifier from 'update-notifier';
import pkg from '../package.json';

const program = new Command();

program
  .name('mycli')
  .description('A professional CLI tool')
  .version(pkg.version);

program.parse(process.argv);

Notice the shebang at the top—#!/usr/bin/env node. This tells the system to run the file with Node.js. Without it, your CLI won’t work when installed globally.

What makes a command useful? It’s not just about parsing arguments. It’s about guiding the user. Let’s build an init command that sets up a new project.

Create src/commands/init.ts.

import { Command } from 'commander';
import inquirer from 'inquirer';
import ora from 'ora';
import chalk from 'chalk';
import fs from 'fs-extra';

export function initCommand(program: Command): void {
  program
    .command('init [project-name]')
    .description('Create a new project')
    .action(async (projectName) => {
      const answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'name',
          message: 'Project name:',
          default: projectName || 'my-project'
        },
        {
          type: 'list',
          name: 'template',
          message: 'Choose a template:',
          choices: ['basic', 'react', 'node-api']
        }
      ]);

      const spinner = ora('Creating project...').start();
      
      // Simulate work
      await new Promise(resolve => setTimeout(resolve, 1000));
      
      spinner.succeed(`Project ${chalk.green(answers.name)} created`);
    });
}

Then register this command in your main CLI file.

import { initCommand } from './commands/init';
initCommand(program);

Now users can run mycli init and get an interactive experience. But what if they want to skip the prompts? Good CLI design offers both interactive and non-interactive paths.

.option('-t, --template <name>', 'Specify template directly')
.action(async (projectName, options) => {
  if (options.template) {
    // Use provided template
  } else {
    // Show interactive prompts
  }
})

Handling errors well separates amateur tools from professional ones. Users should never see a raw stack trace.

try {
  await someOperation();
} catch (error) {
  console.error(chalk.red('Error:'), error.message);
  process.exit(1);
}

What about configuration? Users might want to set defaults. Let’s add a config command.

export function configCommand(program: Command): void {
  program
    .command('config')
    .description('Manage CLI configuration')
    .action(() => {
      const configPath = path.join(os.homedir(), '.myclirc');
      // Read or write configuration
    });
}

Spinners make waiting feel productive. Ora handles this beautifully.

const spinner = ora('Installing dependencies...').start();
try {
  await installDeps();
  spinner.succeed('Dependencies installed');
} catch {
  spinner.fail('Installation failed');
}

Color helps too. Use chalk to highlight important information.

console.log(chalk.blue('Info:'), 'Operation started');
console.log(chalk.green('Success:'), 'File created');
console.log(chalk.yellow('Warning:'), 'This will overwrite data');

How do you make your CLI discoverable? Clear help text is essential. Commander.js generates this automatically from your command definitions.

Testing is non-negotiable. How can you test a CLI? Create a test that runs your commands.

import { exec } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

test('init command works', async () => {
  const { stdout } = await execAsync('node dist/cli.js init --help');
  expect(stdout).toContain('Create a new project');
});

Packaging your CLI for distribution is straightforward. Update your package.json.

{
  "bin": {
    "mycli": "./dist/cli.js"
  },
  "files": ["dist"],
  "prepublishOnly": "npm run build"
}

Users can install it globally with npm install -g my-cli-tool. But what if you update it? Users should know about new versions.

import updateNotifier from 'update-notifier';

const notifier = updateNotifier({ pkg });
if (notifier.update) {
  console.log(`Update available: ${notifier.update.latest}`);
}

Documentation matters. Even the best CLI is useless if people don’t know how to use it. Include examples in your help text.

.command('deploy [environment]')
.description('Deploy to staging or production')
.addExample('mycli deploy staging')
.addExample('mycli deploy production --force')

Building a CLI tool teaches you about user experience in a constrained environment. Every decision—from argument names to error messages—affects how people interact with your tool.

What separates good tools from great ones? Attention to detail. Consistent formatting. Clear feedback. Thoughtful defaults.

I find that the best CLI tools feel like conversations. They ask questions when needed, remember preferences, and explain what they’re doing. They don’t surprise you. They guide you.

Remember that your CLI might be someone’s first interaction with your work. Make it welcoming. Make it helpful. Make it reliable.

Now you have the foundation to build your own CLI tool. Start with a simple command. Add features as you need them. Listen to feedback from users.

What problem will your CLI solve? What workflow will it simplify?

If you found this guide helpful, please share it with others who might benefit. Have you built a CLI tool before? What challenges did you face? Leave a comment below—I’d love to hear about your experiences and answer any questions you might have.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: cli tools,typescript,commander.js,node.js,developer productivity



Similar Posts
Blog Image
Build Event-Driven Microservices with NestJS, RabbitMQ, and Redis: Complete Developer Guide

Learn to build event-driven microservices with NestJS, RabbitMQ & Redis. Complete guide covering architecture, implementation, and best practices for scalable systems.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack applications. Build faster web apps with seamless database operations. Start today!

Blog Image
How to Build a Reliable Offline-First Web App with Workbox and Webpack

Learn how to create fast, offline-capable web apps using Workbox and Webpack for seamless user experiences across all networks.

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Applications in 2024

Learn to integrate Next.js with Prisma ORM for type-safe full-stack development. Build modern web apps with seamless database operations and improved DX.

Blog Image
Next.js Prisma Integration Guide: Build Type-Safe Full-Stack Apps with Modern Database Toolkit

Learn how to integrate Next.js with Prisma ORM for type-safe database operations. Build powerful full-stack apps with seamless data management. Start coding today!

Blog Image
Building Event-Driven Microservices with NestJS, NATS, and MongoDB: Complete Production Guide

Learn to build scalable event-driven microservices using NestJS, NATS, and MongoDB. Master event schemas, distributed transactions, and production deployment strategies.