Skip to content
Draft
7 changes: 7 additions & 0 deletions .changeset/dark-rocks-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Fixed Wrangler's error handling for both invalid commands with and without the `--help` flag, ensuring consistent and clear error messages.

Additionally, it also ensures that if you provide an invalid argument to a valid command, Wrangler will now correctly display that specific commands help menu.
30 changes: 30 additions & 0 deletions packages/wrangler/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,36 @@ describe("wrangler", () => {
"
`);
});

it("should display an error even with --help flag", async () => {
await expect(
runWrangler("invalid-command --help")
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Unknown argument: invalid-command]`
);

expect(std.err).toContain("Unknown argument: invalid-command");
expect(std.out).toContain("wrangler");
expect(std.out).toContain("COMMANDS");
expect(std.out).toContain("ACCOUNT");
});
});

describe("invalid flag on valid command", () => {
it("should display command-specific help for unknown flag", async () => {
await expect(
runWrangler("types --invalid-flag-xyz")
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Unknown arguments: invalid-flag-xyz, invalidFlagXyz]`
);

expect(std.err).toContain("Unknown arguments: invalid-flag-xyz");
expect(std.out).toContain("wrangler types");
expect(std.out).toContain(
"Generate types from your Worker configuration"
);
expect(std.out).not.toContain("ACCOUNT");
});
});

describe("global options", () => {
Expand Down
31 changes: 31 additions & 0 deletions packages/wrangler/src/core/CommandRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export class CommandRegistry {
*/
#categories: CategoryMap;

/**
* Set of legacy command names registered outside the `CommandRegistry` class.
* Used to track commands like `containers`, `pubsub`, etc.
*/
#legacyCommands: Set<string>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't they all be moved to the registry ?

Copy link
Contributor Author

@NuroDev NuroDev Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most commands are registered with the registry, but a handful of others still use the wrangler.command(...) system and we need to aggregate them both into a single source of truth.

To keep this PR simple(r) and not break anything I didn't want to move them to the registry yet. It could be simple, but I'm still not extremely well versed in the CLI setup to know if legacy commands require something that the registry doesn't?

Copy link
Contributor

@vicb vicb Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a similar question on a PR from TK yesterday.

My opinion is that we should stop adding things in the wrong way(TM) and refactor first - unless there is an urgent need to do otherwise.

Having different code paths is also something that also impacts the other PR.

+100 to refactor before doing more changes that will make the needed refactor harder later.

/cc @MattieTK @petebacondarwin

edit: I think the following somehow proves my point:

I'm still not extremely well versed in the CLI setup to know if legacy commands require something that the registry doesn't?

Yes the current code is too complex.
What we should do is to simplify/unify it instead of making if more complex for the next one that will debug/edit it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/**
* Initializes the command registry with the given command registration function.
*/
Expand All @@ -76,6 +82,7 @@ export class CommandRegistry {
this.#registerCommand = registerCommand;
this.#tree = this.#DefinitionTreeRoot.subtree;
this.#categories = new Map();
this.#legacyCommands = new Set<string>();
}

/**
Expand Down Expand Up @@ -134,6 +141,19 @@ export class CommandRegistry {
this.#walkTreeAndRegister(namespace, node, `wrangler ${namespace}`);
}

/**
* Get a set of all top-level command names.
*
* Includes both registry-defined commands & legacy commands.
*/
get topLevelCommands(): Set<string> {
const commands = new Set(this.#tree.keys());
for (const legacyCmd of this.#legacyCommands) {
commands.add(legacyCmd);
}
return commands;
}

/**
* Returns the map of categories to command segments, ordered according to
* the category order. Commands within each category are sorted alphabetically.
Expand Down Expand Up @@ -163,6 +183,14 @@ export class CommandRegistry {
return orderedCategories;
}

/**
* Registers a legacy command that doesn't use the `CommandRegistry` class.
* This is used for hidden commands like `cloudchamber` that use the old yargs pattern.
*/
registerLegacyCommand(command: string): void {
this.#legacyCommands.add(command);
}

/**
* Registers a category for a legacy command that doesn't use the CommandRegistry.
* This is used for commands like `containers`, `pubsub`, etc, that use the old yargs pattern.
Expand All @@ -171,6 +199,9 @@ export class CommandRegistry {
command: string,
category: MetadataCategory
): void {
// Track as a legacy command for `topLevelCommands` getter
this.#legacyCommands.add(command);

const existing = this.#categories.get(category) ?? [];
if (existing.includes(command)) {
return;
Expand Down
28 changes: 20 additions & 8 deletions packages/wrangler/src/core/handle-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,19 +426,31 @@ export async function handleError(
// The workaround is to re-run the parsing with an additional `--help` flag, which will result in the correct help message being displayed.
// The `wrangler` object is "frozen"; we cannot reuse that with different args, so we must create a new CLI parser to generate the help message.

// Check if this is a root-level error (unknown argument at root level)
// by looking at the error message - if it says "Unknown argument" or "Unknown command",
// and there's only one non-flag argument, show the categorized root help
const nonFlagArgs = subCommandParts.filter(
(arg) => !arg.startsWith("-") && arg !== ""
);
const isRootLevelError =
nonFlagArgs.length <= 1 &&
(e.message.includes("Unknown argument") ||
e.message.includes("Unknown command"));

const isUnknownArgOrCommand =
e.message.includes("Unknown argument") ||
e.message.includes("Unknown command");

const unknownArgsMatch = e.message.match(/Unknown arguments?: (.+)/);
const unknownArgs = unknownArgsMatch
? unknownArgsMatch[1].split(", ").map((a) => a.trim())
: [];

// Check if any of the unknown args match the first non-flag argument
// If so, it's an unknown command (not an unknown flag on a valid command)
// Note: we check !arg.startsWith("-") to exclude flag-like args,
// but command names can contain dashes (e.g., "dispatch-namespace")
const isUnknownCommand = unknownArgs.some(
(arg) => arg === nonFlagArgs[0] && !arg.startsWith("-")
);

const isRootLevelError = isUnknownArgOrCommand && isUnknownCommand;

const { wrangler, showHelpWithCategories } = createCLIParser([
...(isRootLevelError ? [] : subCommandParts),
...(isRootLevelError ? [] : nonFlagArgs),
"--help",
]);

Expand Down
22 changes: 18 additions & 4 deletions packages/wrangler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1725,6 +1725,7 @@ export function createCLIParser(argv: string[]) {
// This set to false to allow overwrite of default behaviour
wrangler.version(false);

registry.registerLegacyCommand("cloudchamber");
registry.registerLegacyCommandCategory("containers", "Compute & AI");
registry.registerLegacyCommandCategory("pubsub", "Compute & AI");

Expand All @@ -1746,16 +1747,29 @@ export async function main(argv: string[]): Promise<void> {

// Check if this is a root-level help request (--help or -h with no subcommand)
// In this case, we use our custom help formatter to show command categories
const isRootHelpRequest =
(argv.includes("--help") || argv.includes("-h")) &&
argv.filter((arg) => !arg.startsWith("-")).length === 0;
const hasHelpFlag = argv.includes("--help") || argv.includes("-h");
const nonFlagArgs = argv.filter((arg) => !arg.startsWith("-"));
const isRootHelpRequest = hasHelpFlag && nonFlagArgs.length === 0;

const { wrangler, showHelpWithCategories } = createCLIParser(argv);
const { wrangler, registry, showHelpWithCategories } = createCLIParser(argv);

if (isRootHelpRequest) {
await showHelpWithCategories();
return;
}

// Check for unknown command with a `--help` flag
const [subCommand] = nonFlagArgs;
if (hasHelpFlag && subCommand) {
const knownCommands = registry.topLevelCommands;
if (!knownCommands.has(subCommand)) {
logger.info("");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is here simply to appease formatting to match other errors by adding a spacing around the error.

logger.error(`Unknown argument: ${subCommand}`);
await showHelpWithCategories();
throw new CommandLineArgsError(`Unknown argument: ${subCommand}`);
}
}

let command: string | undefined;
let metricsArgs: Record<string, unknown> | undefined;
let dispatcher: ReturnType<typeof getMetricsDispatcher> | undefined;
Expand Down
Loading