fix(cli): scope unknown-subcommand help to its command tree (#124544)

This commit is contained in:
Peter Steinberger
2026-08-16 05:03:00 -07:00
committed by GitHub
parent bba57301d9
commit 23a034d584
6 changed files with 80 additions and 24 deletions
+19 -9
View File
@@ -16,25 +16,31 @@ function uniqueSortedCommandNames(commands: Iterable<string>): string[] {
);
}
export function formatCliCommandSuggestions(input: string): string | undefined {
export function formatCliCommandSuggestions(
input: string,
commandPath: readonly string[] = [],
candidates?: Iterable<string>,
): string | undefined {
const normalizedInput = input.trim().toLowerCase();
if (!normalizedInput) {
return undefined;
}
const knownCommands = uniqueSortedCommandNames([
...getCoreCliCommandNamesCore(),
...getSubCliEntriesCore().map((entry) => entry.name),
]);
const knownCommands = uniqueSortedCommandNames(
candidates ??
(commandPath.length === 0
? [...getCoreCliCommandNamesCore(), ...getSubCliEntriesCore().map((entry) => entry.name)]
: []),
);
const explicitAlias = EXPLICIT_COMMAND_ALIASES.get(normalizedInput);
if (explicitAlias && knownCommands.includes(explicitAlias)) {
return formatCliSuggestionLines([explicitAlias]);
return formatCliSuggestionLines([explicitAlias], commandPath);
}
const suggestions = findCliCommandSuggestions(normalizedInput, knownCommands);
if (suggestions.length === 0) {
return undefined;
}
return formatCliSuggestionLines(suggestions);
return formatCliSuggestionLines(suggestions, commandPath);
}
function findCliCommandSuggestions(input: string, candidates: readonly string[]): string[] {
@@ -49,9 +55,13 @@ function findCliCommandSuggestions(input: string, candidates: readonly string[])
.map(({ command }) => command);
}
function formatCliSuggestionLines(suggestions: readonly string[]): string {
function formatCliSuggestionLines(
suggestions: readonly string[],
commandPath: readonly string[],
): string {
const commandPrefix = ["openclaw", ...commandPath].join(" ");
const commandLines = suggestions
.map((command) => ` ${formatCliCommand(`openclaw ${command}`)}`)
.map((command) => ` ${formatCliCommand(`${commandPrefix} ${command}`)}`)
.join("\n");
return `Did you mean this?\n${commandLines}`;
}
+11
View File
@@ -103,3 +103,14 @@ export function getCommanderErrorCommandPath(program: Command): string[] | undef
const command = activeErrorCommandByRoot.get(getRootCommand(program));
return command ? getCommanderCommandPath(command) : undefined;
}
/** Return visible children of the non-root command synchronously emitting a parse error. */
export function getCommanderErrorCommandNames(program: Command): string[] | undefined {
const command = activeErrorCommandByRoot.get(getRootCommand(program));
return command && getCommanderCommandPath(command).length
? command
.createHelp()
.visibleCommands(command)
.map((child) => child.name())
: undefined;
}
+23
View File
@@ -13,6 +13,29 @@ describe("formatCliParseErrorOutput", () => {
);
});
it("explains unknown subcommands within the active command tree", () => {
const output = formatCliParseErrorOutput("error: unknown command 'list'\n", {
argv: ["node", "openclaw", "webhooks", "list"],
commandPath: ["webhooks"],
});
expect(output).toBe(
'OpenClaw webhooks has no command "list".\nTry: openclaw webhooks --help\nDocs: https://docs.openclaw.ai/cli\n',
);
});
it("suggests sibling subcommands within the active command tree", () => {
const output = formatCliParseErrorOutput("error: unknown command 'gmial'\n", {
argv: ["node", "openclaw", "webhooks", "gmial"],
commandPath: ["webhooks"],
commandNames: ["gmail"],
});
expect(output).toBe(
'OpenClaw webhooks has no command "gmial".\nDid you mean this?\n openclaw webhooks gmail\nTry: openclaw webhooks --help\nDocs: https://docs.openclaw.ai/cli\n',
);
});
it("suggests close known commands for unknown commands", () => {
const output = formatCliParseErrorOutput("error: unknown command 'upate'\n", {
argv: ["node", "openclaw", "upate"],
+15 -12
View File
@@ -8,6 +8,7 @@ import { formatCliCommandSuggestions } from "./command-suggestions.js";
type FormatCliParseErrorOptions = {
argv?: string[];
commandPath?: string[];
commandNames?: readonly string[];
};
function stripCommanderErrorPrefix(raw: string): string {
@@ -23,11 +24,8 @@ function quote(value: string): string {
function resolveHelpCommand(
argv: string[] | undefined,
options?: { commandPath?: string[]; root?: boolean },
options?: { commandPath?: string[] },
): string {
if (options?.root) {
return formatCliCommand("openclaw --help");
}
const commandPath = options?.commandPath ?? (argv ? getCommandPathWithRootOptions(argv, 2) : []);
if (commandPath.length === 0) {
return formatCliCommand("openclaw --help");
@@ -39,10 +37,7 @@ function lines(...items: Array<string | undefined>): string {
return `${items.filter((item): item is string => Boolean(item)).join("\n")}\n`;
}
function formatHelpHint(
argv: string[] | undefined,
options?: { commandPath?: string[]; root?: boolean },
): string {
function formatHelpHint(argv: string[] | undefined, options?: { commandPath?: string[] }): string {
return `${theme.muted("Try:")} ${theme.command(resolveHelpCommand(argv, options))}`;
}
@@ -59,11 +54,19 @@ export function formatCliParseErrorOutput(
const unknownCommand = message.match(/^unknown command ['"`](.+?)['"`]/i);
if (unknownCommand) {
const command = unknownCommand[1] ?? "";
const commandPath = options.commandPath ?? [];
const hasParentCommand = commandPath.length > 0;
return lines(
theme.error(`OpenClaw does not know the command ${quote(command)}.`),
formatCliCommandSuggestions(command),
formatHelpHint(options.argv, { root: true }),
`${theme.muted("Plugin command?")} ${theme.command(formatCliCommand("openclaw plugins list"))}`,
theme.error(
hasParentCommand
? `OpenClaw ${commandPath.join(" ")} has no command ${quote(command)}.`
: `OpenClaw does not know the command ${quote(command)}.`,
),
formatCliCommandSuggestions(command, commandPath, options.commandNames),
formatHelpHint(options.argv, { commandPath }),
hasParentCommand
? undefined
: `${theme.muted("Plugin command?")} ${theme.command(formatCliCommand("openclaw plugins list"))}`,
formatDocsHint(),
);
}
+4
View File
@@ -167,9 +167,13 @@ describe("configureProgramHelp", () => {
process.argv = ["node", "openclaw", "plugins", "list", "--still-wat"];
const secondError = await program.parseAsync(process.argv).catch((error: unknown) => error);
expect(secondError).toBeInstanceOf(CommanderError);
process.argv = ["node", "openclaw", "plugins", "lis"];
const thirdError = await program.parseAsync(process.argv).catch((error: unknown) => error);
expect(thirdError).toBeInstanceOf(CommanderError);
expect(stderr.match(/Try: openclaw plugins list --help/g)).toHaveLength(2);
expect(stderr).not.toContain("openclaw plugins list list --help");
expect(stderr).toContain("Did you mean this?\n openclaw plugins list\n");
});
it("suppresses banner formatting when parent default help requests it", () => {
+8 -3
View File
@@ -9,7 +9,10 @@ import { isRootVersionInvocation } from "../argv.js";
import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js";
import { replaceCliName, resolveCliName } from "../cli-name.js";
import { CLI_LOG_LEVEL_VALUES, parseCliLogLevelOption } from "../log-level-option.js";
import { getCommanderErrorCommandPath } from "./commander-parse-facts.js";
import {
getCommanderErrorCommandNames,
getCommanderErrorCommandPath,
} from "./commander-parse-facts.js";
import type { ProgramContext } from "./context.js";
import { getCoreCliCommandsWithSubcommands } from "./core-command-descriptors.js";
import { formatCliParseErrorOutput } from "./error-output.js";
@@ -119,13 +122,15 @@ export function configureProgramHelp(
const message = formatProgramHelpOutput(str);
process.stderr.write(formatConsoleDiagnosticBlock({ level: "error", message }));
},
outputError: (str, write) =>
outputError: (str, write) => {
write(
formatCliParseErrorOutput(str, {
argv: process.argv,
commandPath: getCommanderErrorCommandPath(program),
commandNames: getCommanderErrorCommandNames(program),
}),
),
);
},
});
if (isRootVersionInvocation(process.argv)) {