7.4 KiB
7.4 KiB
CLI Patterns
Deep dive into @outfitter/cli patterns.
Creating a CLI
import { createCLI } from "@outfitter/cli";
const cli = createCLI({
name: "myapp",
version: "1.0.0",
description: "My CLI application",
});
cli.program.addCommand(listCommand);
cli.program.addCommand(getCommand);
cli.program.parse();
Command Builder
Type-safe command construction:
import { command } from "@outfitter/cli";
export const myCommand = command("my-command")
.description("What this command does")
.argument("<id>", "Required resource ID")
.argument("[name]", "Optional name")
.option("-l, --limit <n>", "Limit results", parseInt)
.option("-v, --verbose", "Enable verbose output")
.option("-t, --tags <tags...>", "Filter by tags")
.action(async ({ args, flags }) => {
// args.id: string
// args.name: string | undefined
// flags.limit: number | undefined
// flags.verbose: boolean
// flags.tags: string[] | undefined
})
.build();
Output Modes
Automatic Detection
import { output } from "@outfitter/cli";
await output(data); // Human for TTY, JSON for pipes
Mode Priority
- Explicit
modeoption OUTFITTER_JSONL=1env varOUTFITTER_JSON=1env varOUTFITTER_JSON=0forces human- TTY detection fallback
Forcing Modes
// Force JSON
await output(data, { mode: "json" });
// Force human
await output(data, { mode: "human" });
// JSONL for streaming
for await (const item of items) {
await output(item, { mode: "jsonl" });
}
// Output to stderr
await output(errorData, { stream: process.stderr });
Custom Formatters
await output(data, {
formatters: {
human: (data) => formatTable(data),
json: (data) => JSON.stringify(data, null, 2),
},
});
Error Handling
Exit with Error
import { exitWithError } from "@outfitter/cli";
const result = await handler(input, ctx);
if (result.isErr()) {
exitWithError(result.error); // Exit code from error category
}
Exit Code Mapping
| Category | Exit Code |
|---|---|
| validation | 1 |
| not_found | 2 |
| conflict | 3 |
| permission | 4 |
| timeout | 5 |
| rate_limit | 6 |
| network | 7 |
| internal | 8 |
| auth | 9 |
| cancelled | 130 |
Custom Error Output
import { formatError, getExitCode } from "@outfitter/cli";
if (result.isErr()) {
const formatted = formatError(result.error, { verbose: flags.verbose });
await output(formatted, { stream: process.stderr });
process.exit(getExitCode(result.error.category));
}
Pagination
Cursor State
Cursors persist in XDG state directory:
$XDG_STATE_HOME/{toolName}/cursors/{command}/cursor.json
Using Pagination
import { loadCursor, saveCursor, clearCursor } from "@outfitter/cli";
const options = { command: "list", toolName: "myapp" };
// Load previous cursor
const state = loadCursor(options);
// Fetch data with cursor
const results = await listItems({
cursor: state?.cursor,
limit: 20,
});
// Save for --next
if (results.hasMore) {
saveCursor(results.nextCursor, options);
}
// Clear on --reset
if (flags.reset) {
clearCursor(options);
}
Cursor Expiration
const state = loadCursor({
...options,
maxAgeMs: 30 * 60 * 1000, // 30 minutes
});
Pagination Command Pattern
export const listCommand = command("list")
.option("-n, --next", "Continue from previous position")
.option("--reset", "Reset pagination cursor")
.option("-l, --limit <n>", "Results per page", parseInt, 20)
.action(async ({ flags }) => {
const paginationOpts = { command: "list", toolName: "myapp" };
if (flags.reset) {
clearCursor(paginationOpts);
console.log("Cursor reset");
return;
}
const cursor = flags.next ? loadCursor(paginationOpts)?.cursor : undefined;
const result = await listHandler({ cursor, limit: flags.limit }, ctx);
if (result.isErr()) {
exitWithError(result.error);
}
await output(result.value.items);
if (result.value.nextCursor) {
saveCursor(result.value.nextCursor, paginationOpts);
console.log("\nUse --next for more results");
}
})
.build();
Input Parsing
Stdin Reading
import { readStdin } from "@outfitter/cli";
const input = await readStdin(); // Returns string or null if no stdin
Piped Detection
import { isPiped } from "@outfitter/cli";
if (isPiped()) {
const data = await readStdin();
} else {
// Interactive mode
}
Progress Indicators
Note: UI components merged into
@outfitter/cli. Import from@outfitter/clidirectly.
import { createSpinner, createProgressBar } from "@outfitter/cli";
// Spinner
const spinner = createSpinner("Loading...");
spinner.start();
// ... work
spinner.succeed("Done!");
// Progress bar
const progress = createProgressBar({ total: 100 });
for (let i = 0; i <= 100; i++) {
progress.update(i);
}
progress.stop();
Formatting Utilities
Date Range Parsing
Parse human-readable date ranges:
import { parseDateRange } from "@outfitter/cli";
const range = parseDateRange("last 7 days");
// { start: Date, end: Date }
const range2 = parseDateRange("2026-01-01..2026-01-31");
// { start: Date, end: Date }
// Supported formats:
// - "last N days/weeks/months"
// - "today", "yesterday", "this week", "this month"
// - "YYYY-MM-DD..YYYY-MM-DD" (range)
// - "YYYY-MM-DD" (single day)
Duration Formatting
Format milliseconds as human-readable duration:
import { formatDuration } from "@outfitter/cli";
formatDuration(1500); // "1.5s"
formatDuration(65000); // "1m 5s"
formatDuration(3661000); // "1h 1m 1s"
formatDuration(90061000); // "1d 1h 1m"
Byte Formatting
Format bytes as human-readable sizes:
import { formatBytes } from "@outfitter/cli";
formatBytes(1024); // "1 KB"
formatBytes(1536); // "1.5 KB"
formatBytes(1048576); // "1 MB"
formatBytes(1073741824); // "1 GB"
Pluralization
Pluralize words based on count:
import { pluralize } from "@outfitter/cli";
pluralize(1, "file"); // "1 file"
pluralize(5, "file"); // "5 files"
pluralize(0, "item"); // "0 items"
// Custom plural form
pluralize(2, "person", "people"); // "2 people"
Slugification
Convert strings to URL-safe slugs:
import { slugify } from "@outfitter/cli";
slugify("Hello World"); // "hello-world"
slugify("My New Feature!"); // "my-new-feature"
slugify("Café Résumé"); // "cafe-resume"
Custom Renderers
Register custom output renderers for specific data types:
import { registerRenderer, output } from "@outfitter/cli";
interface User {
id: string;
name: string;
email: string;
}
registerRenderer<User>("user", {
human: (user) => `${user.name} <${user.email}>`,
json: (user) => JSON.stringify(user),
});
// Now output() will use your renderer when type matches
await output(user, { type: "user" });
Best Practices
- Handler first - Business logic in handler, CLI is thin adapter
- Output modes - Support both human and JSON output
- Exit codes - Use
exitWithErrorfor consistent codes - Pagination - Use cursor state for
--nextfunctionality - Stdin support - Handle piped input gracefully
- TTY detection - Adapt behavior for interactive vs piped