From d8262487e5fb7fb89d5163f673d569332b597699 Mon Sep 17 00:00:00 2001 From: Ben Truyman Date: Sun, 25 Jan 2026 13:14:25 -0600 Subject: [PATCH] feat: add command groups for organizing subcommands in help Allow parent commands to define groups that organize subcommands in help output with bold headers. Groups appear in definition order, with any ungrouped commands displayed last. Includes validation to prevent duplicate assignments and references to non-existent subcommands. --- README.md | 40 ++++++++++++ src/command.ts | 26 ++++++++ src/help.ts | 58 +++++++++++++++-- src/index.ts | 1 + src/types.ts | 20 ++++++ test/command.test.ts | 69 ++++++++++++++++++++ test/help.test.ts | 146 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 354 insertions(+), 6 deletions(-) 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); + }); + }); });