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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ HELLO, ADA!
| `inherits` | `Options` | No | Options inherited from parents |
| `handler` | `(args, options) => void` | \* | Your code goes here |
| `subcommands` | `Command[]` | \* | Nested commands |
| `groups` | `CommandGroups` | No | Group subcommands in help |

\* A command has either `handler` OR `subcommands`, never both.

Expand Down Expand Up @@ -152,6 +153,45 @@ $ git remote add https://github.com/... --verbose

The `inherits` property tells the leaf command which parent options it should parse and receive in its handler. This enables full type inference for inherited options.

### Command Groups

Organize subcommands into groups for cleaner help output:

```typescript
const cli = command({
name: "my-cli",
groups: {
"Project": ["init", "build", "test"],
"Development": ["serve", "watch"],
},
subcommands: [init, build, test, serve, watch, help],
});
```

```
$ my-cli --help

Usage:
my-cli [options] <command> [args...]

Project:
init Initialize a new project
build Build the project
test Run tests

Development:
serve Start development server
watch Watch for changes

help Show help

Options:
-h, --help Show help
-V, --version Show version
```

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.

### Positional Args

```typescript
Expand Down
26 changes: 26 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { formatHelp, formatParentHelp } from "./help";
import type {
AnyCommand,
ArgsToValues,
CommandGroups,
CommandOptions,
LeafCommandOptions,
MergeOptions,
Expand Down Expand Up @@ -78,6 +79,8 @@ export class Command<
readonly handler?: (args: ArgsToValues<T>, options: OptionsToValues<MergeOptions<I, O>>) => void;
/** Map of subcommands for parent commands */
readonly subcommands?: Map<string, AnyCommand>;
/** Group definitions for organizing subcommands in help output */
readonly groups?: CommandGroups;

constructor(cmdOptions: CommandOptions<T, O, I>) {
this.name = cmdOptions.name;
Expand All @@ -92,6 +95,11 @@ export class Command<
this.args = [] as unknown as T;
this.inherits = {};
this.subcommands = new Map(cmdOptions.subcommands.map((cmd) => [cmd.name, cmd]));

if (cmdOptions.groups) {
this.validateGroups(cmdOptions.groups);
this.groups = cmdOptions.groups;
}
} else {
this.args = cmdOptions.args ?? ([] as unknown as T);
this.inherits = normalizeOptions(cmdOptions.inherits ?? {});
Expand Down Expand Up @@ -136,6 +144,23 @@ export class Command<
}
}

private validateGroups(groups: CommandGroups): void {
const subcommandNames = new Set(this.subcommands!.keys());
const assignedCommands = new Set<string>();

for (const [groupName, commandNames] of Object.entries(groups)) {
for (const cmdName of commandNames) {
if (!subcommandNames.has(cmdName)) {
throw new Error(`Group '${groupName}' references unknown subcommand '${cmdName}'`);
}
if (assignedCommands.has(cmdName)) {
throw new Error(`Subcommand '${cmdName}' is assigned to multiple groups`);
}
assignedCommands.add(cmdName);
}
}
}

/** Returns true if this command has subcommands (is a parent command) */
isParent(): boolean {
return this.subcommands !== undefined && this.subcommands.size > 0;
Expand All @@ -162,6 +187,7 @@ export class Command<
description: this.description,
options: this.options,
subcommands: this.subcommands,
groups: this.groups,
});
}
return formatHelp(this);
Expand Down
58 changes: 52 additions & 6 deletions src/help.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import kleur from "kleur";

import type { AnyCommand, NormalizedOptions, PositionalArg, TypeMap } from "./types";
import type { AnyCommand, CommandGroups, NormalizedOptions, PositionalArg, TypeMap } from "./types";

const VALUE_SUFFIXES: Record<keyof TypeMap, string> = {
number: "=<num>",
Expand All @@ -26,6 +26,7 @@ export interface ParentCommandInfo {
description?: string;
options: NormalizedOptions;
subcommands: Map<string, AnyCommand>;
groups?: CommandGroups;
}

export function formatHelp(command: CommandInfo): string {
Expand Down Expand Up @@ -145,15 +146,18 @@ export function formatParentHelp(command: ParentCommandInfo): string {
);
output.push("");

// List subcommands
output.push(kleur.bold("Commands:"));
const subcommandEntries = Array.from(command.subcommands.values());
const maxNameLen =
subcommandEntries.length > 0 ? Math.max(...subcommandEntries.map((s) => s.name.length)) : 0;

for (const sub of subcommandEntries) {
const padding = " ".repeat(maxNameLen - sub.name.length + 2);
output.push(` ${kleur.yellow(sub.name)}${padding}${sub.description ?? ""}`);
if (command.groups && Object.keys(command.groups).length > 0) {
output.push(...formatGroupedCommands(command, maxNameLen));
} else {
output.push(kleur.bold("Commands:"));
for (const sub of subcommandEntries) {
const padding = " ".repeat(maxNameLen - sub.name.length + 2);
output.push(` ${kleur.yellow(sub.name)}${padding}${sub.description ?? ""}`);
}
}

output.push("");
Expand All @@ -162,3 +166,45 @@ export function formatParentHelp(command: ParentCommandInfo): string {

return output.join("\n");
}

function formatGroupedCommands(command: ParentCommandInfo, maxNameLen: number): string[] {
const output: string[] = [];
const groups = command.groups!;
const subcommands = command.subcommands;

const displayedCommands = new Set<string>();

for (const [groupName, commandNames] of Object.entries(groups)) {
if (commandNames.length === 0) continue;

output.push(kleur.bold(`${groupName}:`));

for (const cmdName of commandNames) {
const sub = subcommands.get(cmdName);
if (sub) {
const padding = " ".repeat(maxNameLen - sub.name.length + 2);
output.push(` ${kleur.yellow(sub.name)}${padding}${sub.description ?? ""}`);
displayedCommands.add(cmdName);
}
}

output.push("");
}

const ungroupedCommands = Array.from(subcommands.values()).filter(
(sub) => !displayedCommands.has(sub.name),
);

if (ungroupedCommands.length > 0) {
for (const sub of ungroupedCommands) {
const padding = " ".repeat(maxNameLen - sub.name.length + 2);
output.push(` ${kleur.yellow(sub.name)}${padding}${sub.description ?? ""}`);
}
}

if (output.length > 0 && output[output.length - 1] === "") {
output.pop();
}

return output;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
};
export type {
AnyCommand,
CommandGroups,
CommandOptions,
LeafCommandOptions,
MergeOptions,
Expand Down
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,20 @@ export type NormalizedOption = Option & { long: string };
/** Record of normalized options */
export type NormalizedOptions = Record<string, NormalizedOption>;

/**
* Maps group names to arrays of subcommand names for organized help output.
* Groups are displayed in definition order, with ungrouped commands appearing last.
*
* @example
* ```typescript
* const groups: CommandGroups = {
* 'Project': ['init', 'build', 'test'],
* 'Development': ['serve', 'watch'],
* };
* ```
*/
export type CommandGroups = Record<string, string[]>;

/** 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 @@ -222,6 +236,12 @@ export type ParentCommandOptions<O extends Options = Options> = BaseCommandOptio
subcommands: AnyCommand[];
/** Options that are parsed at this level and passed to subcommands */
options?: O;
/**
* Optional grouping of subcommands for help display.
* Keys are group names, values are arrays of subcommand names.
* Grouped commands appear first in definition order; ungrouped commands appear last.
*/
groups?: CommandGroups;
/** Must be undefined for parent commands */
handler?: never;
/** Must be undefined for parent commands */
Expand Down
69 changes: 69 additions & 0 deletions test/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1642,4 +1642,73 @@ describe("subcommands", () => {
});
});
});

describe("groups", () => {
it("stores groups property on command", () => {
const init = command({ name: "init", handler: () => {} });
const build = command({ name: "build", handler: () => {} });

const cli = command({
name: "cli",
groups: {
Project: ["init", "build"],
},
subcommands: [init, build],
});

expect(cli.groups).toEqual({ Project: ["init", "build"] });
});

it("throws error when group references unknown subcommand", () => {
const init = command({ name: "init", handler: () => {} });

expect(() =>
command({
name: "cli",
groups: {
Project: ["init", "unknown"],
},
subcommands: [init],
}),
).toThrow("Group 'Project' references unknown subcommand 'unknown'");
});

it("throws error when command is assigned to multiple groups", () => {
const init = command({ name: "init", handler: () => {} });

expect(() =>
command({
name: "cli",
groups: {
Project: ["init"],
Other: ["init"],
},
subcommands: [init],
}),
).toThrow("Subcommand 'init' is assigned to multiple groups");
});

it("allows undefined groups", () => {
const init = command({ name: "init", handler: () => {} });

const cli = command({
name: "cli",
subcommands: [init],
});

expect(cli.groups).toBeUndefined();
});

it("allows empty groups object", () => {
const init = command({ name: "init", handler: () => {} });

const cli = command({
name: "cli",
groups: {},
subcommands: [init],
});

expect(cli.groups).toEqual({});
});
});
});
Loading