diff --git a/src/cli/command-execution-startup.test.ts b/src/cli/command-execution-startup.test.ts index 6a67b5baf04f..6a9886f196b6 100644 --- a/src/cli/command-execution-startup.test.ts +++ b/src/cli/command-execution-startup.test.ts @@ -123,11 +123,21 @@ describe("command-execution-startup", () => { ).toBe(true); }); - it("uses the resolved action command path for protocol startup policy", () => { + it("uses the resolved action command path for every execution startup decision", () => { + const context = mod.resolveCliExecutionStartupContext({ + argv: ["node", "openclaw", "gateway", "--token", "secret", "call", "health"], + commandPath: ["gateway", "call"], + jsonOutputMode: false, + env: {}, + }); + + expect(context.invocation.commandPath).toEqual(["gateway", "secret"]); + expect(context.commandPath).toEqual(["gateway", "call"]); + expect( mod.resolveCliExecutionStartupContext({ argv: ["node", "openclaw", "acp", "--token", "-secret"], - protocolCommandPath: ["acp"], + commandPath: ["acp"], jsonOutputMode: false, env: {}, }).startupPolicy.suppressDoctorStdout, @@ -135,7 +145,7 @@ describe("command-execution-startup", () => { expect( mod.resolveCliExecutionStartupContext({ argv: ["node", "openclaw", "acp", "--verbose", "client"], - protocolCommandPath: ["acp", "client"], + commandPath: ["acp", "client"], jsonOutputMode: false, env: {}, }).startupPolicy.suppressDoctorStdout, diff --git a/src/cli/command-execution-startup.ts b/src/cli/command-execution-startup.ts index 481cbb81ddad..c5e3a12a52bf 100644 --- a/src/cli/command-execution-startup.ts +++ b/src/cli/command-execution-startup.ts @@ -16,21 +16,21 @@ const hasVersionFlag = (argv: readonly string[]) => export function resolveCliExecutionStartupContext(params: { argv: string[]; - protocolCommandPath?: string[]; + commandPath?: string[]; jsonOutputMode: boolean; env?: NodeJS.ProcessEnv; routeMode?: boolean; }) { - // Resolve argv once so startup policy, routing, and bootstrap share the same command path. const invocation = resolveCliArgvInvocation(params.argv); - const { commandPath } = invocation; + // Commander owns the action path after parsing option values. Route-first + // callers omit it and keep using raw argv discovery. + const commandPath = params.commandPath ?? invocation.commandPath; return { invocation, commandPath, startupPolicy: resolveCliStartupPolicy({ argv: params.argv, commandPath, - protocolCommandPath: params.protocolCommandPath, jsonOutputMode: params.jsonOutputMode, env: params.env, routeMode: params.routeMode, diff --git a/src/cli/command-startup-policy.ts b/src/cli/command-startup-policy.ts index 860f792e2939..a3ed5fc437a2 100644 --- a/src/cli/command-startup-policy.ts +++ b/src/cli/command-startup-policy.ts @@ -28,19 +28,13 @@ function shouldLoadPlugins(params: { export function resolveCliStartupPolicy(params: { argv?: string[]; commandPath: string[]; - protocolCommandPath?: string[]; jsonOutputMode: boolean; env?: NodeJS.ProcessEnv; routeMode?: boolean; }) { const commandPolicy = resolveCliCommandPathPolicy(params.commandPath); - // Commander resolves required option values before selecting the action command, so this path - // remains authoritative when a protocol option value itself begins with "-". - const ownsProtocolStdout = params.protocolCommandPath - ? resolveCliCommandPathPolicy(params.protocolCommandPath).ownsProtocolStdout - : commandPolicy.ownsProtocolStdout; // Protocol commands own stdout from process startup, before their action installs later routing. - const suppressDoctorStdout = params.jsonOutputMode || ownsProtocolStdout; + const suppressDoctorStdout = params.jsonOutputMode || commandPolicy.ownsProtocolStdout; const env = params.env ?? process.env; return { suppressDoctorStdout, diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 8fc831a5607e..187e115a491e 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; import { repoInstallSpec } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { loggingState } from "../../logging/state.js"; +import { shouldMigrateStateFromPath } from "../argv.js"; import { isConfigSetJsonParseOnly } from "../config-output-mode.js"; import { setCommandJsonMode } from "./json-mode.js"; import { applyParentDefaultHelpAction } from "./parent-default-help.js"; @@ -149,7 +150,7 @@ describe("registerPreActionHooks", () => { | null = null; function buildProgram() { - const programLocal = new Command().name("openclaw"); + const programLocal = new Command().name("openclaw").enablePositionalOptions(); const agent = programLocal .command("agent") .argument("[note]") @@ -181,6 +182,8 @@ describe("registerPreActionHooks", () => { .action(() => {}); const gateway = programLocal .command("gateway") + .option("--port ") + .option("--token ") .option("--allow-unconfigured") .option("--force") .option("--reset") @@ -191,6 +194,15 @@ describe("registerPreActionHooks", () => { .option("--force") .option("--reset") .action(() => {}); + gateway + .command("call") + .argument("") + .option("--json") + .action(() => {}); + gateway + .command("health") + .option("--json") + .action(() => {}); programLocal .command("backup") .command("create") @@ -890,6 +902,36 @@ describe("registerPreActionHooks", () => { }); }); + it.each([ + { + name: "keeps a remote call migration-free", + argv: ["node", "openclaw", "gateway", "--token", "secret", "call", "health", "--json"], + expectedPath: ["gateway", "call"], + expectedMigration: false, + }, + { + name: "keeps health on the invalid-config allowlist", + argv: ["node", "openclaw", "gateway", "--port", "19083", "health", "--json"], + expectedPath: ["gateway", "health"], + expectedMigration: true, + }, + ])("uses the Commander path past parent option values and $name", async (testCase) => { + const parseProgram = buildProgram(); + process.argv = testCase.argv; + + await parseProgram.parseAsync(process.argv); + + const bootstrap = ensureConfigReadyMock.mock.calls.at(-1)?.[0]; + expect(bootstrap).toEqual({ + runtime: runtimeMock, + commandPath: testCase.expectedPath, + suppressDoctorStdout: true, + }); + expect(shouldMigrateStateFromPath(bootstrap?.commandPath ?? [])).toBe( + testCase.expectedMigration, + ); + }); + it("does not preload plugins for agents list JSON output", async () => { await runPreAction({ parseArgv: ["agents", "list"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 8ca9bfb947d8..bb91e6fd47dd 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -121,7 +121,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) applyResolvedCommandOutputMode(jsonOutputMode); const { commandPath, startupPolicy } = resolveCliExecutionStartupContext({ argv, - protocolCommandPath: getCommanderCommandPath(actionCommand), + commandPath: getCommanderCommandPath(actionCommand), jsonOutputMode, env: process.env, });