Stack Guide
What to build a command line tool with in 2026
A CLI has three jobs: parse arguments without surprising anyone, do the work fast, and install in a single command. This guide picks a default stack for the three languages worth using, then covers the part most tutorials skip entirely - getting your binary onto someone else's machine and keeping it updated.
About 6 min read. Recommendations verified August 2026.
The recommended CLI stack
This is the TypeScript answer, which is the right default for most tools because it gets you to a working command in an afternoon and still compiles to a standalone binary when you need one. Go and Rust equivalents are further down.
| Layer | Pick | Why |
|---|---|---|
| Language | TypeScript | Types on your flag definitions catch the class of bug that only shows up when a user passes the wrong thing. Every npm package is available to you. |
| Runtime | Bun | Runs TypeScript directly with no build step in development, then compiles a self-contained executable with one flag. Node 24 is the conservative alternative. |
| Argument parsing | Commander | Roughly 50 million downloads a week and a dependency of half the tooling you already run. Subcommands, options, and generated help in about 20 lines. |
| Prompts | @clack/prompts | The nicest-looking interactive prompts in the ecosystem, with cancellation handled properly. Gate every prompt behind an interactive-TTY check. |
| Colored output | picocolors | About 1 KB, no dependencies, and it respects NO_COLOR and non-TTY pipes automatically. Chalk is fine but heavier for no gain. |
| Full-screen TUI | Ink | Only when the tool is genuinely a dashboard. React components rendering to the terminal, with the same layout model you already know. |
| Testing | Vitest plus execa | Unit-test the command handlers directly, then spawn the built binary and snapshot stdout, stderr, and the exit code for the paths users actually take. |
| Binaries | bun build --compile | One 50-100 MB file per platform that runs with no runtime installed. Tailwind's standalone CLI and Claude Code both ship this way. |
| Registry | npm with provenance | Publishing from CI with provenance attestation proves which commit and workflow built the tarball, which matters more every year. |
| Release automation | Changesets | Contributors write the changelog entry with their pull request; a GitHub Actions job versions, tags, publishes, and attaches the binaries. |
| Second install channel | Homebrew tap | Your own tap costs one repository and reaches every Mac and Linux developer who will never run npm for a tool that is not a JavaScript dependency. |
The verdict
TypeScript on Bun with Commander and Clack, published to npm and to a Homebrew tap from one GitHub Actions release job. Reach for Go when the binary size matters, and for Rust when raw speed is the entire pitch.
The whole build step, once the code exists:
$ bun build ./src/cli.ts --compile --minify --outfile dist/mytool $ ./dist/mytool --help $ bun build ./src/cli.ts --compile --target=bun-linux-x64 --outfile dist/mytool-linux
Language choice is really a distribution choice
Almost nothing about writing a CLI is hard in any of these languages. What differs is the artifact you hand a user. A TypeScript tool published to npm is one npx away for anyone who already has Node, and completely unreachable for anyone who does not. A Go or Rust tool is a single file they can curl, and a slower afternoon for you.
Pick TypeScript when your audience is web developers, when the tool talks to npm packages or your own SDK, or when you want a working command today. Bun's compile flag removes the old objection that JavaScript CLIs cannot be distributed as binaries - the catch is that each one is 50-100 MB because it embeds the runtime. That is invisible on a laptop and irritating in a Docker layer.
Pick Go when the tool is infrastructure: something that lands in a container image, a CI step, or a server. Cobra gives you the same command tree Kubernetes and Docker use, Bubble Tea covers interactive interfaces, and cross-compiling to every platform is an environment variable. Binaries land in the 5-20 MB range with no runtime, and the language is small enough that a contributor can be productive in a week.
Pick Rust when performance is the product. The tools that changed developer expectations this decade - ripgrep, fd, uv, Biome - are Rust, and they are Rust because the work is CPU-bound file and text processing where a 10x margin is the entire reason anyone switches. clap with derive macros is as ergonomic as Commander, and cargo-dist or cargo-binstall handles releases. If your tool is mostly waiting on HTTP, none of this buys you anything.
Four ways to ship it, and who each one reaches
Support at least two. The first is for your natural audience, the second is for everyone who bounced off the first.
npm and npx
Zero-install trial through npx yourtool, automatic version resolution, and a package page people already know how to read. Requires Node on the machine and gives you no way to ship native dependencies cleanly. Set the bin field, publish from CI, and see the npm cheatsheet for the flags that matter.
Homebrew tap
The default expectation for a developer tool on macOS, and it works on Linux too. A personal tap is just a Git repository of formula files, and GoReleaser can generate and push the formula or cask on every release. Getting into homebrew-core is a separate, much slower process with real popularity requirements.
Install script over curl
One line in your README that detects platform and architecture, downloads the right release asset, verifies a checksum, and drops the binary somewhere on PATH. Fastest possible first run. Publish the script over HTTPS from a domain you control and never ask people to pipe an unverified URL into a shell.
Raw release binaries
Attach signed, checksummed archives for macOS arm64 and x64, Linux x64 and arm64, and Windows x64 to every GitHub release. This is the fallback that makes air-gapped, corporate, and CI users possible, and it is what every other channel downloads from anyway. Wire it into your CI/CD pipeline so a tag is the only manual step.
The other language stacks in full
Go
Cobra, Bubble Tea, GoReleaser
Cobra for commands and flags, Viper if you need layered config, Bubble Tea and Lip Gloss for anything interactive, and GoReleaser to build every target, sign, checksum, cut the GitHub release, and update your Homebrew tap from one YAML file. Test with the standard library and golden files.
Rust
clap, ratatui, cargo-dist
clap with the derive feature turns a struct into a parser with generated help and shell completions. ratatui covers full-screen interfaces, assert_cmd and insta cover testing, and cargo-dist produces installers and a Homebrew formula. Slowest to write, smallest and fastest to run.
Python
Typer, Rich, uv
Worth it only when the tool wraps the Python data or ML ecosystem. Typer builds the parser from type hints, Rich handles tables and progress, and uvx has finally made running a Python CLI without a virtualenv ritual a normal thing to ask of users.
Details that separate a good CLI from an annoying one
- Exit codes are an API. Zero on success, non-zero on failure, and never a stack trace as the user-facing error. Scripts and CI jobs depend on this more than humans do.
- stdout is data, stderr is chatter. Progress bars, spinners, and warnings go to stderr so that piping your output into another command keeps working.
- Detect the TTY. No colors, no spinners, and no interactive prompts when output is piped or CI is set. A CLI that hangs waiting for input in a build job is a broken CLI.
- Honor the conventions. --help, --version, --json, NO_COLOR, and a --dry-run on anything destructive. Ship shell completions once the command tree grows past a handful of verbs.
- Sign what you ship. Unsigned macOS binaries get quarantined by Gatekeeper and unsigned Windows executables get a SmartScreen warning, and both cost you more installs than the certificate costs in money.
CLI tool questions, answered
Should I write my CLI in TypeScript, Go, or Rust?
TypeScript if your users are web developers or the tool leans on npm packages, because you will have something working in an afternoon. Go if the tool ships inside containers or CI images and a 10 MB binary matters. Rust if the work is CPU-bound file or text processing and speed is the reason anyone would switch to you. For an internal tool nobody outside your team installs, use whatever your team already writes.
How do I ship a TypeScript CLI as a single binary?
Run bun build with the compile flag and a target for each platform you support, which produces a self-contained executable that needs no runtime installed. Expect 50-100 MB per file because the runtime is embedded. Node has an experimental single executable application API as well, but Bun's version is the one production tools like the Tailwind standalone CLI actually use.
How do I test a command line tool?
Two layers. Unit-test the command handlers as plain functions with no process involved, which covers your logic cheaply. Then spawn the real built binary with execa or assert_cmd against a temporary directory and snapshot stdout, stderr, and the exit code. The second layer is what catches broken flag parsing, help text drift, and the accidental hang waiting on stdin.
Should I publish to npm or Homebrew?
Both, and it is less work than it sounds. npm gives you zero-install trial through npx and reaches every JavaScript developer; a Homebrew tap reaches the developers who will never install Node for a tool that is not a JavaScript dependency. A tap is one repository with a formula file, and GoReleaser or cargo-dist will update it automatically on each release.
Where to go next
Settle the runtime question in Bun vs Node.js, then wire the release job together with the GitHub Actions guide. If the tool needs a server behind it, the backend API stack covers that half, and the testing tools directory has the rest of the Vitest and Playwright picture.