diff --git a/src/cli/program/action-reparse.test.ts b/src/cli/program/action-reparse.test.ts index 616d00dd2064..0758f97962d5 100644 --- a/src/cli/program/action-reparse.test.ts +++ b/src/cli/program/action-reparse.test.ts @@ -20,6 +20,10 @@ function setRawArgs(command: Command, rawArgs: string[]): void { (command as Command & { rawArgs: string[] }).rawArgs = rawArgs; } +function deleteRawArgs(command: Command): void { + delete (command as Command & { rawArgs?: string[] }).rawArgs; +} + describe("reparseProgramFromActionArgs", () => { beforeEach(() => { vi.clearAllMocks(); @@ -104,12 +108,33 @@ describe("reparseProgramFromActionArgs", () => { expect(buildParseArgvMock).toHaveBeenCalledWith({ programName: "openclaw", rawArgs: ["node", "openclaw", "workspaces", "audit", "export", "--since", "1"], - fallbackArgv: ["export", "--since", "1"], + fallbackArgv: ["workspaces", "audit", "export", "--since", "1"], }); expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]); expect(auditParseAsync).not.toHaveBeenCalled(); }); + it("reconstructs the full nested command path when Commander rawArgs is missing", async () => { + // #83893: nested lazy commands still need their ancestor path if + // Commander stops exposing root rawArgs at runtime. + const root = new Command().name("openclaw"); + const workspaces = root.command("workspaces"); + const audit = workspaces.command("audit"); + const exportCommand = audit.command("export"); + deleteRawArgs(root); + const parseAsync = vi.spyOn(root, "parseAsync").mockResolvedValue(root); + resolveActionArgsMock.mockReturnValue(["--since", "1"]); + + await reparseProgramFromActionArgs(audit, [exportCommand]); + + expect(buildParseArgvMock).toHaveBeenCalledWith({ + programName: "openclaw", + rawArgs: undefined, + fallbackArgv: ["workspaces", "audit", "export", "--since", "1"], + }); + expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]); + }); + it("uses program root when action command is missing", async () => { const program = new Command().name("openclaw"); const parseAsync = vi.spyOn(program, "parseAsync").mockResolvedValue(program); @@ -124,4 +149,23 @@ describe("reparseProgramFromActionArgs", () => { }); expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]); }); + + it("falls back to fallbackArgv when Commander rawArgs is missing from the root command", async () => { + // #83893: rawArgs is a Commander runtime field, so the root command must + // still reparse from reconstructed argv if Commander stops exposing it. + const root = new Command().name("openclaw"); + const configCommand = root.command("config"); + deleteRawArgs(root); + const parseAsync = vi.spyOn(root, "parseAsync").mockResolvedValue(root); + resolveActionArgsMock.mockReturnValue(["set", "key", "value"]); + + await reparseProgramFromActionArgs(root, [configCommand]); + + expect(buildParseArgvMock).toHaveBeenCalledWith({ + programName: "openclaw", + rawArgs: undefined, + fallbackArgv: ["config", "set", "key", "value"], + }); + expect(parseAsync).toHaveBeenCalled(); + }); }); diff --git a/src/cli/program/action-reparse.ts b/src/cli/program/action-reparse.ts index b6ffaacbd2f5..33cd50c8dd56 100644 --- a/src/cli/program/action-reparse.ts +++ b/src/cli/program/action-reparse.ts @@ -3,13 +3,33 @@ import type { Command } from "commander"; import { buildParseArgv } from "../argv.js"; import { resolveActionArgs, resolveCommandOptionArgs } from "./helpers.js"; +function getCommandPathFromRoot(command: Command | undefined): string[] { + const path: string[] = []; + let current = command; + while (current?.parent) { + const name = current.name(); + if (name) { + path.unshift(name); + } + current = current.parent; + } + return path; +} + function buildFallbackArgv(program: Command, actionCommand: Command | undefined): string[] { const actionArgsList = resolveActionArgs(actionCommand); const parentOptionArgs = actionCommand?.parent === program ? resolveCommandOptionArgs(program) : []; - return actionCommand?.name() - ? [...parentOptionArgs, actionCommand.name(), ...actionArgsList] - : [...parentOptionArgs, ...actionArgsList]; + const commandPath = getCommandPathFromRoot(actionCommand); + if (commandPath.length === 0) { + return [...parentOptionArgs, ...actionArgsList]; + } + return [ + ...commandPath.slice(0, -1), + ...parentOptionArgs, + commandPath[commandPath.length - 1], + ...actionArgsList, + ]; } function findRootCommand(cmd: Command): Command { @@ -27,9 +47,8 @@ export async function reparseProgramFromActionArgs( ): Promise { const actionCommand = actionArgs.at(-1) as Command | undefined; // Use the true root program for argv reconstruction and parsing. - // For nested lazy commands (e.g. workspaces → audit), `program` is a sub-command - // whose rawArgs is cleared by Commander's restoreStateBeforeParse(). Only the - // root program retains the rawArgs set by _prepareUserArgs. + // Commander keeps rawArgs as a JS runtime field, not a typed API; if a + // future version removes it, buildParseArgv falls back to reconstructed argv. const rootProgram = findRootCommand(actionCommand ?? program); const rawArgs = (rootProgram as Command & { rawArgs?: string[] }).rawArgs; const fallbackArgv = buildFallbackArgv(program, actionCommand);