mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:50:44 +00:00
fix(cli): preserve lazy command parent flags
This commit is contained in:
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/browser: preserve parent flags while lazy-loading browser subcommands, so `openclaw browser --json open` and `openclaw browser --json tabs` keep machine-readable output after reparsing. Fixes #74574. Thanks @devintegeritsm.
|
||||
- Plugins/runtime-deps: add `openclaw plugins deps` inspection and repair with script-free package-manager defaults shared across plugin installers, so operators can repair missing bundled runtime deps without corrupting JSON output or blocking unrelated conflict-free deps. Thanks @vincentkoc.
|
||||
- Agents/output: strip internal `[tool calls omitted]` replay placeholders from user-facing replies while preserving visible reply whitespace. Fixes #74573. Thanks @blaspat.
|
||||
- Agents/sessions: emit a terminal lifecycle backstop when embedded timeout/error turns return without `agent_end`, so Gateway sessions no longer stay stuck in `running` after failover surfaces a timeout. Fixes #74607. Thanks @millerc79.
|
||||
|
||||
@@ -3,16 +3,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const manageMocks = vi.hoisted(() => {
|
||||
const doctorAction = vi.fn();
|
||||
const openAction = vi.fn();
|
||||
const statusAction = vi.fn();
|
||||
const tabsAction = vi.fn();
|
||||
const registerBrowserManageCommands = vi.fn((browser: Command) => {
|
||||
browser.command("status").description("Show browser status").action(statusAction);
|
||||
browser.command("tabs").description("List tabs").action(tabsAction);
|
||||
browser.command("open").description("Open URL").argument("<url>").action(openAction);
|
||||
browser
|
||||
.command("doctor")
|
||||
.description("Check browser plugin readiness")
|
||||
.option("--deep", "Run a live snapshot probe")
|
||||
.action(doctorAction);
|
||||
});
|
||||
return { doctorAction, registerBrowserManageCommands, statusAction };
|
||||
return { doctorAction, openAction, registerBrowserManageCommands, statusAction, tabsAction };
|
||||
});
|
||||
const inspectMocks = vi.hoisted(() => ({
|
||||
registerBrowserInspectCommands: vi.fn(),
|
||||
@@ -44,7 +48,9 @@ describe("registerBrowserCli lazy browser subcommands", () => {
|
||||
vi.unstubAllEnvs();
|
||||
manageMocks.registerBrowserManageCommands.mockClear();
|
||||
manageMocks.doctorAction.mockClear();
|
||||
manageMocks.openAction.mockClear();
|
||||
manageMocks.statusAction.mockClear();
|
||||
manageMocks.tabsAction.mockClear();
|
||||
inspectMocks.registerBrowserInspectCommands.mockClear();
|
||||
actionInputMocks.registerBrowserActionInputCommands.mockClear();
|
||||
actionObserveMocks.registerBrowserActionObserveCommands.mockClear();
|
||||
@@ -103,6 +109,29 @@ describe("registerBrowserCli lazy browser subcommands", () => {
|
||||
expect(manageMocks.doctorAction.mock.calls[0]?.[0]).toMatchObject({ deep: true });
|
||||
});
|
||||
|
||||
it("preserves parent --json while reparsing lazy manage commands", async () => {
|
||||
const program = new Command();
|
||||
program.name("openclaw");
|
||||
|
||||
registerBrowserCli(program, ["node", "openclaw", "browser", "--json", "open", "about:blank"]);
|
||||
|
||||
await program.parseAsync(["browser", "--json", "open", "about:blank"], { from: "user" });
|
||||
|
||||
expect(manageMocks.openAction).toHaveBeenCalledTimes(1);
|
||||
const openCommand = manageMocks.openAction.mock.calls[0]?.at(-1) as Command | undefined;
|
||||
expect(openCommand?.parent?.opts()).toMatchObject({ json: true });
|
||||
|
||||
const tabsProgram = new Command();
|
||||
tabsProgram.name("openclaw");
|
||||
registerBrowserCli(tabsProgram, ["node", "openclaw", "browser", "--json", "tabs"]);
|
||||
|
||||
await tabsProgram.parseAsync(["browser", "--json", "tabs"], { from: "user" });
|
||||
|
||||
expect(manageMocks.tabsAction).toHaveBeenCalledTimes(1);
|
||||
const tabsCommand = manageMocks.tabsAction.mock.calls[0]?.at(-1) as Command | undefined;
|
||||
expect(tabsCommand?.parent?.opts()).toMatchObject({ json: true });
|
||||
});
|
||||
|
||||
it("can eagerly register all browser groups for compatibility", async () => {
|
||||
vi.stubEnv("OPENCLAW_DISABLE_LAZY_SUBCOMMANDS", "1");
|
||||
const program = new Command();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user