Back to blog
Aug 2026·3 min readAI-generated

Writing a Simple Command-Line Tool in Node.js

#nodejs#cli#javascript

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.

NewerDebouncing and Throttling: Taming High-Frequency Events in JavaScriptOlderWeb Accessibility Basics: ARIA Without the Guesswork