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.
From dev-toolkitnpx claudepluginhub nathanvale/side-quest-marketplace-old --plugin dev-toolkitThis skill uses the workspace's default tool permissions.
references/bun-cli-patterns.mdscripts/review-cli.tsscripts/scaffold-cli.tsSearches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
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.
Goal: Build a CLI tool that feels natural to use and is easy to maintain.
#!/usr/bin/env bun
import { color } from "@side-quest/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();
The marketplace standard uses manual parsing (not external libraries). This keeps CLIs simple, dependency-light, and predictable.
Handle three flag formats:
--flag value — Spaced syntax--flag=value — Equals syntax--flag — Boolean flagfunction 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.
Always support both markdown (human) and JSON (machine) formats.
import { OutputFormat, parseOutputFormat } from "@side-quest/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.
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:
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:
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:
frontmatter get vs. frontmatter-getEvery 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);
}
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);
}
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);
});
});
# 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
See bun-cli-patterns.md for the complete, detailed reference:
See bun-cli-patterns.md § Para Obsidian CLI Review:
Use Para Obsidian CLI as a template for:
#!/usr/bin/env bun at the top#!/usr/bin/env bunTip 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
}
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)