diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 24d5d09413d..3e15eac2bc8 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -5,6 +5,7 @@ const setVerboseMock = vi.fn(); const emitCliBannerMock = vi.fn(); const ensureConfigReadyMock = vi.fn(async () => {}); const ensurePluginRegistryLoadedMock = vi.fn(); +const routeLogsToStderrMock = vi.fn(); const runtimeMock = { log: vi.fn(), @@ -24,6 +25,10 @@ vi.mock("../banner.js", () => ({ emitCliBanner: emitCliBannerMock, })); +vi.mock("../../logging/console.js", () => ({ + routeLogsToStderr: routeLogsToStderrMock, +})); + vi.mock("../cli-name.js", () => ({ resolveCliName: () => "openclaw", })); @@ -270,6 +275,35 @@ describe("registerPreActionHooks", () => { }); }); + it("routes logs to stderr in --json mode so stdout stays clean", async () => { + await runPreAction({ + parseArgv: ["agents"], + processArgv: ["node", "openclaw", "agents", "--json"], + }); + + expect(routeLogsToStderrMock).toHaveBeenCalledOnce(); + + vi.clearAllMocks(); + + // config set --json is parse-only (not JSON output mode), should not route + await runPreAction({ + parseArgv: ["config", "set", "gateway.auth.mode", "local", "--json"], + processArgv: ["node", "openclaw", "config", "set", "gateway.auth.mode", "local", "--json"], + }); + + expect(routeLogsToStderrMock).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + + // non-json command should not route + await runPreAction({ + parseArgv: ["agents"], + processArgv: ["node", "openclaw", "agents"], + }); + + expect(routeLogsToStderrMock).not.toHaveBeenCalled(); + }); + it("bypasses config guard for config validate", async () => { await runPreAction({ parseArgv: ["config", "validate"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 650c8f52686..c3a15427da4 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { setVerbose } from "../../globals.js"; import { isTruthyEnvValue } from "../../infra/env.js"; +import { routeLogsToStderr } from "../../logging/console.js"; import type { LogLevel } from "../../logging/levels.js"; import { defaultRuntime } from "../../runtime.js"; import { @@ -123,6 +124,9 @@ export function registerPreActionHooks(program: Command, programVersion: string) return; } const commandPath = getCommandPathWithRootOptions(argv, 2); + if (isJsonOutputMode(commandPath, argv)) { + routeLogsToStderr(); + } const hideBanner = isTruthyEnvValue(process.env.OPENCLAW_HIDE_BANNER) || commandPath[0] === "update" ||