Writing a Simple Command-Line Tool in Node.js
Most internal CLI tools don't need a framework. A script that renames a batch of files, hits an internal API, or wraps a repetitive git sequence can be a single Node file with a shebang line and a package.json entry — no Commander, no Yargs, no build step. Reach for a framework once you have real subcommands and need generated --help output; until then, it's overhead for a problem you don't have yet.
The minimum viable CLI
Every Node CLI starts the same way: a shebang so the shell knows how to run it, and process.argv for the raw arguments.
#!/usr/bin/env node
const args = process.argv.slice(2);
console.log('You passed:', args);
chmod +x rename-files.js
./rename-files.js --dry-run photos/
# You passed: [ '--dry-run', 'photos/' ]
process.argv always starts with the path to the Node binary and the path to the script itself — .slice(2) drops both and leaves you with what the user actually typed.
Parsing flags without a dependency
For anything past one or two flags, hand-rolling a parser gets tedious fast, but you don't need a full argument-parsing library to do it cleanly. Node's built-in util.parseArgs (stable since Node 20) covers the common cases without adding a dependency:
#!/usr/bin/env node
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
'dry-run': { type: 'boolean', default: false },
output: { type: 'string', short: 'o', default: './out' },
verbose: { type: 'boolean', short: 'v', default: false },
},
allowPositionals: true,
});
console.log(values); // { 'dry-run': true, output: './out', verbose: false }
console.log(positionals); // ['photos/']
./rename-files.js --dry-run -o ./renamed photos/
This gets you named flags, short aliases, defaults, and type coercion (--dry-run becomes a real boolean, not the string "true") with zero external dependencies — worth knowing before reaching for a library out of habit.
Reading files and doing the actual work
A rename tool needs to read a directory and act on each entry. Node's fs/promises API keeps this readable with async/await instead of nested callbacks:
import { readdir, rename } from 'node:fs/promises';
import { join, extname } from 'node:path';
async function renamePhotos(dir, { dryRun }) {
const entries = await readdir(dir);
const photos = entries.filter((name) => ['.jpg', '.png'].includes(extname(name).toLowerCase()));
for (const [index, name] of photos.entries()) {
const ext = extname(name);
const newName = `photo-${String(index + 1).padStart(3, '0')}${ext}`;
const from = join(dir, name);
const to = join(dir, newName);
if (dryRun) {
console.log(`${name} -> ${newName} (dry run)`);
} else {
await rename(from, to);
console.log(`${name} -> ${newName}`);
}
}
}
Note the dry-run branch: for anything that mutates the filesystem, a preview mode that shows exactly what would happen — without doing it — is worth the ten extra lines. It's the difference between a tool people trust running against a real directory and one they only dare run against a backup first.
Giving useful exit codes and errors
A script that always exits 0 is invisible to shell scripting and CI — && chains and if checks depend on the exit code, not on parsing your log output.
async function main() {
const dir = positionals[0];
if (!dir) {
console.error('Error: missing target directory');
console.error('Usage: rename-files.js [--dry-run] [-o <dir>] <directory>');
process.exit(1);
}
try {
await renamePhotos(dir, { dryRun: values['dry-run'] });
} catch (err) {
console.error('Failed:', err.message);
process.exit(1);
}
}
main();
process.exit(1) on both the usage error and the caught exception means rename-files.js photos/ && echo done only prints "done" when the rename actually succeeded — exactly what you want in a pipeline or a pre-commit hook.
Making it installable as a real command
A script that lives in one repo and gets run with node scripts/rename-files.js is fine for a one-off. To make it feel like a real command — runnable as rename-photos from anywhere — add a bin field to package.json:
{
"name": "rename-photos",
"version": "1.0.0",
"type": "module",
"bin": {
"rename-photos": "./rename-files.js"
}
}
npm link
rename-photos --dry-run photos/
npm link creates a symlink into your global node_modules/.bin, so the command becomes available system-wide during development. Published to npm, the same bin field is what makes npx rename-photos or a global npm install -g work for anyone else.
Testing without mocking the filesystem
Because renamePhotos takes a directory path as a parameter rather than hardcoding one, you can point it at a real temporary directory in tests instead of mocking fs:
import { mkdtemp, writeFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, expect } from 'vitest';
test('renames jpg and png files sequentially', async () => {
const dir = await mkdtemp(join(tmpdir(), 'rename-test-'));
await writeFile(join(dir, 'holiday.jpg'), '');
await writeFile(join(dir, 'notes.txt'), '');
await renamePhotos(dir, { dryRun: false });
const files = await readdir(dir);
expect(files).toContain('photo-001.jpg');
expect(files).toContain('notes.txt'); // untouched — not a photo extension
await rm(dir, { recursive: true });
});
A real (temporary) directory catches path-joining and extension-handling bugs that a mocked fs module would happily paper over.
When to actually reach for a framework
Once a tool grows real subcommands (mytool deploy, mytool rollback, mytool status), each with its own flags and help text, hand-rolling that dispatch logic stops paying off — that's the point to bring in Commander or a similar library for the subcommand routing and generated --help. For a single-purpose script, though, parseArgs plus a bin entry gets you a real, shareable command without adding a dependency you'd otherwise have to keep updated.
Takeaway
A useful CLI doesn't require a framework — it requires a shebang line, argument parsing (built into modern Node via parseArgs), meaningful exit codes, and a dry-run mode if it touches the filesystem. Add a bin field when it's time to stop typing node scripts/thing.js and start typing the command name.