diff --git a/src/cli/program/command-suggestions.ts b/src/cli/program/command-suggestions.ts index 212ecd3fd696..4d67ba3fee52 100644 --- a/src/cli/program/command-suggestions.ts +++ b/src/cli/program/command-suggestions.ts @@ -16,25 +16,31 @@ function uniqueSortedCommandNames(commands: Iterable): string[] { ); } -export function formatCliCommandSuggestions(input: string): string | undefined { +export function formatCliCommandSuggestions( + input: string, + commandPath: readonly string[] = [], + candidates?: Iterable, +): 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}`; } diff --git a/src/cli/program/commander-parse-facts.ts b/src/cli/program/commander-parse-facts.ts index 94041abf68d6..41780a7c1741 100644 --- a/src/cli/program/commander-parse-facts.ts +++ b/src/cli/program/commander-parse-facts.ts @@ -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; +} diff --git a/src/cli/program/error-output.test.ts b/src/cli/program/error-output.test.ts index db0332125ec7..6164b6d6db42 100644 --- a/src/cli/program/error-output.test.ts +++ b/src/cli/program/error-output.test.ts @@ -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"], diff --git a/src/cli/program/error-output.ts b/src/cli/program/error-output.ts index 833c95744247..5f93d8ec3530 100644 --- a/src/cli/program/error-output.ts +++ b/src/cli/program/error-output.ts @@ -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 { 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(), ); } diff --git a/src/cli/program/help.test.ts b/src/cli/program/help.test.ts index 35bf548aeca7..6bc2b58972a6 100644 --- a/src/cli/program/help.test.ts +++ b/src/cli/program/help.test.ts @@ -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", () => { diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 76fb1ba1f9ba..f5fb9753515b 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -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)) {