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 October 2026.

A terminal workbench forging three kinds of single-file command-line binaries

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.