fix(cli): preserve lazy command parent flags

This commit is contained in:
Peter Steinberger
2026-04-30 00:48:46 +01:00
parent 36bb723dfb
commit 01254500df
7 changed files with 186 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ import { reparseProgramFromActionArgs } from "./action-reparse.js";
const buildParseArgvMock = vi.hoisted(() => vi.fn());
const resolveActionArgsMock = vi.hoisted(() => vi.fn());
const resolveCommandOptionArgsMock = vi.hoisted(() => vi.fn());
vi.mock("../argv.js", () => ({
buildParseArgv: buildParseArgvMock,
@@ -11,6 +12,7 @@ vi.mock("../argv.js", () => ({
vi.mock("./helpers.js", () => ({
resolveActionArgs: resolveActionArgsMock,
resolveCommandOptionArgs: resolveCommandOptionArgsMock,
}));
describe("reparseProgramFromActionArgs", () => {
@@ -18,6 +20,7 @@ describe("reparseProgramFromActionArgs", () => {
vi.clearAllMocks();
buildParseArgvMock.mockReturnValue(["node", "openclaw", "status"]);
resolveActionArgsMock.mockReturnValue([]);
resolveCommandOptionArgsMock.mockReturnValue([]);
});
it("uses action command name + args as fallback argv", async () => {
@@ -60,6 +63,27 @@ describe("reparseProgramFromActionArgs", () => {
expect(parseAsync).toHaveBeenCalledWith(["node", "openclaw", "status"]);
});
it("preserves explicit parent command options in fallback argv", async () => {
const program = new Command().name("browser");
const parseAsync = vi.spyOn(program, "parseAsync").mockResolvedValue(program);
const actionCommand = {
name: () => "open",
parent: program,
} as unknown as Command;
resolveActionArgsMock.mockReturnValue(["about:blank"]);
resolveCommandOptionArgsMock.mockReturnValue(["--json"]);
await reparseProgramFromActionArgs(program, [actionCommand]);
expect(resolveCommandOptionArgsMock).toHaveBeenCalledWith(program);
expect(buildParseArgvMock).toHaveBeenCalledWith({
programName: "browser",
rawArgs: [],
fallbackArgv: ["--json", "open", "about:blank"],
});
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);

View File

@@ -1,6 +1,15 @@
import type { Command } from "commander";
import { buildParseArgv } from "../argv.js";
import { resolveActionArgs } from "./helpers.js";
import { resolveActionArgs, resolveCommandOptionArgs } from "./helpers.js";
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];
}
export async function reparseProgramFromActionArgs(
program: Command,
@@ -9,10 +18,7 @@ export async function reparseProgramFromActionArgs(
const actionCommand = actionArgs.at(-1) as Command | undefined;
const root = actionCommand?.parent ?? program;
const rawArgs = (root as Command & { rawArgs?: string[] }).rawArgs;
const actionArgsList = resolveActionArgs(actionCommand);
const fallbackArgv = actionCommand?.name()
? [actionCommand.name(), ...actionArgsList]
: actionArgsList;
const fallbackArgv = buildFallbackArgv(program, actionCommand);
const parseArgv = buildParseArgv({
programName: program.name(),
rawArgs,

View File

@@ -1,6 +1,11 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { collectOption, parsePositiveIntOrUndefined, resolveActionArgs } from "./helpers.js";
import {
collectOption,
parsePositiveIntOrUndefined,
resolveActionArgs,
resolveCommandOptionArgs,
} from "./helpers.js";
describe("program helpers", () => {
it("collectOption appends values in order", () => {
@@ -38,4 +43,46 @@ describe("program helpers", () => {
expect(resolveActionArgs(command)).toEqual([]);
expect(resolveActionArgs(undefined)).toEqual([]);
});
it("resolveCommandOptionArgs serializes explicit options", () => {
const command = new Command()
.option("--json", "JSON output", false)
.option("--timeout <ms>", "Timeout", "30000")
.option("--tag <name>", "Tag", collectOption)
.option("--no-progress", "Disable progress");
command.parse([
"node",
"test",
"--json",
"--timeout",
"10",
"--tag",
"a",
"--tag",
"b",
"--no-progress",
]);
expect(resolveCommandOptionArgs(command)).toEqual([
"--json",
"--timeout",
"10",
"--tag",
"a",
"--tag",
"b",
"--no-progress",
]);
});
it("resolveCommandOptionArgs skips defaults", () => {
const command = new Command()
.option("--json", "JSON output", false)
.option("--timeout <ms>", "Timeout", "30000");
command.parse(["node", "test"]);
expect(resolveCommandOptionArgs(command)).toEqual([]);
});
});

View File

@@ -1,3 +1,5 @@
import type { Command } from "commander";
export function collectOption(value: string, previous: string[] = []): string[] {
return [...previous, value];
}
@@ -23,10 +25,76 @@ export function parsePositiveIntOrUndefined(value: unknown): number | undefined
return undefined;
}
export function resolveActionArgs(actionCommand?: import("commander").Command): string[] {
export function resolveActionArgs(actionCommand?: Command): string[] {
if (!actionCommand) {
return [];
}
const args = (actionCommand as import("commander").Command & { args?: string[] }).args;
const args = (actionCommand as Command & { args?: string[] }).args;
return Array.isArray(args) ? args : [];
}
function isDefaultOptionValue(command: Command, name: string): boolean {
if (typeof command.getOptionValueSource !== "function") {
return false;
}
return command.getOptionValueSource(name) === "default";
}
function appendOptionValue(out: string[], flag: string, value: unknown): void {
if (value === undefined) {
return;
}
if (value === false) {
if (flag.startsWith("--no-")) {
out.push(flag);
}
return;
}
if (value === true) {
out.push(flag);
return;
}
const arg = stringifyOptionValue(value);
if (arg !== undefined) {
out.push(flag, arg);
}
}
function stringifyOptionValue(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
if (typeof value === "bigint") {
return value.toString();
}
return undefined;
}
export function resolveCommandOptionArgs(command?: Command): string[] {
if (!command) {
return [];
}
const out: string[] = [];
for (const option of command.options) {
const name = option.attributeName();
if (isDefaultOptionValue(command, name)) {
continue;
}
const flag = option.long ?? option.short;
if (!flag) {
continue;
}
const value = command.getOptionValue(name);
if (Array.isArray(value)) {
for (const item of value) {
appendOptionValue(out, flag, item);
}
continue;
}
appendOptionValue(out, flag, value);
}
return out;
}

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { reparseProgramFromActionArgs } from "./action-reparse.js";
import { removeCommandByName } from "./command-tree.js";
import { resolveCommandOptionArgs } from "./helpers.js";
type RegisterLazyCommandParams = {
program: Command;
@@ -14,25 +15,6 @@ type RegisterLazyCommandParams = {
register: () => Promise<void> | void;
};
function resolvePlaceholderOptionArgs(command: Command): string[] {
const out: string[] = [];
for (const option of command.options) {
const value = command.getOptionValue(option.attributeName());
if (value === undefined || value === false) {
continue;
}
const flag = option.long ?? option.short;
if (!flag) {
continue;
}
out.push(flag);
if (value !== true) {
out.push(String(value));
}
}
return out;
}
export function registerLazyCommand({
program,
name,
@@ -51,7 +33,7 @@ export function registerLazyCommand({
const actionCommand = actionArgs.at(-1) as (Command & { args?: string[] }) | undefined;
if (actionCommand) {
actionCommand.args = [
...resolvePlaceholderOptionArgs(actionCommand),
...resolveCommandOptionArgs(actionCommand),
...(actionCommand.args ?? []),
];
}