From 2d2207da80468883a3f3f00d7ef36694e0a65f0a Mon Sep 17 00:00:00 2001 From: Ben Truyman Date: Sun, 25 Jan 2026 14:01:58 -0600 Subject: [PATCH] feat: add examples support for help output Add optional examples property to commands that displays usage examples in help text. Examples can be simple strings or objects with command and description. Examples appear after description and before usage section, with descriptions shown dimmed and aligned. --- README.md | 38 ++++++++++++++ src/command.ts | 7 ++- src/help.ts | 44 +++++++++++++++- src/index.ts | 2 + src/types.ts | 20 ++++++++ test/command.test.ts | 45 ++++++++++++++++ test/help.test.ts | 119 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 273 insertions(+), 2 deletions(-) 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"); + }); + }); });