Claude-skill-registry bun-cli
Build production-grade CLI tools with Bun. Reference implementation covering argument parsing patterns (--flag value, --flag=value, --flag), dual markdown/JSON output, error handling, subcommands, and testing. Use when building CLIs, designing argument parsing, implementing command structures, reviewing CLI quality, or learning Bun CLI best practices.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/bun-cli" ~/.claude/skills/majiayu000-claude-skill-registry-bun-cli && rm -rf "$T"
skills/data/bun-cli/SKILL.mdBun CLI Development
Build powerful, production-grade CLI tools with Bun. Master argument parsing, output formatting, error handling, subcommands, and testing patterns proven in production across the SideQuest marketplace.
Quick Navigation
- Quick Start — Get a working CLI in 5 minutes
- Core Patterns — Argument parsing, output, usage, errors, subcommands
- Advanced Features — Dry-run, auto-commit, git integration
- Testing Your CLI — Unit and integration test patterns
- Reference — Comprehensive pattern guide + Para Obsidian example (9/10)
Quick Start
Goal: Build a CLI tool that feels natural to use and is easy to maintain.
Minimal CLI Template
#!/usr/bin/env bun import { color } from "@sidequest/core/formatters"; function printUsage(): void { console.log(color("cyan", "My CLI Tool v1.0")); console.log("Usage: my-cli <command> [options]"); console.log(" config Show configuration"); console.log(" help Show this help"); } async function main(): Promise<void> { const [, , command] = process.argv; if (!command || command === "help") { printUsage(); return; } try { switch (command) { case "config": console.log("Config: {...}"); break; default: console.error(`Unknown command: ${command}`); process.exit(1); } } catch (error) { console.error("Error:", error instanceof Error ? error.message : error); process.exit(1); } } main();
Core Patterns
1. Argument Parsing
The marketplace standard uses manual parsing (not external libraries). This keeps CLIs simple, dependency-light, and predictable.
Handle three flag formats:
— Spaced syntax--flag value
— Equals syntax--flag=value
— Boolean flag--flag
function parseArgs(argv: string[]) { const positional: string[] = []; const flags: Record<string, string | boolean> = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (!arg) continue; if (arg.startsWith("--")) { const [keyRaw, value] = arg.split("="); const key = keyRaw?.slice(2); if (!key) continue; const next = argv[i + 1]; if (value !== undefined) { flags[key] = value; } else if (next && !next.startsWith("--")) { flags[key] = next; i++; } else { flags[key] = true; } } else { positional.push(arg); } } const [command, subcommand, ...rest] = positional; return { command: command ?? "", subcommand, positional: rest, flags }; }
For detailed patterns and edge cases, see bun-cli-patterns.md § Argument Parsing.
2. Output Formatting
Always support both markdown (human) and JSON (machine) formats.
import { OutputFormat, parseOutputFormat } from "@sidequest/core/formatters"; type Result = { title: string; items: string[] }; function formatMarkdown(result: Result): string { return `# ${result.title}\n\n${result.items.map(i => `- ${i}`).join("\n")}`; } function formatJson(result: Result): string { return JSON.stringify(result, null, 2); } function formatOutput(result: Result, format: OutputFormat): string { return format === "json" ? formatJson(result) : formatMarkdown(result); } // In main() const format = parseOutputFormat(flags.format); console.log(formatOutput(result, format));
Benefits: Humans read markdown (colored, readable), scripts parse JSON (structured, typeable).
For color palettes and advanced formatting, see bun-cli-patterns.md § Output Formatting.
3. Usage Text
Make your CLI self-documenting with clear, scannable usage text.
function printUsage(): void { const lines = [ color("cyan", "My CLI Tool"), "", "Usage:", " my-cli config [--format md|json]", " my-cli list [path] [--format md|json]", " my-cli create --template <type> [options]", "", "Options:", " --format md|json Output format (default: md)", " --dry-run Show changes without applying", " --help Show this help", "", "Examples:", " my-cli config --format json", " my-cli list . --format md", " my-cli create --template project --dry-run", ]; console.log(lines.map(line => color("cyan", line)).join("\n")); }
Key points:
- Colored headers (cyan)
- Real, copy-paste examples
- All three flag formats shown
- Structure: Usage → Options → Examples
4. Error Handling
Be explicit and contextual with errors.
try { const config = loadConfig(); if (!config.vault) { console.error("Error: VAULT environment variable required"); process.exit(1); } // Do work... } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); process.exit(1); }
Conventions:
- Exit code 0 = success
- Exit code 1 = error
- Prefix errors with "Error:"
- Include contextual information (missing env, invalid file, etc.)
- Avoid stack traces in user output
5. Subcommands
For CLIs with many operations, use two-level commands:
case "frontmatter": { const subcommand = args[0]; switch (subcommand) { case "get": // ... case "validate": // ... case "migrate": // ... default: console.error(`Unknown subcommand: frontmatter ${subcommand}`); process.exit(1); } break; }
Benefits:
- Flat namespace
vs.frontmatter getfrontmatter-get - Easy to add subcommands
- Clear semantic grouping
Advanced Features
Dry-Run Support
Every write operation should support
--dry-run:
const dryRun = flags["dry-run"] === true; const result = await deleteFile(vault, file, { dryRun }); if (dryRun) { console.log("Would delete:", file); } else { console.log("Deleted:", file); }
Auto-Commit Integration
For tools that modify files, consider git integration:
if (flags["auto-commit"]) { const { isRepo, isClean } = await checkGitStatus(vault); if (!isRepo) throw new Error("Must be in a git repository"); if (!isClean) throw new Error("Working tree must be clean"); await autoCommitChanges(vault, changedFiles); }
Testing Your CLI
Unit Testing with Bun
import { describe, expect, test } from "bun:test"; import { parseArgs } from "./args"; describe("CLI argument parsing", () => { test("parses --key value format", () => { const result = parseArgs(["command", "--name", "test"]); expect(result.flags.name).toBe("test"); }); test("parses --key=value format", () => { const result = parseArgs(["command", "--name=test"]); expect(result.flags.name).toBe("test"); }); test("handles boolean flags", () => { const result = parseArgs(["command", "--verbose"]); expect(result.flags.verbose).toBe(true); }); });
Integration Testing
# Test real CLI invocation bun run src/cli.ts config --format json # Verify output is valid JSON bun run src/cli.ts config --format json | jq . # Test error handling bun run src/cli.ts unknown-command echo $? # Should be 1
Reference
📚 Comprehensive Pattern Guide
See bun-cli-patterns.md for the complete, detailed reference:
- File structure — Project layout and organization
- Entry point — Shebang, imports, main flow
- Argument utilities — Parsing key=value, lists, type coercion
- Output utilities — Color palettes, formatting helpers
- Exit codes — Success (0), errors (1-3)
- Configuration & environment — Loading, validation
- Testing — Unit and integration test patterns
- Bun-specific patterns — Process I/O, file I/O, shell commands
- Command dispatch — Simple vs. complex CLI architectures
- Examples — Real implementations from marketplace
- Checklist — Implementation, testing, documentation verification
- Anti-patterns — Don't do these!
- Migration guide — Updating existing CLIs to standard
🔍 Example Implementation
See bun-cli-patterns.md § Para Obsidian CLI Review:
- Score: 9/10 — Exemplary reference implementation
- Real implementation analyzed against standard
- All patterns demonstrated in production code
- Subcommands, dry-run, auto-commit, error handling
Use Para Obsidian CLI as a template for:
- Argument parsing pattern
- Usage output structure
- Output formatting (md/json)
- Error handling
- Subcommand dispatch
Common Pitfalls
❌ Don't
- Use external CLI libraries (oclif, yargs, commander) — Keep it simple
- Skip error handling — Users need clear feedback
- Ignore markdown output — Always support both markdown + JSON
- Create confusing flag names — Be explicit and consistent
- Forget the shebang —
at the top#!/usr/bin/env bun
✅ Do
- Start with manual parsing — It's simpler than you think
- Test all three flag formats — Users will use all of them
- Provide real examples — Copy-paste examples in usage text
- Support --help — Make your CLI self-documenting
- Exit with proper codes — 0 for success, 1 for error
Checklist: Building a CLI
- Shebang at top:
#!/usr/bin/env bun - JSDoc explaining CLI purpose
- Argument parsing (--flag value, --flag=value, --flag)
- Usage function with examples
- Subcommand dispatch (if needed)
- Try/catch error handling with contextual messages
- Support both markdown (default) and JSON output
- Exit codes: 0 for success, 1 for error
- Tests for argument parsing
- Tests for each command/subcommand
- README explaining usage
- Package.json bin entry (if applicable)
Pro Tips
Tip 1: Progressive Disclosure in Help
// Basic help (what I do) my-cli help // Shows: command list + brief descriptions // Advanced help (how to use me) my-cli help create // Shows: create command + all options + examples
Tip 2: Output to Stderr for Errors
// Use console.error for errors (goes to stderr) console.error("Error:", message); // ✅ Correct // Avoid using console.log for errors console.log("Error:", message); // ❌ Goes to stdout
Tip 3: Use Color Strategically
// Color headers and important info console.log(color("green", "✅ Success")); console.log(color("yellow", "⚠️ Warning")); console.error(color("red", "❌ Error")); // Don't color everything — readers get fatigued
Tip 4: Validate at Boundaries
// Validate user input (flags, args) immediately if (!flags.name || typeof flags.name !== "string") { console.error("Error: --name flag required"); process.exit(1); } // Trust internal functions (already validated) function processName(name: string) { // name is guaranteed to be a non-empty string }
FAQ
Q: Should I use oclif or similar frameworks? A: No. Manual parsing is simpler and keeps CLIs lean. The marketplace standard uses manual parsing across all CLIs.
Q: How do I handle secrets in CLIs? A: Use environment variables. Never accept secrets as flags (they'd appear in shell history).
Q: Should subcommands have their own help? A: Yes.
my-cli subcommand --help should show help for that subcommand specifically.
Q: When should I add colors? A: For headers, success messages, and errors. Don't color everything — let contrast do the work.
Q: How do I test CLIs effectively? A: Unit test argument parsing. Integration test actual CLI invocations with real files.
Q: Why manual parsing instead of libraries? A: Zero dependencies, explicit and predictable, easy to extend, familiar across all marketplace CLIs.
Last Updated: 2025-12-05 Status: Reference Implementation Related: bun-cli-patterns.md (comprehensive reference + example)