Building a CLI Tool in 2026: A Modern Node.js Walkthrough
Every CLI tutorial I read years ago stopped right after the happy path worked, a single command that parses a couple of flags and prints something. That is maybe twenty percent of what makes a CLI tool actually pleasant to use. This tutorial covers the other eighty percent, the config file handling, the sensible error messages, the packaging, using a small real tool as the example: a snippet manager you can save, list, and paste from the terminal.
Starting point
npm init -y
npm install commander chalk conf
npm install -D typescript tsx @types/node
Commander handles argument parsing and subcommands, chalk handles colored terminal output, and conf gives you a zero-config way to persist settings and data to a standard OS-appropriate location without hand-rolling file paths for macOS, Linux, and Windows separately.
The command structure
A CLI with subcommands should feel predictable, snip add, snip list, snip get, following the same verb-noun pattern users already know from git and npm.
import { Command } from 'commander';
import { addCommand } from './commands/add';
import { listCommand } from './commands/list';
import { getCommand } from './commands/get';
const program = new Command();
program
.name('snip')
.description('Save and recall terminal snippets')
.version('1.0.0');
program.addCommand(addCommand);
program.addCommand(listCommand);
program.addCommand(getCommand);
program.parse();
Splitting each subcommand into its own file pays off almost immediately once you have more than two or three commands, both for readability and because it makes testing each command's logic in isolation much simpler.
Config and storage that behaves like a real tool
The conf package solves a problem that trips up a lot of first-time CLI authors: where do you actually store persistent data. Writing to a hardcoded path like ~/.snip/data.json works until someone runs your tool on Windows, where that path convention does not apply, or until you need to respect XDG base directory conventions on Linux that some users explicitly configure.
import Conf from 'conf';
interface Snippet {
name: string;
command: string;
tags: string[];
createdAt: string;
}
const store = new Conf<{ snippets: Snippet[] }>({
projectName: 'snip-cli',
defaults: { snippets: [] },
});
export function saveSnippet(snippet: Snippet) {
const current = store.get('snippets');
store.set('snippets', [...current, snippet]);
}
conf resolves to the correct OS-specific config directory automatically, handles JSON serialization, and even supports schema validation if you want to catch corrupted config files early rather than crashing on a malformed read deep in your command logic.
Error messages that respect the user's time
This is the detail that separates tools people recommend to coworkers from tools people abandon after one confusing failure. A generic stack trace on invalid input is a bad experience regardless of how clever your underlying logic is.
import chalk from 'chalk';
export const getCommand = new Command('get')
.argument('
.action((name: string) => {
const snippets = store.get('snippets');
const match = snippets.find((s) => s.name === name);
if (!match) {
console.error(chalk.red('No snippet named "' + name + '" found.'));
const suggestions = snippets
.filter((s) => s.name.includes(name.slice(0, 3)))
.map((s) => s.name);
if (suggestions.length > 0) {
console.error(chalk.dim('Did you mean: ' + suggestions.join(', ') + '?'));
}
process.exitCode = 1;
return;
}
console.log(match.command);
});
The fuzzy did you mean suggestion costs maybe ten lines of code and eliminates a huge fraction of the frustration around typos, which in my experience account for the majority of real-world CLI errors, far more than genuinely invalid input. Also worth calling out, setting process.exitCode rather than calling process.exit directly lets any pending output flush cleanly before the process actually terminates, which matters more than people expect when a command's output is being piped into another program.
Making it installable the way people actually expect
Add a bin field to package.json pointing at your compiled entry point, and a shebang line at the top of that file.
{
"name": "snip-cli",
"bin": {
"snip": "./dist/index.js"
}
}
#!/usr/bin/env node
For local development, npm link gives you a global snip command backed by your working directory, which is essential for testing the actual installed experience rather than only running through tsx during development, since subtle path resolution bugs often only show up once the tool runs from a globally linked bin location rather than a local project folder.
Shell completions are the detail almost nobody bothers with
Commander has built-in support for generating shell completion scripts, and adding this is genuinely one of the highest leverage, lowest effort improvements you can make to a CLI's perceived polish.
program.addCommand(
new Command('completion')
.description('Generate shell completion script')
.action(() => {
console.log(program.createHelp().commandUsage(program));
})
);
For a more complete implementation you would typically integrate a dedicated completions library rather than hand-rolling this, but even a basic version that a user can pipe into their shell's completion directory signals a level of care that most quickly-shipped internal tools skip entirely, and it is often the single feature that makes a tool feel like a real product rather than a script someone wrote in an afternoon.
Publishing and the details that matter after version one
Once you publish to npm, npx snip-cli lets anyone try it without a global install, which is worth testing explicitly because global install permission issues, especially on systems where global npm installs require sudo, are a common first-run failure that will quietly turn away exactly the users who were curious enough to try your tool but not committed enough to fight a permissions error.
Semantic versioning discipline matters more for CLI tools than people expect, because unlike a library where a breaking change surfaces as a type error during a build, a CLI's breaking change surfaces as a confusing runtime failure for someone who updated without reading a changelog. I keep a CHANGELOG.md updated on every release and specifically call out any change to a command's flags or output format, since scripts other people have written around your CLI's output are a real, if invisible, form of API surface you are responsible for.
What actually makes a CLI good
The parsing logic, the part every tutorial covers, is genuinely the easy twenty percent. The parts that make a tool feel trustworthy, sensible error messages, predictable storage locations, shell completions, careful versioning, are the unglamorous eighty percent that most abandoned side-project CLIs never get around to. If you are building one for your team, I would budget roughly that same ratio of effort, and resist the urge to call it done the moment the happy path works.
One more piece of polish worth the effort, especially for a tool other people on your team will actually depend on: a self-check command that verifies your CLI's own environment is sane before doing anything else, checking that required config exists, that a network dependency is reachable if the tool needs one, and printing a clear diagnostic rather than a stack trace if something is missing. snip doctor became one of the more appreciated commands in our internal tool for exactly this reason, because the alternative, someone hitting a cryptic error on their first run and giving up rather than filing an issue, quietly kills adoption for internal tools more often than any missing feature ever does. Internal tools live or die on first-run experience just as much as public ones, and that first run is usually where the actual investment in error handling and diagnostics pays for itself many times over.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.