9-step playbook from 'index.js on my laptop' to 'npx my-cli with semver-released versions on every merge.' Real bin gotchas, real release traps, real agent prompts.
Open in Dock→Developers shipping their first CLI to npm
Shipping a CLI to npm is mostly a fight with three things: getting the bin entry working on Windows + macOS + Linux without hand-rolled hacks, getting authentication right for automated publishes (the OIDC trusted publisher flow vs npm tokens), and hooking up semantic-release so version + changelog + git tag are automatic. This playbook walks the 9 gates with the official docs, the package.json snippets that work, and agent prompts for the parts agents do well: drafting the README, writing conventional-commit messages from a diff, configuring GitHub Actions.
Outcome
Your CLI installable via npm install -g, runnable via npx, with semantic-release + GitHub Actions cutting versions on every merge to main, and a README that doubles as the npm listing.
Time2-4 daysDifficultyintermediateForDevelopers shipping their first CLI tool or library to npm.
Top to bottom. Each step has tasks, pointers, gotchas.
01 / 09
Pick a name and verify it's available on npm
30 min
npm names are first-come-first-served and (mostly) permanent. Names get squatted; check before you commit. Scoped names (@yourusername/cli-tool) are always available under your scope, so if the unscoped name is taken or contested, scope it.
Tasks
Decide unscoped (cli-tool) or scoped (@yourusername/cli-tool)
Check availability: npm view <name> → 404 means available
Reserve the name with an empty 0.0.1 publish if you're not ready to ship for weeks
Add the name to package.json's name field
Verify the name is sane: lowercase, hyphenated, no underscores, ≤214 chars
Names that are 'similar to' an existing popular package (typo-squatting risk) are blocked at publish. Don't pick 'lodahs' or 'reakt'.
Once published, a package name is yours. Unpublishing within 72 hours is allowed, after that you have to file a dispute.
Scoped packages default to private on free accounts. To publish a scoped package as public: npm publish --access public.
02 / 09
Set up package.json with bin entry + types + main
1 hr
The bin entry is what makes your package a CLI. Point it at a file with a #!/usr/bin/env node shebang. The main + types fields tell consumers where to import the library if they're using your package programmatically.
Tasks
Set name, version (start at 0.0.0; semantic-release bumps it), description, license
Set bin: { 'my-cli': './dist/cli.js' } — points to your built CLI entry
Add #!/usr/bin/env node as the first line of dist/cli.js
Set main + types if your package is also a library
Set engines.node to a real LTS minimum (e.g. >=18.0.0)
Set files to whitelist what gets shipped (e.g. ['dist', 'README.md'])
Forgetting the shebang means npm install -g succeeds but my-cli throws 'cannot execute binary'. Check the FIRST line of dist/cli.js.
Forgetting to chmod +x dist/cli.js works on macOS / Linux (npm sets the bit) but breaks tarball extraction on weird filesystems. Set it explicitly in your build.
files whitelist is opt-in. Without it, npm publishes everything not in .npmignore — including .env, .git, and 50 MB of test fixtures.
03 / 09
Pick a CLI framework: commander.js, yargs, or oclif
2-4 hr
commander.js (small, declarative) and yargs (small, fluent API) cover 90% of CLIs. oclif (heavy, plugin system) is for large CLIs with subcommands and a release-train mindset (e.g. Heroku, Salesforce). Pick commander.js if you don't know — it's tiny and stable.
Tasks
npm i commander (or yargs, or @oclif/core)
Set up the basic CLI: program.name, program.description, program.version
Add your commands as program.command('foo <arg>').action(...)
Add flags: program.option('-v, --verbose')
Auto-generated --help is included; verify it reads well
Add error handling: program.exitOverride() to throw instead of process.exit
ESM vs CJS: if your package is 'type': 'module', commander works fine but yargs has CJS-only entry points in older versions. Match types.
Don't use process.argv directly — your tests will get tangled with Vitest / Jest's own argv. Inject argv into the parser.
Subcommands as separate files (commander's program.command('foo', 'desc', { executableFile: ... })) work but break npm pack on Windows. Stick to action-based subcommands.
04 / 09
Bundle for Node with esbuild or tsup
1-2 hr
If your CLI is TypeScript, you ship the COMPILED JS, not .ts files. esbuild and tsup are fast, zero-config bundlers that produce dist/cli.js + dist/index.js + d.ts files. Bundling also lets you tree-shake dependencies for a faster cold start.
Bundling node-native dependencies (sharp, sqlite3, esbuild itself) breaks. Mark them external in tsup.config.ts and let the user install them.
Bundling sets the require / import resolution at build time. If your CLI dynamically requires('./plugins/' + pluginName), bundling kills that. Use external for plugin dirs.
TypeScript declarations (d.ts) generation is slow. Ship them only if you have library consumers (main + types in package.json), not if you're a CLI-only.
05 / 09
Write the README that doubles as the npm listing
3-5 hr
The npm listing IS your README.md. First-screen content matters: install command, one-line example, link to docs. Animated GIFs render on the npm site (within size limits), so a 5-second demo GIF is the highest-leverage time investment.
Tasks
Hero: package name + 1-line value prop + install command (npm i -g my-cli)
Quick start: 1-line npx my-cli example → expected output
Common commands: 3-5 examples, each one-line
Configuration: list every flag + env var
Demo GIF (≤2 MB) showing the most-used flow
Links: docs, GitHub repo, issues
Badges: npm version, npm downloads, build status (shields.io)
Relative image paths in README work on GitHub but break on npmjs.com. Use absolute URLs (https://raw.githubusercontent.com/<org>/<repo>/main/...) for any images.
GIFs over 2 MB don't render on npm. Compress with gifski or ffmpeg before committing.
npm READMEs render markdown but DON'T support GitHub HTML extensions (collapsible <details>, mermaid diagrams). Stick to CommonMark + GFM.
Agent prompt for this step
Draft a README for this CLI tool.
Read package.json + the source. Output:
1. Hero: package name + 1-line value prop + install command (npm i -g <name>) + npx command if relevant.
2. Quick Start: 1-line example invocation + expected output (a console block).
3. Common Commands: 3-5 invocations users hit most often, each annotated.
4. Configuration: a markdown table of every flag (name, type, default, description) and every env var.
5. Programmatic Usage: if the package exports a JS API, an import + 5-line code example.
6. License + repo link.
Tone: developer-to-developer. No marketing fluff. Lead with the install command.
06 / 09
Set up semantic-release + Conventional Commits
2-4 hr
semantic-release reads your git log, finds conventional-commit messages (feat:, fix:, BREAKING CHANGE), decides the next semver, generates a CHANGELOG.md, tags the commit, and publishes to npm. Combined with GitHub Actions on push to main, you stop thinking about versions.
Tasks
npm i -D semantic-release @semantic-release/changelog @semantic-release/git @semantic-release/github
semantic-release REFUSES to publish if your package.json has a non-zero version. Set version to 0.0.0-development; semantic-release sets it on publish.
Conventional Commits with body / footer use blank lines as separators. A typo-ed BREAKING CHANGE: footer (e.g. BREAKING-CHANGES) is not detected and you'll ship a major as a minor.
If your repo's main branch is 'master' or 'develop', set branches in .releaserc.json. The default is 'main'.
Agent prompt for this step
Read this repo's git log and rewrite the recent commit messages as Conventional Commits.
For each commit:
- feat: <description> for new features (minor bump)
- fix: <description> for bug fixes (patch bump)
- chore / docs / refactor / test: <description> (no version bump)
- A line in the body: BREAKING CHANGE: <description> for major bumps
Output:
1. The rewritten messages, in chronological order
2. The semver bump that semantic-release would compute from this set
3. A draft CHANGELOG.md section for the upcoming release
If commits are unclear, propose a rephrase rather than guessing the type.
07 / 09
Set up GitHub Actions to publish on every merge
1-2 hr
GitHub Actions runs the release workflow on push to main. Two auth options for npm: a long-lived NPM_TOKEN secret OR npm's OIDC trusted publisher (configured on npm side, no secret needed). OIDC is the modern default — set it up.
fetch-depth: 0 in actions/checkout is REQUIRED. semantic-release needs the full git history to compute the bump; default fetch-depth: 1 fails silently.
NPM_TOKEN secrets work but rotate every 30-90 days for security. Trusted publisher (OIDC) avoids the rotation by binding the auth to the GitHub Actions workflow identity.
Concurrent runs (e.g. fast merges) race semantic-release. Add concurrency: group: release at workflow level or two releases collide on the same version.
08 / 09
Test the install + first-run experience
2-3 hr
Before the first user hits npm install -g your-cli, sandbox-test it: spin up a fresh container or VM, run install, run the CLI, see what happens. Common breakages: missing peer deps, the bin shebang on Windows, --help formatting in a 80-column terminal.
Tasks
docker run --rm -it node:lts-alpine bash → npm i -g my-cli → my-cli --help
Repeat on node:18-alpine + node:20-alpine (your minimum supported Node)
Test on a real Windows machine if you can (PowerShell + cmd.exe)
Test npx my-cli@latest from a fresh dir (cold-start, no global install)
Smoke-test the --help output in an 80-column terminal
Verify your error messages are user-friendly (no node stack traces)
Windows + node-shebang: the wrapper script (.cmd) is generated by npm-shim. If your shebang is wrong (e.g. uses #!/bin/node instead of #!/usr/bin/env node) the wrapper still works — but on direct execution from WSL, it fails.
Alpine Linux uses musl libc, not glibc. Native modules built for glibc fail. Test alpine specifically.
npx caches packages by content hash. If you publish 1.0.1 and run npx my-cli, you may still get 1.0.0 from cache. Use npx my-cli@latest to force fresh.
09 / 09
Post-launch: monitor weekly downloads, issues, and breaking changes
Ongoing, 2-5 hr/week for the first month
Once published, the operations layer is: weekly download trends (the npm equivalent of MAU), GitHub issues (your support inbox), and the discipline of NEVER breaking the public API on a non-major release.
Tasks
Bookmark npmjs.com/package/<name> for the downloads-per-week graph
Set up GitHub issue triage labels: bug, enhancement, question, breaking
Document the public API in README + JSDoc; commit to semver discipline
When you ship a breaking change: BREAKING CHANGE: in the commit body — semantic-release bumps major automatically
Write a CHANGELOG entry for every minor / major with a migration note
Track first-time-installer feedback: file an issue tagged 'first-run' for any onboarding gap
npm download counts include CI / GitHub Actions installs. A 1k weekly download package may have only 50 unique humans. Don't over-index on the number.
Renaming a package (npm deprecate + npm publish under new name) loses ALL download history + GitHub stars. Keep the original name unless you have to.
npm has no easy 'unpublish'. Once a version is shipped, you can't take it back after 72 hours. If you publish a broken 1.2.0, ship 1.2.1 immediately rather than yanking.
Hand the template to your agent
Workspace-wide agent prompt.
Paste this into your agent's permanent system prompt so the agent reads, writes, and maintains the template's surfaces as you work through the steps.
Agent system prompt
You are an agent on the "Ship a CLI tool to npm" playbook workspace at your-org/ship-a-cli-tool-to-npm.
Your role: maintain the four surfaces (Steps, Pointers, Brief, Release log) as the user works through the 9-step playbook.
Cadence:
- When the user marks a step Done, append a line to the Brief.
- On every npm publish (read package.json version), append a row to Release log: version, date, conventional-commit summary, downloads-per-week 7d after publish.
- Mirror new pointers from steps into the Pointers table.
First MCP tool calls:
1. list_surfaces(workspace_slug="ship-a-cli-tool-to-npm")
2. list_rows(workspace_slug="ship-a-cli-tool-to-npm", surface_slug="steps")
3. get_doc(workspace_slug="ship-a-cli-tool-to-npm", surface_slug="brief")
Do NOT modify canonical step titles. Append substeps as new rows.
FAQ
Common questions on this template.
Do I need to verify my npm account?
Yes for publishing. npm requires email verification on the account that runs npm publish. Two-factor auth (2FA) is required for new accounts created after 2022 and strongly recommended for everyone — turn it on with auth-and-writes mode so your tokens are 2FA-protected too.
Should I use semantic-release or do versions manually?
If you ship more than once a quarter, use semantic-release. Manual versioning is fine for a hobby project but quickly drifts: you forget to update CHANGELOG, you miss a breaking change in the bump, you tag the wrong commit. semantic-release reads conventional commits and gets it right every time, for free, in CI.
Public or scoped package?
Public unscoped (my-cli) is the default for community CLIs and what users expect. Scoped (@yourusername/my-cli) is for: company packages, namespacing to avoid name conflicts, and when the unscoped name is taken. Both are free for public packages.
Can my AI agents help with the CLI?
Yes. Agents are particularly useful for: drafting the README that becomes the npm listing, rewriting commit messages as Conventional Commits before merging, generating the CHANGELOG entry for an upcoming release, configuring GitHub Actions + the .releaserc.json. The playbook ships agent prompts inline.
What does shipping to npm cost?
$0 for public packages. $7/user/month for unlimited private packages on the npm Pro tier. The npm registry doesn't charge by package count, downloads, or storage — only public-vs-private. Most CLI tools live on the free public tier indefinitely.
Open this template as a workspace.
We mint a fresh copy in your org with the steps as table rows, the pointers as a separate table, and the brief as a doc. Bring your agents, start checking off boxes.