Building CLI Tools with Node.js: A Practical Guide for Developers
You write JavaScript all day. You ship web apps, APIs, and microservices. But how often do you build tools that make your own life easier? CLI tools are one of the most underrated superpowers in a developer’s toolkit. With Node.js, you can turn repetitive tasks into fast, sharable commands — and it’s easier than you think.
This isn’t about theory. This is how to build real, usable CLI tools with Node.js — from zero to publish-ready.
1. Start with #!/usr/bin/env node
Every CLI tool needs a shebang. It tells the shell which interpreter to use. For Node.js, that’s:
#!/usr/bin/env node
Put this at the top of your main script file (e.g., cli.js). Without it, your script won’t run as a command.
Example cli.js:
#!/usr/bin/env node
console.log('Hello from your CLI!');
Now make it executable:
chmod +x cli.js
Test it:
./cli.js
# Output: Hello from your CLI!
This is your foundation. Everything else builds on top.
2. Make It Installable with npm
Want to run mytool instead of ./cli.js? Turn it into an npm package.
Create package.json:
{
"name": "my-cli-tool",
"version": "1.0.0",
"bin": {
"mytool": "./cli.js"
},
"preferGlobal": true
}
The bin field maps the command name (mytool) to your script.
Now install it globally:
npm install -g .
Now run:
mytool
# Output: Hello from your CLI!
Boom. You’ve got a global command.
💡 Pro tip: Use
npm linkduring development. It creates a symlink so you don’t have to reinstall after every change.
3. Parse Arguments Like a Pro
Raw process.argv is messy. Use a lightweight parser like minimist or yargs. I prefer yargs for anything beyond basic flags.
Install:
npm install yargs
Update cli.js:
#!/usr/bin/env node
const yargs = require('yargs');
const argv = yargs
.command('greet <name>', 'Greet someone', (yargs) => {
yargs.positional('name', {
describe: 'The person to greet',
type: 'string'
});
}, (argv) => {
console.log(`Hello, ${argv.name}!`);
})
.help()
.argv;
Now try:
mytool greet Alice
# Output: Hello, Alice!
yargs gives you:
- Auto-generated help (
--help) - Type validation
- Positional and optional args
- Subcommands
It’s worth the few extra KB.
4. Handle Input/Output Gracefully
CLIs should be predictable. Don’t console.log() everything — structure your output.
For example, if your tool returns data, support --json:
// Inside your command handler
if (argv.json) {
console.log(JSON.stringify({ message: `Hello, ${argv.name}!` }));
} else {
console.log(`Hello, ${argv.name}!`);
}
Also, use console.error for errors — keeps stdout clean for piping:
if (!argv.name) {
console.error('Error: name is required');
process.exit(1);
}
And always exit with the right code: 0 for success, 1 (or higher) for failure.
5. Read from stdin When It Makes Sense
Good CLI tools play well with others. Support piping.
Example: let users pipe input into your tool.
#!/usr/bin/env node
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
const chunk = process.stdin.read();
if (chunk !== null) {
input += chunk;
}
});
process.stdin.on('end', () => {
console.log(`You said: ${input.trim().toUpperCase()}`);
});
Now test:
echo "hello world" | mytool
# Output: You said: HELLO WORLD
This turns your tool into a Unix-style filter — composable and powerful.
6. Use Configuration Files (When Needed)
Don’t force users to pass the same flags every time. Support config files.
Use find-up and read-pkg-up or just fs + path to look for .mytoolrc in the current or parent dirs.
Quick example:
const fs = require('fs');
const path = require('path');
function loadConfig() {
const configPath = path.resolve(process.cwd(), '.mytoolrc');
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
}
return {};
}
Let users override defaults without cluttering their commands.
7. Add Autocomplete (Optional but Nice)
yargs supports bash/zsh autocomplete out of the box.
After installing your tool globally, run:
bash
mytool --
---
☕
Top comments (0)