· bun / typescript / cli
How to write a CLI in TypeScript with Bun and Commander
Bun + Commander is the fastest path to a TypeScript CLI: native TS execution, no build step, subcommands, and a standalone binary. Tutorial with working code.
By Ethan
1,622 words · 9 min read
If you need a TypeScript CLI in 2026, use Bun and Commander. Bun runs TypeScript natively — no compile step, fast cold start — and Commander.js is the most-downloaded argument-parsing library by a factor of two. This tutorial walks you from bun init to a standalone binary you can ship.
Who this is for
You’ve written TypeScript scripts before. You haven’t packaged one as a proper CLI — with subcommands, --help, and a binary users can install globally. That’s exactly what this covers.
Why Bun
Node-based TypeScript CLIs need a transpile step before execution: ts-node, tsx, or a build script that produces dist/cli.js. Bun eliminates that. bun cli.ts runs directly. No tsconfig tricks, no ts-node hanging on slow imports.
Three things make Bun the right default for CLIs:
Native TypeScript. No transpiler in your dev loop. Write, save, run. You also get Bun’s $ shell API and built-in fetch if you need them — no extra imports.
Fast cold start. Bun starts cold noticeably faster than Node-based CLIs running through ts-node or tsx — see Bun vs Node for benchmarks with methodology. The gap matters most in interactive workflows where the user is hammering the CLI.
Compile to a standalone binary. bun build --compile bundles your code and the Bun runtime into a single file. No runtime dependency on the target machine, no node_modules to ship. The binary carries the full Bun runtime — honest caveat from Bun’s own docs: “Bun’s binary is still way too big.” Go CLIs are considerably smaller. If that matters for your distribution, that’s the TypeScript vs Go trade-off to weigh.
If you’re on the fence about Bun vs Node for scripting generally, Bun vs Node has the fuller comparison. Here we’re focused on CLIs specifically.
Why Commander
Commander.js downloads ~413 million packages per week (May 2026). Yargs is next at ~194 million. Oclif — the framework-level option used by Heroku CLI — pulls ~318 thousand. Commander has 2× the adoption of its nearest competitor.
What you get with Commander v14:
- Boolean flags, value options, required options, variadic arguments, negatable booleans
- Subcommands with nested
.action()handlers - Auto-generated
--helpand--version - Built-in TypeScript types — no
@types/commanderpackage needed (bundled since v8) - Zero runtime dependencies
- Strict by default: unknown options throw immediately
One version note before we start. Commander v15 is in pre-release as of May 2026 and is ESM-only. It requires Node.js v22.12+ for CommonJS interop. Bun handles ESM natively so it would work, but pre-release for production tooling is a bad trade. Pin to v14.0.3. It gets security updates until at least May 2027.
Step 1 — Initialize the project
You’ll need Bun installed. If you haven’t:
curl -fsSL https://bun.sh/install | bash
Then scaffold the project:
mkdir mycli && cd mycli
bun init -y
bun add commander@^14.0.3
bun init produces a minimal package.json, tsconfig.json, and entry file. That’s all you need. Commander is the only dependency.
Step 2 — Write your first CLI command
Replace or create cli.ts with this:
#!/usr/bin/env bun
import { Command } from 'commander';
const program = new Command();
program
.name('greet')
.description('A minimal CLI to greet people')
.version('1.0.0');
program
.argument('<name>', 'person to greet')
.option('-l, --loud', 'shout the greeting')
.action((name, options) => {
const msg = `Hello, ${name}!`;
console.log(options.loud ? msg.toUpperCase() : msg);
});
program.parse();
Run it:
bun run cli.ts World
# Hello, World!
bun run cli.ts World --loud
# HELLO, WORLD!
bun run cli.ts --help
# Usage: greet [options] <name>
#
# Arguments:
# name person to greet
#
# Options:
# -l, --loud shout the greeting
# -V, --version output the version number
# -h, --help display help for command
A few things to notice. .argument('<name>', 'description') declares a required positional argument — angle brackets signal required, square brackets optional. .option('-l, --loud', 'description') registers a boolean flag. Value options look like .option('-p, --port <number>', 'port', '3000') — the third argument is the default.
program.parse() without arguments reads from process.argv. In Bun, that array has the same layout as Node: index 0 is bun, index 1 is the script path, then your actual arguments. Commander handles it correctly out of the box.
The shebang #!/usr/bin/env bun at the top makes the file directly executable on Unix after chmod +x cli.ts. Windows ignores shebangs — the compiled binary (Step 5) is the right path there.
Step 3 — Add subcommands
Most real CLIs need subcommands: mycli init, mycli build, mycli deploy. Commander routes based on the first positional argument:
#!/usr/bin/env bun
import { Command } from 'commander';
const program = new Command();
program
.name('devtool')
.description('Developer productivity CLI')
.version('1.0.0');
program.command('init')
.description('Scaffold a new project')
.argument('<name>', 'project name')
.option('--template <type>', 'starter template', 'minimal')
.action((name, options) => {
console.log(`Creating ${name} with template: ${options.template}`);
});
program.command('build')
.description('Build for production')
.option('--minify', 'enable minification')
.action((options) => {
console.log(`Building${options.minify ? ' (minified)' : ''}…`);
});
program.parse();
bun run cli.ts init my-project --template react
# Creating my-project with template: react
bun run cli.ts build --minify
# Building (minified)…
bun run cli.ts --help
# Usage: devtool [options] [command]
#
# Commands:
# init <name> Scaffold a new project
# build Build for production
Each subcommand is an independent Commander instance chained off the root. Options declared on a subcommand don’t leak to siblings or the root. If you need shared options across subcommands, declare them on the root program before registering subcommands.
For larger CLIs where each subcommand has its own file, Commander supports external subcommand files via .command('serve', 'start the server') — the second string argument tells Commander to look for a <program>-serve.ts file. That pattern keeps things manageable at 10+ subcommands.
Step 4 — Package for npm distribution
To make your CLI installable via npm install -g or bun install -g, add a bin field to package.json:
{
"name": "mycli",
"version": "1.0.0",
"bin": {
"mycli": "./cli.ts"
},
"dependencies": {
"commander": "^14.0.3"
}
}
Test locally:
bun install -g .
mycli --help
When someone installs your package, the package manager creates a shim that runs the entry file through the shebang. This works for users who have Bun installed. For a binary that runs anywhere — no Bun required on the target machine — compile it instead.
Step 5 — Compile to a standalone binary
bun build --compile bundles your TypeScript and the Bun runtime into a single executable:
# Basic build
bun build ./cli.ts --compile --outfile mycli
# Cross-compile for Linux from macOS
bun build ./cli.ts --compile --target=bun-linux-x64 --outfile mycli-linux
# Production-ready: minify + sourcemap + bytecode (2× faster startup)
bun build ./cli.ts --compile --minify --sourcemap --bytecode --outfile mycli
Available targets: bun-linux-x64, bun-linux-arm64, bun-darwin-x64, bun-darwin-arm64, bun-windows-x64.
Run the output directly — no Bun required:
./mycli World --loud
# HELLO, WORLD!
The --bytecode flag pre-compiles JavaScript bytecode and gets you roughly 2× startup improvement on the already-fast Bun baseline. Use it for release builds. Skip it during development where you iterate on the .ts files directly.
Six gotchas
1. allowExcessArguments changed in v13
Commander v13.0.0 flipped allowExcessArguments to false. Before that version, extra arguments were silently ignored. Old tutorials that call program.action((options) => ...) without declaring arguments will fail with a confusing error after upgrading.
Fix: declare your arguments explicitly with .argument(). If you have a legitimate reason to accept arbitrary extra args, opt back in explicitly with .allowExcessArguments(true).
2. Binary size floor
The Bun runtime is bundled whole into every binary — there’s no flag to reduce it. If you’re distributing to end users who expect small downloads, or if you need Go-level binary compactness, read the TypeScript vs Go comparison before committing.
3. Windows .exe auto-append
bun build --compile --target=bun-windows-x64 --outfile mycli produces mycli.exe automatically. Write --outfile mycli, not --outfile mycli.exe. The latter gives you mycli.exe.exe.
4. Shebang on Windows
#!/usr/bin/env bun is ignored by Windows. For cross-platform distribution to Windows users, the compiled binary (bun build --compile) is the correct approach — not the shebang method from Step 2.
5. Commander v15 is pre-release
Commander v15 is ESM-only and in pre-release as of May 2026. Bun handles ESM natively, but shipping on a pre-release library is a support headache. Stick with v14. It’s maintained and gets updates.
6. process.argv layout in Bun
process.argv[0] is bun, process.argv[1] is the script path. Same layout as Node. program.parse() without arguments handles this correctly. You don’t need program.parse(process.argv) or any manual slicing.
When to use Yargs or Oclif
Yargs is the right pick when you need async middleware chains. Yargs has a .middleware() API that runs before action handlers — useful for authentication checks, config loading, or logging that must complete before the command runs. Feature set is otherwise comparable to Commander. TypeScript types come via @types/yargs, not bundled. Bundle is slightly heavier.
Oclif makes sense when you’re building a platform — a CLI that other teams extend with plugins. Heroku CLI and Salesforce CLI both use it. Oclif generates boilerplate, enforces project structure, and has a documented plugin API. The trade-off: ~1,400× fewer weekly downloads than Commander, class-based API that requires more scaffolding, and an architecture that’s overkill for any CLI you’re building alone. If you want to ship today, Commander.
For writing your CLI in Go instead of TypeScript, the Bun vs Deno article covers the TypeScript runtime comparison, and TypeScript vs Go covers the language trade-off directly.
References
| Source | URL |
|---|---|
| Bun standalone executables | https://bun.sh/docs/bundler/executables |
| Bun release notes | https://github.com/oven-sh/bun/releases |
| Commander.js releases | https://github.com/tj/commander.js/releases |
| Commander.js README (v14) | https://github.com/tj/commander.js |
| npmtrends: commander vs yargs vs oclif | https://npmtrends.com/commander-vs-oclif-vs-yargs |
| endoflife.date/bun | https://endoflife.date/bun |