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