From fef9ff0e8397bca8f211cca7dd1c821a67497d1f Mon Sep 17 00:00:00 2001 From: Ben Truyman Date: Thu, 9 Apr 2026 21:06:01 -0500 Subject: [PATCH] fix: handle built-in help flags in Command.run Resolve built-in help before direct Command.run() falls through to argument parsing so leaf commands do not swallow --help as a missing variadic argument. Keep built-in help and version detection behind --, and route nested help lookups from the matched subcommand position so interleaved parent flags still resolve to the deepest command. --- src/command.ts | 33 +++++++ src/index.ts | 17 +++- test/command.test.ts | 199 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 4 deletions(-) diff --git a/src/command.ts b/src/command.ts index f9cefcd..bf78058 100644 --- a/src/command.ts +++ b/src/command.ts @@ -30,6 +30,34 @@ import type { PositionalArg, } from "./types"; +function hasBuiltinHelpFlag(argv: string[]): boolean { + for (const arg of argv) { + if (arg === "--") return false; + if (arg === "--help" || arg === "-h") return true; + } + return false; +} + +function findHelpTarget(cmd: AnyCommand, argv: string[]): AnyCommand { + if (!(cmd instanceof Command) || !cmd.isParent()) { + return cmd; + } + + for (const [index, arg] of argv.entries()) { + if (arg === "--") break; + if (arg === "--help" || arg === "-h") continue; + if (arg.startsWith("-")) continue; + + const subCmd = cmd.getSubcommand(arg); + if (subCmd) { + return findHelpTarget(subCmd, argv.slice(index + 1)); + } + break; + } + + return cmd; +} + function isHybridOptions( opts: CommandOptions, ): opts is HybridCommandOptions { @@ -310,6 +338,11 @@ export class Command< * @throws {UnknownSubcommandError} When an unknown subcommand is provided */ run(argv: string[], inheritedOptions: Record = {}): void | Promise { + if (hasBuiltinHelpFlag(argv)) { + process.stdout.write(findHelpTarget(this, argv).help() + "\n"); + return; + } + if (this.isHybrid()) { return this.runHybrid(argv, inheritedOptions); } else if (this.isParent()) { diff --git a/src/index.ts b/src/index.ts index 3b03d74..b1bbca7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,14 @@ function stderr(message: string): void { const VALID_SHELLS: Shell[] = ["bash", "zsh", "fish"]; +function hasBuiltinFlag(argv: string[], long: string, short: string): boolean { + for (const arg of argv) { + if (arg === "--") return false; + if (arg === long || arg === short) return true; + } + return false; +} + function handleCompletions(cmd: Command, argv: string[]): void { const shell = argv[0]; if (!shell || !VALID_SHELLS.includes(shell as Shell)) { @@ -67,13 +75,14 @@ function findHelpTarget(cmd: AnyCommand, argv: string[]): AnyCommand { return cmd; } - for (const arg of argv) { + for (const [index, arg] of argv.entries()) { + if (arg === "--") break; if (arg === "--help" || arg === "-h") continue; if (arg.startsWith("-")) continue; const subCmd = cmd.getSubcommand(arg); if (subCmd) { - return findHelpTarget(subCmd, argv.slice(1)); + return findHelpTarget(subCmd, argv.slice(index + 1)); } break; } @@ -150,7 +159,7 @@ export async function run(cmd: Command, argv: string[]): Promise< return; } - if (argv.includes("--version") || argv.includes("-V")) { + if (hasBuiltinFlag(argv, "--version", "-V")) { if (cmd.version) { stdout(cmd.version); } else { @@ -159,7 +168,7 @@ export async function run(cmd: Command, argv: string[]): Promise< return; } - if (argv.includes("--help") || argv.includes("-h")) { + if (hasBuiltinFlag(argv, "--help", "-h")) { const helpTarget = findHelpTarget(cmd, argv); stdout(helpTarget.help()); return; diff --git a/test/command.test.ts b/test/command.test.ts index f53f36d..d2bf497 100644 --- a/test/command.test.ts +++ b/test/command.test.ts @@ -1811,6 +1811,205 @@ describe("run", () => { expect(executed).toBeTrue(); }); + + it("shows help and skips handler when Command.run receives --help", () => { + let executed = false; + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + + const cmd = command({ + name: "my-cli", + description: "Test CLI", + handler: () => { + executed = true; + }, + }); + + cmd.run(["--help"]); + + expect(executed).toBeFalse(); + expect(stdoutSpy).toHaveBeenCalled(); + expect(stdoutSpy.mock.calls[0]?.[0]).toContain("Test CLI"); + + stdoutSpy.mockRestore(); + }); + + it("shows help instead of treating --help as missing required variadic arg", () => { + let executed = false; + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + + const cmd = command({ + name: "my-cli", + description: "Variadic CLI", + args: [{ name: "files", type: "string", variadic: true }] as const, + handler: () => { + executed = true; + }, + }); + + expect(() => cmd.run(["--help"])).not.toThrow(); + expect(executed).toBeFalse(); + expect(stdoutSpy).toHaveBeenCalled(); + expect(stdoutSpy.mock.calls[0]?.[0]).toContain("Variadic CLI"); + + stdoutSpy.mockRestore(); + }); + + it("passes literal --help through to variadic args after --", async () => { + let files: string[] = []; + + const cmd = command({ + name: "my-cli", + args: [{ name: "files", type: "string", variadic: true }] as const, + handler: ([receivedFiles]) => { + files = receivedFiles; + }, + }); + + await run(cmd, ["--", "--help", "foo"]); + + expect(files).toEqual(["--help", "foo"]); + }); + + it("routes Command.run help to the correct subcommand", () => { + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + + const add = command({ + name: "add", + description: "Add subcommand help", + handler: () => {}, + }); + + const cli = command({ + name: "my-cli", + description: "Parent CLI help", + subcommands: [add], + }); + + cli.run(["add", "--help"]); + + expect(stdoutSpy).toHaveBeenCalled(); + expect(stdoutSpy.mock.calls[0]?.[0]).toContain("Add subcommand help"); + expect(stdoutSpy.mock.calls[0]?.[0]).not.toContain("Parent CLI help"); + + stdoutSpy.mockRestore(); + }); + + it("routes hybrid Command.run help to the correct subcommand", () => { + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + + const add = command({ + name: "add", + description: "Add subcommand help", + handler: () => {}, + }); + + const cli = command({ + name: "my-cli", + description: "Hybrid CLI help", + subcommands: [add], + handler: () => {}, + }); + + cli.run(["add", "--help"]); + + expect(stdoutSpy).toHaveBeenCalled(); + expect(stdoutSpy.mock.calls[0]?.[0]).toContain("Add subcommand help"); + expect(stdoutSpy.mock.calls[0]?.[0]).not.toContain("Hybrid CLI help"); + + stdoutSpy.mockRestore(); + }); + + it("routes nested Command.run help through interleaved parent options", () => { + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + + const deep = command({ + name: "deep", + description: "Deep subcommand help", + handler: () => {}, + }); + + const level2 = command({ + name: "level2", + description: "Level 2 help", + options: { + verbose: { type: "boolean", long: "verbose" }, + }, + subcommands: [deep], + }); + + const level1 = command({ + name: "level1", + description: "Level 1 help", + options: { + verbose: { type: "boolean", long: "verbose" }, + }, + subcommands: [level2], + }); + + const root = command({ + name: "root", + description: "Root help", + options: { + global: { type: "boolean", long: "global" }, + }, + subcommands: [level1], + }); + + root.run(["--global", "level1", "--verbose", "level2", "deep", "--help"]); + + expect(stdoutSpy).toHaveBeenCalled(); + expect(stdoutSpy.mock.calls[0]?.[0]).toContain("Deep subcommand help"); + expect(stdoutSpy.mock.calls[0]?.[0]).not.toContain("Level 1 help"); + expect(stdoutSpy.mock.calls[0]?.[0]).not.toContain("Root help"); + + stdoutSpy.mockRestore(); + }); + + it("routes nested run() help through interleaved parent options", async () => { + const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + + const deep = command({ + name: "deep", + description: "Deep subcommand help", + handler: () => {}, + }); + + const level2 = command({ + name: "level2", + description: "Level 2 help", + options: { + verbose: { type: "boolean", long: "verbose" }, + }, + subcommands: [deep], + }); + + const level1 = command({ + name: "level1", + description: "Level 1 help", + options: { + verbose: { type: "boolean", long: "verbose" }, + }, + subcommands: [level2], + }); + + const root = command({ + name: "root", + description: "Root help", + options: { + global: { type: "boolean", long: "global" }, + }, + subcommands: [level1], + }); + + await run(root, ["--global", "level1", "--verbose", "level2", "deep", "--help"]); + + expect(stdoutSpy).toHaveBeenCalled(); + expect(stdoutSpy.mock.calls[0]?.[0]).toContain("Deep subcommand help"); + expect(stdoutSpy.mock.calls[0]?.[0]).not.toContain("Level 1 help"); + expect(stdoutSpy.mock.calls[0]?.[0]).not.toContain("Root help"); + + stdoutSpy.mockRestore(); + }); }); describe("version", () => {