A batteries-included Bun + TypeScript CLI template with functional error handling, interactive prompts, and zero-compromise tooling.
Important
This is a template. Before starting development, rename every occurrence of template-cli to your own CLI name — in package.json (name, bin, build script), cli.ts (startSection), and this README.md.
| Tool | Purpose |
|---|---|
| Bun | Runtime, package manager, test runner, bundler |
| TypeScript 7 | Type safety (native-speed via @typescript/native-preview) |
| neverthrow | Functional error handling — no throws, only Result<T, E> |
| @clack/prompts | Interactive terminal prompts and spinners |
| picocolors | Colored terminal output for intros/outros |
| zod | Runtime schema validation |
| oxlint | Fast Rust-based linter |
| oxfmt | Fast Rust-based formatter |
- Bun >= 1.3.0
bun install
bun run dev| Script | Description |
|---|---|
bun run dev |
Run CLI in watch mode (auto-restarts on file changes) |
bun run fmt |
Format all source files |
bun run fmt:check |
Check formatting without writing |
bun run lint |
Lint all source files |
bun run lint:fix |
Lint and auto-fix |
bun run typecheck |
Type-check without emitting |
bun run test |
Run tests with coverage |
bun run check |
Run fmt:check + lint + typecheck + test (CI gate) |
bun run build |
Compile to a single self-contained binary |
cli.ts # Entry point — reads command name, delegates to src/commands/
src/
commands/ # One file per command (mirrors CLI command structure)
registry.ts # COMMAND_REGISTRY — maps command names to lazy imports
help.ts # `template-cli help`
version.ts # `template-cli version`
utils/ # Reusable utilities, one file per feature
shell.ts # Bun $ wrapper returning Result<string, string>
shell.spec.ts # Tests for shell.ts
mocks/ # Shared test factories, reusable across spec files
shell.mock.ts # mockShellSuccess / mockShellFailure factories1. Create src/commands/<name>.ts and export run:
import { ok, type Result } from "neverthrow";
export const description = "Short summary shown in `help` output";
/**
* Runs the `<name>` command.
*
* @param args - Remaining argv after the executable and command name.
* @returns Ok on success, Err with an error message on failure.
*/
export async function run(args: string[]): Promise<Result<void, string>> {
// Use helpers from src/utils/logger.ts for all terminal output.
// Use runCommand() from src/utils/shell.ts for shell/git commands.
// Use parseArgs({ args, strict: true, options: { ... } }) to parse flags.
// For subcommands, dispatch on args[0] and pass args.slice(1) to their run().
return ok(undefined);
}2. Register it in src/commands/registry.ts:
export const COMMAND_REGISTRY: CommandRegistry = {
help: () => import("./help"),
version: () => import("./version"),
<name>: () => import("./<name>"),
};3. Run cli <name> — it works immediately.
4. Write tests in src/commands/<name>.spec.ts following the AAA pattern.
The registry is a single, explicit map of lazy imports. It exists so the same code works in dev (
bun cli.ts) and in the compiled binary (bun build --compile), where the bundler must see every import statically.
| Convention | Rule |
|---|---|
| Types | No any — use unknown and type guards |
| Assertions | No as Type casts — use type guards. as const is the only exception |
| Errors | No throw — return Result<T, E> via neverthrow everywhere |
| Shell output | Always use runShell() from src/utils/shell.ts; never print raw output |
| Imports | Always use the node: protocol for built-ins (node:fs, node:path) |
| Exports | All exported functions require JSDoc |
| Tests | Every exported function needs a .spec.ts file; use the AAA pattern |
| Flag parsing | Each command uses parseArgs({ args, strict: true }) on its own args parameter |
bun run buildProduces a single self-contained binary (no Bun runtime required on the target machine). To rename the output binary, update the build script in package.json:
"build": "bun build --compile --minify cli.ts --outfile your-cli-name"