diff --git a/README.md b/README.md index cbe84f9..ad94523 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ HELLO, ADA! | `handler` | `(args, options) => void` | \* | Your code goes here | | `subcommands` | `Command[]` | \* | Nested commands | | `groups` | `CommandGroups` | No | Group subcommands in help | +| `examples` | `Examples` | No | Usage examples in help | \* A command has either `handler` OR `subcommands`, never both. @@ -192,6 +193,43 @@ Options: Groups appear in definition order. Commands not assigned to any group appear last without a header. This is optional—omit `groups` for a flat command list. +### Examples + +Add usage examples to help output: + +```typescript +const cli = command({ + name: "my-cli", + description: "A deployment tool", + examples: [ + "my-cli deploy", + "my-cli deploy --env staging", + { command: "my-cli deploy --env prod", description: "Deploy to production" }, + ], + handler: () => {}, +}); +``` + +``` +$ my-cli --help + +A deployment tool + +Examples: + my-cli deploy + my-cli deploy --env staging + my-cli deploy --env prod Deploy to production + +Usage: + my-cli [options] + +Options: + -h, --help Show help + -V, --version Show version +``` + +Examples can be simple strings or objects with `{ command, description }` for annotated examples. Descriptions are shown dimmed and aligned. + ### Positional Args ```typescript diff --git a/src/command.ts b/src/command.ts index 49752cd..5b49665 100644 --- a/src/command.ts +++ b/src/command.ts @@ -17,6 +17,7 @@ import type { ArgsToValues, CommandGroups, CommandOptions, + Examples, LeafCommandOptions, MergeOptions, NormalizedOptions, @@ -81,11 +82,14 @@ export class Command< readonly subcommands?: Map; /** Group definitions for organizing subcommands in help output */ readonly groups?: CommandGroups; + /** Examples shown in help output */ + readonly examples?: Examples; constructor(cmdOptions: CommandOptions) { this.name = cmdOptions.name; this.description = cmdOptions.description; this.version = cmdOptions.version; + this.examples = cmdOptions.examples; this.options = normalizeOptions(cmdOptions.options ?? {}); if (isParentOptions(cmdOptions)) { @@ -188,9 +192,10 @@ export class Command< options: this.options, subcommands: this.subcommands, groups: this.groups, + examples: this.examples, }); } - return formatHelp(this); + return formatHelp({ ...this, examples: this.examples }); } /** diff --git a/src/help.ts b/src/help.ts index dad67c1..a3ca5f7 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,6 +1,13 @@ import kleur from "kleur"; -import type { AnyCommand, CommandGroups, NormalizedOptions, PositionalArg, TypeMap } from "./types"; +import type { + AnyCommand, + CommandGroups, + Examples, + NormalizedOptions, + PositionalArg, + TypeMap, +} from "./types"; const VALUE_SUFFIXES: Record = { number: "=", @@ -19,6 +26,7 @@ export interface CommandInfo { args: readonly PositionalArg[]; options: NormalizedOptions; inherits?: NormalizedOptions; + examples?: Examples; } export interface ParentCommandInfo { @@ -27,6 +35,30 @@ export interface ParentCommandInfo { options: NormalizedOptions; subcommands: Map; groups?: CommandGroups; + examples?: Examples; +} + +function formatExamples(examples: Examples): string[] { + const output: string[] = [kleur.bold("Examples:")]; + + const itemsWithDesc = examples.filter( + (e): e is { command: string; description: string } => typeof e !== "string" && !!e.description, + ); + const maxCmdLen = + itemsWithDesc.length > 0 ? Math.max(...itemsWithDesc.map((e) => e.command.length)) : 0; + + for (const example of examples) { + if (typeof example === "string") { + output.push(` ${example}`); + } else if (example.description) { + const padding = " ".repeat(maxCmdLen - example.command.length + 2); + output.push(` ${example.command}${padding}${kleur.dim(example.description)}`); + } else { + output.push(` ${example.command}`); + } + } + + return output; } export function formatHelp(command: CommandInfo): string { @@ -37,6 +69,11 @@ export function formatHelp(command: CommandInfo): string { output.push(""); } + if (command.examples && command.examples.length > 0) { + output.push(...formatExamples(command.examples)); + output.push(""); + } + output.push(...formatUsage(command.name, command.args)); output.push(""); @@ -139,6 +176,11 @@ export function formatParentHelp(command: ParentCommandInfo): string { output.push(""); } + if (command.examples && command.examples.length > 0) { + output.push(...formatExamples(command.examples)); + output.push(""); + } + // Usage line for parent commands output.push(kleur.bold("Usage:")); output.push( diff --git a/src/index.ts b/src/index.ts index 13c270d..0681339 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,8 @@ export type { AnyCommand, CommandGroups, CommandOptions, + Example, + Examples, LeafCommandOptions, MergeOptions, Option, diff --git a/src/types.ts b/src/types.ts index 8accb46..d92f2ad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -119,6 +119,24 @@ export type NormalizedOptions = Record; */ export type CommandGroups = Record; +/** + * A single example for help output. + * Can be a simple command string or an object with command and description. + * + * @example + * ```typescript + * // Simple string + * 'my-cli init myapp' + * + * // With description + * { command: 'my-cli deploy --env staging', description: 'Deploy to staging' } + * ``` + */ +export type Example = string | { command: string; description?: string }; + +/** Array of examples shown in help output */ +export type Examples = Example[]; + /** Converts positional arg definitions to a tuple of their runtime value types */ export type ArgsToValues = { [K in keyof T]: T[K] extends { type: infer U extends keyof TypeMap; variadic: true } @@ -155,6 +173,8 @@ type BaseCommandOptions = { description?: string; /** Version string shown when --version or -V is passed */ version?: string; + /** Examples shown in help output, after description and before usage */ + examples?: Examples; }; /** diff --git a/test/command.test.ts b/test/command.test.ts index a420ef3..12c10e0 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -1711,4 +1711,49 @@ describe("subcommands", () => { expect(cli.groups).toEqual({}); }); }); + + describe("examples", () => { + it("stores examples property on leaf command", () => { + const cmd = command({ + name: "cli", + examples: ["cli init myapp", "cli build --prod"], + handler: () => {}, + }); + + expect(cmd.examples).toEqual(["cli init myapp", "cli build --prod"]); + }); + + it("stores examples property on parent command", () => { + const init = command({ name: "init", handler: () => {} }); + + const cli = command({ + name: "cli", + examples: ["cli init myapp"], + subcommands: [init], + }); + + expect(cli.examples).toEqual(["cli init myapp"]); + }); + + it("allows mixed example formats", () => { + const cmd = command({ + name: "cli", + examples: ["cli init", { command: "cli build", description: "Build the project" }], + handler: () => {}, + }); + + expect(cmd.examples).toHaveLength(2); + expect(cmd.examples![0]).toBe("cli init"); + expect(cmd.examples![1]).toEqual({ command: "cli build", description: "Build the project" }); + }); + + it("allows undefined examples", () => { + const cmd = command({ + name: "cli", + handler: () => {}, + }); + + expect(cmd.examples).toBeUndefined(); + }); + }); }); diff --git a/test/help.test.ts b/test/help.test.ts index 839f4a5..7b48571 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -342,4 +342,123 @@ ${kleur.bold("Options:")} expect(alphaIdx).toBeLessThan(betaIdx); }); }); + + describe("examples", () => { + it("displays simple string examples", () => { + const cmd = command({ + name: "my-cli", + examples: ["my-cli init myapp", "my-cli build --prod"], + handler: () => {}, + }); + + const help = cmd.help(); + + expect(help).toContain(kleur.bold("Examples:")); + expect(help).toContain("my-cli init myapp"); + expect(help).toContain("my-cli build --prod"); + }); + + it("displays examples with descriptions", () => { + const cmd = command({ + name: "my-cli", + examples: [ + { command: "my-cli deploy --env staging", description: "Deploy to staging" }, + { command: "my-cli deploy --env prod", description: "Deploy to production" }, + ], + handler: () => {}, + }); + + const help = cmd.help(); + + expect(help).toContain("my-cli deploy --env staging"); + expect(help).toContain(kleur.dim("Deploy to staging")); + expect(help).toContain("my-cli deploy --env prod"); + expect(help).toContain(kleur.dim("Deploy to production")); + }); + + it("displays mixed examples (strings and objects)", () => { + const cmd = command({ + name: "my-cli", + examples: [ + "my-cli init myapp", + { command: "my-cli build --prod", description: "Production build" }, + ], + handler: () => {}, + }); + + const help = cmd.help(); + + expect(help).toContain("my-cli init myapp"); + expect(help).toContain("my-cli build --prod"); + expect(help).toContain(kleur.dim("Production build")); + }); + + it("displays examples without description (object form)", () => { + const cmd = command({ + name: "my-cli", + examples: [{ command: "my-cli init myapp" }], + handler: () => {}, + }); + + const help = cmd.help(); + + expect(help).toContain("my-cli init myapp"); + }); + + it("does not show Examples section when examples is undefined", () => { + const cmd = command({ + name: "my-cli", + handler: () => {}, + }); + + const help = cmd.help(); + + expect(help).not.toContain("Examples:"); + }); + + it("does not show Examples section when examples is empty", () => { + const cmd = command({ + name: "my-cli", + examples: [], + handler: () => {}, + }); + + const help = cmd.help(); + + expect(help).not.toContain("Examples:"); + }); + + it("shows examples after description and before usage", () => { + const cmd = command({ + name: "my-cli", + description: "A test CLI", + examples: ["my-cli init"], + handler: () => {}, + }); + + const help = cmd.help(); + + const descIdx = help.indexOf("A test CLI"); + const examplesIdx = help.indexOf("Examples:"); + const usageIdx = help.indexOf("Usage:"); + + expect(descIdx).toBeLessThan(examplesIdx); + expect(examplesIdx).toBeLessThan(usageIdx); + }); + + it("works on parent commands", () => { + const init = command({ name: "init", handler: () => {} }); + + const cli = command({ + name: "my-cli", + examples: ["my-cli init myapp", "my-cli build"], + subcommands: [init], + }); + + const help = cli.help(); + + expect(help).toContain(kleur.bold("Examples:")); + expect(help).toContain("my-cli init myapp"); + }); + }); });