August 14, 2025
5 min read
By Cojocaru David & ChatGPT

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

How to Create a CLI Tool with Node.js From Scratch (Even If You’re New)

So you’ve got this boring task you do every day. Maybe it’s renaming 200 files. Or checking if a website is up. Or converting CSV to JSON. Whatever it is, you keep thinking, “There must be a faster way.”

Good news? There is. We can build a tiny Node.js CLI tool in about 20 minutes. We’ll even push it to NPM so your teammates can install it with one line. Ready? Grab your coffee. Let’s code.

Why Node.js Still Rocks for CLI Tools in 2025

I used to reach for Python or Go when I needed a quick script. Then I realized: I already know JavaScript. Why learn another language for a 50-line tool?

Here’s why Node.js wins:

  • No context switch. Same language from browser → server → CLI.
  • NPM is massive. Need to parse YAML? There’s a package. Want spinners? Got one. Need colors? Two keystrokes.
  • Cross-platform by default. Windows, macOS, Linux one codebase covers them all.
  • Fast startup. Node 20 boots in ~40 ms on my M2 Mac. That’s quicker than my terminal prompt loads.

And the best part? If you mess up, you fix it in plain JavaScript. No weird compiler errors.

Quick Checklist Before We Start

Make sure you’ve got these on your machine:

  • Node.js 18+ (20.x is even better)
  • npm or pnpm (I like pnpm’s speed, but npm works fine)
  • A terminal you like (iTerm, Windows Terminal, the default doesn’t matter)

Run node -v and npm -v. If you see version numbers, you’re golden.

Step 1: Spin Up a New Project in 30 Seconds

mkdir todo-cli && cd todo-cli
npm init -y

That -y flag skips the questionnaire. We’ll tweak the file later.

Step 2: Install the Tiny Helpers We Need

npm install commander chalk@5 ora

What each does:

  • commander - parses flags and subcommands so we don’t have to.
  • chalk - makes text pretty (colors, bold, underline).
  • ora - gives us those slick loading spinners you see everywhere.

Step 3: Create the Entry File

Create index.js and paste this starter:

#!/usr/bin/env node
import { program } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
 
program
  .name('todo')
  .description('A tiny CLI to manage your daily tasks')
  .version('1.0.0');
 
program
  .command('add <task>')
  .description('Add a new task')
  .action((task) => {
    const spinner = ora('Saving task...').start();
    setTimeout(() => {
      spinner.succeed(chalk.green(`Task added: ${task}`));
    }, 500);
  });
 
program.parse();

Pro tip: Notice the top line #!/usr/bin/env node. That shebang tells Unix systems this file should run with Node. On Windows, NPM handles it for us.

Step 4: Make It Runnable Locally

Open package.json and add:

"type": "module",
"bin": {
  "todo": "./index.js"
}

Then link it:

npm link

Now type todo add "buy milk" anywhere in your terminal. Boom. Your first command works.

Step 5: Add More Commands (Because One Is Boring)

Let’s list and delete tasks. We’ll keep them in a simple JSON file for now.

5.1 Create a Tiny Data Layer

Create lib/store.js:

import { readFileSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
 
const file = join(homedir(), '.todo.json');
 
export function readTasks() {
  try {
    return JSON.parse(readFileSync(file, 'utf8'));
  } catch {
    return [];
  }
}
 
export function writeTasks(tasks) {
  writeFileSync(file, JSON.stringify(tasks, null, 2));
}

Why homedir()? Keeps the file out of your repo and follows OS conventions.

5.2 Add List, Delete, and Done Commands

Back in index.js, add:

import { readTasks, writeTasks } from './lib/store.js';
 
program
  .command('list')
  .description('Show all tasks')
  .action(() => {
    const tasks = readTasks();
    if (tasks.length === 0) {
      console.log(chalk.yellow('No tasks yet 🎉'));
      return;
    }
    tasks.forEach((t, i) => {
      const icon = t.done ? '✅' : '⏳';
      console.log(`${i}: ${icon} ${t.text}`);
    });
  });
 
program
  .command('done <index>')
  .description('Mark task as done')
  .action((index) => {
    const tasks = readTasks();
    if (!tasks[index]) {
      console.log(chalk.red('Task not found'));
      return;
    }
    tasks[index].done = true;
    writeTasks(tasks);
    console.log(chalk.green('Marked as done!'));
  });
 
program
  .command('delete <index>')
  .description('Delete a task by index')
  .action((index) => {
    const tasks = readTasks();
    if (!tasks[index]) {
      console.log(chalk.red('Task not found'));
      return;
    }
    const removed = tasks.splice(index, 1)[0];
    writeTasks(tasks);
    console.log(chalk.red(`Deleted: ${removed.text}`));
  });

Try it:

todo add "write blog post"
todo list
todo done 0
todo delete 0

Looks like magic, right?

Step 6: Handle Edge Cases Like a Pro

Real users do weird stuff. Let’s guard against:

  • Empty task names
  • Invalid indexes
  • Missing JSON file (we already handled it, but still)

Add this helper in lib/validate.js:

export function isValidIndex(idx, arr) {
  const n = Number(idx);
  return Number.isInteger(n) && n >= 0 && n < arr.length;
}

Use it inside the commands:

if (!isValidIndex(index, tasks)) {
  console.log(chalk.red('Invalid index'));
  return;
}

Your future self will thank you.

Step 7: Add Colors, Emojis, and Spinners (Because Fun Matters)

Remember chalk and ora? Sprinkle them everywhere:

  • Success → chalk.green
  • Errors → chalk.red
  • Info → chalk.blue
  • Spinners for any async work longer than 200 ms

Users subconsciously trust tools that feel polished. It’s like wearing a clean T-shirt to a meeting small detail, big impact.

Step 8: Test Your CLI Without Losing Your Mind

You don’t need a fancy framework. Add a simple test script:

"scripts": {
  "test": "node test.js"
}

Create test.js:

import { execSync } from 'child_process';
 
function run(cmd) {
  return execSync(`node index.js ${cmd}`, { encoding: 'utf8' });
}
 
console.log(run('add "test task"'));
console.log(run('list'));
console.log(run('done 0'));
console.log(run('delete 0'));

Run npm test. If everything prints correctly, you’re solid.

Step 9: Publish to NPM So Your Friends Can Install It

  1. Create an account at npmjs.com.
  2. Log in from your terminal: npm login
  3. Pick a unique name. If “todo-cli” is taken, try “todo-cli-2025-yourname”.
  4. Bump version if needed: npm version patch
  5. Ship it: npm publish

Your teammates can now run:

npm install -g todo-cli-yourname
todo add "ship the thing"

Pretty sweet.

Common Pitfalls and How to Dodge Them

PitfallQuick Fix
”command not found” after npm linkRe-link or restart your terminal.
Windows shows weird colorsTell users to use Windows Terminal or enable ANSI in cmd.
JSON file gets corruptedWrap writes in try/catch, or migrate to a tiny SQLite DB later.
Scripts too slowUse zx or compile with pkg for a single binary.

Ideas to Level Up Your CLI

Once you nail the basics, try these:

  • Interactive prompts with enquirer or @inquirer/prompts.
  • Autocomplete using omelette.
  • Progress bars with cli-progress.
  • Config files in ~/.config/your-tool.
  • Themes so users can pick color schemes.

The rabbit hole is deep, but each step makes your tool feel first-class.

TL;DR: Your 5-Minute Recap

  1. mkdir + npm init
  2. npm install commander chalk
  3. index.js with shebang
  4. npm link to test locally
  5. npm publish to share with the world

“Every expert was once a beginner who refused to give up.”
_ Someone on the internet, probably_

You just built a cross-platform CLI tool in plain JavaScript. Next time you catch yourself doing a repetitive task, remember: you can automate it in under an hour. And now you know how.

#NodeCLI #JavaScript #Automation #DeveloperTools #NPM