---
title: "07 — Command Line Applications — Talking to the Terminal"
uid: cli-applications
tags: ["process", "commander", "prompts", "inquirer", "nodejs", "argv", "roadmap:nodejs", "cli"]
excerpt: "A CLI is a Node process with three streams — stdin, stdout, stderr — and every CLI library is sugar over reading and writing those streams."
date: 2026-08-13T03:27:56+0000
source: https://www.aveshina.my.id/en/blog/cli-applications
---

"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.

```figure
<svg viewBox="0 0 740 180" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="The three standard streams of a CLI process. A Node process box in the middle. stdin flows in from the left (keyboard). stdout flows out to the upper right (normal output, redirectable with >). stderr flows out to the lower right (diagnostics, not redirected by >).">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">
    <defs>
      <marker id="carrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
        <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
      </marker>
    </defs>

    <!-- stdin source -->
    <rect x="30" y="60" width="120" height="48" rx="8" fill="#fce7f3" stroke="#db2777" stroke-width="1.5"/>
    <text x="90" y="82" font-size="11" font-weight="700" fill="#500724" text-anchor="middle">keyboard</text>
    <text x="90" y="98" font-size="9" fill="#500724" text-anchor="middle">terminal input</text>

    <!-- process -->
    <rect x="280" y="50" width="160" height="68" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="360" y="76" font-size="12" font-weight="700" fill="#1e1b4b" text-anchor="middle">Node process</text>
    <text x="360" y="94" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b" text-anchor="middle">process.stdin</text>
    <text x="360" y="108" font-size="10" font-family="ui-monospace, monospace" fill="#1e1b4b" text-anchor="middle">.stdout  .stderr</text>

    <!-- stdout dest -->
    <rect x="570" y="26" width="140" height="40" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="640" y="44" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">stdout</text>
    <text x="640" y="58" font-size="9" fill="#052e16" text-anchor="middle">console.log · redirectable with &gt;</text>

    <!-- stderr dest -->
    <rect x="570" y="100" width="140" height="40" rx="8" fill="#fee2e2" stroke="#dc2626" stroke-width="1.5"/>
    <text x="640" y="118" font-size="11" font-weight="700" fill="#7f1d1d" text-anchor="middle">stderr</text>
    <text x="640" y="132" font-size="9" fill="#7f1d1d" text-anchor="middle">console.error · survives &gt;</text>

    <!-- arrows -->
    <line x1="150" y1="84" x2="278" y2="84" stroke="#64748b" stroke-width="1.5" marker-end="url(#carrow)"/>
    <text x="214" y="76" font-size="9" font-style="italic" fill="#64748b" text-anchor="middle">stdin</text>
    <path d="M440,70 C500,70 520,50 568,46" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#carrow)"/>
    <path d="M440,100 C500,100 520,120 568,124" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#carrow)"/>

    <text x="360" y="158" font-size="9" font-style="italic" fill="#64748b" text-anchor="middle">mycli &gt; out.txt  ▸  stdout goes to file, stderr still shows on screen</text>
  </g>
</svg>
```

## 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](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](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](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](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](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](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](https://github.com/SBoudrias/Inquirer.js#readme)

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

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

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

```quiz
Q: In process.argv, the user-supplied arguments start at which index?
- 0
- 2
correct: 1
explain: Index 0 is the path to the node binary, index 1 is the path to the script file, and the user's arguments begin at index 2. Slice(2) is the standard way to get just the user args.

Q: Why separate stdout and stderr?
- stdout is faster than stderr
- stdout carries normal output (piped with >); stderr carries diagnostics so they survive a redirect
correct: 1
explain: A user can redirect stdout to a file while still seeing errors on screen because stderr is a separate stream. console.log writes to stdout, console.error to stderr.

Q: An exit code of 0 from a CLI means…
- the command failed
- the command succeeded
correct: 1
explain: 0 is the success exit code; any non-zero value signals an error. The shell (and CI) branch on this number to decide whether to continue.

Q: You need to ask the user to pick one option from a list of five, with arrow-key navigation. Reach for…
- Commander.js
- Inquirer.js
correct: 1
explain: Inquirer handles interactive list/checkbox/confirm prompts. Commander parses flags and routes subcommands — it does not render interactive prompts.

Q: process.env is best used for…
- hardcoding default port numbers so they never change
- reading environment-specific values like ports, API keys, and database URLs that change between deployments
correct: 1
explain: process.env is the configuration contract — environment-sensitive values are read from it rather than hardcoded, so the same code runs across dev, staging, and production with different values.
```
