How to Build a CLI Tool with Bun | Full Stack Guide
Bun makes CLI tools absurdly fast to build. No bundler config, no transpiler setup — just TypeScript that runs natively with built-in file I/O, an HTTP client, and sub-millisecond startup times. This guide covers building a practical CLI tool from argument parsing to npm publishing, using only Bun's native APIs.
Prerequisites
- -Bun 1.1+
Install Bun, the all-in-one JavaScript runtime with native TypeScript support and built-in APIs for file, shell, and network operations.
- -npm Account
You need an npm account to publish your CLI tool as a package that others can install globally.
- -Basic TypeScript Knowledge
Familiarity with TypeScript types, async/await, and module imports.
Initialize the Bun Project
Create a new directory and initialize it with bun init. This generates a package.json and tsconfig.json tuned for Bun's runtime. Set the bin field in package.json so npm knows which file to run when your CLI command is invoked.
mkdir my-cli && cd my-cli
bun init
# Create the CLI entry point
mkdir -p src
touch src/index.tsTip: Bun's init sets target to 'bun' in tsconfig.json, enabling Bun-specific type definitions automatically.
Tip: Add the shebang #!/usr/bin/env bun at the top of your entry file so it runs with Bun when invoked directly.
Parse Arguments and Build the Command Router
Build argument parsing using Bun's native util.parseArgs (from Node.js compatibility) or roll a lightweight parser with process.argv. Define your CLI's commands and flags, then route to the correct handler. This pattern scales well — each command is a separate function that receives parsed options.
#!/usr/bin/env bun
// src/index.ts
import { parseArgs } from 'util';
const { values, positionals } = parseArgs({
args: Bun.argv.slice(2),
options: {
help: { type: 'boolean', short: 'h' },
output: { type: 'string', short: 'o', default: './output' },
verbose: { type: 'boolean', short: 'v' },
},
allowPositionals: true,
strict: false,
});
const command = positionals[0];
const commands: Record<string, () => Promise<void>> = {
init: () => import('./commands/init').then(m => m.run(values)),
generate: () => import('./commands/generate').then(m => m.run(values)),
serve: () => import('./commands/serve').then(m => m.run(values)),
};
if (values.help || !command) {
console.log(`
Usage: my-cli <command> [options]
Commands:
init Initialize a new project
generate Generate files from templates
serve Start a local dev server
Options:
-h, --help Show this help message
-o, --output Output directory (default: ./output)
-v, --verbose Enable verbose logging
`);
process.exit(0);
}
const handler = commands[command];
if (!handler) {
console.error(`Unknown command: ${command}. Run with --help for usage.`);
process.exit(1);
}
await handler();Tip: Use Bun.argv instead of process.argv — same data, but Bun.argv is typed and slightly faster.
Tip: Lazy-import command handlers so the CLI starts instantly even with many commands.
Use Bun's Native File APIs for I/O Operations
Implement your commands using Bun.file() and Bun.write() for file operations. These APIs are significantly faster than Node's fs module because they use system calls directly. Bun.file() returns a BunFile that supports .text(), .json(), .arrayBuffer(), and streaming reads.
// src/commands/generate.ts
import { join } from 'path';
import { exists, mkdir } from 'fs/promises';
interface GenerateOptions {
output?: string;
verbose?: boolean;
}
const templates: Record<string, string> = {
'index.html': '<!DOCTYPE html>\n<html>\n<head><title>{{name}}</title></head>\n<body>\n <h1>{{name}}</h1>\n</body>\n</html>',
'style.css': ':root {\n --primary: #3b82f6;\n}\nbody {\n font-family: system-ui, sans-serif;\n margin: 0;\n padding: 2rem;\n}',
'main.ts': 'console.log("Hello from {{name}}");',
};
export async function run(options: GenerateOptions) {
const outputDir = options.output ?? './output';
const configPath = join(process.cwd(), 'project.json');
// Read config with Bun.file()
const configFile = Bun.file(configPath);
if (!(await configFile.exists())) {
console.error('No project.json found. Run "my-cli init" first.');
process.exit(1);
}
const config = await configFile.json();
const name = config.name ?? 'my-project';
// Ensure output directory exists
if (!(await exists(outputDir))) {
await mkdir(outputDir, { recursive: true });
}
// Write files with Bun.write()
for (const [filename, template] of Object.entries(templates)) {
const content = template.replaceAll('{{name}}', name);
const filepath = join(outputDir, filename);
await Bun.write(filepath, content);
if (options.verbose) {
console.log(` Created ${filepath} (${content.length} bytes)`);
}
}
console.log(`Generated ${Object.keys(templates).length} files in ${outputDir}`);
}Tip: Bun.write() accepts strings, Uint8Arrays, Blobs, or Response objects — pipe HTTP responses directly to disk.
Tip: Use Bun.file(path).exists() instead of fs.access() — it's cleaner and returns a boolean.
Add a Local Dev Server with Bun.serve
Build a serve command using Bun.serve() to start a local HTTP server that serves your generated files. Bun.serve is built on uWebSockets and handles thousands of concurrent connections. Add basic MIME type detection and directory listing for a complete local development experience.
// src/commands/serve.ts
import { join } from 'path';
interface ServeOptions {
output?: string;
verbose?: boolean;
}
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.ts': 'application/typescript',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
};
export async function run(options: ServeOptions) {
const dir = options.output ?? './output';
const port = 3000;
const server = Bun.serve({
port,
async fetch(req) {
const url = new URL(req.url);
let pathname = url.pathname === '/' ? '/index.html' : url.pathname;
const filepath = join(process.cwd(), dir, pathname);
const file = Bun.file(filepath);
if (await file.exists()) {
const ext = pathname.substring(pathname.lastIndexOf('.'));
return new Response(file, {
headers: {
'Content-Type': mimeTypes[ext] ?? 'application/octet-stream',
},
});
}
return new Response('Not Found', { status: 404 });
},
});
console.log(`Serving ${dir} at http://localhost:${server.port}`);
console.log('Press Ctrl+C to stop.');
}Tip: Bun.serve returns a Server object — call server.stop() in cleanup code or signal handlers.
Tip: Bun.file() streams large files automatically instead of buffering them in memory.
Add Colored Output and Progress Indicators
Make your CLI output professional with ANSI color codes and a simple spinner for long-running operations. Bun supports ANSI escape sequences natively in the terminal. Build a small utility module for consistent styling across all commands.
// src/utils/log.ts
const isColorSupported = process.env.NO_COLOR === undefined;
const codes = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
} as const;
function color(code: keyof typeof codes, text: string): string {
return isColorSupported ? `${codes[code]}${text}${codes.reset}` : text;
}
export const log = {
success: (msg: string) => console.log(color('green', ` \u2713 ${msg}`)),
warn: (msg: string) => console.log(color('yellow', ` ! ${msg}`)),
error: (msg: string) => console.error(color('red', ` \u2717 ${msg}`)),
info: (msg: string) => console.log(color('cyan', ` i ${msg}`)),
step: (n: number, total: number, msg: string) =>
console.log(` ${color('dim', `[${n}/${total}]`)} ${msg}`),
};
export function spinner(message: string) {
const frames = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r ${color('cyan', frames[i++ % frames.length])} ${message}`);
}, 80);
return {
stop: (final?: string) => {
clearInterval(interval);
process.stdout.write(`\r${' '.repeat(message.length + 10)}\r`);
if (final) log.success(final);
},
};
}Tip: Respect the NO_COLOR environment variable (https://no-color.org/) — some users and CI systems disable color output.
Tip: Keep spinner frames as Unicode braille characters — they render in all modern terminals.
Configure package.json and Publish to npm
Set up your package.json with the correct bin field, files whitelist, and type declarations. Use bun build --compile to produce a standalone binary for users who don't have Bun installed, or publish the TypeScript source for Bun users. Run npm publish to push your CLI to the registry.
{
"name": "my-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"my-cli": "./src/index.ts"
},
"files": [
"src/**/*.ts"
],
"scripts": {
"dev": "bun run src/index.ts",
"build": "bun build --compile --minify src/index.ts --outfile my-cli",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest"
}
}Tip: Use bun build --compile to produce a single executable binary that includes the Bun runtime — no dependencies needed.
Tip: Set the files field to only include src/ so you don't ship test files or config to npm.
Tip: Test your CLI locally with 'bun link' before publishing to npm.
Next Steps
- -Add interactive prompts using Bun's built-in readline or the prompts package for guided setup flows.
- -Write tests with Bun's built-in test runner using bun:test for fast, zero-config testing.
- -Add a plugin system that loads commands from node_modules or local directories.
- -Set up GitHub Actions to auto-publish to npm on tagged releases.
Need help building this?
I've shipped production projects with these stacks. Let's build yours together.
Let's Talk