Build a Bookmark Manager CLI
Build a TypeScript CLI tool that saves, searches, and deletes bookmarks.
Introduction
30 Second Summary
You save links constantly. Documentation pages, tutorials, blog posts, tools you want to try later. But they pile up across browser tabs, notes apps, and random bookmarks folders until you can never find them again.
In this project, you will build a command-line bookmark manager from scratch using TypeScript and Node.js. Every line of logic is yours. No frameworks, no libraries, just you translating ideas into working code.
What You'll Build
A command-line bookmark manager that persists data to a local JSON file, with commands for adding, listing, searching, filtering, and deleting bookmarks.
By the end of this project, you'll have:
- A working CLI tool with 5 commands (add, list, delete, search, filter) that you built from an empty folder.
- Written every line of logic yourself using only TypeScript interfaces and Node.js built-ins with zero external libraries.
- A transferable mental model for structuring code into layers: define your data shape first, then write operations on that shape.
- Secret Mission: add tag statistics and upgrade your filter command to support multiple tags with AND/OR logic.
Are there any prerequisites?
You should have a basic understanding of what variables, functions, arrays, and objects are. You do not need to be able to write them from scratch yet. This project is designed to rebuild that muscle.
Set Up the Project
To build your bookmark manager, you need a TypeScript project that runs directly on Node.js without any extra compilation steps.
In this step, you'll create the project folder, install TypeScript, configure it for native execution, and verify everything works before writing any application code.
In this step, get ready to:
- Initialize a new npm project and install TypeScript.
- Configure TypeScript for native Node.js execution.
- Verify that TypeScript files run directly with node.
Initialize the project and install TypeScript
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir bookmark-cli
cd bookmark-cli
- Confirm the folder exists by running:
ls
You should see the bookmark-cli folder listed in the output.
- Initialize a new npm project by running:
npm init -y
This creates a package.json file with default settings for your project.
- Install TypeScript and Node.js type definitions as dev dependencies by running:
npm install -D typescript @types/node
What does this command do?
- typescript is the TypeScript compiler that checks your code for type errors.
- @types/node provides type definitions for Node.js built-in modules like fs and process.
- The -D flag saves them as development dependencies since they are only needed during development, not at runtime.
- Confirm TypeScript installed correctly by running:
npx tsc --version
You should see Version 6.0.3 (or a similar version number) in the output.
Don't see a version number?
- Make sure you ran npm install -D typescript @types/node from inside the bookmark-cli folder.
- Check that a node_modules folder exists in your project directory.
- If you see a permissions error, try closing and reopening your terminal.
Why is my TypeScript install not working?
Configure TypeScript and the project
Node.js can run TypeScript files directly by stripping out the type annotations at runtime. But you still need a configuration file so your editor and the type checker know your project's rules.
- Create a new file called tsconfig.json in the root of your bookmark-cli folder.
- Add the following configuration:
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}
What do these settings do?
- strict enables all strict type-checking options so TypeScript catches more bugs.
- noEmit tells TypeScript to only check types without generating JavaScript files. Node.js runs your .ts files directly.
- erasableSyntaxOnly prevents you from using TypeScript features (like enums) that require code generation, which would break native execution.
- verbatimModuleSyntax ensures you use import type for type-only imports so Node.js doesn't try to load them as values at runtime.
- Save tsconfig.json.
Now you need to update package.json to enable ES modules and add a type-checking script.
- Open package.json in your editor.
- Replace its entire contents with:
{
"name": "bookmark-cli",
"version": "1.0.0",
"type": "module",
"scripts": {
"check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^6.0.3",
"@types/node": "^22.0.0"
}
}
What changed in package.json?
- "type": "module" tells Node.js to use import/export syntax instead of the older require() style.
- The check script runs the TypeScript compiler for type checking only. You will use this throughout the project.
- The extra fields from npm init (like main, keywords, license) are removed because this project does not need them.
Your devDependencies version numbers may be slightly different (like ^22.15.3 instead of ^22.0.0). That is fine. The caret range means any compatible version works.
- Save package.json.
- Create the src/ directory where all your source files will live by running:
mkdir src
- Confirm the directory was created by running:
ls
You should see src, node_modules, package.json, package-lock.json, and tsconfig.json in the output.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}
Here is the full package.json:
{
"name": "bookmark-cli",
"version": "1.0.0",
"type": "module",
"scripts": {
"check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^6.0.3",
"@types/node": "^22.0.0"
}
}
Verify TypeScript runs natively
Node.js v22.18.0+ can execute TypeScript files directly by stripping type annotations at runtime. Let's confirm this works in your project.
- Create a test file by adding the following code to a new file src/test.ts:
// Quick test to confirm TypeScript runs natively
const greeting: string = 'TypeScript is running natively!';
console.log(greeting);
- Save src/test.ts.
- Run the file directly with Node.js:
node src/test.ts
You should see TypeScript is running natively! printed in your terminal.
How does this work without compiling?
Node.js reads your .ts file and strips out the type annotation (: string) before executing. The runtime never sees the TypeScript syntax. It just runs plain JavaScript.
This means you get instant feedback without a build step. TypeScript still checks your types separately when you ask it to.
- Confirm the type checker works separately by running:
npx tsc --noEmit
No output means zero type errors. The type checker scanned your code and found nothing wrong.
Seeing an error from tsc?
- Make sure tsconfig.json is in the root of your bookmark-cli folder (not inside src/).
- Check that "include": ["src/**/*.ts"] is present in your tsconfig.json.
- Verify you have the src/test.ts file saved inside the src/ directory.
Why is tsc --noEmit showing errors?
- Delete the test file since you no longer need it:
rm src/test.ts
Your project is now fully configured. Node.js runs your TypeScript directly, and the type checker catches bugs without generating any JavaScript files.
Your environment is ready. Next up, you'll define the data shapes for your bookmarks and build the storage layer that reads and writes them to disk.
Define Types and Build the Storage Layer
Your TypeScript environment is ready to go. Now it's time to write real code.
Before writing any logic, you need to define what your data looks like. TypeScript interfaces let you describe the exact shape of a bookmark before you write a single function that creates or manipulates one. Once the shapes are locked in, you'll build a storage layer that reads and writes bookmarks to a JSON file on disk.
In this step, get ready to:
- Define TypeScript interfaces for your bookmark data.
- Build a storage module that reads and writes a JSON file.
- Test that your storage layer persists data correctly.
Define your data shapes in src/types.ts
Every bookmark in your manager needs the same set of fields: an ID, a URL, a title, some tags, and a creation timestamp. Defining these fields as a TypeScript interface means the compiler will catch you if you ever try to create a bookmark with missing or mistyped fields.
- Create a new file called src/types.ts and add the following code:
export interface Bookmark {
id: string;
url: string;
title: string;
tags: string[];
createdAt: string;
}
export interface BookmarkStore {
bookmarks: Bookmark[];
}
What does this code do?
You've defined two interfaces that describe your data:
- Bookmark describes a single bookmark with five fields. The tags field is an array of strings so a bookmark can belong to multiple categories.
- BookmarkStore wraps an array of bookmarks. This is the shape of the entire JSON file on disk.
- export makes these interfaces available to other files that need to reference the same shapes.
- Save src/types.ts.
- Confirm TypeScript is happy with your types by running:
npx tsc --noEmit
You should see no output at all. Silence from the TypeScript compiler means zero errors.
Seeing an error?
- Check that the file is saved in the src/ folder, not in the project root. Your tsconfig.json only includes files matching src/**/*.ts.
- Make sure you used string[] for the tags field, not Array<string>. Both work, but double-check the syntax matches exactly.
Why is TypeScript rejecting my types?
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
export interface Bookmark {
id: string;
url: string;
title: string;
tags: string[];
createdAt: string;
}
export interface BookmarkStore {
bookmarks: Bookmark[];
}
Build the storage module in src/storage.ts
Your types describe what a bookmark looks like. Now you need a way to save bookmarks to disk and load them back. You'll isolate all file system access into a single module so the rest of your code never touches the disk directly.
- Create a new file called src/storage.ts and add the imports, file path constant, and loadStore function:
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import type { BookmarkStore } from './types.ts';
const STORE_PATH = './bookmarks.json';
export function loadStore(): BookmarkStore {
// If the file doesn't exist yet, return an empty store
if (!existsSync(STORE_PATH)) {
return { bookmarks: [] };
}
// Read the file and parse it as JSON
const raw = readFileSync(STORE_PATH, 'utf-8');
return JSON.parse(raw) as BookmarkStore;
}
What does this code do?
- readFileSync, writeFileSync, and existsSync are Node.js built-in functions for working with files. The node:fs prefix tells Node.js you want its built-in file system module.
- import type is required because BookmarkStore is only used as a type annotation. Node.js type stripping needs the type keyword to know this import can be erased at runtime.
- STORE_PATH is a constant so you only define the filename in one place.
- loadStore handles the first-run case gracefully. If no JSON file exists yet, it returns an empty store instead of crashing.
Why import type instead of import?
Node.js runs your .ts files by stripping away all type annotations at runtime. If you use a plain import for something that only exists as a type, Node.js will try to load it as a real value and throw an error. The import type syntax tells both TypeScript and Node.js that this import is purely for type checking and can be completely removed at runtime.
- Add the saveStore function below loadStore:
export function saveStore(store: BookmarkStore): void {
// Convert the store object to formatted JSON
const data = JSON.stringify(store, null, 2);
writeFileSync(STORE_PATH, data, 'utf-8');
}
What does this code do?
- JSON.stringify(store, null, 2) converts your bookmark data into a nicely formatted JSON string. The 2 adds two-space indentation so the file is human-readable.
- writeFileSync writes that string to disk, replacing anything that was there before.
- The return type void tells TypeScript this function doesn't return anything. It just writes and exits.
- Save src/storage.ts.
- Run the type checker to confirm everything compiles:
npx tsc --noEmit
You should see no output. Both files pass type checking.
Getting a module or import error?
- Make sure your import path includes the .ts extension: ./types.ts not ./types. Node.js requires explicit file extensions.
- Check that you used import type (with the type keyword) for the BookmarkStore import. A plain import will cause an error because verbatimModuleSyntax is enabled in your tsconfig.json.
Help me fix my storage.ts import errors
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import type { BookmarkStore } from './types.ts';
const STORE_PATH = './bookmarks.json';
export function loadStore(): BookmarkStore {
if (!existsSync(STORE_PATH)) {
return { bookmarks: [] };
}
const raw = readFileSync(STORE_PATH, 'utf-8');
return JSON.parse(raw) as BookmarkStore;
}
export function saveStore(store: BookmarkStore): void {
const data = JSON.stringify(store, null, 2);
writeFileSync(STORE_PATH, data, 'utf-8');
}
Test the storage layer
Your storage module compiles, but you haven't seen it actually work yet. A quick throwaway script will confirm that data survives a round trip to disk.
- Create a temporary test file src/test-storage.ts with the following code:
import { loadStore, saveStore } from './storage.ts';
// Load the store (should be empty on first run)
const store = loadStore();
console.log('Initial store:', store);
// Add a test bookmark and save
store.bookmarks.push({
id: 'test-1',
url: 'https://example.com',
title: 'Test Bookmark',
tags: ['test'],
createdAt: new Date().toISOString(),
});
saveStore(store);
// Reload from disk to confirm persistence
const reloaded = loadStore();
console.log('After save:', reloaded);
What does this code do?
This script exercises the full read-write cycle. It loads an empty store, pushes a fake bookmark into it, saves to disk, then reads it back. If the reloaded data matches what you saved, your storage layer works.
- Run the test script:
node src/test-storage.ts
You should see two lines of output. The first shows an empty bookmarks array. The second shows your test bookmark with all five fields populated.
- Confirm that bookmarks.json was created in your project root by running:
ls bookmarks.json
You should see bookmarks.json listed in the output.
Not seeing the expected output?
- If you see an error about import syntax, confirm your package.json has "type": "module". Without it, Node.js treats your files as CommonJS and rejects ES module imports.
- If the file runs but bookmarks.json doesn't appear, make sure you're running the command from the bookmark-cli project directory (not from inside src/).
Help me debug my storage test
- Now that you've verified everything works, clean up the test files by running:
rm src/test-storage.ts bookmarks.json
Your test script and the generated JSON file are gone. The storage module itself remains ready for use.
Your data layer is locked in. You have types that describe what a bookmark is, and two functions that handle all disk access. Next up, you'll build the command router that accepts instructions from the terminal and decides what to do with them.
Build the CLI Argument Parser and Command Router
Your storage layer handles reading and writing data. Now you need a way for users to actually talk to your tool.
Every CLI tool needs an entry point that listens for what the user typed, figures out which command they want, and routes them to the right function. In this step, you'll build that routing layer from scratch using nothing but process.argv and a switch statement.
In this step, get ready to:
- Parse command-line arguments from process.argv.
- Write a help command that shows usage instructions.
- Build a switch-based command router with input validation.
Create the entry point src/index.ts
When you run a command like node src/index.ts add https://example.com "My Site", Node.js stores every word in an array called process.argv. The first two entries are always the path to Node.js and the path to your script. Everything after that is what the user actually typed.
- Create a new file called src/index.ts in your code editor.
- Add the argument parsing and help function by pasting this code:
// Grab everything after "node src/index.ts"
const args = process.argv.slice(2);
// The first argument is always the command name (e.g. "add", "list", "help")
const command = args[0];
// Display usage instructions for all available commands
function printHelp(): void {
console.log(`
Bookmark Manager CLI
Usage:
node src/index.ts <command> [options]
Commands:
add <url> <title> [tags] Add a new bookmark (tags are comma-separated)
list List all bookmarks
delete <id> Delete a bookmark by ID
search <query> Search bookmarks by title or URL
filter <tag> Filter bookmarks by tag
help Show this help message
Examples:
node src/index.ts add https://nodejs.org "Node.js Docs" "docs,node"
node src/index.ts search node
node src/index.ts filter docs
node src/index.ts delete abc123
`);
}
What does this code do?
process.argv.slice(2) strips out the Node.js path and script path, leaving only the user's actual input as an array of strings.
args[0] is always the command name. The remaining items (args[1], args[2], etc.) are the arguments for that specific command.
printHelp() outputs a template literal with all the commands, their required arguments, and examples. This is the first thing a user sees when they need guidance.
- Save src/index.ts.
- You won't see a change yet. The next chunk adds the switch statement that actually uses these variables.
Implement the help command and input validation
The router needs to look at the command name and decide what to do. A switch statement is perfect here because each command is a distinct string to match against.
- In src/index.ts, add the following switch statement below the printHelp function:
switch (command) {
case 'add': {
// Extract URL and title from the arguments
const url = args[1];
const title = args[2];
// Validate that required arguments are present
if (!url || !title) {
console.log('Usage: node src/index.ts add <url> <title> [tags]');
process.exit(1);
}
break;
}
case 'list': {
break;
}
case 'delete': {
const id = args[1];
if (!id) {
console.log('Usage: node src/index.ts delete <id>');
process.exit(1);
}
break;
}
case 'search': {
const query = args[1];
if (!query) {
console.log('Usage: node src/index.ts search <query>');
process.exit(1);
}
break;
}
case 'filter': {
const tag = args[1];
if (!tag) {
console.log('Usage: node src/index.ts filter <tag>');
process.exit(1);
}
break;
}
case 'help':
default: {
printHelp();
break;
}
}
What does this code do?
Each case matches one command name. The curly braces around each case create a block scope, so variables like url and id don't leak between cases.
Each case that requires arguments validates them immediately. If something is missing, it prints a usage hint and exits with code 1 (which signals an error to the terminal).
The help case and default share the same block. If someone types an unrecognized command (or no command at all), they get the help message.
Why a switch instead of if/else?
A switch statement makes the routing intent obvious at a glance. Each case reads like a menu of commands. It also gives you block scoping with curly braces and a clean default fallback for unrecognized input.
- Save src/index.ts.
- Test it by running this command in your terminal:
node src/index.ts help
You should see the full help message listing all commands, their arguments, and examples.
Seeing an error instead of the help message?
- Check that your file is saved as src/index.ts (not in the project root).
- Make sure the switch statement is placed below the printHelp function, not inside it.
- Verify your package.json still has "type": "module". Without it, Node.js won't recognize the file as an ES module.
Help me debug my index.ts file
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
const args = process.argv.slice(2);
const command = args[0];
function printHelp(): void {
console.log(`
Bookmark Manager CLI
Usage:
node src/index.ts <command> [options]
Commands:
add <url> <title> [tags] Add a new bookmark (tags are comma-separated)
list List all bookmarks
delete <id> Delete a bookmark by ID
search <query> Search bookmarks by title or URL
filter <tag> Filter bookmarks by tag
help Show this help message
Examples:
node src/index.ts add https://nodejs.org "Node.js Docs" "docs,node"
node src/index.ts search node
node src/index.ts filter docs
node src/index.ts delete abc123
`);
}
switch (command) {
case 'add': {
const url = args[1];
const title = args[2];
if (!url || !title) {
console.log('Usage: node src/index.ts add <url> <title> [tags]');
process.exit(1);
}
break;
}
case 'list': {
break;
}
case 'delete': {
const id = args[1];
if (!id) {
console.log('Usage: node src/index.ts delete <id>');
process.exit(1);
}
break;
}
case 'search': {
const query = args[1];
if (!query) {
console.log('Usage: node src/index.ts search <query>');
process.exit(1);
}
break;
}
case 'filter': {
const tag = args[1];
if (!tag) {
console.log('Usage: node src/index.ts filter <tag>');
process.exit(1);
}
break;
}
case 'help':
default: {
printHelp();
break;
}
}
Test the router
The router's job is to parse arguments and guard against missing input. It does not contain any business logic. Let's confirm both the happy path and the error path work.
- Test the error path by running the add command without any arguments:
node src/index.ts add
You should see the usage message: Usage: node src/index.ts add <url> <title> [tags].
- Confirm type checking still passes by running:
npx tsc --noEmit
You should see no output, which means zero type errors across all files.
Seeing type errors from tsc?
- Make sure you haven't accidentally added an import statement at the top of src/index.ts. This file doesn't import anything yet.
- Check for mismatched curly braces in the switch statement. Each case needs both an opening { and closing }.
Help me fix my TypeScript errors
Why doesn't the router contain business logic?
The router's only job is to figure out which command the user wants and validate that they provided enough information. The actual work (adding bookmarks, searching, deleting) will live in a separate commands.ts file.
This separation means you can change how a command works without touching the argument parsing, and vice versa.
Your CLI now understands commands and validates input. Next up, you'll implement the actual command functions that add, list, and delete bookmarks.
Implement Add, List, and Delete Commands
You have a solid foundation: types that define what a bookmark looks like, and a storage layer that can read and write to disk. Now it is time to write the actual operations that create, display, and remove bookmarks.
Each command is a small logic puzzle. You will use array methods like find, findIndex, and splice to manipulate the bookmarks array. By the end of this step, you will have three working commands that persist data between runs.
In this step, get ready to:
- Implement the addBookmark function with duplicate detection.
- Implement the listBookmarks function with formatted output.
- Implement the deleteBookmark function using findIndex and splice.
Create the commands file and implement addBookmark
Every command needs to load the store, do something to it, and save it back. The addBookmark function also needs to generate a unique ID and check for duplicate URLs before saving.
- Create a new file called src/commands.ts in your code editor.
- Add the following code to set up the imports, ID generator, and addBookmark function:
import { loadStore, saveStore } from './storage.ts';
import type { Bookmark } from './types.ts';
// Generate a unique ID from timestamp + random characters
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
export function addBookmark(url: string, title: string, tags: string[]): void {
const store = loadStore();
// Check if a bookmark with this URL already exists
const duplicate = store.bookmarks.find((b) => b.url === url);
if (duplicate) {
console.log(`Bookmark already exists: "${duplicate.title}" (${duplicate.url})`);
return;
}
// Build the new bookmark object
const bookmark: Bookmark = {
id: generateId(),
url,
title,
tags,
createdAt: new Date().toISOString(),
};
// Add to the store and save to disk
store.bookmarks.push(bookmark);
saveStore(store);
console.log(`Added: "${title}" (${url})`);
}
What does this code do?
The file starts by importing your storage functions and the Bookmark type. Notice the import type keyword. Node.js requires this for imports that are only used as types, because type stripping erases them at runtime.
The generateId helper creates a short, unique string by combining the current timestamp (as a base-36 string) with 6 random characters. It is not exported because only this module uses it.
The addBookmark function uses find to check for duplicate URLs before saving. If a match exists, it logs a message and returns early instead of creating a duplicate entry.
- Save src/commands.ts.
- Confirm the types are correct by running:
npx tsc --noEmit
You should see no output, which means zero type errors.
Seeing a type error?
Check that your import uses import type { Bookmark } with the type keyword. Without it, Node.js treats the import as a value import and the TypeScript compiler flags it under verbatimModuleSyntax.
Also confirm your import paths end in .ts (e.g. './storage.ts' not './storage').
help me fix a type error in my commands.ts file
Implement listBookmarks
The list command loads the store, handles the case where no bookmarks exist yet, and formats each bookmark as a readable line.
- In src/commands.ts, add the following function below addBookmark:
export function listBookmarks(): void {
const store = loadStore();
// Handle the empty store case
if (store.bookmarks.length === 0) {
console.log('No bookmarks saved yet.');
return;
}
// Print a header with the total count
console.log(`\nYou have ${store.bookmarks.length} bookmark(s):\n`);
// Format each bookmark as: id | title | url [tags]
for (const b of store.bookmarks) {
const tags = b.tags.length > 0 ? ` [${b.tags.join(', ')}]` : '';
console.log(` ${b.id} | ${b.title} | ${b.url}${tags}`);
}
console.log('');
}
What does this code do?
The function first checks for an empty array and returns early with a friendly message. This prevents the loop from running on nothing and showing a confusing blank output.
For each bookmark, it builds a formatted string with the ID, title, URL, and tags. The ternary on b.tags.length ensures the brackets only appear when there are actual tags.
- Save src/commands.ts.
- Run npx tsc --noEmit again to confirm no type errors.
Getting a 'not assignable' error?
Make sure the for...of loop variable is const b and that you are accessing properties like b.tags that match the Bookmark interface exactly.
help me fix a type assignment error in listBookmarks
Implement deleteBookmark and wire up the router
The delete command finds a bookmark by ID, removes it from the array, and saves the updated store. After implementing it, you will connect all three commands to the router in src/index.ts so you can test the full flow.
- In src/commands.ts, add the following function below listBookmarks:
export function deleteBookmark(id: string): void {
const store = loadStore();
// Find the position of the bookmark with this ID
const index = store.bookmarks.findIndex((b) => b.id === id);
if (index === -1) {
console.log(`No bookmark found with id: ${id}`);
return;
}
// Remove the bookmark and save
const removed = store.bookmarks.splice(index, 1)[0];
saveStore(store);
console.log(`Deleted: "${removed.title}" (${removed.url})`);
}
What does this code do?
The findIndex method returns the position of the first matching element, or -1 if nothing matches. This lets you check for a missing bookmark before trying to remove it.
The splice(index, 1) call removes exactly one element at that position and returns it in an array. Grabbing [0] pulls out the removed bookmark so you can log its title in the confirmation message.
- Save src/commands.ts.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/commands.ts before moving on.
ⓧ I'd like to double check the full code
import { loadStore, saveStore } from './storage.ts';
import type { Bookmark } from './types.ts';
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
export function addBookmark(url: string, title: string, tags: string[]): void {
const store = loadStore();
const duplicate = store.bookmarks.find((b) => b.url === url);
if (duplicate) {
console.log(`Bookmark already exists: "${duplicate.title}" (${duplicate.url})`);
return;
}
const bookmark: Bookmark = {
id: generateId(),
url,
title,
tags,
createdAt: new Date().toISOString(),
};
store.bookmarks.push(bookmark);
saveStore(store);
console.log(`Added: "${title}" (${url})`);
}
export function listBookmarks(): void {
const store = loadStore();
if (store.bookmarks.length === 0) {
console.log('No bookmarks saved yet.');
return;
}
console.log(`\nYou have ${store.bookmarks.length} bookmark(s):\n`);
for (const b of store.bookmarks) {
const tags = b.tags.length > 0 ? ` [${b.tags.join(', ')}]` : '';
console.log(` ${b.id} | ${b.title} | ${b.url}${tags}`);
}
console.log('');
}
export function deleteBookmark(id: string): void {
const store = loadStore();
const index = store.bookmarks.findIndex((b) => b.id === id);
if (index === -1) {
console.log(`No bookmark found with id: ${id}`);
return;
}
const removed = store.bookmarks.splice(index, 1)[0];
saveStore(store);
console.log(`Deleted: "${removed.title}" (${removed.url})`);
}
Now you need to connect these functions to the router. Your src/index.ts currently has the switch cases ready, but they do not call anything yet.
- In src/index.ts, add this import block at the very top of the file (above const args):
import {
addBookmark,
listBookmarks,
deleteBookmark,
} from './commands.ts';
- In the case 'add' block, find the line that says const title = args[2]; and add this line directly below it:
const tags = args[3] ? args[3].split(',').map((t) => t.trim()) : [];
- Still in the case 'add' block, find the line console.log('Usage: node src/index.ts add <url> <title> [tags]'); and add this line directly below it:
console.log('Example: node src/index.ts add https://nodejs.org "Node.js Docs" "docs,node"');
- Still in the case 'add' block, add the function call just before break;:
addBookmark(url, title, tags);
- In the case 'list' block, add this line before break;:
listBookmarks();
- In the case 'delete' block, add this line after the if (!id) check and before break;:
deleteBookmark(id);
- Save src/index.ts.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/index.ts before running the test.
ⓧ I'd like to double check the full code
import {
addBookmark,
listBookmarks,
deleteBookmark,
} from './commands.ts';
const args = process.argv.slice(2);
const command = args[0];
function printHelp(): void {
console.log(`
Bookmark Manager CLI
Usage:
node src/index.ts <command> [options]
Commands:
add <url> <title> [tags] Add a new bookmark (tags are comma-separated)
list List all bookmarks
delete <id> Delete a bookmark by ID
search <query> Search bookmarks by title or URL
filter <tag> Filter bookmarks by tag
help Show this help message
Examples:
node src/index.ts add https://nodejs.org "Node.js Docs" "docs,node"
node src/index.ts search node
node src/index.ts filter docs
node src/index.ts delete abc123
`);
}
switch (command) {
case 'add': {
const url = args[1];
const title = args[2];
const tags = args[3] ? args[3].split(',').map((t) => t.trim()) : [];
if (!url || !title) {
console.log('Usage: node src/index.ts add <url> <title> [tags]');
console.log('Example: node src/index.ts add https://nodejs.org "Node.js Docs" "docs,node"');
process.exit(1);
}
addBookmark(url, title, tags);
break;
}
case 'list': {
listBookmarks();
break;
}
case 'delete': {
const id = args[1];
if (!id) {
console.log('Usage: node src/index.ts delete <id>');
process.exit(1);
}
deleteBookmark(id);
break;
}
case 'search': {
const query = args[1];
if (!query) {
console.log('Usage: node src/index.ts search <query>');
process.exit(1);
}
break;
}
case 'filter': {
const tag = args[1];
if (!tag) {
console.log('Usage: node src/index.ts filter <tag>');
process.exit(1);
}
break;
}
case 'help':
default: {
printHelp();
break;
}
}
Time to test the full flow. You will add a bookmark, list it, delete it by ID, and list again to confirm it is gone.
- Add a test bookmark by running:
node src/index.ts add https://example.com "Example Site" "test,demo"
You should see: Added: "Example Site" (https://example.com).
- List your bookmarks by running:
node src/index.ts list
You should see your bookmark printed with its auto-generated ID, title, URL, and tags.
- Copy the ID from the list output, then delete the bookmark by running:
node src/index.ts delete [[BOOKMARK_ID="the ID from the list output"]]
You should see a confirmation message showing the deleted bookmark's title and URL.
- List bookmarks one more time to confirm the deletion:
node src/index.ts list
You should see: No bookmarks saved yet..
Seeing 'Cannot find module' or no output?
If you see a module error, check that the import path at the top of src/index.ts ends in .ts ('./commands.ts' not './commands').
If the add command runs but list shows nothing, make sure addBookmark(url, title, tags) is placed after the if (!url || !title) check in the case 'add' block. If it is inside the if-block, the function only runs when validation fails.
help me debug why my bookmark CLI commands aren't working
- Clean up the test data by deleting the bookmarks.json file so you start fresh:
rm bookmarks.json
Your first three commands are working end-to-end with persistent data. Next up, you will implement search and filter so you can find specific bookmarks without scrolling through the whole list.
Implement Search and Filter Commands
Your bookmark manager can add, list, and delete entries. Those are the basics of any data tool, but the real power comes from being able to find things fast.
In this step, you'll write two functions that search and filter your bookmarks. Both rely on Array.filter() with different matching logic inside. One matches on free text, the other matches on tags.
In this step, get ready to:
- Write a case-insensitive search function that matches on title or URL.
- Write a tag filter function using Array.some() for flexible matching.
- Wire up both functions and run a full end-to-end test of all 5 commands.
Implement searchBookmarks
Searching means checking whether the user's query appears anywhere inside a bookmark's title or URL. To make it forgiving, you'll convert everything to lowercase before comparing.
- In src/commands.ts, add the following function below your deleteBookmark function:
export function searchBookmarks(query: string): void {
const store = loadStore();
// Convert the query to lowercase once, so we don't repeat it in the loop
const lowerQuery = query.toLowerCase();
// Keep only bookmarks where the title OR url contains the query
const results = store.bookmarks.filter(
(b) =>
b.title.toLowerCase().includes(lowerQuery) ||
b.url.toLowerCase().includes(lowerQuery)
);
if (results.length === 0) {
console.log(`No bookmarks match "${query}".`);
return;
}
console.log(`\nFound ${results.length} result(s) for "${query}":\n`);
for (const b of results) {
const tags = b.tags.length > 0 ? ` [${b.tags.join(', ')}]` : '';
console.log(` ${b.id} | ${b.title} | ${b.url}${tags}`);
}
console.log('');
}
What does this code do?
The function loads all bookmarks, then uses .filter() to keep only those where the title or URL includes the search query.
By calling .toLowerCase() on both sides of the comparison, the search becomes case-insensitive. Searching for "node" will match "Node.js Docs" without the user worrying about capitalization.
- Save src/commands.ts.
- You won't see a change yet. The next function builds on the same pattern, and you'll test both together once they're wired up.
Implement filterByTag
Filtering by tag is a different kind of match. Instead of scanning free text, you need to check whether a bookmark's tags array contains a specific value. The Array.some() method is perfect for this. It returns true as soon as any element in the array passes the test.
- Still in src/commands.ts, add this function below searchBookmarks:
export function filterByTag(tag: string): void {
const store = loadStore();
// Normalize the filter tag to lowercase for case-insensitive matching
const lowerTag = tag.toLowerCase();
// Keep bookmarks where at least one tag matches
const results = store.bookmarks.filter((b) =>
b.tags.some((t) => t.toLowerCase() === lowerTag)
);
if (results.length === 0) {
console.log(`No bookmarks tagged with "${tag}".`);
return;
}
console.log(`\nBookmarks tagged "${tag}" (${results.length}):\n`);
for (const b of results) {
const tags = b.tags.length > 0 ? ` [${b.tags.join(', ')}]` : '';
console.log(` ${b.id} | ${b.title} | ${b.url}${tags}`);
}
console.log('');
}
What does this code do?
This function loads all bookmarks, then uses .filter() combined with .some() to find entries where at least one tag matches the filter value.
Notice the difference from search. Search uses .includes() for partial string matching ("node" matches "nodejs.org"). Filter uses strict equality (===) because tags are discrete labels that should match exactly.
- Save src/commands.ts.
Seeing red squiggles in your editor?
If your editor shows type errors on the new functions, make sure both functions are placed after the closing brace of deleteBookmark. Check that you have the export keyword at the start of each function declaration.
Help me fix type errors in my commands.ts file.
Wire up commands and test the full flow
Your two new functions exist in src/commands.ts but the entry point doesn't know about them yet. You need to import them and call them inside the existing switch cases.
- In src/index.ts, find the import block at the top of the file:
import {
addBookmark,
listBookmarks,
deleteBookmark,
} from './commands.ts';
- Replace it with this updated import that includes searchBookmarks and filterByTag:
import {
addBookmark,
listBookmarks,
deleteBookmark,
searchBookmarks,
filterByTag,
} from './commands.ts';
- Next, find the search case in the switch statement. It currently ends with just a break after validating the argument:
case 'search': {
const query = args[1];
if (!query) {
console.log('Usage: node src/index.ts search <query>');
process.exit(1);
}
break;
}
- Add the searchBookmarks(query) call right before the break, so the case looks like this:
case 'search': {
const query = args[1];
if (!query) {
console.log('Usage: node src/index.ts search <query>');
process.exit(1);
}
searchBookmarks(query);
break;
}
- Do the same for the filter case. Find it below the search case:
case 'filter': {
const tag = args[1];
if (!tag) {
console.log('Usage: node src/index.ts filter <tag>');
process.exit(1);
}
break;
}
- Add the filterByTag(tag) call right before the break:
case 'filter': {
const tag = args[1];
if (!tag) {
console.log('Usage: node src/index.ts filter <tag>');
process.exit(1);
}
filterByTag(tag);
break;
}
Why were the cases already there without function calls?
In the previous step, you built the argument validation for all commands up front. This is a common pattern: route first, implement later. It meant your CLI already gave helpful error messages for missing arguments, even before the command logic existed.
- Save src/index.ts.
Now test all 5 commands in sequence. First, add a few bookmarks with different tags by running each of these commands one at a time:
- Add your first test bookmark by running:
node src/index.ts add https://nodejs.org "Node.js Docs" "docs,node"
- Add a second bookmark:
node src/index.ts add https://typescriptlang.org "TypeScript Handbook" "docs,typescript"
- Add a third bookmark:
node src/index.ts add https://github.com "GitHub" "tools,code"
- List all bookmarks to confirm they're saved:
node src/index.ts list
You should see all 3 bookmarks printed with their IDs, titles, URLs, and tags.
- Test the search command by searching for a partial title:
node src/index.ts search node
You should see 1 result: the Node.js Docs bookmark. The search matched "node" against both the title and URL.
- Test the filter command by filtering for a shared tag:
node src/index.ts filter docs
You should see 2 results: the Node.js Docs and TypeScript Handbook bookmarks, because both have the "docs" tag.
- Test the delete command by copying one of the bookmark IDs from the list output and running:
node src/index.ts delete [[BOOKMARK_ID="the ID from your list output"]]
- Run node src/index.ts list one more time to confirm the bookmark is gone.
- Finally, run the type checker to confirm zero errors across all files:
npx tsc --noEmit
If you see no output, that means zero type errors. Your entire project is type-safe.
Seeing type errors from tsc?
Check that your import at the top of src/index.ts includes all five function names: addBookmark, listBookmarks, deleteBookmark, searchBookmarks, and filterByTag. Make sure each function in src/commands.ts has the export keyword.
Help me fix the tsc errors in my bookmark CLI project.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full src/commands.ts:
import { loadStore, saveStore } from './storage.ts';
import type { Bookmark } from './types.ts';
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
export function addBookmark(url: string, title: string, tags: string[]): void {
const store = loadStore();
const duplicate = store.bookmarks.find((b) => b.url === url);
if (duplicate) {
console.log(`Bookmark already exists: "${duplicate.title}" (${duplicate.url})`);
return;
}
const bookmark: Bookmark = {
id: generateId(),
url,
title,
tags,
createdAt: new Date().toISOString(),
};
store.bookmarks.push(bookmark);
saveStore(store);
console.log(`Added: "${title}" (${url})`);
}
export function listBookmarks(): void {
const store = loadStore();
if (store.bookmarks.length === 0) {
console.log('No bookmarks saved yet.');
return;
}
console.log(`\nYou have ${store.bookmarks.length} bookmark(s):\n`);
for (const b of store.bookmarks) {
const tags = b.tags.length > 0 ? ` [${b.tags.join(', ')}]` : '';
console.log(` ${b.id} | ${b.title} | ${b.url}${tags}`);
}
console.log('');
}
export function deleteBookmark(id: string): void {
const store = loadStore();
const index = store.bookmarks.findIndex((b) => b.id === id);
if (index === -1) {
console.log(`No bookmark found with id: ${id}`);
return;
}
const removed = store.bookmarks.splice(index, 1)[0];
saveStore(store);
console.log(`Deleted: "${removed.title}" (${removed.url})`);
}
export function searchBookmarks(query: string): void {
const store = loadStore();
const lowerQuery = query.toLowerCase();
const results = store.bookmarks.filter(
(b) =>
b.title.toLowerCase().includes(lowerQuery) ||
b.url.toLowerCase().includes(lowerQuery)
);
if (results.length === 0) {
console.log(`No bookmarks match "${query}".`);
return;
}
console.log(`\nFound ${results.length} result(s) for "${query}":\n`);
for (const b of results) {
const tags = b.tags.length > 0 ? ` [${b.tags.join(', ')}]` : '';
console.log(` ${b.id} | ${b.title} | ${b.url}${tags}`);
}
console.log('');
}
export function filterByTag(tag: string): void {
const store = loadStore();
const lowerTag = tag.toLowerCase();
const results = store.bookmarks.filter((b) =>
b.tags.some((t) => t.toLowerCase() === lowerTag)
);
if (results.length === 0) {
console.log(`No bookmarks tagged with "${tag}".`);
return;
}
console.log(`\nBookmarks tagged "${tag}" (${results.length}):\n`);
for (const b of results) {
const tags = b.tags.length > 0 ? ` [${b.tags.join(', ')}]` : '';
console.log(` ${b.id} | ${b.title} | ${b.url}${tags}`);
}
console.log('');
}
And here is the full src/index.ts:
import {
addBookmark,
listBookmarks,
deleteBookmark,
searchBookmarks,
filterByTag,
} from './commands.ts';
const args = process.argv.slice(2);
const command = args[0];
function printHelp(): void {
console.log(`
Bookmark Manager CLI
Usage:
node src/index.ts <command> [options]
Commands:
add <url> <title> [tags] Add a new bookmark (tags are comma-separated)
list List all bookmarks
delete <id> Delete a bookmark by ID
search <query> Search bookmarks by title or URL
filter <tag> Filter bookmarks by tag
help Show this help message
Examples:
node src/index.ts add https://nodejs.org "Node.js Docs" "docs,node"
node src/index.ts search node
node src/index.ts filter docs
node src/index.ts delete abc123
`);
}
switch (command) {
case 'add': {
const url = args[1];
const title = args[2];
const tags = args[3] ? args[3].split(',').map((t) => t.trim()) : [];
if (!url || !title) {
console.log('Usage: node src/index.ts add <url> <title> [tags]');
console.log('Example: node src/index.ts add https://nodejs.org "Node.js Docs" "docs,node"');
process.exit(1);
}
addBookmark(url, title, tags);
break;
}
case 'list': {
listBookmarks();
break;
}
case 'delete': {
const id = args[1];
if (!id) {
console.log('Usage: node src/index.ts delete <id>');
process.exit(1);
}
deleteBookmark(id);
break;
}
case 'search': {
const query = args[1];
if (!query) {
console.log('Usage: node src/index.ts search <query>');
process.exit(1);
}
searchBookmarks(query);
break;
}
case 'filter': {
const tag = args[1];
if (!tag) {
console.log('Usage: node src/index.ts filter <tag>');
process.exit(1);
}
filterByTag(tag);
break;
}
case 'help':
default: {
printHelp();
break;
}
}
Secret mission
Multi-Tag Filter and Tag Statistics
Your bookmark manager works great for single-tag filtering. But what if you want to find bookmarks that have BOTH "docs" and "node" tags? Or see which tags you use most often? Time to level up your array logic with data aggregation and compound filtering.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for future use or delete them entirely.
Resources you used:
- The bookmark-cli project directory on your Desktop (contains all source files, config, and node_modules/).
- The bookmarks.json data file inside your project directory (created when you first ran the add command).
Keep everything running
No action needed. Choose this if you want to keep using your bookmark manager or continue extending it with new commands.
- Your project stays in the bookmark-cli folder on your Desktop.
- Your saved bookmarks persist in bookmarks.json and will be there next time you run a command.
- TypeScript and @types/node remain installed inside node_modules/ (about 50-80 MB of disk space).
Pause - I'll come back to this later
There are no running processes to stop. Your project is already paused by default since the CLI only runs when you execute a command.
- To free up disk space while keeping your source code, delete the node_modules/ folder. You can restore it later by running npm install inside the project directory.
- Remove node_modules/ by running this command from inside your bookmark-cli folder:
rm -rf node_modules
- Your source files and bookmarks.json data remain untouched.
Delete - I don't want to use this again
Remove the entire project directory. There are no cloud resources, accounts, or services to clean up.
- Navigate to your Desktop and delete the bookmark-cli folder by running:
macOS
cd ~/Desktop
rm -rf bookmark-cli
Windows
cd ~\Desktop
rmdir /s /q bookmark-cli
- This removes all source files, node_modules/, bookmarks.json, and every other file in the project.
- Node.js itself remains installed on your system since you use it for other projects.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional CLI tool from an empty folder using nothing but TypeScript and Node.js built-ins.
You've learned how to:
- Define TypeScript interfaces that model your data shape before writing any logic, then build operations on top of that shape.
- Implement file-based persistence using readFileSync and writeFileSync, and write array manipulation logic (filter, find, findIndex, some, splice) to implement real CRUD operations.
- Parse command-line arguments and route them to the correct handler using a switch statement, keeping your entry point free of business logic.
- Secret Mission: Aggregated data with a frequency counter and implemented compound AND/OR filtering logic using every and some.
Ready to quiz yourself?