1 The Problem
We want a real command-line tool: tasks add "Buy milk", tasks list, tasks done 2 — commands and arguments, just like git or npm. It teaches argparse, the proper way to build CLI tools that feel professional, not like a toy menu.
2 How to Think About It
Think about commands and arguments, before any code:
add, list, done. → 2. Each takes arguments (the task text, or which number). → 3. process.argv carries each subcommand’s own arguments, parsed by hand — Node has no built-in flag parser, so a real CLI tool typically reaches for a small library once the argument list grows. → 4. Tasks persist in a file between commands. This is how real CLI tools are structured.
3 The Build — explained part by part
Here is the complete task manager. Read each part’s note below — you should understand the whole thing from the notes alone.
import * as fs from "node:fs";
const FILE = "tasks.json";
export interface Task {
task: string;
done: boolean;
}
export function load(): Task[] {
return fs.existsSync(FILE) ? (JSON.parse(fs.readFileSync(FILE, "utf-8")) as Task[]) : [];
}
export function save(tasks: Task[]): void {
fs.writeFileSync(FILE, JSON.stringify(tasks));
}
export function addTask(tasks: Task[], text: string): Task[] {
tasks.push({ task: text, done: false });
return tasks;
}
export function markDone(tasks: Task[], number: number): Task[] {
tasks[number - 1].done = true;
return tasks;
}
function add(text: string): void {
const tasks = load();
addTask(tasks, text);
save(tasks);
console.log(`Added: ${text}`);
}
function list(): void {
load().forEach((t, i) => {
const mark = t.done ? "x" : " ";
console.log(`${i + 1}. [${mark}] ${t.task}`);
});
}
function done(number: number): void {
const tasks = load();
markDone(tasks, number);
save(tasks);
console.log(`Marked task ${number} done.`);
}
function main(): void {
const [command, ...rest] = process.argv.slice(2);
switch (command) {
case "add":
add(rest.join(" "));
break;
case "list":
list();
break;
case "done":
done(Number(rest[0]));
break;
default:
console.log("A simple task manager. Usage: add <text> | list | done <number>");
}
}
if (require.main === module) {
main();
}addTask or markDone hand back anything else by mistake.const [command, ...rest] = process.argv.slice(2) — Node puts the executable and script path as the first two entries of
process.argv, so .slice(2) drops those, leaving the user’s own arguments; destructuring then splits off the first word (add/list/done) from the rest, the same role Python’s argparse subparsers play, done here by hand since the standard library has no built-in argument parser.switch (command) { case "add": ... } — dispatches to one of three small functions by name, each of which loads the file, changes it, saves it, and prints a confirmation — mirroring the Python version’s
add/list_tasks/done functions exactly.markDone(tasks, number) —
tasks[number - 1] converts the 1-based number a human types into the 0-based index JavaScript arrays actually use, identical to the Python version’s own number - 1.
.slice(2) and treating process.argv[0] (the path to node itself) as the command.process.argv.slice(2) for a Node CLI’s own arguments.done with a number that does not exist, like node tasks.js done 99, and getting a confusing TypeError: Cannot set properties of undefined.rest.join(",") instead of rest.join(" ").add "Buy milk" arrives as ["Buy milk"] already, but an unquoted add Buy milk arrives as ["Buy", "milk"] — rejoin with a space to recover the original text.4 Test & Prove Each Part
How do we know this works? We pull the real logic into small, plain functions and check each one against cases we already know the answer to.
import { test } from "node:test";
import assert from "node:assert/strict";
import { addTask, markDone, type Task } from "./cli-task-manager";
test("adding appends an undone task", () => {
const tasks: Task[] = addTask([], "Buy milk");
assert.equal(tasks[0].task, "Buy milk");
assert.equal(tasks[0].done, false);
});
test("marking done sets the flag", () => {
const tasks: Task[] = addTask([], "Task");
markDone(tasks, 1);
assert.equal(tasks[0].done, true);
});
test("marking task 2 done leaves task 1 alone", () => {
const tasks: Task[] = addTask(addTask([], "a"), "b");
markDone(tasks, 2);
assert.equal(tasks[0].done, false);
assert.equal(tasks[1].done, true);
});Compile with npx tsc then run node --test tasks.test.js. addTask/markDone operate on a plain Task[], so the tests never touch tasks.json or process.argv at all.
5 The Interface
What it expects
go run tasks.go add "Buy milk"What it returns
1. [ ] Buy milk
2. [x] Walk dog6 Run It & Automate It
Save the code as tasks.ts, compile with npx tsc, and run the compiled file with extra arguments after it: node tasks.js add "Buy milk".
npx tsc tasks.ts && node tasks.js add "Buy milk"Each command (add / list / done) is a separate invocation, just like a real CLI tool.
A CI tool like Jenkins runs the type-checker and tests automatically whenever the code changes — every line below has a plain explanation.
$ node tasks.js add "Buy milk"
Added: Buy milk
$ node tasks.js add "Walk dog"
Added: Walk dog
$ node tasks.js list
1. [ ] Buy milk
2. [ ] Walk dog
$ node tasks.js done 1
Marked task 1 done.
$ node tasks.js list
1. [x] Buy milk
2. [ ] Walk dogswitch only recognises add, list, and done exactly.TypeError: Cannot set properties of undefined (setting 'done')done with a task number that does not exist. Run list first to see the valid numbers.// Jenkinsfile — runs the type-checker and tests automatically every time the code changes.
pipeline {
agent any // run on any available machine
stages {
stage('Get the code') {
steps { checkout scm } // download the latest code
}
stage('Set up Node') {
steps {
sh 'node --version' // confirm Node is installed
sh 'npm install -D typescript @types/node' // zero runtime deps — just the compiler and its Node types
}
}
stage('Type-check and test') {
steps {
sh 'npx tsc --noEmit' // catch type errors before anything runs
sh 'npx tsc' // compile to plain JavaScript
sh 'node --test tasks.test.js' // Node's built-in test runner, no extra install needed
}
}
}
post {
success { echo 'All tests passed.' }
failure { echo 'A test failed — look above.' }
}
}
You have a working cli task manager. Extend it:
- Validate the task number. Print a friendly error instead of crashing when
donegets an out-of-range number. (Teaches: a bounds check before indexing.) - Add a
removecommand. Delete a task by number, as the to-do list project does. (Teaches:Array.prototype.splice.) - Support flags. Let
list --doneshow only completed tasks. (Teaches: checking for a flag insiderest.) - Colour the output. Print done tasks in green using ANSI escape codes. (Teaches: terminal escape sequences, no library required.)
process.argv by hand for a small CLI (the role argparse plays in Python), model a record with an interface, and keep the add/mark-done logic in plain, file-free functions for easy testing. Related reference: Interfaces vs. Types, The TypeScript Compiler.