diff --git a/README.md b/README.md index ccde371..cbe84f9 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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] [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 diff --git a/src/command.ts b/src/command.ts index f19d9f0..49752cd 100644 --- a/src/command.ts +++ b/src/command.ts @@ -15,6 +15,7 @@ import { formatHelp, formatParentHelp } from "./help"; import type { AnyCommand, ArgsToValues, + CommandGroups, CommandOptions, LeafCommandOptions, MergeOptions, @@ -78,6 +79,8 @@ export class Command< readonly handler?: (args: ArgsToValues, options: OptionsToValues>) => void; /** Map of subcommands for parent commands */ readonly subcommands?: Map; + /** Group definitions for organizing subcommands in help output */ + readonly groups?: CommandGroups; constructor(cmdOptions: CommandOptions) { this.name = cmdOptions.name; @@ -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 ?? {}); @@ -136,6 +144,23 @@ export class Command< } } + private validateGroups(groups: CommandGroups): void { + const subcommandNames = new Set(this.subcommands!.keys()); + const assignedCommands = new Set(); + + 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; @@ -162,6 +187,7 @@ export class Command< description: this.description, options: this.options, subcommands: this.subcommands, + groups: this.groups, }); } return formatHelp(this); diff --git a/src/help.ts b/src/help.ts index a682a41..dad67c1 100644 --- a/src/help.ts +++ b/src/help.ts @@ -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 = { number: "=", @@ -26,6 +26,7 @@ export interface ParentCommandInfo { description?: string; options: NormalizedOptions; subcommands: Map; + groups?: CommandGroups; } export function formatHelp(command: CommandInfo): string { @@ -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(""); @@ -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(); + + 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; +} diff --git a/src/index.ts b/src/index.ts index b1814e5..13c270d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ export { }; export type { AnyCommand, + CommandGroups, CommandOptions, LeafCommandOptions, MergeOptions, diff --git a/src/types.ts b/src/types.ts index d79cedc..8accb46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -105,6 +105,20 @@ export type NormalizedOption = Option & { long: string }; /** Record of normalized options */ export type NormalizedOptions = Record; +/** + * 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; + /** 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 } @@ -222,6 +236,12 @@ export type ParentCommandOptions = 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 */ diff --git a/test/command.test.ts b/test/command.test.ts index 93b4903..a420ef3 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -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({}); + }); + }); }); diff --git a/test/help.test.ts b/test/help.test.ts index cd084da..839f4a5 100644 --- a/test/help.test.ts +++ b/test/help.test.ts @@ -196,4 +196,150 @@ ${kleur.bold("Options:")} expect(help).toContain("-c, --[no-]color"); }); + + describe("command grouping", () => { + it("displays grouped commands with bold headers", () => { + const init = command({ name: "init", description: "Initialize project", handler: () => {} }); + const build = command({ name: "build", description: "Build project", handler: () => {} }); + const serve = command({ name: "serve", description: "Start dev server", handler: () => {} }); + + const cli = command({ + name: "my-cli", + groups: { + Project: ["init", "build"], + Development: ["serve"], + }, + subcommands: [init, build, serve], + }); + + const help = cli.help(); + + expect(help).toContain(kleur.bold("Project:")); + expect(help).toContain(kleur.bold("Development:")); + expect(help).toContain("init"); + expect(help).toContain("Initialize project"); + expect(help).toContain("build"); + expect(help).toContain("serve"); + }); + + it("displays commands in group definition order", () => { + const alpha = command({ name: "alpha", handler: () => {} }); + const beta = command({ name: "beta", handler: () => {} }); + const gamma = command({ name: "gamma", handler: () => {} }); + + const cli = command({ + name: "my-cli", + groups: { + Second: ["beta"], + First: ["alpha"], + Third: ["gamma"], + }, + subcommands: [alpha, beta, gamma], + }); + + const help = cli.help(); + + const secondIdx = help.indexOf("Second:"); + const firstIdx = help.indexOf("First:"); + const thirdIdx = help.indexOf("Third:"); + + expect(secondIdx).toBeLessThan(firstIdx); + expect(firstIdx).toBeLessThan(thirdIdx); + }); + + it("displays ungrouped commands last without header", () => { + const init = command({ name: "init", description: "Initialize", handler: () => {} }); + const build = command({ name: "build", description: "Build", handler: () => {} }); + const help_cmd = command({ name: "help", description: "Show help", handler: () => {} }); + + const cli = command({ + name: "my-cli", + groups: { + Project: ["init", "build"], + }, + subcommands: [init, build, help_cmd], + }); + + const help = cli.help(); + + expect(help).toContain(kleur.bold("Project:")); + expect(help).toContain("help"); + expect(help).toContain("Show help"); + + const projectIdx = help.indexOf("Project:"); + const helpIdx = help.indexOf("help"); + expect(projectIdx).toBeLessThan(helpIdx); + }); + + it("falls back to flat list when groups is empty object", () => { + const init = command({ name: "init", handler: () => {} }); + const build = command({ name: "build", handler: () => {} }); + + const cli = command({ + name: "my-cli", + groups: {}, + subcommands: [init, build], + }); + + const help = cli.help(); + + expect(help).toContain(kleur.bold("Commands:")); + expect(help).not.toContain("Project:"); + }); + + it("falls back to flat list when groups is undefined", () => { + const init = command({ name: "init", handler: () => {} }); + const build = command({ name: "build", handler: () => {} }); + + const cli = command({ + name: "my-cli", + subcommands: [init, build], + }); + + const help = cli.help(); + + expect(help).toContain(kleur.bold("Commands:")); + }); + + it("skips empty groups", () => { + const init = command({ name: "init", handler: () => {} }); + + const cli = command({ + name: "my-cli", + groups: { + Empty: [], + Project: ["init"], + }, + subcommands: [init], + }); + + const help = cli.help(); + + expect(help).not.toContain("Empty:"); + expect(help).toContain("Project:"); + }); + + it("displays commands within groups in array order", () => { + const alpha = command({ name: "alpha", handler: () => {} }); + const beta = command({ name: "beta", handler: () => {} }); + const gamma = command({ name: "gamma", handler: () => {} }); + + const cli = command({ + name: "my-cli", + groups: { + Letters: ["gamma", "alpha", "beta"], + }, + subcommands: [alpha, beta, gamma], + }); + + const help = cli.help(); + + const gammaIdx = help.indexOf("gamma"); + const alphaIdx = help.indexOf("alpha"); + const betaIdx = help.indexOf("beta"); + + expect(gammaIdx).toBeLessThan(alphaIdx); + expect(alphaIdx).toBeLessThan(betaIdx); + }); + }); });