TypeScript: Build a CLI with tsc, the bin Field and npm link (Shebang Included)
November 6, 2026
Welcome to episode 33 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! This is the last episode of the task tracker. So far we ran it with node and a file name. A real tool has a name, like git or npm, and runs from anywhere. Today we build it, and install it. We are in the tasks project, in the cli file. Every code block below is the exact code and output from the video, ready to copy.
- All episodes are in the TypeScript from Zero playlist.
- Versions used: Node.js 26.13.0, npm 11.20.0, TypeScript 7.0.2.
Setup
We start in the terminal and open the project in VS Code:
cd tasks
code .1. The CLI
First we put the pieces together. We replace the cli file with the full program. It loads the tasks, and reads the command. Add saves a new task, list prints all of them, and anything else shows how to use the tool. We try it with node.
src/cli.ts:
import { parseArgs } from "node:util";
import { describe } from "./task.ts";
import type { Task } from "./task.ts";
import { loadTasks, saveTasks } from "./store.ts";
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
allowPositionals: true,
options: {
title: { type: "string", short: "t" },
},
});
const tasks = await loadTasks();
const command = positionals[0];
if (command === "add" && values.title !== undefined) {
const task: Task = { id: tasks.length + 1, title: values.title, status: "todo" };
await saveTasks([...tasks, task]);
console.log("Added:", describe(task));
} else if (command === "list") {
for (const task of tasks) {
console.log(describe(task));
}
} else {
console.log("Usage: tasks add --title <text> | tasks list");
}$ node src/cli.ts add --title "Buy milk"
Added: #1 Buy milk (not started)$ node src/cli.ts list
#1 Buy milk (not started)2. Build
Node can run our files directly, but a tool that we share should be plain JavaScript. tsc, without any file name, builds the whole project into the dist folder, as set in the T S config file. In dist we find the finished files. And look at the first lines of cli.js: the imports end with.js, not.ts. That is the rewrite option at work.
$ npx tsc$ ls dist/*.js
dist/cli.js
dist/demo.js
dist/errors.js
dist/index.js
dist/show.js
dist/stats.js
dist/store.js
dist/task.js
dist/update.js$ head -4 dist/cli.js
import { parseArgs } from "node:util";
import { describe } from "./task.js";
import { loadTasks, saveTasks } from "./store.js";
const { values, positionals } = parseArgs({3. bin
To get a command, we add a bin field to the package file. It says: the command tasks runs the file dist slash cli.js. npm link then installs the command on this computer, using a link to our folder. We try it. And it fails with strange errors. The system does not know that this is a Node program.
$ npm pkg set bin.tasks=dist/cli.js$ npm link
added 1 package, and audited 3 packages in 85ms
found 0 vulnerabilities$ tasks list
/opt/node26/bin/tasks: 1: import: not found
/opt/node26/bin/tasks: 2: import: not found
/opt/node26/bin/tasks: 3: import: not found
/opt/node26/bin/tasks: 4: Syntax error: "(" unexpected4. Shebang
The first line of a program tells the system which program should run it. It is called a shebang: hash, exclamation mark, then env node. We add it at the top of the source file, build again, and tsc keeps it in the output. Now the command works from anywhere. We add a task, and list them.
src/cli.ts:
#!/usr/bin/env node
import { parseArgs } from "node:util";
import { describe } from "./task.ts";
import type { Task } from "./task.ts";
import { loadTasks, saveTasks } from "./store.ts";
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
allowPositionals: true,
options: {
title: { type: "string", short: "t" },
},
});
const tasks = await loadTasks();
const command = positionals[0];
if (command === "add" && values.title !== undefined) {
const task: Task = { id: tasks.length + 1, title: values.title, status: "todo" };
await saveTasks([...tasks, task]);
console.log("Added:", describe(task));
} else if (command === "list") {
for (const task of tasks) {
console.log(describe(task));
}
} else {
console.log("Usage: tasks add --title <text> | tasks list");
}$ npx tsc$ tasks list
#1 Buy milk (not started)$ tasks add --title "Ship it"
Added: #2 Ship it (not started)Recap
Today: the full tool, tsc builds the project into dist, the bin field, the shebang line, and npm link. That finishes Part 3, and our task tracker. Next episode: Part 4, where we check data with a library called Zod.