A template for building full-featured CLI tools with Bun, Commander, and Inquirer.
Tip
This is a GitHub template repository. Click Use this template to start a new
CLI from it, or clone it directly: git clone https://github.com/sethdavis512/buncli.git
A shared internal CLI is one of the fastest ways to bring consistency to a fragmented team. Here are practical ways to put it to use:
- Standardized project scaffolding — Prompt developers through required setup steps (repo name, tech stack, environment config) so every new project starts from the same baseline instead of whoever's laptop template from two years ago.
- Environment and service status checks — Poll staging and production endpoints, database connections, and third-party integrations to surface outages or misconfigurations before they become support tickets.
- Guided deployment workflows — Walk engineers through pre-flight checks, environment selection, and confirmation steps before pushing to production, reducing the chance of a rushed deploy skipping critical steps.
- Internal data lookups — Query internal APIs, databases, or spreadsheets by record ID, customer name, or date range — no more digging through five dashboards to answer a straightforward question.
- Automated release notes generation — Pull merged PRs and commit history since the last tag and format them into a changelog, consistently, every time.
- Onboarding automation — New hire runs one command and gets their local environment configured: dependencies installed,
.envfiles populated from a secrets manager, and seed data loaded. - Cross-team task automation — Trigger repetitive multi-step workflows (cache invalidation, permission grants, feature flag toggles) that currently live in someone's personal notes or Slack history.
- Audit and compliance reporting — Generate point-in-time snapshots of user access, configuration state, or data exports in a consistent format for security reviews or client reporting.
- Secrets and credential rotation — Walk through rotating API keys or tokens across services with confirmation prompts and automatic validation that the new credentials work before the old ones are revoked.
- Incident response runbooks — Encode the steps engineers take during an outage (pull logs, restart services, notify stakeholders) into a guided interactive script so the playbook is actually followed under pressure.
- Bun — Runtime, bundler, and package manager
- Commander — Command parsing, options, subcommands, and help text
- @inquirer/prompts — Interactive prompts (input, select, confirm, checkbox, etc.)
- TypeScript — Type safety out of the box (Bun runs
.tsdirectly)
bun installbun run index.ts # Run the CLI
bun run index.ts greet # Run the greet commandThe template ships with npm scripts for running and compiling:
| Script | What it does |
|---|---|
bun start | Run the CLI (bun run index.ts) |
bun run compile | Compile a standalone binary to dist/buncli |
bun run compile:all | Cross-compile binaries for Linux, macOS, and Windows |
bun run compile:{darwin,linux,windows}-{x64,arm64} | Compile a single platform target |
buncli/
├── index.ts # Entry point
├── package.json
├── tsconfig.json
├── CLAUDE.md # AI assistant instructions & Bun API reference
└── README.md
As your CLI grows, consider organizing into:
buncli/
├── index.ts # Entry point — parse args, register commands
├── commands/ # One file per command or command group
│ ├── init.ts
│ ├── build.ts
│ └── deploy.ts
├── lib/ # Shared utilities
│ ├── config.ts # Read/write config files
│ ├── prompts.ts # Reusable prompt flows
│ └── output.ts # Formatted output helpers
├── package.json
└── tsconfig.json
import{Command}from'commander';constprogram=newCommand();program.name('mycli').description('What your tool does').version('1.0.0');program.command('greet').description('Greet a user').argument('<name>','name to greet').option('-s, --shout','uppercase the greeting').action((name,options)=>{constgreeting=`Hello, ${name}!`;console.log(options.shout ? greeting.toUpperCase() : greeting);});program.parse();Use @inquirer/prompts when your command needs user input:
import{input,// free-form textselect,// pick one from a listcheckbox,// pick multiple from a listconfirm,// yes/nopassword,// masked textnumber,// numeric input with validationsearch,// filterable list (async search supported)expand,// compact choice with shortcut keyseditor,// opens $VISUAL/$EDITOR for long textrawlist,// numbered list (type a number to choose)}from'@inquirer/prompts';// Text input with validationconstname=awaitinput({message: 'Project name',validate: (value)=>value.length>0||'Name is required',});// Single choice from a list (arrow keys to navigate)constframework=awaitselect({message: 'Pick a framework',choices: [{name: 'React',value: 'react'},{name: 'Vue',value: 'vue'},{name: 'Svelte',value: 'svelte'},],});// Multiple selection (space to toggle, enter to submit)constfeatures=awaitcheckbox({message: 'Select features',choices: [{name: 'TypeScript',value: 'ts',checked: true},{name: 'Linting',value: 'lint'},{name: 'Testing',value: 'test'},],});// Yes/no confirmationconstproceed=awaitconfirm({message: 'Create project?'});// Masked password inputconsttoken=awaitpassword({message: 'API token'});// Numeric inputconstport=awaitnumber({message: 'Port',default: 3000});// Searchable list — great for long lists, supports async sourcesconstdep=awaitsearch({message: 'Search packages',source: async(term)=>{if(!term)return[];constres=awaitfetch(`https://registry.npmjs.org/-/v1/search?text=${term}&size=5`);constdata=awaitres.json();returndata.objects.map((o: any)=>({name: o.package.name,value: o.package.name}));},});// Conditional prompts — ask follow-ups based on previous answersconstwantDeploy=awaitconfirm({message: 'Set up deployment?'});if(wantDeploy){constprovider=awaitselect({message: 'Deploy target',choices: [{name: 'Railway',value: 'railway'},{name: 'Fly.io',value: 'fly'},{name: 'Docker',value: 'docker'},],});}Use Bun Shell for system operations:
import{$}from'bun';// Run a command and get outputconstbranch=await$`git branch --show-current`.text();// Pipe commandsawait$`cat package.json | jq '.dependencies'`;// Handle errorsconst{ exitCode }=await$`git status`.nothrow().quiet();if(exitCode!==0){console.error('Not a git repository');process.exit(1);}// Readconstpkg=awaitBun.file('package.json').json();// WriteawaitBun.write('config.json',JSON.stringify(config,null,2));// Check existenceconstexists=awaitBun.file('config.json').exists();import{ExitPromptError}from'@inquirer/prompts';try{// ... your CLI logic}catch(error){if(errorinstanceofExitPromptError){// User pressed Ctrl+C during a promptprocess.exit(0);}console.error('Error:',errorinstanceofError ? error.message : error);process.exit(1);}Bun supports ANSI escape codes natively. For convenience, use a package like chalk or write simple helpers:
constbold=(s: string)=>`\x1b[1m${s}\x1b[0m`;constgreen=(s: string)=>`\x1b[32m${s}\x1b[0m`;constred=(s: string)=>`\x1b[31m${s}\x1b[0m`;constdim=(s: string)=>`\x1b[2m${s}\x1b[0m`;console.log(green('✓'),bold('Project created'));console.log(red('✗'),'Something went wrong');console.log(dim('hint: run `mycli --help` for usage'));Ship your CLI as a single executable with no dependencies:
# Development
bun build --compile ./index.ts --outfile mycli
# Production (smaller, faster startup)
bun build --compile --minify --sourcemap --bytecode ./index.ts --outfile mycliCross-compile for other platforms:
bun build --compile --target=bun-linux-x64 ./index.ts --outfile mycli
bun build --compile --target=bun-darwin-arm64 ./index.ts --outfile mycli
bun build --compile --target=bun-windows-x64 ./index.ts --outfile mycliAdd a bin field to package.json:
{
"name": "mycli",
"bin": {
"mycli": "./index.ts"
}
}Then users can install with:
bun install -g mycliCompile your CLI into a self-contained executable that embeds the Bun runtime alongside your code. The resulting binary has zero external dependencies — no Node.js, no Bun, no node_modules — so users can download a single file and run it immediately.
This is ideal for distributing internal tooling to teammates who aren't set up with a JavaScript runtime, shipping a CLI to end users without requiring any install steps, or deploying to CI environments where you want a predictable, hermetic tool.
bun build --compile ./index.ts --outfile mycli
./mycli --helpHost the binary anywhere reachable — an S3 bucket, a GitHub Release asset, or your own file server. Users download the file for their platform, make it executable (chmod +x mycli on macOS/Linux), and they're done.
| Task | How |
|---|---|
| Parse commands & options | commander — program.command().option().action() |
| Interactive prompts | @inquirer/prompts — input, select, confirm, checkbox |
| Run shell commands | import { $ } from 'bun' — await $`cmd` |
| Read files | await Bun.file('path').text() or .json() |
| Write files | await Bun.write('path', content) |
| Environment variables | process.env.VAR (Bun loads .env automatically) |
| Compile to binary | bun build --compile ./index.ts --outfile mycli |
| Cross-compile | --target=bun-{linux,darwin,windows}-{x64,arm64} |
MIT © Seth Davis