In the previous tutorial, we learned about TypeScript configuration. Now let’s put everything together and build a complete CLI tool — a bookmark manager that stores, searches, and exports bookmarks from your terminal.
This is the final project of the TypeScript tutorial series. We will use Commander for argument parsing, Zod for validation, chalk for colors, and TypeScript for type safety throughout.
What We Are Building
A CLI tool called bm (bookmark manager) with these commands:
bm add "TypeScript Docs" https://typescriptlang.org --tags typescript,docs
bm list
bm list --tag typescript
bm search "docs"
bm delete <id>
bm export --format json
bm export --format csv
Project Setup
mkdir bookmark-cli
cd bookmark-cli
npm init -y
Install dependencies:
npm install commander chalk zod
npm install --save-dev typescript @types/node tsx
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}
package.json
{
"name": "bookmark-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"bm": "./dist/index.js"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Project Structure
src/
index.ts # Entry point — CLI commands
types.ts # Type definitions and Zod schemas
store.ts # File-based storage
commands/
add.ts # Add bookmark command
list.ts # List bookmarks command
search.ts # Search bookmarks command
delete.ts # Delete bookmark command
export.ts # Export bookmarks command
Define Types and Schemas
// src/types.ts
import { z } from "zod";
export const BookmarkSchema = z.object({
id: z.string(),
title: z.string().min(1),
url: z.string().url(),
tags: z.array(z.string()).default([]),
createdAt: z.string().datetime(),
});
export type Bookmark = z.infer<typeof BookmarkSchema>;
export const BookmarkStoreSchema = z.object({
bookmarks: z.array(BookmarkSchema),
});
export type BookmarkStore = z.infer<typeof BookmarkStoreSchema>;
export const AddBookmarkInput = z.object({
title: z.string().min(1, "Title is required"),
url: z.string().url("Invalid URL format"),
tags: z.array(z.string()).default([]),
});
export type AddBookmarkInputType = z.infer<typeof AddBookmarkInput>;
File-Based Storage
// src/store.ts
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { Bookmark, BookmarkStore, BookmarkStoreSchema } from "./types.js";
const STORE_PATH = path.join(os.homedir(), ".bookmarks.json");
function readStore(): BookmarkStore {
try {
const data = fs.readFileSync(STORE_PATH, "utf-8");
return BookmarkStoreSchema.parse(JSON.parse(data));
} catch {
return { bookmarks: [] };
}
}
function writeStore(store: BookmarkStore): void {
fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
}
export function getAllBookmarks(): Bookmark[] {
return readStore().bookmarks;
}
export function addBookmark(bookmark: Bookmark): void {
const store = readStore();
store.bookmarks.push(bookmark);
writeStore(store);
}
export function deleteBookmark(id: string): boolean {
const store = readStore();
const index = store.bookmarks.findIndex(b => b.id === id);
if (index === -1) return false;
store.bookmarks.splice(index, 1);
writeStore(store);
return true;
}
export function searchBookmarks(query: string): Bookmark[] {
const bookmarks = getAllBookmarks();
const lower = query.toLowerCase();
return bookmarks.filter(
b =>
b.title.toLowerCase().includes(lower) ||
b.url.toLowerCase().includes(lower) ||
b.tags.some(t => t.toLowerCase().includes(lower))
);
}
export function getBookmarksByTag(tag: string): Bookmark[] {
const bookmarks = getAllBookmarks();
return bookmarks.filter(b =>
b.tags.some(t => t.toLowerCase() === tag.toLowerCase())
);
}
Command: Add Bookmark
// src/commands/add.ts
import crypto from "node:crypto";
import chalk from "chalk";
import { AddBookmarkInput, Bookmark } from "../types.js";
import { addBookmark } from "../store.js";
export function handleAdd(title: string, url: string, options: { tags?: string }): void {
const tags = options.tags ? options.tags.split(",").map(t => t.trim()) : [];
const result = AddBookmarkInput.safeParse({ title, url, tags });
if (!result.success) {
for (const issue of result.error.issues) {
console.error(chalk.red(`Error: ${issue.message}`));
}
process.exit(1);
}
const bookmark: Bookmark = {
id: crypto.randomUUID().slice(0, 8),
title: result.data.title,
url: result.data.url,
tags: result.data.tags,
createdAt: new Date().toISOString(),
};
addBookmark(bookmark);
console.log(chalk.green(`Added: ${bookmark.title}`));
console.log(chalk.gray(` ID: ${bookmark.id}`));
console.log(chalk.gray(` URL: ${bookmark.url}`));
if (bookmark.tags.length > 0) {
console.log(chalk.gray(` Tags: ${bookmark.tags.join(", ")}`));
}
}
Command: List Bookmarks
// src/commands/list.ts
import chalk from "chalk";
import { Bookmark } from "../types.js";
import { getAllBookmarks, getBookmarksByTag } from "../store.js";
function formatBookmark(bookmark: Bookmark): void {
console.log(chalk.bold(` ${bookmark.title}`));
console.log(chalk.blue(` ${bookmark.url}`));
console.log(chalk.gray(` ID: ${bookmark.id} Tags: ${bookmark.tags.join(", ") || "none"}`));
console.log();
}
export function handleList(options: { tag?: string }): void {
const bookmarks = options.tag
? getBookmarksByTag(options.tag)
: getAllBookmarks();
if (bookmarks.length === 0) {
console.log(chalk.yellow("No bookmarks found."));
return;
}
console.log(chalk.bold(`\nBookmarks (${bookmarks.length}):\n`));
for (const bookmark of bookmarks) {
formatBookmark(bookmark);
}
}
Command: Search Bookmarks
// src/commands/search.ts
import chalk from "chalk";
import { searchBookmarks } from "../store.js";
export function handleSearch(query: string): void {
const results = searchBookmarks(query);
if (results.length === 0) {
console.log(chalk.yellow(`No bookmarks matching "${query}".`));
return;
}
console.log(chalk.bold(`\nResults for "${query}" (${results.length}):\n`));
for (const bookmark of results) {
console.log(chalk.bold(` ${bookmark.title}`));
console.log(chalk.blue(` ${bookmark.url}`));
console.log(chalk.gray(` ID: ${bookmark.id}`));
console.log();
}
}
Command: Delete Bookmark
// src/commands/delete.ts
import chalk from "chalk";
import { deleteBookmark } from "../store.js";
export function handleDelete(id: string): void {
const deleted = deleteBookmark(id);
if (deleted) {
console.log(chalk.green(`Deleted bookmark ${id}.`));
} else {
console.log(chalk.red(`Bookmark ${id} not found.`));
process.exit(1);
}
}
Command: Export Bookmarks
// src/commands/export.ts
import chalk from "chalk";
import { getAllBookmarks } from "../store.js";
type ExportFormat = "json" | "csv";
export function handleExport(options: { format: string }): void {
const format = options.format as ExportFormat;
const bookmarks = getAllBookmarks();
if (bookmarks.length === 0) {
console.log(chalk.yellow("No bookmarks to export."));
return;
}
switch (format) {
case "json":
console.log(JSON.stringify(bookmarks, null, 2));
break;
case "csv": {
// CSV escape: wrap in quotes and double any internal quotes
const csvField = (v: string) => `"${v.replace(/"/g, '""')}"`;
console.log("id,title,url,tags,createdAt");
for (const b of bookmarks) {
const row = [
csvField(b.id),
csvField(b.title),
csvField(b.url),
csvField(b.tags.join(";")),
csvField(b.createdAt),
].join(",");
console.log(row);
}
break;
}
default:
console.error(chalk.red(`Unknown format: ${format}. Use "json" or "csv".`));
process.exit(1);
}
}
Entry Point — Wiring Everything Together
#!/usr/bin/env node
// src/index.ts
import { Command } from "commander";
import { handleAdd } from "./commands/add.js";
import { handleList } from "./commands/list.js";
import { handleSearch } from "./commands/search.js";
import { handleDelete } from "./commands/delete.js";
import { handleExport } from "./commands/export.js";
const program = new Command();
program
.name("bm")
.description("A type-safe bookmark manager for your terminal")
.version("1.0.0");
program
.command("add")
.description("Add a new bookmark")
.argument("<title>", "Bookmark title")
.argument("<url>", "Bookmark URL")
.option("-t, --tags <tags>", "Comma-separated tags")
.action(handleAdd);
program
.command("list")
.description("List all bookmarks")
.option("-t, --tag <tag>", "Filter by tag")
.action(handleList);
program
.command("search")
.description("Search bookmarks")
.argument("<query>", "Search query")
.action(handleSearch);
program
.command("delete")
.description("Delete a bookmark")
.argument("<id>", "Bookmark ID")
.action(handleDelete);
program
.command("export")
.description("Export bookmarks")
.option("-f, --format <format>", "Export format (json or csv)", "json")
.action(handleExport);
program.parse();
Building and Running
Development
npx tsx src/index.ts add "TypeScript Docs" https://typescriptlang.org --tags typescript,docs
npx tsx src/index.ts list
npx tsx src/index.ts search "typescript"
Production Build
npx tsc
node dist/index.js list
Install Globally
npm link
bm add "GitHub" https://github.com --tags dev,code
bm list
Publishing to npm
Update package.json:
{
"name": "@kemalcodes/bookmark-cli",
"version": "1.0.0",
"bin": {
"bm": "./dist/index.js"
},
"files": ["dist"],
"scripts": {
"prepublishOnly": "tsc"
}
}
Make sure the entry point has the shebang line:
#!/usr/bin/env node
Publish:
npm publish --access public
Now anyone can install and use your tool:
npx @kemalcodes/bookmark-cli add "TypeScript" https://typescriptlang.org
What We Covered in This Series
This was the final tutorial in the TypeScript series. Here is what you learned across all 25 tutorials:
Foundations (1-8): Types, functions, interfaces, unions, enums, type narrowing Intermediate (9-16): Generics, classes, modules, utility types, mapped types, template literals, error handling, async/await Practical (17-22): React, Node.js, Next.js, testing, Zod, tRPC Advanced (23-25): Advanced patterns, tsconfig, and this CLI project
You now have the knowledge to build type-safe applications at every level — from small scripts to full-stack production applications.