← thecodex.expert · The Codex Family of Knowledge
Tier 3 · Upper-Intermediate · TypeScript Project

CLI Task Manager

A proper command-line task manager with commands and arguments, like real CLI tools. Learn to build a polished terminal program.

🧠 Teaches how to think spoonfed, every age Last verified:

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.

Where this shows up: every developer tool — git, docker, npm, pip. Building proper CLIs with subcommands and arguments is a core skill for automation, dev tools, and scripts others will use.

2 How to Think About It

Think about commands and arguments, before any code:

The plan — in plain English
1. The tool has subcommands: 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.

add

list

done

Command line input

argparse parses it

Which subcommand?

Add task

Show tasks

Mark task done

Save to file

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.

TypeScripttasks.ts
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();
}
⚠ No in-browser playground here
Running real, type-checked TypeScript in the browser needs either a full copy of the compiler or a third-party CDN script — the same kind of external dependency this site avoids relying on for a core teaching example. Copy the code below and run it with Node on your own machine instead; the “Run It” section explains exactly how.
What each part does — in plain words
interface Task { task: string; done: boolean } — every task in the file has exactly these two fields; the compiler will not let 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.
Common mistakes — and how to avoid them
✗ Forgetting .slice(2) and treating process.argv[0] (the path to node itself) as the command.
✓ Always start from process.argv.slice(2) for a Node CLI’s own arguments.
✗ Running done with a number that does not exist, like node tasks.js done 99, and getting a confusing TypeError: Cannot set properties of undefined.
✓ A real version should check the number is in range before indexing — see “Try this next” below. The Python original has the same gap.
✗ Joining multi-word task text with the wrong separator, e.g. 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.

Adding appends an undone task
Marking done sets the flag
Marking task 2 done leaves task 1 alone
TypeScripttasks.test.ts
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

INPUTaddtasks add "text"
What it expects
go run tasks.go add "Buy milk"
OUTPUTlisttasks list
What it returns
1. [ ] Buy milk
2. [x] Walk dog

6 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".

Run it locally
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.

What you should see when it works
Terminala real run
$ 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 dog
If it breaks — how to fix it
🚨 “A simple task manager…” usage message prints no matter what I type
Check the exact spelling of the subcommand — the switch only recognises add, list, and done exactly.
🚨 TypeError: Cannot set properties of undefined (setting 'done')
You ran done with a task number that does not exist. Run list first to see the valid numbers.
GroovyJenkinsfile
// Jenkinsfile &mdash; 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 &mdash; 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 &mdash; look above.' }
    }
}
🎯 Try this next — make it yours

You have a working cli task manager. Extend it:

  1. Validate the task number. Print a friendly error instead of crashing when done gets an out-of-range number. (Teaches: a bounds check before indexing.)
  2. Add a remove command. Delete a task by number, as the to-do list project does. (Teaches: Array.prototype.splice.)
  3. Support flags. Let list --done show only completed tasks. (Teaches: checking for a flag inside rest.)
  4. Colour the output. Print done tasks in green using ANSI escape codes. (Teaches: terminal escape sequences, no library required.)
What you learned
You learned to parse 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.