Ship a CLI tool to npm with semver releases
A 9-step playbook. Open in Dock and you'll get four surfaces seeded:
- **Steps** (table) — the 9 gates as rows, owner + due + status
- **Pointers** (table) — every official npm doc + tool linked from this playbook
- **Brief** (doc) — the canonical write-up you maintain alongside the work
- **Release log** (table) — one row per published version (date, version, conventional-commit summary, downloads)
Read `Steps` top-to-bottom on first open. Each row is one of the 9 steps. Click into a step to see the tasks, pointers, and the agent prompt for that step.
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.
Estimated time: 2-4 days
Difficulty: intermediate
For: Developers shipping their first CLI tool or library to npm.
What you'll need
Pre-register or install before you start.
- npm (Free for public) — The registry. Free public packages, paid private ($7/user/mo for unlimited private).
- semantic-release (Free (npm)) — Reads conventional commits, decides next semver, generates CHANGELOG, publishes to npm + tags git.
- Conventional Commits (Free (spec)) — The commit message convention semantic-release reads (feat:, fix:, BREAKING CHANGE).
- commander.js / yargs (Free (npm)) — Argument parsing for Node CLIs. Handles flags, subcommands, help text.
- GitHub Actions (Free for public repos) — Where the publish workflow runs on every merge to main.
The template · 9 steps
Step 1: Pick a name and verify it's available on npm
Estimated time: 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
→ 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
Pointers
- [Official] Naming a package
- [Official] Package name disputes
[!CAUTION] Gotchas
- 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.
Step 2: Set up package.json with bin entry + types + main
Estimated time: 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'])
- Set repository, homepage, bugs URLs
Pointers
- [Official] package.json reference
- [Official] Node CLI bin field
[!CAUTION] Gotchas
- 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.
Step 3: Pick a CLI framework: commander.js, yargs, or oclif
Estimated time: 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
').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
Pointers
- [Code] commander.js
- [Code] yargs
- [Code] oclif — Plugin-based framework for large CLIs.
[!CAUTION] Gotchas
- 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.
Step 4: Bundle for Node with esbuild or tsup
Estimated time: 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.
Tasks
- npm i -D tsup (or esbuild directly)
- tsup.config.ts: entry: ['src/cli.ts', 'src/index.ts'], format: ['esm', 'cjs'], dts: true
- Add a build script: 'build': 'tsup'
- Run npm run build, verify dist/ has cli.js + cli.cjs + index.d.ts
- Add the shebang via tsup banner: { js: '#!/usr/bin/env node' } in the cli entry
- Run dist/cli.js --help to confirm the bundled CLI runs
Pointers
[!CAUTION] Gotchas
- 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.
Step 5: Write the README that doubles as the npm listing
Estimated time: 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)
- License at the bottom
Pointers
- [Official] npm README rendering
- [Tool] shields.io badges
[!CAUTION] Gotchas
- Relative image paths in README work on GitHub but break on npmjs.com. Use absolute URLs (https://raw.githubusercontent.com/
/ /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
, 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.
Step 6: Set up semantic-release + Conventional Commits
Estimated time: 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
- Configure .releaserc.json with branches: main, plugins: [commit-analyzer, release-notes-generator, changelog, npm, git, github]
- Adopt Conventional Commits in your commit messages (or rebase to clean up history)
- Add commitlint + husky to enforce conventional commits on commit (optional but recommended)
- Test locally: npx semantic-release --dry-run
- Add CHANGELOG.md to your .gitignore.exception (it gets committed by the release plugin)
Pointers
- [Code] semantic-release
- [Official] Conventional Commits spec
- [Code] commitlint
[!CAUTION] Gotchas
- 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.
Step 7: Set up GitHub Actions to publish on every merge
Estimated time: 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.
Tasks
- Create .github/workflows/release.yml
- Trigger: on push to main
- Steps: actions/checkout (fetch-depth 0), setup-node, npm ci, npm test, npm run build, npx semantic-release
- Add permissions: contents: write + id-token: write (for OIDC)
- Set up trusted publisher on npm: package settings → publishing → add GitHub Actions
- Push a feat: commit to main and watch the workflow publish a 0.1.0
Pointers
- [Official] Trusted Publishers (OIDC) for npm
- [Official] GitHub Actions for Node
[!CAUTION] Gotchas
- 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.
Step 8: Test the install + first-run experience
Estimated time: 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)
Pointers
- [Official] Cross-platform Node CLIs
[!CAUTION] Gotchas
- 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.
Step 9: Post-launch: monitor weekly downloads, issues, and breaking changes
Estimated time: 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/
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
Pointers
- [Official] npm package downloads
- [Official] Semantic Versioning spec
[!CAUTION] Gotchas
- 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
Paste the prompt below into your agent's permanent system prompt so the agent reads, writes, and maintains this workspace as you work through the steps.
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
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.