playbook/outfitter-agents/plugins/outfitter-stack/skills/stack-patterns/references/cli.md

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

  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

// 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/cli directly.

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

  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