refactor(cli): separate json payload output from logging

This commit is contained in:
Peter Steinberger
2026-03-22 23:19:14 +00:00
parent 274af0486a
commit 4ee41cc6f3
89 changed files with 710 additions and 693 deletions

View File

@@ -0,0 +1,58 @@
import type { Command } from "commander";
import { hasFlag } from "../argv.js";
const jsonModeSymbol = Symbol("openclaw.cli.jsonMode");
type JsonMode = "output" | "parse-only";
type JsonModeCommand = Command & {
[jsonModeSymbol]?: JsonMode;
};
function commandDefinesJsonOption(command: Command): boolean {
return command.options.some((option) => option.long === "--json");
}
function getDeclaredCommandJsonMode(command: Command): JsonMode | null {
for (let current: Command | null = command; current; current = current.parent ?? null) {
const metadata = (current as JsonModeCommand)[jsonModeSymbol];
if (metadata) {
return metadata;
}
if (commandDefinesJsonOption(current)) {
return "output";
}
}
return null;
}
function commandSelectedJsonFlag(command: Command, argv: string[]): boolean {
const commandWithGlobals = command as Command & {
optsWithGlobals?: <T extends Record<string, unknown>>() => T;
};
if (typeof commandWithGlobals.optsWithGlobals === "function") {
const resolved = commandWithGlobals.optsWithGlobals<Record<string, unknown>>().json;
if (resolved === true) {
return true;
}
}
return hasFlag(argv, "--json");
}
export function setCommandJsonMode(command: Command, mode: JsonMode): Command {
(command as JsonModeCommand)[jsonModeSymbol] = mode;
return command;
}
export function getCommandJsonMode(
command: Command,
argv: string[] = process.argv,
): JsonMode | null {
if (!commandSelectedJsonFlag(command, argv)) {
return null;
}
return getDeclaredCommandJsonMode(command);
}
export function isCommandJsonOutputMode(command: Command, argv: string[] = process.argv): boolean {
return getCommandJsonMode(command, argv) === "output";
}

View File

@@ -1,5 +1,6 @@
import { Command } from "commander";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { setCommandJsonMode } from "./json-mode.js";
const setVerboseMock = vi.fn();
const emitCliBannerMock = vi.fn();
@@ -10,6 +11,8 @@ const routeLogsToStderrMock = vi.fn();
const runtimeMock = {
log: vi.fn(),
error: vi.fn(),
writeStdout: vi.fn(),
writeJson: vi.fn(),
exit: vi.fn(),
};
@@ -100,7 +103,10 @@ describe("registerPreActionHooks", () => {
function buildProgram() {
const program = new Command().name("openclaw");
program.command("status").action(() => {});
program
.command("status")
.option("--json")
.action(() => {});
program
.command("backup")
.command("create")
@@ -109,7 +115,11 @@ describe("registerPreActionHooks", () => {
program.command("doctor").action(() => {});
program.command("completion").action(() => {});
program.command("secrets").action(() => {});
program.command("agents").action(() => {});
program
.command("agents")
.command("list")
.option("--json")
.action(() => {});
program.command("configure").action(() => {});
program.command("onboard").action(() => {});
const channels = program.command("channels");
@@ -125,8 +135,7 @@ describe("registerPreActionHooks", () => {
.option("--json")
.action(() => {});
const config = program.command("config");
config
.command("set")
setCommandJsonMode(config.command("set"), "parse-only")
.argument("<path>")
.argument("<value>")
.option("--json")
@@ -277,8 +286,8 @@ describe("registerPreActionHooks", () => {
it("routes logs to stderr in --json mode so stdout stays clean", async () => {
await runPreAction({
parseArgv: ["agents"],
processArgv: ["node", "openclaw", "agents", "--json"],
parseArgv: ["agents", "list"],
processArgv: ["node", "openclaw", "agents", "list", "--json"],
});
expect(routeLogsToStderrMock).toHaveBeenCalledOnce();
@@ -297,8 +306,8 @@ describe("registerPreActionHooks", () => {
// non-json command should not route
await runPreAction({
parseArgv: ["agents"],
processArgv: ["node", "openclaw", "agents"],
parseArgv: ["agents", "list"],
processArgv: ["node", "openclaw", "agents", "list"],
});
expect(routeLogsToStderrMock).not.toHaveBeenCalled();

View File

@@ -4,14 +4,10 @@ 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 {
getCommandPathWithRootOptions,
getVerboseFlag,
hasFlag,
hasHelpOrVersion,
} from "../argv.js";
import { getCommandPathWithRootOptions, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
import { emitCliBanner } from "../banner.js";
import { resolveCliName } from "../cli-name.js";
import { isCommandJsonOutputMode } from "./json-mode.js";
function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand;
@@ -37,7 +33,6 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([
"health",
]);
const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["backup", "doctor", "completion", "secrets"]);
const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]);
let configGuardModulePromise: Promise<typeof import("./config-guard.js")> | undefined;
let pluginRegistryModulePromise: Promise<typeof import("../plugin-registry.js")> | undefined;
@@ -71,12 +66,12 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" {
return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all";
}
function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean {
function shouldLoadPluginsForCommand(commandPath: string[], jsonOutputMode: boolean): boolean {
const [primary, secondary] = commandPath;
if (!primary || !PLUGIN_REQUIRED_COMMANDS.has(primary)) {
return false;
}
if ((primary === "status" || primary === "health") && hasFlag(argv, "--json")) {
if ((primary === "status" || primary === "health") && jsonOutputMode) {
return false;
}
// Setup wizard and channels add should stay manifest-first and load selected plugins on demand.
@@ -105,17 +100,6 @@ function getCliLogLevel(actionCommand: Command): LogLevel | undefined {
return typeof logLevel === "string" ? (logLevel as LogLevel) : undefined;
}
function isJsonOutputMode(commandPath: string[], argv: string[]): boolean {
if (!hasFlag(argv, "--json")) {
return false;
}
const key = `${commandPath[0] ?? ""} ${commandPath[1] ?? ""}`.trim();
if (JSON_PARSE_ONLY_COMMANDS.has(key)) {
return false;
}
return true;
}
export function registerPreActionHooks(program: Command, programVersion: string) {
program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand);
@@ -124,7 +108,8 @@ export function registerPreActionHooks(program: Command, programVersion: string)
return;
}
const commandPath = getCommandPathWithRootOptions(argv, 2);
if (isJsonOutputMode(commandPath, argv)) {
const jsonOutputMode = isCommandJsonOutputMode(actionCommand, argv);
if (jsonOutputMode) {
routeLogsToStderr();
}
const hideBanner =
@@ -147,15 +132,14 @@ export function registerPreActionHooks(program: Command, programVersion: string)
if (shouldBypassConfigGuard(commandPath)) {
return;
}
const suppressDoctorStdout = isJsonOutputMode(commandPath, argv);
const { ensureConfigReady } = await loadConfigGuardModule();
await ensureConfigReady({
runtime: defaultRuntime,
commandPath,
...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}),
...(jsonOutputMode ? { suppressDoctorStdout: true } : {}),
});
// Load plugins for commands that need channel access
if (shouldLoadPluginsForCommand(commandPath, argv)) {
if (shouldLoadPluginsForCommand(commandPath, jsonOutputMode)) {
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) });
}