Small CLI tools are one of the most common things people write in Node.
process.argv gives raw strings. For real flags use the built-in `util.parseArgs`, which handles --name value, --name=value, short aliases and booleans:
- Declare
optionswith atype("string"or"boolean"), optionalshort,defaultandmultiple. - Set
allowPositionals: trueto accept bare arguments. - Unknown flags throw by default (
strict: true), so typos don't pass silently.
For interactive questions, use `node:readline/promises`. Create an interface over stdin/stdout, await rl.question("Name? "), and rl.close() when done — an open interface keeps the process alive.
Tools that work well in pipelines also read stdin: cat data.txt | node count.js. process.stdin is a readable stream, so you can iterate it with for await, or use readline.createInterface({ input: process.stdin }) and for await (const line of rl) to get one line at a time. process.stdin.isTTY tells you whether a human is typing or data is piped in.
For output, keep it simple: console.log for results, console.error for messages. util.styleText("red", "error") adds colour without a dependency and is ignored automatically when output isn't a terminal in recent Node versions; console.table prints arrays of objects nicely.
When a CLI grows subcommands, help text and completion, a library like commander is reasonable, but many tools never need more than parseArgs.
Example
// grep-lite.js — usage: node grep-lite.js --ignore-case error < app.log
import { parseArgs, styleText } from "node:util";
import { createInterface } from "node:readline/promises";
const { values, positionals } = parseArgs({
options: {
"ignore-case": { type: "boolean", short: "i", default: false },
max: { type: "string", default: "100" },
},
allowPositionals: true,
});
const [pattern] = positionals;
if (!pattern) {
console.error("usage: grep-lite [-i] [--max N] <pattern>");
process.exit(2);
}
const re = new RegExp(pattern, values["ignore-case"] ? "i" : "");
let hits = 0;
const rl = createInterface({ input: process.stdin });
for await (const line of rl) {
if (re.test(line) && hits++ < Number(values.max)) console.log(styleText("yellow", line));
}Remember
util.parseArgshandles flags, aliases and positionals without dependenciesreadline/promisesfor questions; close the interface when finishedfor awaitover a readline interface reads piped stdin line by line
Watch out for
- Forgetting
rl.close(), so the process hangs waiting for input.