Building CLI Tools (commander)

Reviewed & published by Brayan K

A CLI tool is a Node program you run from the terminal that reads arguments, options, and flags to do a job — and commander is the popular library that parses those inputs and generates help text for you.

Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll read raw arguments with process.argv, build a parser by hand to understand the mechanics, then use commander for commands, options, and flags, and finally make your tool installable with a shebang and a bin entry.

What You'll Learn in This Lesson

1️⃣ process.argv: the Raw Arguments

When you run node greet.js --name Ada, Node fills process.argv with the executable path, the script path, then your arguments. So you start with process.argv.slice(2) to get just the user's input. The hand-written parser below turns --name Ada --excited into a tidy options object — value-bearing options become strings, lone flags become true.

// process.argv is the raw list of command-line arguments. The first two
// entries are always the node binary and the script path; the REST are yours.
// Run as:  node greet.js --name Ada --excited
//
// The Run button starts this file with no arguments, so the next line stands
// in for that command line. Delete it and pass real arguments once you are
// running the file yourself — everything below is unchanged either way.
process.argv.push('--name', 'Ada', '--excited');

const args = process.argv.slice(2);   // drop node + script path
console.log('Raw args:', args);

// A tiny parser: turn ['--name','Ada','--excited'] into an options object.
const options = {};
for (let i = 0; i < args.length; i++) {
  const arg = args[i];
  if (arg.startsWith('--')) {
    const key = arg.slice(2);
    const next = args[i + 1];
    if (next && !next.startsWith('--')) {   // --name Ada  (has a value)
      options[key] = next;
      i++;                                   // skip the value we just consumed
    } else {
      options[key] = true;                   // --excited  (a boolean flag)
    }
  }
}
console.log('Parsed:', options);

2️⃣ commander: Commands, Options & Flags

Hand-parsing gets old fast. commander lets you declare your CLI: a name, version, commands (with required <arguments>), options that take a value (--count <n>, with a default), and boolean flags (--excited). It parses everything, validates it, and auto-generates --help and --version.

// commander handles parsing, help text, validation, and subcommands for you.
// Install once:  npm install commander
#!/usr/bin/env node
const { program } = require('commander');

program
  .name('greet')
  .description('A friendly CLI')
  .version('1.0.0');

// A command with an argument, an option-with-value, and a boolean flag.
program
  .command('hello <name>')                 // required argument
  .option('-c, --count <n>', 'times to greet', '1')   // option with default
  .option('--excited', 'add an exclamation mark')      // boolean flag
  .action((name, opts) => {
    const mark = opts.excited ? '!' : '';
    for (let i = 0; i < Number(opts.count); i++) {
      console.log(`Hello, ${name}${mark}`);
    }
  });

program.parse();

// Usage:
//   greet hello Ada --count 2 --excited
//   greet --help        (commander auto-generates this)

3️⃣ Making It an Installable Command

To let users type greet instead of node index.js, add a shebang (#!/usr/bin/env node) as the first line, then map a command name to your file in package.json's bin field. Run npm link to test it locally. For interactive prompts, libraries like inquirer ask the user questions and read their answers.

// 1) Add a shebang as the FIRST line of your CLI file so the OS runs it with node:
#!/usr/bin/env node

// 2) In package.json, map a command name to that file with "bin":
{
  "name": "greet-cli",
  "version": "1.0.0",
  "bin": { "greet": "./index.js" }
}

// 3) Link it locally to test the 'greet' command on your PATH:
//      npm link
//    Now you can run:  greet hello Ada
//
// 4) For interactive prompts (asking questions), libraries like
//    'inquirer' or '@inquirer/prompts' read answers from the user:
//      const { input } = require('@inquirer/prompts');
//      const name = await input({ message: 'Your name?' });

Your turn. The parser below works once you fill in the single blank marked ___. Follow the 👉 hint, then run it.

// 🎯 YOUR TURN — replace each ___ then run it.
const args = ['--task', 'build', '--watch'];   // pretend these came from argv
const options = {};
for (let i = 0; i < args.length; i++) {
  const arg = args[i];
  if (arg.startsWith('--')) {
    const key = arg.___(2);                     // 👉 drop the leading "--": slice
    const next = args[i + 1];
    if (next && !next.startsWith('--')) {
      options[key] = next;
      i++;
    } else {
      options[key] = true;                      // a boolean flag
    }
  }
}
console.log(options);

// ✅ Expected output:
//    { task: 'build', watch: true }

Mini-Challenge: A Command Router

No blanks — just a brief and an outline. Build a git-style router where the first word is the command and the rest are flags, then act on the add command. Build it, run it, and compare with the example output.

// 🎯 MINI-CHALLENGE: a mini command router
// 1. Take a pretend argv: ['add', '--title', 'Buy milk', '--priority', 'high'].
// 2. The FIRST word is the command; pull it off with destructuring.
// 3. Parse the rest into a flags object (--key value pairs).
// 4. Print the command and flags, and if command === 'add',
//    print: Adding "<title>" with priority <priority>
//
// ✅ Example output:
//    Command: add
//    Flags: { title: 'Buy milk', priority: 'high' }
//    Adding "Buy milk" with priority high

const argv = ['add', '--title', 'Buy milk', '--priority', 'high'];
// your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — CLI Building Blocks

TaskCode
Get your argsprocess.argv.slice(2)
Define a commandprogram.command('hello <name>')
Option with value.option('-c, --count <n>')
Boolean flag.option('--excited')
Make executable#!/usr/bin/env node
Name the command"bin": { "greet": "./index.js" }
Test locallynpm link

🎉 Lesson Complete!

Practice quiz

What does process.argv contain?

  • Only environment variables
  • The exit code
  • The command-line arguments the process started with
  • The current directory

Answer: The command-line arguments the process started with. process.argv is an array of command-line arguments passed to the process.

What are the first two elements of process.argv?

  • The node executable path and the script path
  • The first two user arguments
  • PORT and HOST
  • Always undefined

Answer: The node executable path and the script path. argv[0] is the node binary path and argv[1] is the script path; your arguments follow.

Why do CLI scripts usually call process.argv.slice(2)?

  • To remove the last two arguments
  • To convert arguments to numbers
  • To sort the arguments
  • To drop the node and script paths and keep only user arguments

Answer: To drop the node and script paths and keep only user arguments. slice(2) drops the first two entries (node + script path), leaving just the user's arguments.

Why use commander instead of hand-parsing argv?

  • It runs scripts faster
  • It handles options, flags, defaults, subcommands, and auto help
  • It is required by Node
  • It encrypts arguments

Answer: It handles options, flags, defaults, subcommands, and auto help. commander parses options, validates input, supports subcommands, and auto-generates --help and --version.

What does the #!/usr/bin/env node shebang line do?

  • Tells the OS to run the file with node found on PATH
  • Imports the node module
  • Sets an environment variable
  • Comments out the first line

Answer: Tells the OS to run the file with node found on PATH. The shebang tells the OS which interpreter to use; env node finds node on the user's PATH portably.

Where must the shebang line appear in a CLI file?

  • On the last line
  • Anywhere in the file
  • On the very first line
  • Inside the main function

Answer: On the very first line. The shebang only works as the very first line of the script.

Which package.json field maps a command name to your script file?

  • main
  • bin
  • scripts
  • exports

Answer: bin. The bin field, like "bin": { "greet": "./index.js" }, maps a command name to the entry file.

What does npm link do during development?

  • Publishes the package to npm
  • Installs all dependencies
  • Runs the test suite
  • Symlinks your command globally so you can run it by name

Answer: Symlinks your command globally so you can run it by name. npm link symlinks the bin command onto your PATH so you can test it like an installed tool.

In the hand-written parser, how is a token like '--name' detected as an option?

  • It contains a space
  • It starts with '--'
  • It is all uppercase
  • It is the first argument

Answer: It starts with '--'. The parser checks arg.startsWith('--') to treat a token as an option key.

Which library is mentioned for asking the user interactive questions?

  • commander only
  • express
  • inquirer / @inquirer/prompts
  • dotenv

Answer: inquirer / @inquirer/prompts. For interactive prompts, libraries like inquirer or @inquirer/prompts read answers from the user.

Continue this course

Frequently asked questions

What is process.argv and what's in it?

process.argv is an array of the command-line arguments the process was started with. The first element is always the path to the Node executable, the second is the path to the script being run, and everything after that is the arguments the user typed. That's why you almost always start with process.argv.slice(2) — to drop those first two and get just your arguments. For a quick script with one or two parameters, reading argv directly is fine; once you need named options, defaults, help text, and subcommands, hand-parsing argv gets tedious and error-prone, which is exactly the gap a library like commander fills.

Why use commander instead of parsing process.argv myself?

Hand-parsing works for trivial cases, but a real CLI needs to handle options with values (--count 3), boolean flags (--verbose), short aliases (-v), default values, required arguments, subcommands (git-style: tool add, tool remove), validation, and a generated --help and --version. commander gives you all of that declaratively: you describe your commands and options, and it parses, validates, and auto-generates help text. The result is less buggy code and a CLI that behaves like the tools users already know. Alternatives like yargs and oclif offer similar features; commander is popular for being small and straightforward.

What does the #!/usr/bin/env node shebang line do?

The shebang (#!) on the very first line of a script tells the operating system which interpreter to run the file with. #!/usr/bin/env node says 'find node on the user's PATH and run this file with it', which is more portable than hardcoding a path like /usr/local/bin/node. Combined with making the file executable and mapping it in package.json's bin field, this is what lets users type your command's name (for example greet) instead of node greet.js. Without the shebang, the OS wouldn't know it's a Node program when invoked directly as an executable.

How do I make my CLI installable as a command?

Three pieces work together. First, add the #!/usr/bin/env node shebang to your entry file so it can run as an executable. Second, in package.json add a bin field mapping a command name to that file, like "bin": { "greet": "./index.js" } — when the package is installed, npm puts a small launcher for greet on the user's PATH. Third, test it: run npm link in your project to symlink the command globally during development, then just type greet. When you're ready to share it, publish the package to npm (the next lesson) and users can install it with npm install -g your-cli.