writing/tutorial/2026/03
TutorialMar 30, 2026·25 min read

Build a Professional CLI Tool with Node.js and TypeScript in 2026

Learn how to build, test, and publish a professional command-line tool using Node.js and TypeScript. Covers argument parsing, interactive prompts, colored output, and npm publishing.

Command-line tools remain the backbone of developer workflows. From git and npm to eslint and prettier, CLIs power the modern development experience. In this tutorial, you will build a professional-grade CLI tool from scratch using Node.js and TypeScript — complete with argument parsing, interactive prompts, colored output, and everything needed to publish it on npm.

What You Will Build

We will create taskr — a task management CLI that lets developers manage project tasks directly from the terminal. By the end, you will have a tool that supports:

  • Adding, listing, completing, and removing tasks
  • Interactive mode with prompts
  • Colored and formatted terminal output
  • Persistent storage using a JSON file
  • Proper error handling and help text
  • A publishable npm package

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed
  • npm or pnpm package manager
  • Basic TypeScript knowledge
  • A terminal you are comfortable with
  • A text editor (VS Code recommended)

Step 1: Project Setup

Create a new directory and initialize the project:

mkdir taskr && cd taskr
npm init -y

Install TypeScript and the required dependencies:

npm install commander chalk inquirer conf ora
npm install -D typescript @types/node @types/inquirer tsx tsup

Here is what each package does:

PackagePurpose
commanderArgument and command parsing
chalkColored terminal output
inquirerInteractive prompts
confPersistent configuration/storage
oraElegant terminal spinners
tsxRun TypeScript directly during development
tsupBundle TypeScript for distribution

Step 2: TypeScript Configuration

Create a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Step 3: Define the Data Model

Create the source directory and data types:

mkdir -p src

Create src/types.ts:

export interface Task {
  id: string;
  title: string;
  description?: string;
  status: "todo" | "in-progress" | "done";
  priority: "low" | "medium" | "high";
  createdAt: string;
  completedAt?: string;
}
 
export interface TaskStore {
  tasks: Task[];
}

Step 4: Build the Storage Layer

Create src/store.ts to handle persistent storage:

import Conf from "conf";
import { randomUUID } from "node:crypto";
import type { Task, TaskStore } from "./types.js";
 
const config = new Conf<TaskStore>({
  projectName: "taskr",
  defaults: {
    tasks: [],
  },
});
 
export function getAllTasks(): Task[] {
  return config.get("tasks");
}
 
export function getTaskById(id: string): Task | undefined {
  return getAllTasks().find((t) => t.id === id || t.id.startsWith(id));
}
 
export function addTask(
  title: string,
  options: { description?: string; priority?: Task["priority"] } = {}
): Task {
  const task: Task = {
    id: randomUUID().slice(0, 8),
    title,
    description: options.description,
    status: "todo",
    priority: options.priority ?? "medium",
    createdAt: new Date().toISOString(),
  };
 
  const tasks = getAllTasks();
  tasks.push(task);
  config.set("tasks", tasks);
  return task;
}
 
export function completeTask(id: string): Task | null {
  const tasks = getAllTasks();
  const task = tasks.find((t) => t.id === id || t.id.startsWith(id));
 
  if (!task) return null;
 
  task.status = "done";
  task.completedAt = new Date().toISOString();
  config.set("tasks", tasks);
  return task;
}
 
export function removeTask(id: string): boolean {
  const tasks = getAllTasks();
  const index = tasks.findIndex((t) => t.id === id || t.id.startsWith(id));
 
  if (index === -1) return false;
 
  tasks.splice(index, 1);
  config.set("tasks", tasks);
  return true;
}
 
export function clearAllTasks(): void {
  config.set("tasks", []);
}

The conf library automatically determines the correct storage path for each operating system — ~/.config/taskr on Linux, ~/Library/Preferences/taskr on macOS, and %APPDATA%/taskr on Windows.

Step 5: Create the Output Formatter

Create src/formatter.ts for beautiful terminal output:

import chalk from "chalk";
import type { Task } from "./types.js";
 
const priorityColors = {
  low: chalk.gray,
  medium: chalk.yellow,
  high: chalk.red,
};
 
const statusIcons = {
  todo: "○",
  "in-progress": "◑",
  done: "●",
};
 
export function formatTask(task: Task): string {
  const icon = statusIcons[task.status];
  const priority = priorityColors[task.priority](`[${task.priority}]`);
  const id = chalk.dim(`#${task.id}`);
  const title =
    task.status === "done" ? chalk.strikethrough(task.title) : task.title;
 
  let line = `  ${icon} ${id} ${title} ${priority}`;
 
  if (task.description) {
    line += `\n    ${chalk.dim(task.description)}`;
  }
 
  return line;
}
 
export function formatTaskList(tasks: Task[]): string {
  if (tasks.length === 0) {
    return chalk.dim("  No tasks found. Add one with: taskr add <title>");
  }
 
  const groups = {
    "In Progress": tasks.filter((t) => t.status === "in-progress"),
    "To Do": tasks.filter((t) => t.status === "todo"),
    Done: tasks.filter((t) => t.status === "done"),
  };
 
  const lines: string[] = [];
 
  for (const [label, group] of Object.entries(groups)) {
    if (group.length === 0) continue;
    lines.push(`\n${chalk.bold.underline(label)} (${group.length})`);
    group.forEach((task) => lines.push(formatTask(task)));
  }
 
  return lines.join("\n");
}
 
export function success(message: string): void {
  console.log(chalk.green("✓"), message);
}
 
export function error(message: string): void {
  console.error(chalk.red("✗"), message);
}
 
export function info(message: string): void {
  console.log(chalk.blue("ℹ"), message);
}

Step 6: Add Interactive Mode

Create src/interactive.ts for the interactive prompts:

import inquirer from "inquirer";
import type { Task } from "./types.js";
import { addTask, getAllTasks, completeTask, removeTask } from "./store.js";
import { formatTaskList, success, error } from "./formatter.js";
 
export async function interactiveMode(): Promise<void> {
  const { action } = await inquirer.prompt([
    {
      type: "list",
      name: "action",
      message: "What would you like to do?",
      choices: [
        { name: "📋 List all tasks", value: "list" },
        { name: "➕ Add a new task", value: "add" },
        { name: "✅ Complete a task", value: "complete" },
        { name: "🗑️  Remove a task", value: "remove" },
        { name: "🚪 Exit", value: "exit" },
      ],
    },
  ]);
 
  switch (action) {
    case "list":
      console.log(formatTaskList(getAllTasks()));
      break;
 
    case "add":
      await interactiveAdd();
      break;
 
    case "complete":
      await interactiveComplete();
      break;
 
    case "remove":
      await interactiveRemove();
      break;
 
    case "exit":
      return;
  }
 
  // Loop back
  await interactiveMode();
}
 
async function interactiveAdd(): Promise<void> {
  const answers = await inquirer.prompt([
    {
      type: "input",
      name: "title",
      message: "Task title:",
      validate: (input: string) =>
        input.trim().length > 0 || "Title cannot be empty",
    },
    {
      type: "input",
      name: "description",
      message: "Description (optional):",
    },
    {
      type: "list",
      name: "priority",
      message: "Priority:",
      choices: ["low", "medium", "high"],
      default: "medium",
    },
  ]);
 
  const task = addTask(answers.title, {
    description: answers.description || undefined,
    priority: answers.priority,
  });
 
  success(`Added task: ${task.title} (#${task.id})`);
}
 
async function interactiveComplete(): Promise<void> {
  const tasks = getAllTasks().filter((t) => t.status !== "done");
 
  if (tasks.length === 0) {
    error("No pending tasks to complete.");
    return;
  }
 
  const { taskId } = await inquirer.prompt([
    {
      type: "list",
      name: "taskId",
      message: "Select task to complete:",
      choices: tasks.map((t) => ({
        name: `${t.title} [${t.priority}]`,
        value: t.id,
      })),
    },
  ]);
 
  const task = completeTask(taskId);
  if (task) {
    success(`Completed: ${task.title}`);
  }
}
 
async function interactiveRemove(): Promise<void> {
  const tasks = getAllTasks();
 
  if (tasks.length === 0) {
    error("No tasks to remove.");
    return;
  }
 
  const { taskId } = await inquirer.prompt([
    {
      type: "list",
      name: "taskId",
      message: "Select task to remove:",
      choices: tasks.map((t) => ({
        name: `${t.title} (${t.status})`,
        value: t.id,
      })),
    },
  ]);
 
  const { confirm } = await inquirer.prompt([
    {
      type: "confirm",
      name: "confirm",
      message: "Are you sure?",
      default: false,
    },
  ]);
 
  if (confirm) {
    removeTask(taskId);
    success("Task removed.");
  }
}

Step 7: Wire Up the CLI Entry Point

Create src/cli.ts — the main entry point:

#!/usr/bin/env node
 
import { Command } from "commander";
import chalk from "chalk";
import ora from "ora";
import {
  addTask,
  getAllTasks,
  completeTask,
  removeTask,
  clearAllTasks,
} from "./store.js";
import { formatTaskList, formatTask, success, error, info } from "./formatter.js";
import { interactiveMode } from "./interactive.js";
 
const program = new Command();
 
program
  .name("taskr")
  .description("A minimal task manager for your terminal")
  .version("1.0.0");
 
// Interactive mode (default when no command given)
program
  .action(async () => {
    console.log(chalk.bold("\n🚀 Taskr — Terminal Task Manager\n"));
    await interactiveMode();
  });
 
// Add command
program
  .command("add <title>")
  .description("Add a new task")
  .option("-d, --description <desc>", "Task description")
  .option("-p, --priority <level>", "Priority: low, medium, high", "medium")
  .action((title: string, options) => {
    const task = addTask(title, {
      description: options.description,
      priority: options.priority,
    });
    success(`Added: ${task.title} ${chalk.dim(`(#${task.id})`)}`);
  });
 
// List command
program
  .command("list")
  .alias("ls")
  .description("List all tasks")
  .option("-s, --status <status>", "Filter by status: todo, in-progress, done")
  .option("-p, --priority <level>", "Filter by priority: low, medium, high")
  .action((options) => {
    let tasks = getAllTasks();
 
    if (options.status) {
      tasks = tasks.filter((t) => t.status === options.status);
    }
    if (options.priority) {
      tasks = tasks.filter((t) => t.priority === options.priority);
    }
 
    console.log(formatTaskList(tasks));
    console.log();
  });
 
// Complete command
program
  .command("done <id>")
  .description("Mark a task as complete")
  .action((id: string) => {
    const task = completeTask(id);
    if (task) {
      success(`Completed: ${task.title}`);
    } else {
      error(`Task not found: ${id}`);
    }
  });
 
// Remove command
program
  .command("rm <id>")
  .description("Remove a task")
  .action((id: string) => {
    const removed = removeTask(id);
    if (removed) {
      success("Task removed.");
    } else {
      error(`Task not found: ${id}`);
    }
  });
 
// Clear command
program
  .command("clear")
  .description("Remove all tasks")
  .action(() => {
    const spinner = ora("Clearing all tasks...").start();
    clearAllTasks();
    spinner.succeed("All tasks cleared.");
  });
 
program.parse();

Step 8: Add the Shebang and Package Configuration

Update your package.json to configure the CLI:

{
  "name": "taskr-cli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "taskr": "./dist/cli.js"
  },
  "files": ["dist"],
  "scripts": {
    "dev": "tsx src/cli.ts",
    "build": "tsup src/cli.ts --format esm --dts --clean",
    "prepublishOnly": "npm run build"
  }
}

Create tsup.config.ts for the build configuration:

import { defineConfig } from "tsup";
 
export default defineConfig({
  entry: ["src/cli.ts"],
  format: ["esm"],
  target: "node20",
  clean: true,
  dts: true,
  banner: {
    js: "#!/usr/bin/env node",
  },
});

Step 9: Test During Development

Run your CLI in development mode using tsx:

# Interactive mode
npx tsx src/cli.ts
 
# Add a task
npx tsx src/cli.ts add "Write documentation" -p high
 
# List tasks
npx tsx src/cli.ts list
 
# Complete a task (use the short ID from the list)
npx tsx src/cli.ts done a1b2c3d4
 
# Filter tasks
npx tsx src/cli.ts list --status todo --priority high

You can also link it globally for testing:

npm run build
npm link
taskr add "My first task"
taskr ls

Step 10: Add Unit Tests

Install the test framework:

npm install -D vitest

Create src/__tests__/store.test.ts:

import { describe, it, expect, beforeEach } from "vitest";
import { addTask, getAllTasks, completeTask, removeTask, clearAllTasks } from "../store.js";
 
describe("Task Store", () => {
  beforeEach(() => {
    clearAllTasks();
  });
 
  it("should add a task", () => {
    const task = addTask("Test task");
    expect(task.title).toBe("Test task");
    expect(task.status).toBe("todo");
    expect(task.priority).toBe("medium");
  });
 
  it("should list all tasks", () => {
    addTask("Task 1");
    addTask("Task 2");
    const tasks = getAllTasks();
    expect(tasks).toHaveLength(2);
  });
 
  it("should complete a task", () => {
    const task = addTask("Test task");
    const completed = completeTask(task.id);
    expect(completed?.status).toBe("done");
    expect(completed?.completedAt).toBeDefined();
  });
 
  it("should remove a task", () => {
    const task = addTask("Test task");
    const removed = removeTask(task.id);
    expect(removed).toBe(true);
    expect(getAllTasks()).toHaveLength(0);
  });
 
  it("should find tasks by partial ID", () => {
    const task = addTask("Test task");
    const shortId = task.id.slice(0, 4);
    const completed = completeTask(shortId);
    expect(completed?.title).toBe("Test task");
  });
});

Add the test script to package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Step 11: Prepare for npm Publishing

Make sure your package.json has the required metadata:

{
  "name": "taskr-cli",
  "version": "1.0.0",
  "description": "A minimal task manager for your terminal",
  "type": "module",
  "bin": {
    "taskr": "./dist/cli.js"
  },
  "files": ["dist"],
  "keywords": ["cli", "task", "todo", "terminal", "productivity"],
  "author": "Your Name",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/yourusername/taskr-cli"
  },
  "engines": {
    "node": ">=20.0.0"
  }
}

Build and publish:

npm run build
npm login
npm publish

After publishing, anyone can install and use your CLI:

npx taskr-cli
# or
npm install -g taskr-cli
taskr add "Hello world"

Step 12: Add a GitHub Actions CI Pipeline

Create .github/workflows/ci.yml:

name: CI
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20, 22]
 
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test
      - run: npm run build

Troubleshooting

"Cannot find module" errors when running built output: Make sure your tsconfig.json has "moduleResolution": "bundler" and all imports use the .js extension (even for .ts files). TypeScript requires this for ESM output.

"Permission denied" when running the CLI globally: After building, ensure the output file is executable:

chmod +x dist/cli.js

Chalk colors not showing: Some CI environments or minimal terminals do not support colors. Chalk automatically detects this, but you can force colors with FORCE_COLOR=1.

Next Steps

Now that you have a working CLI tool, consider these enhancements:

  • Add subcommand groups for complex CLIs using Commander nested commands
  • Implement plugins by dynamically loading modules from a config directory
  • Add shell completions using omelette or tabtab packages
  • Create a TUI (text user interface) using blessed or ink for richer interactions
  • Support multiple output formats (JSON, table, YAML) for scripting integration

Conclusion

You have built a complete, professional CLI tool with TypeScript — from project setup and argument parsing to interactive prompts, colored output, testing, and npm publishing. CLI tools are a powerful way to automate workflows, share utilities with your team, and contribute to the open-source ecosystem. The patterns you learned here — Commander for parsing, Chalk for output, Inquirer for interaction, and Conf for persistence — form the standard toolkit used by most popular Node.js CLIs today.