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
33 changes: 33 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends readonly PositionalArg[], O extends Options, I extends Options>(
opts: CommandOptions<T, O, I>,
): opts is HybridCommandOptions<T, O, I> {
Expand Down Expand Up @@ -310,6 +338,11 @@ export class Command<
* @throws {UnknownSubcommandError} When an unknown subcommand is provided
*/
run(argv: string[], inheritedOptions: Record<string, unknown> = {}): void | Promise<void> {
if (hasBuiltinHelpFlag(argv)) {
process.stdout.write(findHelpTarget(this, argv).help() + "\n");
return;
}

if (this.isHybrid()) {
return this.runHybrid(argv, inheritedOptions);
} else if (this.isParent()) {
Expand Down
17 changes: 13 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, any, any>, argv: string[]): void {
const shell = argv[0];
if (!shell || !VALID_SHELLS.includes(shell as Shell)) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -150,7 +159,7 @@ export async function run(cmd: Command<any, any, any>, argv: string[]): Promise<
return;
}

if (argv.includes("--version") || argv.includes("-V")) {
if (hasBuiltinFlag(argv, "--version", "-V")) {
if (cmd.version) {
stdout(cmd.version);
} else {
Expand All @@ -159,7 +168,7 @@ export async function run(cmd: Command<any, any, any>, 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;
Expand Down
199 changes: 199 additions & 0 deletions test/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down