diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index fec33456a47..4c2a4928cd1 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -672,6 +672,26 @@ describe("runCli exit behavior", () => { expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); }); + it("rejects unowned command roots even when --help is appended (regression for #81077)", async () => { + await expect(runCli(["node", "openclaw", "foo", "--help"])).rejects.toThrow( + 'No built-in command or plugin CLI metadata owns "foo"', + ); + + expect(startProxyMock).not.toHaveBeenCalled(); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(buildProgramMock).not.toHaveBeenCalled(); + expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); + }); + + it("rejects unowned command roots even when --version is appended", async () => { + await expect(runCli(["node", "openclaw", "foo", "--version"])).rejects.toThrow( + 'No built-in command or plugin CLI metadata owns "foo"', + ); + + expect(startProxyMock).not.toHaveBeenCalled(); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + }); + it("does not suggest plugins.allow for unknown command roots before proxy startup", async () => { loadConfigMock.mockReturnValueOnce({ plugins: { diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 8e1c9e8c240..5840f7f9fd4 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -397,7 +397,6 @@ async function resolveUnownedCliPrimary(params: { const invocation = resolveCliArgvInvocation(rewriteUpdateFlagArgv(params.argv)); const { primary } = invocation; if ( - invocation.hasHelpOrVersion || !primary || primary === "help" || isReservedNonPluginCommandRoot(primary) || @@ -616,6 +615,19 @@ export async function runCli(argv: string[] = process.argv) { } } + // Reject unowned command roots before help/version routing, so that + // `openclaw --help` surfaces the same Unknown command error as + // `openclaw ` instead of silently showing generic top-level help. + // Runs after legitimate precomputed help fast paths so known help commands + // still dispatch normally. See #81077. + { + const config = await readBestEffortCliConfig(); + const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config }); + if (unownedPrimary) { + throw new Error(await resolveUnownedCliPrimaryMessage({ primary: unownedPrimary, config })); + } + } + const shouldRunBareRootCrestodian = shouldStartCrestodianForBareRoot(normalizedArgv); const shouldRunModernOnboardCrestodian = shouldStartCrestodianForModernOnboard(normalizedArgv); if (shouldRunBareRootCrestodian || shouldRunModernOnboardCrestodian) {