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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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.


Want to read more tutorials? Check out our latest tutorial on Plant Biology Basics for Bioinformatics.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles