AV
HomeAboutProjectBlog

© 2026 Ave syah Shina. All rights reserved.

  1. Home
  2. Blog
  3. 07 — Command Line Applications — Talking to the Terminal

07 — Command Line Applications — Talking to the Terminal

August 13, 20267 min read
Download as Markdown

"console.log and read the args" was my CLI model, and it stopped at the surface. The model that finally stuck: a CLI is just a Node process with three streams attached — standard input, standard output, standard error — and every CLI library is sugar over reading and writing those streams. Once I saw the streams underneath, the libraries stopped feeling like magic [1].

The framing that landed for me is the streams-first view, then the libraries built on top.

The process object: argv, env, and exit codes

Every Node CLI starts with the process global — the running OS process itself. Three properties on it cover most of what a CLI needs.

process.argv is an array of the command-line arguments [2]. The first element is the path to the Node binary, the second is the path to the script, and the rest are the arguments the user typed:

node mycli.js --port 3000 users
// process.argv === ['/usr/bin/node', '/path/mycli.js', '--port', '3000', 'users']
const args = process.argv.slice(2); // ['--port', '3000', 'users']

Parsing that array by hand is the lowest-level option — fine for two flags, painful for ten. The libraries exist precisely to lift this work up.

process.env is an object holding every environment variable visible to the process [3]. This is where ports, API keys, and database URLs live — values that change between environments and must not be hardcoded. process.env.PORT, process.env.DATABASE_URL, process.env.API_KEY is the canonical pattern. A .env file paired with the dotenv package loads these at startup in development, so the same process.env.X reads work locally and in production.

Exit codes are how a CLI signals success or failure to the shell that launched it [4]. An exit code of 0 means success; any non-zero code means an error. The shell (&&, scripts, CI) checks this number to decide whether to continue. process.exit(1) exits immediately with a failure code; setting process.exitCode = 1 lets the process finish naturally but still exit non-zero.

stdin, stdout, stderr: the three streams

A process has three standard streams inherited from the terminal, and they are the actual substrate of every CLI:

  • stdout — where normal output goes. console.log is sugar over process.stdout.write with a newline and formatting [5]. For raw control (writing without a newline, or piping binary), process.stdout.write is the direct call.
  • stderr — where diagnostic output goes. console.error writes here. The point of separating them: a user can pipe stdout into a file (mycli > out.txt) while still seeing errors on screen, because stderr is not redirected by >.
  • stdin — where input comes from, typically the keyboard [6]. process.stdin is a Readable stream; the readline module wraps it into a line-by-line interface for interactive prompts.
keyboard terminal input Node process process.stdin .stdout .stderr stdout console.log · redirectable with > stderr console.error · survives > stdin mycli > out.txt ▸ stdout goes to file, stderr still shows on screen

Inquirer and prompts: rich interactive input

For a CLI that needs multi-step, multi-type input — pick from a list, confirm a dangerous action, type a password — reading process.stdin by hand is grim. Inquirer.js is the standard library for this, supporting text, password, list, checkbox, and confirm prompt types [7]. prompts is a lighter alternative with a similar API [8].

import inquirer from 'inquirer';

const answers = await inquirer.prompt([
{ type: 'input', name: 'name', message: 'Project name?' },
{ type: 'confirm', name: 'ts', message: 'Use TypeScript?', default: true },
{ type: 'list', name: 'pkg', message: 'Package manager?', choices: ['npm', 'pnpm', 'yarn'] }
]);
// answers.name, answers.ts, answers.pkg

The library renders the prompt, handles keyboard navigation, validates the input, and resolves the promise with an answers object. This is what makes scaffolders like create-next-app feel polished — they are Inquirer (or a sibling) driving stdin/stdout under the hood.

Commander.js: parsing flags and routing subcommands

The other half of a real CLI is argument parsing — turning mycli users add --admin ave into a routed function call. Commander.js is the de-facto standard, giving me option parsing, subcommands, help text, and type coercion for free [9].

import { program } from 'commander';

program
.name('mycli')
.description('A small example CLI')
.version('1.0.0');

program
.command('users <name>')
.option('--admin', 'make the user an admin')
.action((name, opts) => {
console.log(`adding ${name}${opts.admin ? ' as admin' : ''}`);
});

program.parse();

That gives me mycli --help, mycli users ave, mycli users ave --admin, all routed correctly. For anything beyond two flags, Commander pays for itself within the first command.

chalk, figlet, cli-progress: making output readable

The remaining libraries in this layer are about presentation. chalk styles terminal output — colors, bold, underline — so errors read red and success reads green across different terminals [10]. figlet renders text as large ASCII art, the stylized app name on a CLI's startup banner. cli-progress draws progress bars for long operations like downloads or migrations. None of these change behavior; they all write to stdout, just with ANSI escape codes that terminals interpret as styling. The discipline: styling is decoration on the streams, never a substitute for clear exit codes and good stderr messages.

How I use this

The model I keep is "this is a process with three streams." A few habits fall out. I keep stdout and stderr separate — normal output goes to stdout so it can be piped, diagnostics and errors go to stderr so they survive a redirect. I set a non-zero process.exitCode on failure rather than calling process.exit() abruptly, because the latter can truncate in-flight stdout writes. For anything beyond a single flag, I reach for Commander on the input side and Inquirer (or prompts) for interactive collection. And I treat process.env as the configuration contract — every environment-sensitive value reads from it, never hardcoded. The streams-first view is what makes the libraries legible.

References

[1] Okta Developer, "Build a Command Line Application with Node.js," 2019. [Online]. Available: https://developer.okta.com/blog/2019/06/18/command-line-app-with-nodejs

[2] OpenJS Foundation, "process.argv," Node.js API Docs. [Online]. Available: https://nodejs.org/docs/latest/api/process.html#processargv

[3] OpenJS Foundation, "How to read environment variables from Node.js," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/command-line/how-to-read-environment-variables-from-nodejs

[4] OpenJS Foundation, "Process: Event 'exit'," Node.js API Docs. [Online]. Available: https://nodejs.org/api/process.html#event-exit

[5] OpenJS Foundation, "process.stdout," Node.js API Docs. [Online]. Available: https://nodejs.org/api/process.html#processstdout

[6] OpenJS Foundation, "Accept input from the command line in Node.js," nodejs.org. [Online]. Available: https://nodejs.org/en/learn/command-line/accept-input-from-the-command-line-in-nodejs

[7] S. Boudrias, "Inquirer.js," GitHub. [Online]. Available: https://github.com/SBoudrias/Inquirer.js#readme

[8] "prompts," npm. [Online]. Available: https://www.npmjs.com/package/prompts

[9] "commander," npm. [Online]. Available: https://www.npmjs.com/package/commander

[10] "chalk," GitHub. [Online]. Available: https://github.com/chalk/chalk#readme

Knowledge check · Question 1 of 5

In process.argv, the user-supplied arguments start at which index?

Comments

Leave a Comment

You must be signed in to comment

0 Comments

No comments yet. Be the first to comment!