Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ArgsToValues,
CommandGroups,
CommandOptions,
Examples,
LeafCommandOptions,
MergeOptions,
NormalizedOptions,
Expand Down Expand Up @@ -81,11 +82,14 @@ export class Command<
readonly subcommands?: Map<string, AnyCommand>;
/** Group definitions for organizing subcommands in help output */
readonly groups?: CommandGroups;
/** Examples shown in help output */
readonly examples?: Examples;

constructor(cmdOptions: CommandOptions<T, O, I>) {
this.name = cmdOptions.name;
this.description = cmdOptions.description;
this.version = cmdOptions.version;
this.examples = cmdOptions.examples;
this.options = normalizeOptions(cmdOptions.options ?? {});

if (isParentOptions(cmdOptions)) {
Expand Down Expand Up @@ -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 });
}

/**
Expand Down
44 changes: 43 additions & 1 deletion src/help.ts
Original file line number Diff line number Diff line change
@@ -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<keyof TypeMap, string> = {
number: "=<num>",
Expand All @@ -19,6 +26,7 @@ export interface CommandInfo {
args: readonly PositionalArg[];
options: NormalizedOptions;
inherits?: NormalizedOptions;
examples?: Examples;
}

export interface ParentCommandInfo {
Expand All @@ -27,6 +35,30 @@ export interface ParentCommandInfo {
options: NormalizedOptions;
subcommands: Map<string, AnyCommand>;
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 {
Expand All @@ -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("");

Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type {
AnyCommand,
CommandGroups,
CommandOptions,
Example,
Examples,
LeafCommandOptions,
MergeOptions,
Option,
Expand Down
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,24 @@ export type NormalizedOptions = Record<string, NormalizedOption>;
*/
export type CommandGroups = Record<string, string[]>;

/**
* 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<T extends readonly PositionalArg[]> = {
[K in keyof T]: T[K] extends { type: infer U extends keyof TypeMap; variadic: true }
Expand Down Expand Up @@ -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;
};

/**
Expand Down
45 changes: 45 additions & 0 deletions test/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
119 changes: 119 additions & 0 deletions test/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});