From 2d6fd54ebdfe83d57cf157ba46f61867c71fe990 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 14 May 2026 19:40:25 +0800 Subject: [PATCH] fix(cli): keep plugin json output parseable Co-authored-by: Eric Milgram, PhD <4348294+ScientificProgrammer@users.noreply.github.com> --- CHANGELOG.md | 1 + src/cli/run-main.exit.test.ts | 64 +++++++++++++++++++++++++++++++++++ src/cli/run-main.ts | 41 +++++++++++++++++++--- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d11b69bcdcf..df3eee33aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 45986b3defd..802db7eb15b 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -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")); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index ebefe4bbc96..ec3082b8934 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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 (