Skip to content

Commit 0e1da03

Browse files
cgduseksteipete
authored andcommitted
fix(cli): route plugin logs to stderr during --json output
1 parent 46a455d commit 0e1da03

4 files changed

Lines changed: 115 additions & 8 deletions

File tree

src/cli/program/preaction.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Command } from "commander";
22
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { loggingState } from "../../logging/state.js";
34
import { setCommandJsonMode } from "./json-mode.js";
45

56
const setVerboseMock = vi.fn();
@@ -58,6 +59,7 @@ let originalProcessArgv: string[];
5859
let originalProcessTitle: string;
5960
let originalNodeNoWarnings: string | undefined;
6061
let originalHideBanner: string | undefined;
62+
let originalForceStderr: boolean;
6163

6264
beforeAll(async () => {
6365
({ registerPreActionHooks } = await import("./preaction.js"));
@@ -76,13 +78,16 @@ beforeEach(() => {
7678
originalProcessTitle = process.title;
7779
originalNodeNoWarnings = process.env.NODE_NO_WARNINGS;
7880
originalHideBanner = process.env.OPENCLAW_HIDE_BANNER;
81+
originalForceStderr = loggingState.forceConsoleToStderr;
82+
loggingState.forceConsoleToStderr = false;
7983
delete process.env.NODE_NO_WARNINGS;
8084
delete process.env.OPENCLAW_HIDE_BANNER;
8185
});
8286

8387
afterEach(() => {
8488
process.argv = originalProcessArgv;
8589
process.title = originalProcessTitle;
90+
loggingState.forceConsoleToStderr = originalForceStderr;
8691
if (originalNodeNoWarnings === undefined) {
8792
delete process.env.NODE_NO_WARNINGS;
8893
} else {
@@ -340,6 +345,39 @@ describe("registerPreActionHooks", () => {
340345
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
341346
});
342347

348+
it("routes logs to stderr during plugin loading in --json mode and restores after", async () => {
349+
let stderrDuringPluginLoad = false;
350+
ensurePluginRegistryLoadedMock.mockImplementation(() => {
351+
stderrDuringPluginLoad = loggingState.forceConsoleToStderr;
352+
});
353+
354+
await runPreAction({
355+
parseArgv: ["agents"],
356+
processArgv: ["node", "openclaw", "agents", "--json"],
357+
});
358+
359+
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();
360+
expect(stderrDuringPluginLoad).toBe(true);
361+
// Flag must be restored after plugin loading completes
362+
expect(loggingState.forceConsoleToStderr).toBe(false);
363+
});
364+
365+
it("does not route logs to stderr during plugin loading without --json", async () => {
366+
let stderrDuringPluginLoad = false;
367+
ensurePluginRegistryLoadedMock.mockImplementation(() => {
368+
stderrDuringPluginLoad = loggingState.forceConsoleToStderr;
369+
});
370+
371+
await runPreAction({
372+
parseArgv: ["agents"],
373+
processArgv: ["node", "openclaw", "agents"],
374+
});
375+
376+
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();
377+
expect(stderrDuringPluginLoad).toBe(false);
378+
expect(loggingState.forceConsoleToStderr).toBe(false);
379+
});
380+
343381
beforeAll(() => {
344382
program = buildProgram();
345383
const hooks = (

src/cli/program/preaction.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { setVerbose } from "../../globals.js";
33
import { isTruthyEnvValue } from "../../infra/env.js";
44
import { routeLogsToStderr } from "../../logging/console.js";
55
import type { LogLevel } from "../../logging/levels.js";
6+
import { loggingState } from "../../logging/state.js";
67
import { defaultRuntime } from "../../runtime.js";
78
import { getCommandPathWithRootOptions, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
89
import { emitCliBanner } from "../banner.js";
@@ -138,10 +139,21 @@ export function registerPreActionHooks(program: Command, programVersion: string)
138139
commandPath,
139140
...(jsonOutputMode ? { suppressDoctorStdout: true } : {}),
140141
});
141-
// Load plugins for commands that need channel access
142+
<<<<<<< HEAD
143+
// Load plugins for commands that need channel access.
144+
// When --json output is active, temporarily route logs to stderr so plugin
145+
// registration messages don't corrupt the JSON payload on stdout.
142146
if (shouldLoadPluginsForCommand(commandPath, jsonOutputMode)) {
143147
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
144-
ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) });
148+
const prev = loggingState.forceConsoleToStderr;
149+
if (jsonOutputMode) {
150+
loggingState.forceConsoleToStderr = true;
151+
}
152+
try {
153+
ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) });
154+
} finally {
155+
loggingState.forceConsoleToStderr = prev;
156+
}
145157
}
146158
});
147159
}

src/cli/route.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,21 +34,31 @@ vi.mock("../runtime.js", () => ({
3434

3535
describe("tryRouteCli", () => {
3636
let tryRouteCli: typeof import("./route.js").tryRouteCli;
37+
// After vi.resetModules(), reimported modules get fresh loggingState.
38+
// Capture the same reference that route.js uses.
39+
let loggingState: typeof import("../logging/state.js").loggingState;
3740
let originalDisableRouteFirst: string | undefined;
41+
let originalForceStderr: boolean;
3842

3943
beforeEach(async () => {
4044
vi.clearAllMocks();
4145
originalDisableRouteFirst = process.env.OPENCLAW_DISABLE_ROUTE_FIRST;
4246
delete process.env.OPENCLAW_DISABLE_ROUTE_FIRST;
4347
vi.resetModules();
4448
({ tryRouteCli } = await import("./route.js"));
49+
({ loggingState } = await import("../logging/state.js"));
50+
originalForceStderr = loggingState.forceConsoleToStderr;
51+
loggingState.forceConsoleToStderr = false;
4552
findRoutedCommandMock.mockReturnValue({
4653
loadPlugins: (argv: string[]) => !argv.includes("--json"),
4754
run: runRouteMock,
4855
});
4956
});
5057

5158
afterEach(() => {
59+
if (loggingState) {
60+
loggingState.forceConsoleToStderr = originalForceStderr;
61+
}
5262
if (originalDisableRouteFirst === undefined) {
5363
delete process.env.OPENCLAW_DISABLE_ROUTE_FIRST;
5464
} else {
@@ -78,6 +88,44 @@ describe("tryRouteCli", () => {
7888
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" });
7989
});
8090

91+
it("routes logs to stderr during plugin loading in --json mode and restores after", async () => {
92+
findRoutedCommandMock.mockReturnValue({
93+
loadPlugins: true,
94+
run: runRouteMock,
95+
});
96+
97+
// Capture the value inside the mock callback using the same loggingState
98+
// reference that route.js sees (both imported after vi.resetModules()).
99+
const captured: boolean[] = [];
100+
ensurePluginRegistryLoadedMock.mockImplementation(() => {
101+
captured.push(loggingState.forceConsoleToStderr);
102+
});
103+
104+
await tryRouteCli(["node", "openclaw", "agents", "--json"]);
105+
106+
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();
107+
expect(captured[0]).toBe(true);
108+
expect(loggingState.forceConsoleToStderr).toBe(false);
109+
});
110+
111+
it("does not route logs to stderr during plugin loading without --json", async () => {
112+
findRoutedCommandMock.mockReturnValue({
113+
loadPlugins: true,
114+
run: runRouteMock,
115+
});
116+
117+
const captured: boolean[] = [];
118+
ensurePluginRegistryLoadedMock.mockImplementation(() => {
119+
captured.push(loggingState.forceConsoleToStderr);
120+
});
121+
122+
await tryRouteCli(["node", "openclaw", "agents"]);
123+
124+
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();
125+
expect(captured[0]).toBe(false);
126+
expect(loggingState.forceConsoleToStderr).toBe(false);
127+
});
128+
81129
it("routes status when root options precede the command", async () => {
82130
await expect(tryRouteCli(["node", "openclaw", "--log-level", "debug", "status"])).resolves.toBe(
83131
true,

src/cli/route.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isTruthyEnvValue } from "../infra/env.js";
2+
import { loggingState } from "../logging/state.js";
23
import { defaultRuntime } from "../runtime.js";
34
import { VERSION } from "../version.js";
45
import { getCommandPathWithRootOptions, hasFlag, hasHelpOrVersion } from "./argv.js";
@@ -22,12 +23,20 @@ async function prepareRoutedCommand(params: {
2223
typeof params.loadPlugins === "function" ? params.loadPlugins(params.argv) : params.loadPlugins;
2324
if (shouldLoadPlugins) {
2425
const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js");
25-
ensurePluginRegistryLoaded({
26-
scope:
27-
params.commandPath[0] === "status" || params.commandPath[0] === "health"
28-
? "channels"
29-
: "all",
30-
});
26+
const prev = loggingState.forceConsoleToStderr;
27+
if (suppressDoctorStdout) {
28+
loggingState.forceConsoleToStderr = true;
29+
}
30+
try {
31+
ensurePluginRegistryLoaded({
32+
scope:
33+
params.commandPath[0] === "status" || params.commandPath[0] === "health"
34+
? "channels"
35+
: "all",
36+
});
37+
} finally {
38+
loggingState.forceConsoleToStderr = prev;
39+
}
3140
}
3241
}
3342

0 commit comments

Comments
 (0)