📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
# CLI Patterns
|
||||
|
||||
Deep dive into @outfitter/cli patterns.
|
||||
|
||||
## Creating a CLI
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
import { output } from "@outfitter/cli";
|
||||
|
||||
await output(data); // Human for TTY, JSON for pipes
|
||||
```
|
||||
|
||||
### Mode Priority
|
||||
|
||||
1. Explicit `mode` option
|
||||
2. `OUTFITTER_JSONL=1` env var
|
||||
3. `OUTFITTER_JSON=1` env var
|
||||
4. `OUTFITTER_JSON=0` forces human
|
||||
5. TTY detection fallback
|
||||
|
||||
### Forcing Modes
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
await output(data, {
|
||||
formatters: {
|
||||
human: (data) => formatTable(data),
|
||||
json: (data) => JSON.stringify(data, null, 2),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Exit with Error
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
const state = loadCursor({
|
||||
...options,
|
||||
maxAgeMs: 30 * 60 * 1000, // 30 minutes
|
||||
});
|
||||
```
|
||||
|
||||
### Pagination Command Pattern
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
import { readStdin } from "@outfitter/cli";
|
||||
|
||||
const input = await readStdin(); // Returns string or null if no stdin
|
||||
```
|
||||
|
||||
### Piped Detection
|
||||
|
||||
```typescript
|
||||
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/cli` directly.
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
1. **Handler first** - Business logic in handler, CLI is thin adapter
|
||||
2. **Output modes** - Support both human and JSON output
|
||||
3. **Exit codes** - Use `exitWithError` for consistent codes
|
||||
4. **Pagination** - Use cursor state for `--next` functionality
|
||||
5. **Stdin support** - Handle piped input gracefully
|
||||
6. **TTY detection** - Adapt behavior for interactive vs piped
|
||||
Reference in New Issue
Block a user