fix(cli): keep plugin json output parseable

Co-authored-by: Eric Milgram, PhD <4348294+ScientificProgrammer@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-05-14 19:40:25 +08:00
parent 8813b79990
commit 2d6fd54ebd
3 changed files with 101 additions and 5 deletions

View File

@@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai
- CLI tables: preserve muted/color styling on wrapped continuation lines after multiline cells, keeping `openclaw plugins list` descriptions readable.
- Process execution: collapse case-insensitive duplicate child environment keys on Windows so caller-provided overrides such as `PATH` cannot be shadowed by host `Path`.
- Browser CLI: request the existing `operator.admin` gateway scope explicitly for browser control commands, avoiding unnecessary scope-upgrade approval loops. Fixes #81555. (#81716) Thanks @joshavant.
- CLI/plugins: route lazy plugin command-registration chatter to stderr only during JSON-output command registration, keeping plugin-backed `--json` stdout parseable without changing parse-only or pass-through `--json` behavior. Fixes #81535. (#81536) Thanks @ScientificProgrammer and @vincentkoc.
- Web: honor explicitly configured global `web_search` providers during provider ownership resolution while keeping sandboxed `web_fetch` limited to bundled providers.
- Plugins/doctor: repair configured legacy npm declaration stubs by reinstalling their npm packages into the managed plugin root instead of loading workspace `node_modules`, and warn when discovery sees those stubs. Fixes #79632. Thanks @Dylanzhang1128 and @vincentkoc.
- Channels: keep configured third-party channel plugins visible in `openclaw channels list` when their manifest declares `channels` but has not added `channelConfigs` metadata yet. Fixes #81334. (#81340) Thanks @AllynSheep and @vincentkoc.

View File

@@ -1,6 +1,7 @@
import process from "node:process";
import { CommanderError } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loggingState } from "../logging/state.js";
import { runCli, shouldStartProxyForCli } from "./run-main.js";
const tryRouteCliMock = vi.hoisted(() => vi.fn());
@@ -269,6 +270,7 @@ describe("runCli exit behavior", () => {
resolveManifestCliCommandSurfaceOwnerMock.mockReturnValue(undefined);
delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH;
delete process.env.OPENCLAW_HIDE_BANNER;
loggingState.forceConsoleToStderr = false;
});
it("does not force process.exit after successful routed command", async () => {
@@ -596,6 +598,68 @@ describe("runCli exit behavior", () => {
expect(parseAsync).toHaveBeenCalledWith(argv);
});
it("routes lazy plugin registration logs to stderr only during --json registration", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "memory" ? ["memory"] : [],
);
let stderrDuringPluginRegistration = false;
let stderrDuringParse = true;
registerPluginCliCommandsFromValidatedConfigMock.mockImplementationOnce(async () => {
stderrDuringPluginRegistration = loggingState.forceConsoleToStderr;
return {};
});
const parseAsync = vi.fn().mockImplementationOnce(async () => {
stderrDuringParse = loggingState.forceConsoleToStderr;
});
buildProgramMock.mockReturnValueOnce({
commands: [],
parseAsync,
});
await runCli(["node", "openclaw", "memory", "search", "query", "--json"]);
expect(registerPluginCliCommandsFromValidatedConfigMock).toHaveBeenCalledWith(
expect.anything(),
undefined,
undefined,
{ mode: "lazy", primary: "memory" },
);
expect(stderrDuringPluginRegistration).toBe(true);
expect(stderrDuringParse).toBe(false);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it("does not route lazy plugin registration logs for pass-through --json after terminator", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "memory" ? ["memory"] : [],
);
let stderrDuringPluginRegistration = true;
registerPluginCliCommandsFromValidatedConfigMock.mockImplementationOnce(async () => {
stderrDuringPluginRegistration = loggingState.forceConsoleToStderr;
return {};
});
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
buildProgramMock.mockReturnValueOnce({
commands: [],
parseAsync,
});
await runCli(["node", "openclaw", "memory", "--", "--json"]);
expect(registerPluginCliCommandsFromValidatedConfigMock).toHaveBeenCalledWith(
expect.anything(),
undefined,
undefined,
{ mode: "lazy", primary: "memory" },
);
expect(stderrDuringPluginRegistration).toBe(false);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it("fails protected commands when managed proxy activation fails", async () => {
startProxyMock.mockRejectedValueOnce(new Error("proxy: enabled but no HTTP proxy URL"));

View File

@@ -134,7 +134,15 @@ export function isGatewayRunFastPathArgv(argv: string[]): boolean {
}
function hasJsonOutputFlag(argv: string[]): boolean {
return argv.some((arg) => arg === "--json" || arg.startsWith("--json="));
for (const arg of argv) {
if (arg === "--") {
return false;
}
if (arg === "--json" || arg.startsWith("--json=")) {
return true;
}
}
return false;
}
async function tryRunGatewayRunFastPath(
@@ -725,10 +733,33 @@ export async function runCli(argv: string[] = process.argv) {
const config = await startupTrace.measure("register-plugin-commands", async () => {
const { registerPluginCliCommandsFromValidatedConfig } =
await import("../plugins/cli.js");
return await registerPluginCliCommandsFromValidatedConfig(program, undefined, undefined, {
mode: "lazy",
primary,
});
if (!hasJsonOutputFlag(parseArgv)) {
return await registerPluginCliCommandsFromValidatedConfig(
program,
undefined,
undefined,
{
mode: "lazy",
primary,
},
);
}
const { loggingState } = await import("../logging/state.js");
const previousForceStderr = loggingState.forceConsoleToStderr;
loggingState.forceConsoleToStderr = true;
try {
return await registerPluginCliCommandsFromValidatedConfig(
program,
undefined,
undefined,
{
mode: "lazy",
primary,
},
);
} finally {
loggingState.forceConsoleToStderr = previousForceStderr;
}
});
if (config) {
if (