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

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 -yInstall TypeScript and the required dependencies:
npm install commander chalk inquirer conf ora
npm install -D typescript @types/node @types/inquirer tsx tsupHere is what each package does:
| Package | Purpose |
|---|---|
commander | Argument and command parsing |
chalk | Colored terminal output |
inquirer | Interactive prompts |
conf | Persistent configuration/storage |
ora | Elegant terminal spinners |
tsx | Run TypeScript directly during development |
tsup | Bundle 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 srcCreate 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 highYou can also link it globally for testing:
npm run build
npm link
taskr add "My first task"
taskr lsStep 10: Add Unit Tests
Install the test framework:
npm install -D vitestCreate 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 publishAfter 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 buildTroubleshooting
"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.jsChalk 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
omeletteortabtabpackages - Create a TUI (text user interface) using
blessedorinkfor 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.
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

AI SDK 4.0: New Features and Use Cases
Discover the new features and use cases of AI SDK 4.0, including PDF support, computer use, and more.

Introduction to Model Context Protocol (MCP)
Learn about the Model Context Protocol (MCP), its use cases, advantages, and how to build and use an MCP server with TypeScript.

Making Outgoing Calls with Twilio Voice and OpenAI
Learn how to make outgoing calls using Twilio Voice and OpenAI Realtime API with Node.js.