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.