A template for building CLI tools with Bun, Commander and Inquirer that work equally well for people at a terminal and for AI agents.
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, with 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: A 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.
Every one of these is also something you'll want an AI agent to run for you. See Built for AI Agents.
- Bun: runtime, bundler, test runner 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 # Show help
bun run index.ts greet # Prompts for a name in a terminal
bun run index.ts greet Ada --json # {"name":"Ada","greeting":"Hello, Ada!"}
bun run index.ts schema # The whole interface as JSON
bun run index.ts skills get core # The agent operating guide| Script | What it does |
|---|---|
bun start |
Run the CLI (bun run index.ts) |
bun test |
Run the integration tests in index.test.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: global flags, error handling, registers commands
├── index.test.ts # Integration tests that run the real CLI like an agent would
├── commands/ # One file per command, each exports register<Name>(program)
│ ├── greet.ts # Example command: argument with prompt fallback, --json output
│ ├── schema.ts # Prints the command tree as JSON
│ └── skills.ts # Prints agent guides embedded in the binary
├── lib/
│ └── cli.ts # Context (--json, interactivity), output, errors, exit codes
├── skills/ # Markdown guides for agents, compiled into the binary
│ ├── core.md # Operating guide: flags, output, exit codes, errors
│ ├── greet.md # Per-command guide
│ └── stub.md # SKILL.md printed by `buncli skills stub`
├── env.d.ts # Types for importing .md files as text
├── package.json
├── tsconfig.json
├── CLAUDE.md # Instructions for AI assistants working on this repo
└── README.md
Coding agents (Claude Code, Codex, Cursor and others) are now major users of CLIs. They call commands from a shell, read stdout and stderr, and branch on exit codes. A good --help page is the foundation, but it isn't enough. An agent can't answer a prompt, can't tell an error printed to stdout from a result, and will burn tokens guessing flags.
This template builds in the patterns that agent-friendly CLIs have converged on. A person at a terminal still gets prompts and readable output; an agent gets structure.
Agent instructions ship inside the binary, so they always match the installed version. This is the pattern agent-browser uses.
buncli skills # list guides
buncli skills get core # operating guide: flags, output, exit codes, errors
buncli skills get greet # per-command guideThe guides are markdown files in skills/, imported as text (with { type: 'text' }) and inlined by bun build --compile. Editing a guide and rebuilding is all it takes to update every agent that uses the new binary.
Agents only find a tool they've been told about. buncli skills stub prints a short SKILL.md that names the tool and tells the agent to run buncli skills get core first. It deliberately holds no command reference, so it never goes stale.
# Claude Code, for one user
mkdir -p ~/.claude/skills/buncli
buncli skills stub > ~/.claude/skills/buncli/SKILL.md
# Or for one repository, checked in so the whole team gets it
mkdir -p .claude/skills/buncli
buncli skills stub > .claude/skills/buncli/SKILL.mdFor agents without skills support, paste the stub's body into AGENTS.md or the agent's equivalent instructions file.
buncli schema prints every command, argument and option as JSON, plus the exit codes and the error shape. It's generated from Commander's command tree, so it can't drift from the real interface, and it needs no config, auth or network.
buncli schema{
"name": "buncli",
"version": "0.1.0",
"globalOptions": [{ "flags": "--json", "name": "json", "takesValue": false, ... }],
"commands": [
{
"name": "greet",
"usage": "[options] [name]",
"arguments": [{ "name": "name", "required": false, "variadic": false, ... }],
...
}
],
"exitCodes": { "0": "success", "1": "runtime error", "2": "usage error: ..." }
}This closes the main gap between CLIs and MCP servers: an MCP server lists its tools, and now so does the CLI, without loading anything into the agent's context until it asks.
--jsonprints the result as one line of JSON on stdout. It works before or after the command name.- Results go to stdout. Errors, hints and prompts go to stderr. A failed command leaves stdout empty.
- Text output has no color or spinners, so piping it never produces escape codes. If you add color, skip it when
process.stdout.isTTYis false orNO_COLORis set.
Commands accept every input as an argument or flag and fall back to a prompt only when a person can answer it. Prompting is off when any of these are true:
--non-interactiveis passed--jsonis passed (a prompt would corrupt the JSON)- stdin or stdout is not a terminal, which covers agents, pipes and cron
- the
CIenvironment variable is set
With prompting off, a missing input fails right away with exit code 2 and a hint naming the argument to pass, instead of hanging forever.
Every error has a stable code to branch on, a message, and usually a hint with the fix. With --json it's one line on stderr:
buncli greet --json{"error":{"code":"missing_input","message":"Missing required input: name.","hint":"Pass it as an argument: `buncli greet <name>`."}}Without --json, the same error is human-readable:
Error [missing_input]: Missing required input: name.
Hint: Pass it as an argument: `buncli greet <name>`.
Unknown commands and options are hard failures, never guesses.
| Code | Meaning | Agent should |
|---|---|---|
| 0 | Success | Parse stdout |
| 1 | Runtime error | Read the error; retry only if the cause was external |
| 2 | Usage error: bad or missing arguments, options or input | Fix the command line; don't retry unchanged |
A command never prints an error and exits 0.
- Put it in
commands/<name>.tsasregister<Name>(program)and call it fromindex.ts. - Accept every input as an argument or flag. Wrap any prompt in
ask()so it fails cleanly when nobody can answer. - Print the result with
output(ctx, data, text): one JSON object for--json, readable text otherwise. - Throw
usageError()for bad input andCliErrorfor runtime failures, each with ahint. - Add copy-pasteable examples with
.addHelpText('after', ...). - For anything destructive, require an explicit
--yesflag in non-interactive mode, and consider--dry-run. - If the command needs more explanation than
--helpcan hold, addskills/<name>.mdand register it incommands/skills.ts. - Add cases to
index.test.tsfor--jsonoutput, missing input (exit 2) and errors. - Keep the contract stable: add commands, options and JSON fields, but don't rename or repurpose them.
- Arcjet: Designing a CLI for AI agents
- Speakeasy: Making your CLI agent-friendly
- nerveband/cli-best-practices
- Writing CLI skills
- agent-browser skills
Each command lives in its own file and registers itself on the root program. Global settings in index.ts (--json, --non-interactive, error handling) are inherited automatically.
// commands/deploy.ts
import type { Command } from 'commander';
import { getContext, output } from '../lib/cli.ts';
export function registerDeploy(program: Command): void {
program
.command('deploy')
.description('Deploy a service')
.argument('<service>', 'service to deploy')
.option('-e, --env <env>', 'target environment', 'staging')
.addHelpText('after', `
Examples:
$ buncli deploy api --env production --json`)
.action((service: string, options: { env: string }, command: Command) => {
const ctx = getContext(command);
// ... do the work
output(ctx, { service, env: options.env, status: 'deployed' }, `Deployed ${service} to ${options.env}`);
});
}// index.ts
import { registerDeploy } from './commands/deploy.ts';
registerDeploy(program);Use @inquirer/prompts when a person can fill in what's missing, and wrap the call in ask() so agents and scripts get a usage error instead of a hung prompt:
import { input } from '@inquirer/prompts';
import { ask, getContext } from '../lib/cli.ts';
const ctx = getContext(command);
const name = options.name ?? await ask(
ctx,
{ name: '--name', hint: 'Pass it with `--name <name>`.' },
() => input({ message: 'Project name' }),
);The prompts available:
import {
input, // free-form text
select, // pick one from a list
checkbox, // pick multiple from a list
confirm, // yes/no
password, // masked text
number, // numeric input with validation
search, // filterable list (async search supported)
expand, // compact choice with shortcut keys
editor, // opens $VISUAL/$EDITOR for long text
rawlist, // numbered list (type a number to choose)
} from '@inquirer/prompts';
// Text input with validation
const name = await input({
message: 'Project name',
validate: (value) => value.length > 0 || 'Name is required',
});
// Single choice from a list (arrow keys to navigate)
const framework = await select({
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)
const features = await checkbox({
message: 'Select features',
choices: [
{ name: 'TypeScript', value: 'ts', checked: true },
{ name: 'Linting', value: 'lint' },
{ name: 'Testing', value: 'test' },
],
});
// Yes/no confirmation
const proceed = await confirm({ message: 'Create project?' });
// Masked password input
const token = await password({ message: 'API token' });
// Numeric input
const port = await number({ message: 'Port', default: 3000 });
// Searchable list: great for long lists, supports async sources
const dep = await search({
message: 'Search packages',
source: async (term) => {
if (!term) return [];
const res = await fetch(`https://registry.npmjs.org/-/v1/search?text=${term}&size=5`);
const data = (await res.json()) as { objects: { package: { name: string } }[] };
return data.objects.map((o) => ({ name: o.package.name, value: o.package.name }));
},
});
// Conditional prompts: ask follow-ups based on previous answers
const wantDeploy = await confirm({ message: 'Set up deployment?' });
if (wantDeploy) {
const provider = await select({
message: 'Deploy target',
choices: [
{ name: 'Railway', value: 'railway' },
{ name: 'Fly.io', value: 'fly' },
{ name: 'Docker', value: 'docker' },
],
});
}For a confirmation on a destructive action, take a --yes flag and only prompt when it's missing:
const confirmed = options.yes || await ask(
ctx,
{ name: '--yes', hint: 'Pass --yes to confirm.' },
() => confirm({ message: `Delete ${service}?` }),
);Use Bun Shell for system operations:
import { $ } from 'bun';
import { CliError } from '../lib/cli.ts';
// Run a command and get output
const branch = await $`git branch --show-current`.text();
// Pipe commands
await $`cat package.json | jq '.dependencies'`;
// Handle errors
const { exitCode } = await $`git status`.nothrow().quiet();
if (exitCode !== 0) {
throw new CliError('not_a_repo', 'Not a git repository.', { hint: 'Run this inside a git checkout.' });
}// Read
const pkg = await Bun.file('package.json').json();
// Write
await Bun.write('config.json', JSON.stringify(config, null, 2));
// Check existence
const exists = await Bun.file('config.json').exists();Throw instead of calling process.exit(). index.ts catches everything and reportError() in lib/cli.ts prints it (as JSON with --json) and sets the exit code:
import { CliError, usageError } from '../lib/cli.ts';
// The caller passed something wrong: exit code 2
throw usageError('invalid_env', `Unknown environment "${env}".`, 'Use one of: staging, production.');
// Something failed at runtime: exit code 1
throw new CliError('deploy_failed', 'The deploy API returned 503.', { hint: 'Check status.example.com and retry.' });reportError() also handles Commander's parse errors (exit 2), --help and --version (exit 0), Ctrl+C during a prompt (inquirer's ExitPromptError, exit 0) and unexpected errors (exit 1).
Bun supports ANSI escape codes natively. Only color output for a terminal, so agents and pipes get clean text:
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
const paint = (code: number) => (s: string) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
const bold = paint(1);
const green = paint(32);
const red = paint(31);
const dim = paint(2);
console.log(green('✓'), bold('Project created'));
console.error(red('✗'), 'Something went wrong');
console.error(dim('hint: run `mycli --help` for usage'));index.test.ts runs the real CLI with no terminal attached, the way an agent would, and checks stdout, stderr and exit codes:
bun testAdd cases there for each new command's --json output and failure modes.
Ship your CLI as a single executable with no dependencies. The skills/ guides are compiled in too.
# 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 mycliNote
--bytecode builds compile to CommonJS, which doesn't allow top-level await. Keep index.ts free of it (it uses program.parseAsync().catch(...)).
Add 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. Then have them install the agent skill with mycli skills stub (see Built for AI Agents).
| Task | How |
|---|---|
| Add a command | commands/<name>.ts exporting register<Name>(program), called from index.ts |
| Parse commands & options | commander: program.command().option().action() |
| Interactive prompts | @inquirer/prompts (input, select, confirm, checkbox) wrapped in ask() |
| Print a result | output(ctx, data, text) from lib/cli.ts |
| Fail with a hint | throw usageError(code, message, hint) or throw new CliError(...) |
| Describe the CLI for agents | buncli schema |
| Teach agents the CLI | skills/*.md, served by buncli skills get <name> |
| Install the agent skill | buncli skills stub > ~/.claude/skills/buncli/SKILL.md |
| Run shell commands | import { $ } from 'bun', then 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) |
| Test | bun test |
| Compile to binary | bun build --compile ./index.ts --outfile mycli |
| Cross-compile | --target=bun-{linux,darwin,windows}-{x64,arm64} |
MIT © Seth Davis