5.8 KiB
5.8 KiB
CLI Command Template
Commander.js command that wraps a handler.
Template
import { command, output, exitWithError } from "@outfitter/cli";
import { createContext } from "@outfitter/contracts";
import { myHandler } from "../handlers/my-handler.js";
export const myCommand = command("my-command")
// ========================================================================
// Metadata
// ========================================================================
.description("Brief description of what this command does")
// ========================================================================
// Arguments (positional)
// ========================================================================
.argument("<id>", "Required resource ID")
.argument("[name]", "Optional name")
// ========================================================================
// Options (flags)
// ========================================================================
.option("-l, --limit <n>", "Maximum number of results", parseInt)
.option("-v, --verbose", "Enable verbose output")
.option("-t, --tags <tags...>", "Filter by tags (multiple allowed)")
.option("--include-deleted", "Include deleted items")
.option("-o, --output <format>", "Output format", "table")
// ========================================================================
// Action
// ========================================================================
.action(async ({ args, flags }) => {
// Create context
const ctx = createContext({});
// Call handler
const result = await myHandler(
{
id: args.id,
name: args.name,
limit: flags.limit,
tags: flags.tags,
includeDeleted: flags.includeDeleted,
},
ctx
);
// Handle error
if (result.isErr()) {
exitWithError(result.error);
}
// Output success
await output(result.value);
})
// ========================================================================
// Build
// ========================================================================
.build();
Registration
import { createCLI } from "@outfitter/cli";
import { myCommand } from "./commands/my-command.js";
import { otherCommand } from "./commands/other-command.js";
const cli = createCLI({
name: "myapp",
version: "1.0.0",
description: "My CLI application",
});
// Register commands
cli.program.addCommand(myCommand);
cli.program.addCommand(otherCommand);
// Parse and execute
cli.program.parse();
Checklist
- Description is clear and concise
- Arguments use
<required>and[optional]syntax - Options have short and long forms where appropriate
- Numeric options use
parseIntorparseFloat - Handler is called with structured input
- Errors use
exitWithError()for correct exit codes - Success uses
await output()for format detection
Patterns
Pagination Support
import { loadCursor, saveCursor, clearCursor } from "@outfitter/cli";
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 ctx = createContext({});
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();
Subcommands
import { Command } from "commander";
const userCommand = new Command("user")
.description("User management commands");
userCommand.addCommand(
command("create")
.argument("<email>", "User email")
.action(async ({ args }) => { /* ... */ })
.build()
);
userCommand.addCommand(
command("delete")
.argument("<id>", "User ID")
.option("--force", "Skip confirmation")
.action(async ({ args, flags }) => { /* ... */ })
.build()
);
cli.program.addCommand(userCommand);
Interactive Prompts
import { confirm, text, select } from "@clack/prompts";
export const deleteCommand = command("delete")
.argument("<id>", "Resource ID")
.option("--force", "Skip confirmation")
.action(async ({ args, flags }) => {
if (!flags.force) {
const confirmed = await confirm({
message: `Delete resource ${args.id}?`,
});
if (!confirmed) {
console.log("Cancelled");
return;
}
}
// Proceed with deletion
})
.build();
Test Template
import { describe, test, expect } from "bun:test";
import { createCliHarness } from "@outfitter/testing";
import { myCommand } from "../commands/my-command.js";
const harness = createCliHarness(myCommand);
describe("my-command", () => {
test("outputs JSON with --json flag", async () => {
const result = await harness.run(["test-id", "--json"]);
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toMatchObject({ id: "test-id" });
});
test("exits with error for missing resource", async () => {
const result = await harness.run(["missing-id"]);
expect(result.exitCode).toBe(2); // not_found
expect(result.stderr).toContain("not found");
});
test("validates required arguments", async () => {
const result = await harness.run([]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("required");
});
});