mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 19:32:27 +00:00
refactor(cli): separate json payload output from logging
This commit is contained in:
58
src/cli/program/json-mode.ts
Normal file
58
src/cli/program/json-mode.ts
Normal 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";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user