diff --git a/src/cli/browser-cli-actions-input/register.files-downloads.ts b/src/cli/browser-cli-actions-input/register.files-downloads.ts index a818aee1f2c..6045da6ffaf 100644 --- a/src/cli/browser-cli-actions-input/register.files-downloads.ts +++ b/src/cli/browser-cli-actions-input/register.files-downloads.ts @@ -38,7 +38,7 @@ async function runBrowserPostAction(params: { { timeoutMs: params.timeoutMs }, ); if (params.parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(params.describeSuccess(result)); diff --git a/src/cli/browser-cli-actions-input/register.form-wait-eval.ts b/src/cli/browser-cli-actions-input/register.form-wait-eval.ts index a49e768daf5..291e43c56e2 100644 --- a/src/cli/browser-cli-actions-input/register.form-wait-eval.ts +++ b/src/cli/browser-cli-actions-input/register.form-wait-eval.ts @@ -116,10 +116,10 @@ export function registerBrowserFormWaitEvalCommands( }, }); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } - defaultRuntime.log(JSON.stringify(result.result ?? null, null, 2)); + defaultRuntime.writeJson(result.result ?? null); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); diff --git a/src/cli/browser-cli-actions-input/register.navigation.ts b/src/cli/browser-cli-actions-input/register.navigation.ts index 393ffb7e56f..9b15274c23e 100644 --- a/src/cli/browser-cli-actions-input/register.navigation.ts +++ b/src/cli/browser-cli-actions-input/register.navigation.ts @@ -31,7 +31,7 @@ export function registerBrowserNavigationCommands( { timeoutMs: 20000 }, ); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(`navigated to ${result.url ?? url}`); diff --git a/src/cli/browser-cli-actions-input/shared.ts b/src/cli/browser-cli-actions-input/shared.ts index 8d9415b3a5f..9bc3928dc5f 100644 --- a/src/cli/browser-cli-actions-input/shared.ts +++ b/src/cli/browser-cli-actions-input/shared.ts @@ -46,7 +46,7 @@ export function logBrowserActionResult( successMessage: string, ) { if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(successMessage); diff --git a/src/cli/browser-cli-actions-observe.ts b/src/cli/browser-cli-actions-observe.ts index 22c5fbd37da..bea6ec58a27 100644 --- a/src/cli/browser-cli-actions-observe.ts +++ b/src/cli/browser-cli-actions-observe.ts @@ -39,10 +39,10 @@ export function registerBrowserActionObserveCommands( { timeoutMs: 20000 }, ); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } - defaultRuntime.log(JSON.stringify(result.messages, null, 2)); + defaultRuntime.writeJson(result.messages); }); }); @@ -65,7 +65,7 @@ export function registerBrowserActionObserveCommands( { timeoutMs: 20000 }, ); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(`PDF: ${shortenHomePath(result.path)}`); @@ -107,7 +107,7 @@ export function registerBrowserActionObserveCommands( { timeoutMs: timeoutMs ?? 20000 }, ); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(result.response.body); diff --git a/src/cli/browser-cli-debug.ts b/src/cli/browser-cli-debug.ts index c10b308e0e2..fa6cc24c9ae 100644 --- a/src/cli/browser-cli-debug.ts +++ b/src/cli/browser-cli-debug.ts @@ -39,7 +39,7 @@ function printJsonResult(parent: BrowserParentOpts, result: unknown): boolean { if (!parent.json) { return false; } - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return true; } diff --git a/src/cli/browser-cli-inspect.ts b/src/cli/browser-cli-inspect.ts index 31846e21069..021fa7ef02d 100644 --- a/src/cli/browser-cli-inspect.ts +++ b/src/cli/browser-cli-inspect.ts @@ -39,7 +39,7 @@ export function registerBrowserInspectCommands( { timeoutMs: 20000 }, ); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(`MEDIA:${shortenHomePath(result.path)}`); @@ -106,19 +106,13 @@ export function registerBrowserInspectCommands( await fs.writeFile(opts.out, payload, "utf8"); } if (parent?.json) { - defaultRuntime.log( - JSON.stringify( - { - ok: true, - out: opts.out, - ...(result.format === "ai" && result.imagePath - ? { imagePath: result.imagePath } - : {}), - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + ok: true, + out: opts.out, + ...(result.format === "ai" && result.imagePath + ? { imagePath: result.imagePath } + : {}), + }); } else { defaultRuntime.log(shortenHomePath(opts.out)); if (result.format === "ai" && result.imagePath) { @@ -129,7 +123,7 @@ export function registerBrowserInspectCommands( } if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } diff --git a/src/cli/browser-cli-manage.ts b/src/cli/browser-cli-manage.ts index 1c096b1a73b..dea1111d3cc 100644 --- a/src/cli/browser-cli-manage.ts +++ b/src/cli/browser-cli-manage.ts @@ -25,7 +25,7 @@ function printJsonResult(parent: BrowserParentOpts, payload: unknown): boolean { if (!parent?.json) { return false; } - defaultRuntime.log(JSON.stringify(payload, null, 2)); + defaultRuntime.writeJson(payload); return true; } @@ -89,7 +89,7 @@ function runBrowserCommand(action: () => Promise) { function logBrowserTabs(tabs: BrowserTab[], json?: boolean) { if (json) { - defaultRuntime.log(JSON.stringify({ tabs }, null, 2)); + defaultRuntime.writeJson({ tabs }); return; } if (tabs.length === 0) { diff --git a/src/cli/browser-cli-resize.ts b/src/cli/browser-cli-resize.ts index 1ba31cb29f2..840cf17fd62 100644 --- a/src/cli/browser-cli-resize.ts +++ b/src/cli/browser-cli-resize.ts @@ -30,7 +30,7 @@ export async function runBrowserResizeWithOutput(params: { ); if (params.parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(params.successMessage); diff --git a/src/cli/browser-cli-state.cookies-storage.ts b/src/cli/browser-cli-state.cookies-storage.ts index 01190b5b48f..d29c4c5f7d2 100644 --- a/src/cli/browser-cli-state.cookies-storage.ts +++ b/src/cli/browser-cli-state.cookies-storage.ts @@ -36,7 +36,7 @@ async function runMutationRequest(params: { try { const result = await callBrowserRequest(params.parent, params.request, { timeoutMs: 20000 }); if (params.parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(params.successMessage); @@ -72,10 +72,10 @@ export function registerBrowserCookiesAndStorageCommands( { timeoutMs: 20000 }, ); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } - defaultRuntime.log(JSON.stringify(result.cookies ?? [], null, 2)); + defaultRuntime.writeJson(result.cookies ?? []); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); @@ -165,10 +165,10 @@ export function registerBrowserCookiesAndStorageCommands( { timeoutMs: 20000 }, ); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } - defaultRuntime.log(JSON.stringify(result.values ?? {}, null, 2)); + defaultRuntime.writeJson(result.values ?? {}); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1); diff --git a/src/cli/browser-cli-state.ts b/src/cli/browser-cli-state.ts index 338e0469fe9..576e57723c8 100644 --- a/src/cli/browser-cli-state.ts +++ b/src/cli/browser-cli-state.ts @@ -38,7 +38,7 @@ async function runBrowserSetRequest(params: { { timeoutMs: 20000 }, ); if (params.parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(params.successMessage); @@ -139,7 +139,7 @@ export function registerBrowserStateCommands( { timeoutMs: 20000 }, ); if (parent?.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log("headers set"); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index e7a94ae99ab..c47a765bf61 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -18,7 +18,7 @@ import { import { validateConfigObjectRaw } from "../config/validation.js"; import { SecretProviderSchema } from "../config/zod-schema.core.js"; import { danger, info, success } from "../globals.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { formatExecSecretRefIdValidationMessage, @@ -51,6 +51,7 @@ import { type ConfigSetOptions, } from "./config-set-input.js"; import { resolveConfigSetMode } from "./config-set-parser.js"; +import { setCommandJsonMode } from "./program/json-mode.js"; type PathSegment = string; type ConfigSetParseOpts = { @@ -1072,7 +1073,7 @@ export async function runConfigSet(opts: { ); } if (opts.cliOptions.json) { - runtime.log(JSON.stringify(dryRunResult, null, 2)); + writeRuntimeJson(runtime, dryRunResult); } else { if (!dryRunResult.checks.schema && !dryRunResult.checks.resolvability) { runtime.log( @@ -1120,7 +1121,7 @@ export async function runConfigSet(opts: { opts.cliOptions.json && err instanceof ConfigSetDryRunValidationError ) { - runtime.log(JSON.stringify(err.result, null, 2)); + writeRuntimeJson(runtime, err.result); runtime.exit(1); return; } @@ -1142,7 +1143,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime return; } if (opts.json) { - runtime.log(JSON.stringify(res.value ?? null, null, 2)); + writeRuntimeJson(runtime, res.value ?? null); return; } if ( @@ -1153,7 +1154,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime runtime.log(String(res.value)); return; } - runtime.log(JSON.stringify(res.value ?? null, null, 2)); + writeRuntimeJson(runtime, res.value ?? null); } catch (err) { runtime.error(danger(String(err))); runtime.exit(1); @@ -1205,7 +1206,7 @@ export async function runConfigValidate(opts: { json?: boolean; runtime?: Runtim if (!snapshot.exists) { if (opts.json) { - runtime.log(JSON.stringify({ valid: false, path: outputPath, error: "file not found" })); + writeRuntimeJson(runtime, { valid: false, path: outputPath, error: "file not found" }, 0); } else { runtime.error(danger(`Config file not found: ${shortPath}`)); } @@ -1217,7 +1218,7 @@ export async function runConfigValidate(opts: { json?: boolean; runtime?: Runtim const issues = normalizeConfigIssues(snapshot.issues); if (opts.json) { - runtime.log(JSON.stringify({ valid: false, path: outputPath, issues }, null, 2)); + writeRuntimeJson(runtime, { valid: false, path: outputPath, issues }); } else { runtime.error(danger(`Config invalid at ${shortPath}:`)); for (const line of formatConfigIssueLines(issues, danger("×"), { normalizeRoot: true })) { @@ -1231,13 +1232,13 @@ export async function runConfigValidate(opts: { json?: boolean; runtime?: Runtim } if (opts.json) { - runtime.log(JSON.stringify({ valid: true, path: outputPath })); + writeRuntimeJson(runtime, { valid: true, path: outputPath }, 0); } else { runtime.log(success(`Config valid: ${shortPath}`)); } } catch (err) { if (opts.json) { - runtime.log(JSON.stringify({ valid: false, path: outputPath, error: String(err) })); + writeRuntimeJson(runtime, { valid: false, path: outputPath, error: String(err) }, 0); } else { runtime.error(danger(`Config validation error: ${String(err)}`)); } @@ -1276,8 +1277,7 @@ export function registerConfigCli(program: Command) { await runConfigGet({ path, json: Boolean(opts.json) }); }); - cmd - .command("set") + setCommandJsonMode(cmd.command("set"), "parse-only") .description(CONFIG_SET_DESCRIPTION) .argument("[path]", "Config path (dot or bracket notation)") .argument("[value]", "Value (JSON/JSON5 or raw string)") diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index b2007fc3f1a..41eeccb7036 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -341,7 +341,7 @@ export function registerCronEditCommand(cron: Command) { id, patch, }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + defaultRuntime.writeJson(res); await warnIfCronSchedulerDisabled(opts); } catch (err) { defaultRuntime.error(danger(String(err))); diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 3574a63ab27..088c3d426dc 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -4,7 +4,7 @@ import { resolveCronStaggerMs } from "../../cron/stagger.js"; import type { CronJob, CronSchedule } from "../../cron/types.js"; import { danger } from "../../globals.js"; import { formatDurationHuman } from "../../infra/format-time/format-duration.ts"; -import { defaultRuntime } from "../../runtime.js"; +import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js"; @@ -13,7 +13,7 @@ export const getCronChannelOptions = () => ["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|"); export function printCronJson(value: unknown) { - defaultRuntime.log(JSON.stringify(value, null, 2)); + defaultRuntime.writeJson(value); } export function handleCronCliError(err: unknown) { @@ -184,7 +184,7 @@ const formatStatus = (job: CronJob) => { return job.state.lastStatus ?? "idle"; }; -export function printCronList(jobs: CronJob[], runtime = defaultRuntime) { +export function printCronList(jobs: CronJob[], runtime: RuntimeEnv = defaultRuntime) { if (jobs.length === 0) { runtime.log("No cron jobs."); return; diff --git a/src/cli/daemon-cli/response.ts b/src/cli/daemon-cli/response.ts index 7b6f6d2a07e..1863b7d5aaf 100644 --- a/src/cli/daemon-cli/response.ts +++ b/src/cli/daemon-cli/response.ts @@ -21,7 +21,7 @@ export type DaemonActionResponse = { }; export function emitDaemonActionJson(payload: DaemonActionResponse) { - defaultRuntime.log(JSON.stringify(payload, null, 2)); + defaultRuntime.writeJson(payload); } export function buildDaemonServiceSnapshot(service: GatewayService, loaded: boolean) { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 088a3654797..781ba2b2c8d 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -52,7 +52,7 @@ function sanitizeDaemonStatusForJson(status: DaemonStatus): DaemonStatus { export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { if (opts.json) { const sanitized = sanitizeDaemonStatusForJson(status); - defaultRuntime.log(JSON.stringify(sanitized, null, 2)); + defaultRuntime.writeJson(sanitized); return; } diff --git a/src/cli/devices-cli.ts b/src/cli/devices-cli.ts index 96b2db3a332..27760991eb9 100644 --- a/src/cli/devices-cli.ts +++ b/src/cli/devices-cli.ts @@ -246,7 +246,7 @@ export function registerDevicesCli(program: Command) { .action(async (opts: DevicesRpcOpts) => { const list = await listPairingWithFallback(opts); if (opts.json) { - defaultRuntime.log(JSON.stringify(list, null, 2)); + defaultRuntime.writeJson(list); return; } if (list.pending?.length) { @@ -323,7 +323,7 @@ export function registerDevicesCli(program: Command) { } const result = await callGatewayCli("device.pair.remove", opts, { deviceId: trimmed }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log(`${theme.warn("Removed")} ${theme.command(trimmed)}`); @@ -366,16 +366,10 @@ export function registerDevicesCli(program: Command) { } } if (opts.json) { - defaultRuntime.log( - JSON.stringify( - { - removedDevices: removedDeviceIds, - rejectedPending: rejectedRequestIds, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + removedDevices: removedDeviceIds, + rejectedPending: rejectedRequestIds, + }); return; } defaultRuntime.log( @@ -413,7 +407,7 @@ export function registerDevicesCli(program: Command) { return; } if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId; @@ -431,7 +425,7 @@ export function registerDevicesCli(program: Command) { .action(async (requestId: string, opts: DevicesRpcOpts) => { const result = await callGatewayCli("device.pair.reject", opts, { requestId }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } const deviceId = (result as { deviceId?: string })?.deviceId; @@ -456,7 +450,7 @@ export function registerDevicesCli(program: Command) { role: required.role, scopes: Array.isArray(opts.scope) ? opts.scope : undefined, }); - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); }), ); @@ -475,7 +469,7 @@ export function registerDevicesCli(program: Command) { deviceId: required.deviceId, role: required.role, }); - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); }), ); } diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index 3566d96fa47..e73fca9b498 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -160,7 +160,7 @@ export function registerDirectoryCli(program: Command) { runtime: defaultRuntime, }); if (params.opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } printDirectoryList({ title: params.title, emptyMessage: params.emptyMessage, entries: result }); @@ -179,7 +179,7 @@ export function registerDirectoryCli(program: Command) { } const result = await fn({ cfg, accountId, runtime: defaultRuntime }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } if (!result) { @@ -272,7 +272,7 @@ export function registerDirectoryCli(program: Command) { runtime: defaultRuntime, }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } printDirectoryList({ diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts index f9781d2f38e..f97a6ff8178 100644 --- a/src/cli/dns-cli.ts +++ b/src/cli/dns-cli.ts @@ -154,16 +154,10 @@ export function registerDnsCli(program: Command) { ); defaultRuntime.log(""); defaultRuntime.log(theme.heading("Recommended ~/.openclaw/openclaw.json:")); - defaultRuntime.log( - JSON.stringify( - { - gateway: { bind: "auto" }, - discovery: { wideArea: { enabled: true, domain: wideAreaDomain } }, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + gateway: { bind: "auto" }, + discovery: { wideArea: { enabled: true, domain: wideAreaDomain } }, + }); defaultRuntime.log(""); defaultRuntime.log(theme.heading("Tailscale admin (DNS → Nameservers):")); defaultRuntime.log( diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index c243fb7a0aa..37f8b03bf95 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -135,7 +135,7 @@ async function saveSnapshotTargeted(params: { ? saveSnapshotLocal(params.file) : await saveSnapshot(params.opts, params.nodeId, params.file, params.baseHash); if (params.opts.json) { - defaultRuntime.log(JSON.stringify(next)); + defaultRuntime.writeJson(next, 0); return; } defaultRuntime.log(theme.muted(`Target: ${params.targetLabel}`)); @@ -365,7 +365,7 @@ export function registerExecApprovalsCli(program: Command) { try { const { snapshot, nodeId, source } = await loadSnapshotTarget(opts); if (opts.json) { - defaultRuntime.log(JSON.stringify(snapshot)); + defaultRuntime.writeJson(snapshot, 0); return; } diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index d19e53d10b9..9ae380e4de6 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -124,14 +124,14 @@ export function registerGatewayCli(program: Command) { const params = JSON.parse(String(opts.params ?? "{}")); const result = await callGatewayCli(method, { ...rpcOpts, config }, params); if (rpcOpts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } const rich = isRich(); defaultRuntime.log( `${colorize(rich, theme.heading, "Gateway call")}: ${colorize(rich, theme.muted, String(method))}`, ); - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); }, "Gateway call failed"); }), ); @@ -148,7 +148,7 @@ export function registerGatewayCli(program: Command) { const config = await readBestEffortConfig(); const result = await callGatewayCli("usage.cost", { ...rpcOpts, config }, { days }); if (rpcOpts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } const rich = isRich(); @@ -170,7 +170,7 @@ export function registerGatewayCli(program: Command) { const config = await readBestEffortConfig(); const result = await callGatewayCli("health", { ...rpcOpts, config }); if (rpcOpts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } const rich = isRich(); @@ -242,18 +242,12 @@ export function registerGatewayCli(program: Command) { const port = pickGatewayPort(b); return { ...b, wsUrl: host ? `ws://${host}:${port}` : null }; }); - defaultRuntime.log( - JSON.stringify( - { - timeoutMs, - domains, - count: enriched.length, - beacons: enriched, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + timeoutMs, + domains, + count: enriched.length, + beacons: enriched, + }); return; } diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 23ec7dd584d..26a1bef2d33 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -20,7 +20,7 @@ import { waitForActiveTasks, } from "../../process/command-queue.js"; import { createRestartIterationHook } from "../../process/restart-recovery.js"; -import type { defaultRuntime } from "../../runtime.js"; +import type { RuntimeEnv } from "../../runtime.js"; const gatewayLog = createSubsystemLogger("gateway"); @@ -28,7 +28,7 @@ type GatewayRunSignalAction = "stop" | "restart"; export async function runGatewayLoop(params: { start: () => Promise>>; - runtime: typeof defaultRuntime; + runtime: RuntimeEnv; lockPort?: number; }) { let lock = await acquireGatewayLock({ port: params.lockPort }); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index 7b974c95987..26a04e97076 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -145,6 +145,14 @@ function exitHooksCliWithError(err: unknown): never { process.exit(1); } +function writeHooksOutput(value: string, json: boolean | undefined): void { + if (json) { + defaultRuntime.writeStdout(value); + return; + } + defaultRuntime.log(value); +} + async function runHooksCliAction(action: () => Promise | void): Promise { try { await action(); @@ -455,7 +463,7 @@ export function registerHooksCli(program: Command): void { runHooksCliAction(async () => { const config = loadConfig(); const report = buildHooksReport(config); - defaultRuntime.log(formatHooksList(report, opts)); + writeHooksOutput(formatHooksList(report, opts), opts.json); }), ); @@ -467,7 +475,7 @@ export function registerHooksCli(program: Command): void { runHooksCliAction(async () => { const config = loadConfig(); const report = buildHooksReport(config); - defaultRuntime.log(formatHookInfo(report, name, opts)); + writeHooksOutput(formatHookInfo(report, name, opts), opts.json); }), ); @@ -479,7 +487,7 @@ export function registerHooksCli(program: Command): void { runHooksCliAction(async () => { const config = loadConfig(); const report = buildHooksReport(config); - defaultRuntime.log(formatHooksCheck(report, opts)); + writeHooksOutput(formatHooksCheck(report, opts), opts.json); }), ); diff --git a/src/cli/mcp-cli.ts b/src/cli/mcp-cli.ts index 61956468b82..677b3db28a9 100644 --- a/src/cli/mcp-cli.ts +++ b/src/cli/mcp-cli.ts @@ -14,7 +14,7 @@ function fail(message: string): never { } function printJson(value: unknown): void { - defaultRuntime.log(JSON.stringify(value, null, 2)); + defaultRuntime.writeJson(value); } export function registerMcpCli(program: Command) { diff --git a/src/cli/memory-cli.runtime.ts b/src/cli/memory-cli.runtime.ts index 0299e20fadc..93fb3fddf56 100644 --- a/src/cli/memory-cli.runtime.ts +++ b/src/cli/memory-cli.runtime.ts @@ -414,7 +414,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { } if (opts.json) { - defaultRuntime.log(JSON.stringify(allResults, null, 2)); + defaultRuntime.writeJson(allResults); return; } @@ -723,7 +723,7 @@ export async function runMemorySearch( return; } if (opts.json) { - defaultRuntime.log(JSON.stringify({ results }, null, 2)); + defaultRuntime.writeJson({ results }); return; } if (results.length === 0) { diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index f56b8af3fff..761d7cf91f8 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -223,7 +223,7 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) { }; if (json) { - defaultRuntime.log(JSON.stringify(payload, null, 2)); + defaultRuntime.writeJson(payload); return; } diff --git a/src/cli/nodes-cli/register.camera.ts b/src/cli/nodes-cli/register.camera.ts index 9c813cecc5f..de6b0791a3c 100644 --- a/src/cli/nodes-cli/register.camera.ts +++ b/src/cli/nodes-cli/register.camera.ts @@ -66,7 +66,7 @@ export function registerNodesCameraCommands(nodes: Command) { const devices = Array.isArray(payload.devices) ? payload.devices : []; if (opts.json) { - defaultRuntime.log(JSON.stringify(devices, null, 2)); + defaultRuntime.writeJson(devices); return; } @@ -184,7 +184,7 @@ export function registerNodesCameraCommands(nodes: Command) { } if (opts.json) { - defaultRuntime.log(JSON.stringify({ files: results }, null, 2)); + defaultRuntime.writeJson({ files: results }); return; } defaultRuntime.log(results.map((r) => `MEDIA:${shortenHomePath(r.path)}`).join("\n")); @@ -241,20 +241,14 @@ export function registerNodesCameraCommands(nodes: Command) { }); if (opts.json) { - defaultRuntime.log( - JSON.stringify( - { - file: { - facing, - path: filePath, - durationMs: payload.durationMs, - hasAudio: payload.hasAudio, - }, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + file: { + facing, + path: filePath, + durationMs: payload.durationMs, + hasAudio: payload.hasAudio, + }, + }); return; } defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`); diff --git a/src/cli/nodes-cli/register.canvas.ts b/src/cli/nodes-cli/register.canvas.ts index 9604d66b021..6b3a112f50b 100644 --- a/src/cli/nodes-cli/register.canvas.ts +++ b/src/cli/nodes-cli/register.canvas.ts @@ -65,9 +65,7 @@ export function registerNodesCanvasCommands(nodes: Command) { await writeBase64ToFile(filePath, payload.base64); if (opts.json) { - defaultRuntime.log( - JSON.stringify({ file: { path: filePath, format: payload.format } }, null, 2), - ); + defaultRuntime.writeJson({ file: { path: filePath, format: payload.format } }); return; } defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`); @@ -169,7 +167,7 @@ export function registerNodesCanvasCommands(nodes: Command) { javaScript: js, }); if (opts.json) { - defaultRuntime.log(JSON.stringify(raw, null, 2)); + defaultRuntime.writeJson(raw); return; } const payload = diff --git a/src/cli/nodes-cli/register.invoke.ts b/src/cli/nodes-cli/register.invoke.ts index 0bd1fdad895..71507565f56 100644 --- a/src/cli/nodes-cli/register.invoke.ts +++ b/src/cli/nodes-cli/register.invoke.ts @@ -333,7 +333,7 @@ export function registerNodesInvokeCommands(nodes: Command) { } const result = await callGatewayCli("node.invoke", opts, invokeParams); - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); }); }), { timeoutMs: 30_000 }, @@ -421,7 +421,7 @@ export function registerNodesInvokeCommands(nodes: Command) { const result = await callGatewayCli("node.invoke", opts, invokeParams); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } diff --git a/src/cli/nodes-cli/register.location.ts b/src/cli/nodes-cli/register.location.ts index c4dd02356e8..557e57dc769 100644 --- a/src/cli/nodes-cli/register.location.ts +++ b/src/cli/nodes-cli/register.location.ts @@ -61,7 +61,7 @@ export function registerNodesLocationCommands(nodes: Command) { : {}; if (opts.json) { - defaultRuntime.log(JSON.stringify(payload, null, 2)); + defaultRuntime.writeJson(payload); return; } @@ -73,7 +73,7 @@ export function registerNodesLocationCommands(nodes: Command) { defaultRuntime.log(`${lat},${lon}${accText}`); return; } - defaultRuntime.log(JSON.stringify(payload)); + defaultRuntime.writeJson(payload, 0); }); }), { timeoutMs: 30_000 }, diff --git a/src/cli/nodes-cli/register.notify.ts b/src/cli/nodes-cli/register.notify.ts index 25952a2d8a5..b506975bc31 100644 --- a/src/cli/nodes-cli/register.notify.ts +++ b/src/cli/nodes-cli/register.notify.ts @@ -46,7 +46,7 @@ export function registerNodesNotifyCommand(nodes: Command) { const result = await callGatewayCli("node.invoke", opts, invokeParams); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } const { ok } = getNodesTheme(); diff --git a/src/cli/nodes-cli/register.pairing.ts b/src/cli/nodes-cli/register.pairing.ts index fd649fae754..0ee095032de 100644 --- a/src/cli/nodes-cli/register.pairing.ts +++ b/src/cli/nodes-cli/register.pairing.ts @@ -17,7 +17,7 @@ export function registerNodesPairingCommands(nodes: Command) { const result = await callGatewayCli("node.pair.list", opts, {}); const { pending } = parsePairingList(result); if (opts.json) { - defaultRuntime.log(JSON.stringify(pending, null, 2)); + defaultRuntime.writeJson(pending); return; } if (pending.length === 0) { @@ -50,7 +50,7 @@ export function registerNodesPairingCommands(nodes: Command) { const result = await callGatewayCli("node.pair.approve", opts, { requestId, }); - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); }); }), ); @@ -65,7 +65,7 @@ export function registerNodesPairingCommands(nodes: Command) { const result = await callGatewayCli("node.pair.reject", opts, { requestId, }); - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); }); }), ); @@ -90,7 +90,7 @@ export function registerNodesPairingCommands(nodes: Command) { displayName: name, }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } const { ok } = getNodesTheme(); diff --git a/src/cli/nodes-cli/register.push.ts b/src/cli/nodes-cli/register.push.ts index a4a6fa37626..0264d7a581d 100644 --- a/src/cli/nodes-cli/register.push.ts +++ b/src/cli/nodes-cli/register.push.ts @@ -52,7 +52,7 @@ export function registerNodesPushCommand(nodes: Command) { const result = await callGatewayCli("push.test", opts, params); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } diff --git a/src/cli/nodes-cli/register.screen.ts b/src/cli/nodes-cli/register.screen.ts index 06cbc6599cd..3ea8d91fae6 100644 --- a/src/cli/nodes-cli/register.screen.ts +++ b/src/cli/nodes-cli/register.screen.ts @@ -57,21 +57,15 @@ export function registerNodesScreenCommands(nodes: Command) { const written = await writeScreenRecordToFile(filePath, parsed.base64); if (opts.json) { - defaultRuntime.log( - JSON.stringify( - { - file: { - path: written.path, - durationMs: parsed.durationMs, - fps: parsed.fps, - screenIndex: parsed.screenIndex, - hasAudio: parsed.hasAudio, - }, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + file: { + path: written.path, + durationMs: parsed.durationMs, + fps: parsed.fps, + screenIndex: parsed.screenIndex, + hasAudio: parsed.hasAudio, + }, + }); return; } defaultRuntime.log(`MEDIA:${shortenHomePath(written.path)}`); diff --git a/src/cli/nodes-cli/register.status.ts b/src/cli/nodes-cli/register.status.ts index 03e00cbbec4..19b2454247b 100644 --- a/src/cli/nodes-cli/register.status.ts +++ b/src/cli/nodes-cli/register.status.ts @@ -147,7 +147,7 @@ export function registerNodesStatusCommands(nodes: Command) { if (opts.json) { const ts = typeof obj.ts === "number" ? obj.ts : Date.now(); - defaultRuntime.log(JSON.stringify({ ...obj, ts, nodes: filtered }, null, 2)); + defaultRuntime.writeJson({ ...obj, ts, nodes: filtered }); return; } @@ -223,7 +223,7 @@ export function registerNodesStatusCommands(nodes: Command) { nodeId, }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } @@ -350,9 +350,7 @@ export function registerNodesStatusCommands(nodes: Command) { ); if (opts.json) { - defaultRuntime.log( - JSON.stringify({ pending: pendingRows, paired: filteredPaired }, null, 2), - ); + defaultRuntime.writeJson({ pending: pendingRows, paired: filteredPaired }); return; } diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index 7c8cbc750ea..b106f45fc0c 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -80,7 +80,7 @@ export function registerPairingCli(program: Command) { ? await listChannelPairingRequests(channel, process.env, accountId) : await listChannelPairingRequests(channel); if (opts.json) { - defaultRuntime.log(JSON.stringify({ channel, requests }, null, 2)); + defaultRuntime.writeJson({ channel, requests }); return; } if (requests.length === 0) { diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 0129b0c463b..19e251635d5 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -191,7 +191,7 @@ export function registerPluginsCli(program: Command) { plugins: list, diagnostics: report.diagnostics, }; - defaultRuntime.log(JSON.stringify(payload, null, 2)); + defaultRuntime.writeJson(payload); return; } @@ -298,7 +298,7 @@ export function registerPluginsCli(program: Command) { })); if (opts.json) { - defaultRuntime.log(JSON.stringify(inspectAllWithInstall, null, 2)); + defaultRuntime.writeJson(inspectAllWithInstall); return; } @@ -367,16 +367,10 @@ export function registerPluginsCli(program: Command) { const install = cfg.plugins?.installs?.[inspect.plugin.id]; if (opts.json) { - defaultRuntime.log( - JSON.stringify( - { - ...inspect, - install, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + ...inspect, + install, + }); return; } @@ -760,18 +754,12 @@ export function registerPluginsCli(program: Command) { } if (opts.json) { - defaultRuntime.log( - JSON.stringify( - { - source: result.sourceLabel, - name: result.manifest.name, - version: result.manifest.version, - plugins: result.manifest.plugins, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + source: result.sourceLabel, + name: result.manifest.name, + version: result.manifest.version, + plugins: result.manifest.plugins, + }); return; } diff --git a/src/cli/program/json-mode.ts b/src/cli/program/json-mode.ts new file mode 100644 index 00000000000..b289bea9117 --- /dev/null +++ b/src/cli/program/json-mode.ts @@ -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; + }; + if (typeof commandWithGlobals.optsWithGlobals === "function") { + const resolved = commandWithGlobals.optsWithGlobals>().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"; +} diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 3e15eac2bc8..4021a5e260d 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -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("") .argument("") .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(); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index c3a15427da4..b37e211be89 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -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 | undefined; let pluginRegistryModulePromise: Promise | 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) }); } diff --git a/src/cli/qr-cli.ts b/src/cli/qr-cli.ts index b7ff0345cad..f543033c4d7 100644 --- a/src/cli/qr-cli.ts +++ b/src/cli/qr-cli.ts @@ -225,18 +225,12 @@ export function registerQrCli(program: Command) { } if (opts.json) { - defaultRuntime.log( - JSON.stringify( - { - setupCode, - gatewayUrl: resolved.payload.url, - auth: resolved.authLabel, - urlSource: resolved.urlSource, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + setupCode, + gatewayUrl: resolved.payload.url, + auth: resolved.authLabel, + urlSource: resolved.urlSource, + }); return; } diff --git a/src/cli/secrets-cli.ts b/src/cli/secrets-cli.ts index 4e0257b5dd9..a6c1e0d4d45 100644 --- a/src/cli/secrets-cli.ts +++ b/src/cli/secrets-cli.ts @@ -64,7 +64,7 @@ export function registerSecretsCli(program: Command) { expectFinal: false, }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } const warningCount = Number( @@ -97,7 +97,7 @@ export function registerSecretsCli(program: Command) { allowExec: Boolean(opts.allowExec), }); if (opts.json) { - defaultRuntime.log(JSON.stringify(report, null, 2)); + defaultRuntime.writeJson(report); } else { defaultRuntime.log( `Secrets audit: ${report.status}. plaintext=${report.summary.plaintextCount}, unresolved=${report.summary.unresolvedRefCount}, shadowed=${report.summary.shadowedRefCount}, legacy=${report.summary.legacyResidueCount}.`, @@ -162,16 +162,10 @@ export function registerSecretsCli(program: Command) { fs.writeFileSync(opts.planOut, `${JSON.stringify(configured.plan, null, 2)}\n`, "utf8"); } if (opts.json) { - defaultRuntime.log( - JSON.stringify( - { - plan: configured.plan, - preflight: configured.preflight, - }, - null, - 2, - ), - ); + defaultRuntime.writeJson({ + plan: configured.plan, + preflight: configured.preflight, + }); } else { defaultRuntime.log( `Preflight: changed=${configured.preflight.changed}, files=${configured.preflight.changedFiles.length}, warnings=${configured.preflight.warningCount}.`, @@ -228,7 +222,7 @@ export function registerSecretsCli(program: Command) { allowExec: Boolean(opts.allowExec), }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } defaultRuntime.log( @@ -259,7 +253,7 @@ export function registerSecretsCli(program: Command) { allowExec: Boolean(opts.allowExec), }); if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } if (opts.dryRun) { diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index 586e5e0f114..a6180c7c1cc 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -86,14 +86,10 @@ export function registerSecurityCli(program: Command) { }); if (opts.json) { - defaultRuntime.log( - JSON.stringify( - fixResult - ? { fix: fixResult, report, secretDiagnostics } - : { ...report, secretDiagnostics }, - null, - 2, - ), + defaultRuntime.writeJson( + fixResult + ? { fix: fixResult, report, secretDiagnostics } + : { ...report, secretDiagnostics }, ); return; } diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 3c66a4b156b..034f1debf4e 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -71,7 +71,7 @@ export function registerSkillsCli(program: Command) { limit: opts.limit, }); if (opts.json) { - defaultRuntime.log(JSON.stringify({ results }, null, 2)); + defaultRuntime.writeJson({ results }); return; } if (results.length === 0) { diff --git a/src/cli/system-cli.ts b/src/cli/system-cli.ts index ae5b2033c01..64d75d65794 100644 --- a/src/cli/system-cli.ts +++ b/src/cli/system-cli.ts @@ -28,7 +28,7 @@ async function runSystemGatewayCommand( try { const result = await action(); if (opts.json || successText === undefined) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); } else { defaultRuntime.log(successText); } diff --git a/src/cli/update-cli/progress.ts b/src/cli/update-cli/progress.ts index 8d397a73d58..b2c193a1c35 100644 --- a/src/cli/update-cli/progress.ts +++ b/src/cli/update-cli/progress.ts @@ -138,7 +138,7 @@ type PrintResultOptions = UpdateCommandOptions & { export function printResult(result: UpdateRunResult, opts: PrintResultOptions): void { if (opts.json) { - defaultRuntime.log(JSON.stringify(result, null, 2)); + defaultRuntime.writeJson(result); return; } diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index 8266a1e5f21..d3d9365f5c5 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -70,22 +70,16 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise 0) { params.runtime.exit(1); } @@ -176,16 +176,13 @@ export async function agentsBindingsCommand( (binding) => !filterAgentId || normalizeAgentId(binding.agentId) === filterAgentId, ); if (opts.json) { - runtime.log( - JSON.stringify( - filtered.map((binding) => ({ - agentId: normalizeAgentId(binding.agentId), - match: binding.match, - description: describeBinding(binding), - })), - null, - 2, - ), + writeRuntimeJson( + runtime, + filtered.map((binding) => ({ + agentId: normalizeAgentId(binding.agentId), + match: binding.match, + description: describeBinding(binding), + })), ); return; } diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index 659d51f3220..13795bcf779 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -3,7 +3,7 @@ import { writeConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { createQuietRuntime, requireValidConfig } from "./agents.command-shared.js"; @@ -81,20 +81,14 @@ export async function agentsDeleteCommand( await moveToTrash(sessionsDir, quietRuntime); if (opts.json) { - runtime.log( - JSON.stringify( - { - agentId, - workspace: workspaceDir, - agentDir, - sessionsDir, - removedBindings: result.removedBindings, - removedAllow: result.removedAllow, - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + agentId, + workspace: workspaceDir, + agentDir, + sessionsDir, + removedBindings: result.removedBindings, + removedAllow: result.removedAllow, + }); } else { runtime.log(`Deleted agent: ${agentId}`); } diff --git a/src/commands/agents.commands.identity.ts b/src/commands/agents.commands.identity.ts index 3d205078484..3329efe25a0 100644 --- a/src/commands/agents.commands.identity.ts +++ b/src/commands/agents.commands.identity.ts @@ -7,7 +7,7 @@ import { writeConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import type { IdentityConfig } from "../config/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, shortenHomePath } from "../utils.js"; import { requireValidConfig } from "./agents.command-shared.js"; @@ -198,18 +198,12 @@ export async function agentsSetIdentityCommand( await writeConfigFile(nextConfig); if (opts.json) { - runtime.log( - JSON.stringify( - { - agentId, - identity: nextIdentity, - workspace: workspaceDir ?? null, - identityFile: identityFilePath ?? null, - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + agentId, + identity: nextIdentity, + workspace: workspaceDir ?? null, + identityFile: identityFilePath ?? null, + }); return; } diff --git a/src/commands/agents.commands.list.ts b/src/commands/agents.commands.list.ts index 5e7eec3da77..b18b4a4b8f4 100644 --- a/src/commands/agents.commands.list.ts +++ b/src/commands/agents.commands.list.ts @@ -2,7 +2,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { listRouteBindings } from "../config/bindings.js"; import type { AgentRouteBinding } from "../config/types.js"; import { normalizeAgentId } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { shortenHomePath } from "../utils.js"; import { describeBinding } from "./agents.bindings.js"; @@ -122,7 +122,7 @@ export async function agentsListCommand( } if (opts.json) { - runtime.log(JSON.stringify(summaries, null, 2)); + writeRuntimeJson(runtime, summaries); return; } diff --git a/src/commands/backup-verify.ts b/src/commands/backup-verify.ts index 0199c8de259..3b8fdef066f 100644 --- a/src/commands/backup-verify.ts +++ b/src/commands/backup-verify.ts @@ -1,6 +1,6 @@ import path from "node:path"; import * as tar from "tar"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; const WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE = /^[A-Za-z]:[\\/]/; @@ -319,6 +319,10 @@ export async function backupVerifyCommand( entryCount: rawEntries.length, }; - runtime.log(opts.json ? JSON.stringify(result, null, 2) : formatResult(result)); + if (opts.json) { + writeRuntimeJson(runtime, result); + } else { + runtime.log(formatResult(result)); + } return result; } diff --git a/src/commands/backup.ts b/src/commands/backup.ts index ab4397db0f3..4c3b798a7b3 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -4,7 +4,7 @@ import { type BackupCreateOptions, type BackupCreateResult, } from "../infra/backup-create.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { backupVerifyCommand } from "./backup-verify.js"; export type { BackupCreateOptions, BackupCreateResult } from "../infra/backup-create.js"; @@ -23,9 +23,10 @@ export async function backupCreateCommand( ); result.verified = true; } - const output = opts.json - ? JSON.stringify(result, null, 2) - : formatBackupCreateSummary(result).join("\n"); - runtime.log(output); + if (opts.json) { + writeRuntimeJson(runtime, result); + } else { + runtime.log(formatBackupCreateSummary(result).join("\n")); + } return result; } diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index d2165eb284d..1fc7e69beac 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -12,7 +12,7 @@ import type { } from "../../channels/plugins/types.js"; import { writeConfigFile, type OpenClawConfig } from "../../config/config.js"; import { danger } from "../../globals.js"; -import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; +import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; import { formatChannelAccountLabel, requireValidConfig } from "./shared.js"; @@ -266,7 +266,7 @@ export async function channelsCapabilitiesCommand( } if (opts.json) { - runtime.log(JSON.stringify({ channels: reports }, null, 2)); + writeRuntimeJson(runtime, { channels: reports }); return; } diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 06caefb3d98..e0c69809799 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -4,7 +4,7 @@ import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js"; import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js"; import { withProgress } from "../../cli/progress.js"; import { formatUsageReportLines, loadProviderUsageSummary } from "../../infra/provider-usage.js"; -import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; +import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { formatChannelAccountLabel, requireValidConfig } from "./shared.js"; @@ -126,7 +126,7 @@ export async function channelsListCommand( chat[plugin.id] = plugin.config.listAccountIds(cfg); } const payload = { chat, auth: authProfiles, ...(usage ? { usage } : {}) }; - runtime.log(JSON.stringify(payload, null, 2)); + writeRuntimeJson(runtime, payload); return; } diff --git a/src/commands/channels/logs.ts b/src/commands/channels/logs.ts index 2682f170e49..e3863b9289a 100644 --- a/src/commands/channels/logs.ts +++ b/src/commands/channels/logs.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { getResolvedLoggerSettings } from "../../logging.js"; import { parseLogLine } from "../../logging/parse-log-line.js"; -import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; +import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; export type ChannelsLogsOptions = { @@ -93,7 +93,7 @@ export async function channelsLogsCommand( const lines = filtered.slice(Math.max(0, filtered.length - limit)); if (opts.json) { - runtime.log(JSON.stringify({ file, channel, lines }, null, 2)); + writeRuntimeJson(runtime, { file, channel, lines }); return; } diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 59bd870c106..a3b3e66ab9e 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -5,7 +5,7 @@ import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targ import { loadConfig, writeConfigFile } from "../../config/config.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; export type ChannelsResolveOptions = { @@ -166,7 +166,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti } if (opts.json) { - runtime.log(JSON.stringify(results, null, 2)); + writeRuntimeJson(runtime, results); return; } diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 2cbdaf17726..058df802671 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -16,7 +16,7 @@ import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config import { callGateway } from "../../gateway/call.js"; import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; -import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; +import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { @@ -301,7 +301,7 @@ export async function channelsStatusCommand( }), ); if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); + writeRuntimeJson(runtime, payload); return; } runtime.log(formatGatewayChannelsStatusLines(payload).join("\n")); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index c338d7fe55b..ddde2006c84 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -3,7 +3,7 @@ import { readBestEffortConfig, resolveGatewayPort } from "../config/config.js"; import { probeGateway } from "../gateway/probe.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import { buildNetworkHints, @@ -264,61 +264,55 @@ export async function gatewayStatusCommand( } if (opts.json) { - runtime.log( - JSON.stringify( - { - ok, - degraded, - ts: Date.now(), - durationMs: Date.now() - startedAt, - timeoutMs: overallTimeoutMs, - primaryTargetId: primary?.target.id ?? null, - warnings, - network, - discovery: { - timeoutMs: discoveryTimeoutMs, - count: discovery.length, - beacons: discovery.map((b) => ({ - instanceName: b.instanceName, - displayName: b.displayName ?? null, - domain: b.domain ?? null, - host: b.host ?? null, - lanHost: b.lanHost ?? null, - tailnetDns: b.tailnetDns ?? null, - gatewayPort: b.gatewayPort ?? null, - sshPort: b.sshPort ?? null, - wsUrl: (() => { - const host = b.tailnetDns || b.lanHost || b.host; - const port = b.gatewayPort ?? 18789; - return host ? `ws://${host}:${port}` : null; - })(), - })), - }, - targets: probed.map((p) => ({ - id: p.target.id, - kind: p.target.kind, - url: p.target.url, - active: p.target.active, - tunnel: p.target.tunnel ?? null, - connect: { - ok: isProbeReachable(p.probe), - rpcOk: p.probe.ok, - scopeLimited: isScopeLimitedProbeFailure(p.probe), - latencyMs: p.probe.connectLatencyMs, - error: p.probe.error, - close: p.probe.close, - }, - self: p.self, - config: p.configSummary, - health: p.probe.health, - summary: p.probe.status, - presence: p.probe.presence, - })), + writeRuntimeJson(runtime, { + ok, + degraded, + ts: Date.now(), + durationMs: Date.now() - startedAt, + timeoutMs: overallTimeoutMs, + primaryTargetId: primary?.target.id ?? null, + warnings, + network, + discovery: { + timeoutMs: discoveryTimeoutMs, + count: discovery.length, + beacons: discovery.map((b) => ({ + instanceName: b.instanceName, + displayName: b.displayName ?? null, + domain: b.domain ?? null, + host: b.host ?? null, + lanHost: b.lanHost ?? null, + tailnetDns: b.tailnetDns ?? null, + gatewayPort: b.gatewayPort ?? null, + sshPort: b.sshPort ?? null, + wsUrl: (() => { + const host = b.tailnetDns || b.lanHost || b.host; + const port = b.gatewayPort ?? 18789; + return host ? `ws://${host}:${port}` : null; + })(), + })), + }, + targets: probed.map((p) => ({ + id: p.target.id, + kind: p.target.kind, + url: p.target.url, + active: p.target.active, + tunnel: p.target.tunnel ?? null, + connect: { + ok: isProbeReachable(p.probe), + rpcOk: p.probe.ok, + scopeLimited: isScopeLimitedProbeFailure(p.probe), + latencyMs: p.probe.connectLatencyMs, + error: p.probe.error, + close: p.probe.close, }, - null, - 2, - ), - ); + self: p.self, + config: p.configSummary, + health: p.probe.health, + summary: p.probe.status, + presence: p.probe.presence, + })), + }); if (!ok) { runtime.exit(1); } diff --git a/src/commands/health.ts b/src/commands/health.ts index 301cb55282e..b8953f5a7fd 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -17,7 +17,7 @@ import { } from "../infra/heartbeat-summary.js"; import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js"; import { normalizeAgentId } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { styleHealthChannelLine } from "../terminal/health-style.js"; import { isRich } from "../terminal/theme.js"; @@ -618,7 +618,7 @@ export async function healthCommand( const fatal = false; if (opts.json) { - runtime.log(JSON.stringify(summary, null, 2)); + writeRuntimeJson(runtime, summary); } else { const debugEnabled = isTruthyEnvValue(process.env.OPENCLAW_DEBUG_HEALTH); const rich = isRich(); diff --git a/src/commands/message.ts b/src/commands/message.ts index 52540e8916d..e83fe9291c2 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -10,7 +10,7 @@ import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; import { runMessageAction } from "../infra/outbound/message-action-runner.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { buildMessageCliJson, formatMessageCliText } from "./message-format.js"; @@ -80,7 +80,7 @@ export async function messageCommand( : await run(); if (json) { - runtime.log(JSON.stringify(buildMessageCliJson(result), null, 2)); + writeRuntimeJson(runtime, buildMessageCliJson(result)); return; } diff --git a/src/commands/models/aliases.ts b/src/commands/models/aliases.ts index 6fb1279b86d..c265b13fadf 100644 --- a/src/commands/models/aliases.ts +++ b/src/commands/models/aliases.ts @@ -1,5 +1,5 @@ import { logConfigUpdated } from "../../config/logging.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { loadModelsConfig } from "./load-config.js"; import { ensureFlagCompatibility, @@ -27,7 +27,7 @@ export async function modelsAliasesListCommand( ); if (opts.json) { - runtime.log(JSON.stringify({ aliases }, null, 2)); + writeRuntimeJson(runtime, { aliases }); return; } if (opts.plain) { diff --git a/src/commands/models/auth-order.ts b/src/commands/models/auth-order.ts index e8c374ecea1..7f90117b762 100644 --- a/src/commands/models/auth-order.ts +++ b/src/commands/models/auth-order.ts @@ -5,7 +5,7 @@ import { setAuthProfileOrder, } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { normalizeStringEntries } from "../../shared/string-normalization.js"; import { shortenHomePath } from "../../utils.js"; import { loadModelsConfig } from "./load-config.js"; @@ -54,19 +54,13 @@ export async function modelsAuthOrderGetCommand( const order = describeOrder(store, provider); if (opts.json) { - runtime.log( - JSON.stringify( - { - agentId, - agentDir, - provider, - authStorePath: shortenHomePath(`${agentDir}/auth-profiles.json`), - order: order.length > 0 ? order : null, - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + agentId, + agentDir, + provider, + authStorePath: shortenHomePath(`${agentDir}/auth-profiles.json`), + order: order.length > 0 ? order : null, + }); return; } diff --git a/src/commands/models/fallbacks-shared.ts b/src/commands/models/fallbacks-shared.ts index b7ffb79f222..9d49ad28217 100644 --- a/src/commands/models/fallbacks-shared.ts +++ b/src/commands/models/fallbacks-shared.ts @@ -3,7 +3,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import { resolveAgentModelFallbackValues, toAgentModelListLike } from "../../config/model-input.js"; import type { AgentModelEntryConfig } from "../../config/types.agent-defaults.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { loadModelsConfig } from "./load-config.js"; import { DEFAULT_PROVIDER, @@ -50,7 +50,7 @@ export async function listFallbacksCommand( const fallbacks = getFallbacks(cfg, params.key); if (opts.json) { - runtime.log(JSON.stringify({ fallbacks }, null, 2)); + writeRuntimeJson(runtime, { fallbacks }); return; } if (opts.plain) { diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index e17fcd2cc4b..99f4bf13256 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -36,7 +36,7 @@ import { type UsageProviderId, } from "../../infra/provider-usage.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { colorize, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; @@ -324,49 +324,43 @@ export async function modelsStatusCommand( })(); if (opts.json) { - runtime.log( - JSON.stringify( - { - configPath, - ...(agentId ? { agentId } : {}), - agentDir, - defaultModel: defaultLabel, - resolvedDefault: resolvedLabel, - fallbacks, - imageModel: imageModel || null, - imageFallbacks, - ...(agentId - ? { - modelConfig: { - defaultSource: agentModelPrimary ? "agent" : "defaults", - fallbacksSource: agentFallbacksOverride !== undefined ? "agent" : "defaults", - }, - } - : {}), - aliases, - allowed, - auth: { - storePath: resolveAuthStorePathForDisplay(agentDir), - shellEnvFallback: { - enabled: shellFallbackEnabled, - appliedKeys: applied, + writeRuntimeJson(runtime, { + configPath, + ...(agentId ? { agentId } : {}), + agentDir, + defaultModel: defaultLabel, + resolvedDefault: resolvedLabel, + fallbacks, + imageModel: imageModel || null, + imageFallbacks, + ...(agentId + ? { + modelConfig: { + defaultSource: agentModelPrimary ? "agent" : "defaults", + fallbacksSource: agentFallbacksOverride !== undefined ? "agent" : "defaults", }, - providersWithOAuth: providersWithOauth, - missingProvidersInUse, - providers: providerAuth, - unusableProfiles, - oauth: { - warnAfterMs: authHealth.warnAfterMs, - profiles: authHealth.profiles, - providers: authHealth.providers, - }, - probes: probeSummary, - }, + } + : {}), + aliases, + allowed, + auth: { + storePath: resolveAuthStorePathForDisplay(agentDir), + shellEnvFallback: { + enabled: shellFallbackEnabled, + appliedKeys: applied, }, - null, - 2, - ), - ); + providersWithOAuth: providersWithOauth, + missingProvidersInUse, + providers: providerAuth, + unusableProfiles, + oauth: { + warnAfterMs: authHealth.warnAfterMs, + profiles: authHealth.profiles, + providers: authHealth.providers, + }, + probes: probeSummary, + }, + }); if (opts.check) { runtime.exit(checkStatus); } diff --git a/src/commands/models/list.table.ts b/src/commands/models/list.table.ts index 3211ce57b15..f1d24c4701f 100644 --- a/src/commands/models/list.table.ts +++ b/src/commands/models/list.table.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { colorize, theme } from "../../terminal/theme.js"; import { formatTag, isRich, pad, truncate } from "./list.format.js"; import type { ModelRow } from "./list.types.js"; @@ -16,16 +16,10 @@ export function printModelTable( opts: { json?: boolean; plain?: boolean } = {}, ) { if (opts.json) { - runtime.log( - JSON.stringify( - { - count: rows.length, - models: rows, - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + count: rows.length, + models: rows, + }); return; } diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 39d7f61fba2..47d56517a9f 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -4,7 +4,7 @@ import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-s import { withProgressTotals } from "../../cli/progress.js"; import { logConfigUpdated } from "../../config/logging.js"; import { toAgentModelListLike } from "../../config/model-input.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { stylePromptHint, stylePromptMessage, @@ -217,7 +217,7 @@ export async function modelsScanCommand( ); printScanTable(sortScanResults(results), runtime); } else { - runtime.log(JSON.stringify(results, null, 2)); + writeRuntimeJson(runtime, results); } return; } @@ -328,20 +328,14 @@ export async function modelsScanCommand( }); if (opts.json) { - runtime.log( - JSON.stringify( - { - selected, - selectedImages, - setDefault: Boolean(opts.setDefault), - setImage: Boolean(opts.setImage), - results, - warnings: [], - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + selected, + selectedImages, + setDefault: Boolean(opts.setDefault), + setImage: Boolean(opts.setImage), + results, + warnings: [], + }); return; } diff --git a/src/commands/onboard-non-interactive/local/output.ts b/src/commands/onboard-non-interactive/local/output.ts index a91df06aee6..ef8af8e01ad 100644 --- a/src/commands/onboard-non-interactive/local/output.ts +++ b/src/commands/onboard-non-interactive/local/output.ts @@ -1,4 +1,4 @@ -import type { RuntimeEnv } from "../../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../../runtime.js"; import type { OnboardOptions } from "../../onboard-types.js"; export type GatewayHealthFailureDiagnostics = { @@ -41,24 +41,18 @@ export function logNonInteractiveOnboardingJson(params: { if (!params.opts.json) { return; } - params.runtime.log( - JSON.stringify( - { - ok: true, - mode: params.mode, - workspace: params.workspaceDir, - authChoice: params.authChoice, - gateway: params.gateway, - installDaemon: Boolean(params.installDaemon), - daemonInstall: params.daemonInstall, - daemonRuntime: params.daemonRuntime, - skipSkills: Boolean(params.skipSkills), - skipHealth: Boolean(params.skipHealth), - }, - null, - 2, - ), - ); + writeRuntimeJson(params.runtime, { + ok: true, + mode: params.mode, + workspace: params.workspaceDir, + authChoice: params.authChoice, + gateway: params.gateway, + installDaemon: Boolean(params.installDaemon), + daemonInstall: params.daemonInstall, + daemonRuntime: params.daemonRuntime, + skipSkills: Boolean(params.skipSkills), + skipHealth: Boolean(params.skipHealth), + }); } function formatGatewayRuntimeSummary( @@ -109,25 +103,19 @@ export function logNonInteractiveOnboardingFailure(params: { const gatewayRuntime = formatGatewayRuntimeSummary(params.diagnostics); if (params.opts.json) { - params.runtime.error( - JSON.stringify( - { - ok: false, - mode: params.mode, - phase: params.phase, - message: params.message, - detail: params.detail, - gateway: params.gateway, - installDaemon: Boolean(params.installDaemon), - daemonInstall: params.daemonInstall, - daemonRuntime: params.daemonRuntime, - diagnostics: params.diagnostics, - hints: hints.length > 0 ? hints : undefined, - }, - null, - 2, - ), - ); + writeRuntimeJson(params.runtime, { + ok: false, + mode: params.mode, + phase: params.phase, + message: params.message, + detail: params.detail, + gateway: params.gateway, + installDaemon: Boolean(params.installDaemon), + daemonInstall: params.daemonInstall, + daemonRuntime: params.daemonRuntime, + diagnostics: params.diagnostics, + hints: hints.length > 0 ? hints : undefined, + }); return; } diff --git a/src/commands/onboard-non-interactive/remote.ts b/src/commands/onboard-non-interactive/remote.ts index f58488b89e5..aac22302cbc 100644 --- a/src/commands/onboard-non-interactive/remote.ts +++ b/src/commands/onboard-non-interactive/remote.ts @@ -2,7 +2,7 @@ import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import { writeConfigFile } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; -import type { RuntimeEnv } from "../../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { applyWizardMetadata } from "../onboard-helpers.js"; import type { OnboardOptions } from "../onboard-types.js"; @@ -42,7 +42,7 @@ export async function runNonInteractiveRemoteSetup(params: { auth: opts.remoteToken ? "token" : "none", }; if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); + writeRuntimeJson(runtime, payload); } else { runtime.log(`Remote gateway: ${remoteUrl}`); runtime.log(`Auth: ${payload.auth}`); diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index f91ab07c819..75e0e9a644f 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -19,7 +19,7 @@ import { parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; @@ -262,7 +262,7 @@ export async function sandboxExplainCommand( } as const; if (opts.json) { - runtime.log(`${JSON.stringify(payload, null, 2)}\n`); + writeRuntimeJson(runtime, payload); return; } diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index d6b494fc5aa..e2f9df194aa 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -7,7 +7,7 @@ import { type SandboxBrowserInfo, type SandboxContainerInfo, } from "../agents/sandbox.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { displayBrowsers, displayContainers, @@ -48,7 +48,7 @@ export async function sandboxListCommand( const browsers = opts.browser ? await listSandboxBrowsers().catch(() => []) : []; if (opts.json) { - runtime.log(JSON.stringify({ containers, browsers }, null, 2)); + writeRuntimeJson(runtime, { containers, browsers }); return; } diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index a0b1d072386..a5ef7f7921e 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -12,7 +12,7 @@ import { type SessionEntry, type SessionMaintenanceApplyReport, } from "../config/sessions.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { isRich, theme } from "../terminal/theme.js"; import { resolveSessionStoreTargetsOrExit, @@ -325,21 +325,15 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti if (opts.dryRun) { if (opts.json) { if (previewResults.length === 1) { - runtime.log(JSON.stringify(previewResults[0]?.summary ?? {}, null, 2)); + writeRuntimeJson(runtime, previewResults[0]?.summary ?? {}); return; } - runtime.log( - JSON.stringify( - { - allAgents: true, - mode, - dryRun: true, - stores: previewResults.map((result) => result.summary), - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + allAgents: true, + mode, + dryRun: true, + stores: previewResults.map((result) => result.summary), + }); return; } @@ -436,21 +430,15 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti if (opts.json) { if (appliedSummaries.length === 1) { - runtime.log(JSON.stringify(appliedSummaries[0] ?? {}, null, 2)); + writeRuntimeJson(runtime, appliedSummaries[0] ?? {}); return; } - runtime.log( - JSON.stringify( - { - allAgents: true, - mode, - dryRun: false, - stores: appliedSummaries, - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + allAgents: true, + mode, + dryRun: false, + stores: appliedSummaries, + }); return; } diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index b72dfbe985a..3d988d45518 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -5,7 +5,7 @@ import { loadSessionStore, resolveFreshSessionTotalTokens } from "../config/sess import { classifySessionKey } from "../gateway/session-utils.js"; import { info } from "../globals.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { isRich, theme } from "../terminal/theme.js"; import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js"; import { @@ -142,36 +142,30 @@ export async function sessionsCommand( if (opts.json) { const multi = targets.length > 1; const aggregate = aggregateAgents || multi; - runtime.log( - JSON.stringify( - { - path: aggregate ? null : (targets[0]?.storePath ?? null), - stores: aggregate - ? targets.map((target) => ({ - agentId: target.agentId, - path: target.storePath, - })) - : undefined, - allAgents: aggregateAgents ? true : undefined, - count: rows.length, - activeMinutes: activeMinutes ?? null, - sessions: rows.map((r) => { - const model = resolveSessionDisplayModel(cfg, r, displayDefaults); - return { - ...r, - totalTokens: resolveFreshSessionTotalTokens(r) ?? null, - totalTokensFresh: - typeof r.totalTokens === "number" ? r.totalTokensFresh !== false : false, - contextTokens: - r.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null, - model, - }; - }), - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + path: aggregate ? null : (targets[0]?.storePath ?? null), + stores: aggregate + ? targets.map((target) => ({ + agentId: target.agentId, + path: target.storePath, + })) + : undefined, + allAgents: aggregateAgents ? true : undefined, + count: rows.length, + activeMinutes: activeMinutes ?? null, + sessions: rows.map((r) => { + const model = resolveSessionDisplayModel(cfg, r, displayDefaults); + return { + ...r, + totalTokens: resolveFreshSessionTotalTokens(r) ?? null, + totalTokensFresh: + typeof r.totalTokens === "number" ? r.totalTokensFresh !== false : false, + contextTokens: + r.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null, + model, + }; + }), + }); return; } diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts index 2a004f4a231..8dff1b7bbfb 100644 --- a/src/commands/status-json.ts +++ b/src/commands/status-json.ts @@ -1,6 +1,6 @@ import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; import { scanStatusJsonFast } from "./status.scan.fast-json.js"; @@ -83,36 +83,30 @@ export async function statusJsonCommand( gitBranch: scan.update.git?.branch ?? null, }); - runtime.log( - JSON.stringify( - { - ...scan.summary, - os: scan.osSummary, - update: scan.update, - updateChannel: channelInfo.channel, - updateChannelSource: channelInfo.source, - memory: scan.memory, - memoryPlugin: scan.memoryPlugin, - gateway: { - mode: scan.gatewayMode, - url: scan.gatewayConnection.url, - urlSource: scan.gatewayConnection.urlSource, - misconfigured: scan.remoteUrlMissing, - reachable: scan.gatewayReachable, - connectLatencyMs: scan.gatewayProbe?.connectLatencyMs ?? null, - self: scan.gatewaySelf, - error: scan.gatewayProbe?.error ?? null, - authWarning: scan.gatewayProbeAuthWarning ?? null, - }, - gatewayService: daemon, - nodeService: nodeDaemon, - agents: scan.agentStatus, - secretDiagnostics: scan.secretDiagnostics, - ...(securityAudit ? { securityAudit } : {}), - ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + ...scan.summary, + os: scan.osSummary, + update: scan.update, + updateChannel: channelInfo.channel, + updateChannelSource: channelInfo.source, + memory: scan.memory, + memoryPlugin: scan.memoryPlugin, + gateway: { + mode: scan.gatewayMode, + url: scan.gatewayConnection.url, + urlSource: scan.gatewayConnection.urlSource, + misconfigured: scan.remoteUrlMissing, + reachable: scan.gatewayReachable, + connectLatencyMs: scan.gatewayProbe?.connectLatencyMs ?? null, + self: scan.gatewaySelf, + error: scan.gatewayProbe?.error ?? null, + authWarning: scan.gatewayProbeAuthWarning ?? null, + }, + gatewayService: daemon, + nodeService: nodeDaemon, + agents: scan.agentStatus, + secretDiagnostics: scan.secretDiagnostics, + ...(securityAudit ? { securityAudit } : {}), + ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), + }); } diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 363828ed550..7357882ac1b 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -17,7 +17,7 @@ import { formatPluginCompatibilityNotice, summarizePluginCompatibility, } from "../plugins/status.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js"; @@ -196,42 +196,36 @@ export async function statusCommand( getDaemonStatusSummary(), getNodeDaemonStatusSummary(), ]); - runtime.log( - JSON.stringify( - { - ...summary, - os: osSummary, - update, - updateChannel: channelInfo.channel, - updateChannelSource: channelInfo.source, - memory, - memoryPlugin, - gateway: { - mode: gatewayMode, - url: gatewayConnection.url, - urlSource: gatewayConnection.urlSource, - misconfigured: remoteUrlMissing, - reachable: gatewayReachable, - connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null, - self: gatewaySelf, - error: gatewayProbe?.error ?? null, - authWarning: gatewayProbeAuthWarning ?? null, - }, - gatewayService: daemon, - nodeService: nodeDaemon, - agents: agentStatus, - securityAudit, - secretDiagnostics, - pluginCompatibility: { - count: pluginCompatibility.length, - warnings: pluginCompatibility, - }, - ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), - }, - null, - 2, - ), - ); + writeRuntimeJson(runtime, { + ...summary, + os: osSummary, + update, + updateChannel: channelInfo.channel, + updateChannelSource: channelInfo.source, + memory, + memoryPlugin, + gateway: { + mode: gatewayMode, + url: gatewayConnection.url, + urlSource: gatewayConnection.urlSource, + misconfigured: remoteUrlMissing, + reachable: gatewayReachable, + connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null, + self: gatewaySelf, + error: gatewayProbe?.error ?? null, + authWarning: gatewayProbeAuthWarning ?? null, + }, + gatewayService: daemon, + nodeService: nodeDaemon, + agents: agentStatus, + securityAudit, + secretDiagnostics, + pluginCompatibility: { + count: pluginCompatibility.length, + warnings: pluginCompatibility, + }, + ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), + }); return; } diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 01dd6aa17d3..97ad3d8d473 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -107,7 +107,7 @@ function createManager(options?: { const log = createSubsystemLogger("gateway/server-channels-test"); const channelLogs = { discord: log } as Record; const runtime = runtimeForLogger(log); - const channelRuntimeEnvs = { discord: runtime } as Record; + const channelRuntimeEnvs = { discord: runtime } as unknown as Record; return createChannelManager({ loadConfig: () => options?.loadConfig?.() ?? {}, channelLogs, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 7a4c18b6593..582c285a9c9 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -575,7 +575,7 @@ export async function startGatewayServer( ) as Record>; const channelRuntimeEnvs = Object.fromEntries( Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]), - ) as Record; + ) as unknown as Record; const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []); const gatewayMethods = Array.from(new Set([...baseGatewayMethods, ...channelMethods])); let pluginServices: PluginServicesHandle | null = null; diff --git a/src/hooks/gmail-ops.ts b/src/hooks/gmail-ops.ts index a7ff69e351a..d30143ba127 100644 --- a/src/hooks/gmail-ops.ts +++ b/src/hooks/gmail-ops.ts @@ -254,7 +254,7 @@ export async function runGmailSetup(opts: GmailSetupOptions) { }; if (opts.json) { - defaultRuntime.log(JSON.stringify(summary, null, 2)); + defaultRuntime.writeJson(summary); return; } diff --git a/src/logging/console-capture.test.ts b/src/logging/console-capture.test.ts index cc5d6a6638f..cb22349f6da 100644 --- a/src/logging/console-capture.test.ts +++ b/src/logging/console-capture.test.ts @@ -9,6 +9,7 @@ import { setConsoleTimestampPrefix, setLoggerOverride, } from "../logging.js"; +import { defaultRuntime } from "../runtime.js"; import { loggingState } from "./state.js"; import { captureConsoleSnapshot, @@ -101,7 +102,7 @@ describe("enableConsoleCapture", () => { expect(warn).toHaveBeenCalledWith("12:34:56 [exec] hello"); }); - it("leaves JSON output unchanged when timestamp prefix is enabled", () => { + it("prefixes JSON console output when timestamp prefix is enabled", () => { setLoggerOverride({ level: "info", file: tempLogPath() }); const log = vi.fn(); console.log = log; @@ -109,7 +110,24 @@ describe("enableConsoleCapture", () => { enableConsoleCapture(); const payload = JSON.stringify({ ok: true }); console.log(payload); - expect(log).toHaveBeenCalledWith(payload); + expect(log).toHaveBeenCalledTimes(1); + const firstArg = String(log.mock.calls[0]?.[0] ?? ""); + expect(firstArg).toMatch(/^(?:\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}T)/); + expect(firstArg.endsWith(` ${payload}`)).toBe(true); + }); + + it("keeps diagnostics on stderr while runtime JSON stays on stdout", () => { + setLoggerOverride({ level: "info", file: tempLogPath() }); + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + routeLogsToStderr(); + enableConsoleCapture(); + + console.log("diag"); + defaultRuntime.writeJson({ ok: true }); + + expect(stderrWrite).toHaveBeenCalledWith("diag\n"); + expect(stdoutWrite).toHaveBeenCalledWith('{\n "ok": true\n}\n'); }); it.each([ diff --git a/src/logging/console.ts b/src/logging/console.ts index 3d787a1b9fc..8f5ba3b75c6 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -189,19 +189,6 @@ function hasTimestampPrefix(value: string): boolean { ); } -function isJsonPayload(value: string): boolean { - const trimmed = value.trim(); - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { - return false; - } - try { - JSON.parse(trimmed); - return true; - } catch { - return false; - } -} - /** * Route console.* calls through file logging while still emitting to stdout/stderr. * This keeps user-facing output unchanged but guarantees every console call is captured in log files. @@ -262,10 +249,7 @@ export function enableConsoleCapture(): void { } const trimmed = stripAnsi(formatted).trimStart(); const shouldPrefixTimestamp = - loggingState.consoleTimestampPrefix && - trimmed.length > 0 && - !hasTimestampPrefix(trimmed) && - !isJsonPayload(trimmed); + loggingState.consoleTimestampPrefix && trimmed.length > 0 && !hasTimestampPrefix(trimmed); const timestamp = shouldPrefixTimestamp ? formatConsoleTimestamp(getConsoleSettings().style) : ""; @@ -288,9 +272,8 @@ export function enableConsoleCapture(): void { } catch { // never block console output on logging failures } - if (loggingState.forceConsoleToStderr && !isJsonPayload(trimmed)) { - // In --json mode, route diagnostic logs to stderr but let JSON - // payloads (the actual command output) through to stdout via orig(). + if (loggingState.forceConsoleToStderr) { + // In --json mode, all console.* writes are diagnostics and should stay off stdout. try { const line = timestamp ? `${timestamp} ${formatted}` : formatted; process.stderr.write(`${line}\n`); diff --git a/src/logging/subsystem.ts b/src/logging/subsystem.ts index 5c6ce58a43d..fec90dc1f9f 100644 --- a/src/logging/subsystem.ts +++ b/src/logging/subsystem.ts @@ -1,7 +1,7 @@ import { Chalk } from "chalk"; import type { Logger as TsLogger } from "tslog"; import { isVerbose } from "../globals.js"; -import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { defaultRuntime, type OutputRuntimeEnv, type RuntimeEnv } from "../runtime.js"; import { clearActiveProgressLine } from "../terminal/progress-line.js"; import { formatConsoleTimestamp, @@ -404,7 +404,7 @@ export function createSubsystemLogger(subsystem: string): SubsystemLogger { export function runtimeForLogger( logger: SubsystemLogger, exit: RuntimeEnv["exit"] = defaultRuntime.exit, -): RuntimeEnv { +): OutputRuntimeEnv { const formatArgs = (...args: unknown[]) => args .map((arg) => formatRuntimeArg(arg)) @@ -413,6 +413,10 @@ export function runtimeForLogger( return { log: (...args: unknown[]) => logger.info(formatArgs(...args)), error: (...args: unknown[]) => logger.error(formatArgs(...args)), + writeStdout: (value: string) => logger.info(value), + writeJson: (value: unknown, space = 2) => { + logger.info(JSON.stringify(value, null, space > 0 ? space : undefined)); + }, exit, }; } @@ -420,6 +424,6 @@ export function runtimeForLogger( export function createSubsystemRuntime( subsystem: string, exit: RuntimeEnv["exit"] = defaultRuntime.exit, -): RuntimeEnv { +): OutputRuntimeEnv { return runtimeForLogger(createSubsystemLogger(subsystem), exit); } diff --git a/src/plugin-sdk/runtime.test.ts b/src/plugin-sdk/runtime.test.ts index 0dedb79e8e1..33be75b6fa6 100644 --- a/src/plugin-sdk/runtime.test.ts +++ b/src/plugin-sdk/runtime.test.ts @@ -32,8 +32,12 @@ describe("resolveRuntimeEnv", () => { const resolved = resolveRuntimeEnv({ logger }); resolved.log?.("hello %s", "world"); resolved.error?.("bad %d", 7); + resolved.writeStdout("plain"); + resolved.writeJson({ ok: true }); expect(logger.info).toHaveBeenCalledWith("hello world"); expect(logger.error).toHaveBeenCalledWith("bad 7"); + expect(logger.info).toHaveBeenCalledWith("plain"); + expect(logger.info).toHaveBeenCalledWith('{\n "ok": true\n}'); }); }); diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index cec54a53e08..2406eb76785 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -1,6 +1,6 @@ import { format } from "node:util"; -import type { RuntimeEnv } from "../runtime.js"; -export type { RuntimeEnv } from "../runtime.js"; +import type { OutputRuntimeEnv, RuntimeEnv } from "../runtime.js"; +export type { OutputRuntimeEnv, RuntimeEnv } from "../runtime.js"; export { createNonExitingRuntime, defaultRuntime } from "../runtime.js"; export { danger, @@ -29,7 +29,7 @@ type LoggerLike = { export function createLoggerBackedRuntime(params: { logger: LoggerLike; exitError?: (code: number) => Error; -}): RuntimeEnv { +}): OutputRuntimeEnv { return { log: (...args) => { params.logger.info(format(...args)); @@ -37,6 +37,12 @@ export function createLoggerBackedRuntime(params: { error: (...args) => { params.logger.error(format(...args)); }, + writeStdout: (value) => { + params.logger.info(value); + }, + writeJson: (value, space = 2) => { + params.logger.info(JSON.stringify(value, null, space > 0 ? space : undefined)); + }, exit: (code: number): never => { throw params.exitError?.(code) ?? new Error(`exit ${code}`); }, @@ -44,22 +50,48 @@ export function createLoggerBackedRuntime(params: { } /** Reuse an existing runtime when present, otherwise synthesize one from the provided logger. */ +export function resolveRuntimeEnv(params: { + runtime: RuntimeEnv; + logger: LoggerLike; + exitError?: (code: number) => Error; +}): RuntimeEnv; +export function resolveRuntimeEnv(params: { + runtime?: undefined; + logger: LoggerLike; + exitError?: (code: number) => Error; +}): OutputRuntimeEnv; export function resolveRuntimeEnv(params: { runtime?: RuntimeEnv; logger: LoggerLike; exitError?: (code: number) => Error; -}): RuntimeEnv { +}): RuntimeEnv | OutputRuntimeEnv { return params.runtime ?? createLoggerBackedRuntime(params); } /** Resolve a runtime that treats exit requests as unsupported errors instead of process termination. */ +export function resolveRuntimeEnvWithUnavailableExit(params: { + runtime: RuntimeEnv; + logger: LoggerLike; + unavailableMessage?: string; +}): RuntimeEnv; +export function resolveRuntimeEnvWithUnavailableExit(params: { + runtime?: undefined; + logger: LoggerLike; + unavailableMessage?: string; +}): OutputRuntimeEnv; export function resolveRuntimeEnvWithUnavailableExit(params: { runtime?: RuntimeEnv; logger: LoggerLike; unavailableMessage?: string; -}): RuntimeEnv { +}): RuntimeEnv | OutputRuntimeEnv { + if (params.runtime) { + return resolveRuntimeEnv({ + runtime: params.runtime, + logger: params.logger, + exitError: () => new Error(params.unavailableMessage ?? "Runtime exit not available"), + }); + } return resolveRuntimeEnv({ - runtime: params.runtime, logger: params.logger, exitError: () => new Error(params.unavailableMessage ?? "Runtime exit not available"), }); diff --git a/src/plugin-sdk/testing.ts b/src/plugin-sdk/testing.ts index 94c8d6d9ff6..5d014d104b0 100644 --- a/src/plugin-sdk/testing.ts +++ b/src/plugin-sdk/testing.ts @@ -9,7 +9,7 @@ export { removeAckReactionAfterReply, shouldAckReaction } from "../channels/ack- export type { ChannelAccountSnapshot, ChannelGatewayContext } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; -export type { RuntimeEnv } from "../runtime.js"; +export type { OutputRuntimeEnv, RuntimeEnv } from "../runtime.js"; export type { MockFn } from "../test-utils/vitest-mock-fn.js"; /** Create a tiny Windows `.cmd` shim fixture for plugin tests that spawn CLIs. */ diff --git a/src/runtime.ts b/src/runtime.ts index dcb1b305e6d..0b6b16bd41d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -7,6 +7,11 @@ export type RuntimeEnv = { exit: (code: number) => void; }; +export type OutputRuntimeEnv = RuntimeEnv & { + writeStdout: (value: string) => void; + writeJson: (value: unknown, space?: number) => void; +}; + function shouldEmitRuntimeLog(env: NodeJS.ProcessEnv = process.env): boolean { if (env.VITEST !== "true") { return true; @@ -18,7 +23,49 @@ function shouldEmitRuntimeLog(env: NodeJS.ProcessEnv = process.env): boolean { return typeof maybeMockedLog.mock === "object"; } -function createRuntimeIo(): Pick { +function shouldEmitRuntimeStdout(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.VITEST !== "true") { + return true; + } + if (env.OPENCLAW_TEST_RUNTIME_LOG === "1") { + return true; + } + const stdout = process.stdout as NodeJS.WriteStream & { + write: { + mock?: unknown; + }; + }; + return typeof stdout.write.mock === "object"; +} + +function isPipeClosedError(err: unknown): boolean { + const code = (err as { code?: string })?.code; + return code === "EPIPE" || code === "EIO"; +} + +function hasRuntimeOutputWriter( + runtime: RuntimeEnv | OutputRuntimeEnv, +): runtime is OutputRuntimeEnv { + return typeof (runtime as Partial).writeStdout === "function"; +} + +function writeStdout(value: string): void { + if (!shouldEmitRuntimeStdout()) { + return; + } + clearActiveProgressLine(); + const line = value.endsWith("\n") ? value : `${value}\n`; + try { + process.stdout.write(line); + } catch (err) { + if (isPipeClosedError(err)) { + return; + } + throw err; + } +} + +function createRuntimeIo(): Pick { return { log: (...args: Parameters) => { if (!shouldEmitRuntimeLog()) { @@ -31,10 +78,14 @@ function createRuntimeIo(): Pick { clearActiveProgressLine(); console.error(...args); }, + writeStdout, + writeJson: (value: unknown, space = 2) => { + writeStdout(JSON.stringify(value, null, space > 0 ? space : undefined)); + }, }; } -export const defaultRuntime: RuntimeEnv = { +export const defaultRuntime: OutputRuntimeEnv = { ...createRuntimeIo(), exit: (code) => { restoreTerminalState("runtime exit", { resumeStdinIfPaused: false }); @@ -43,7 +94,7 @@ export const defaultRuntime: RuntimeEnv = { }, }; -export function createNonExitingRuntime(): RuntimeEnv { +export function createNonExitingRuntime(): OutputRuntimeEnv { return { ...createRuntimeIo(), exit: (code: number) => { @@ -51,3 +102,23 @@ export function createNonExitingRuntime(): RuntimeEnv { }, }; } + +export function writeRuntimeStdout(runtime: RuntimeEnv | OutputRuntimeEnv, value: string): void { + if (hasRuntimeOutputWriter(runtime)) { + runtime.writeStdout(value); + return; + } + runtime.log(value); +} + +export function writeRuntimeJson( + runtime: RuntimeEnv | OutputRuntimeEnv, + value: unknown, + space = 2, +): void { + if (hasRuntimeOutputWriter(runtime)) { + runtime.writeJson(value, space); + return; + } + runtime.log(JSON.stringify(value, null, space > 0 ? space : undefined)); +} diff --git a/test/helpers/extensions/runtime-env.ts b/test/helpers/extensions/runtime-env.ts index 2ad7f32a718..6486c18c0fe 100644 --- a/test/helpers/extensions/runtime-env.ts +++ b/test/helpers/extensions/runtime-env.ts @@ -1,13 +1,15 @@ -import type { RuntimeEnv } from "openclaw/plugin-sdk/testing"; +import type { OutputRuntimeEnv } from "openclaw/plugin-sdk/testing"; import { vi } from "vitest"; -export function createRuntimeEnv(options?: { +export function createRuntimeEnv(options?: { throwOnExit?: boolean; -}): RuntimeEnv { +}): OutputRuntimeEnv { const throwOnExit = options?.throwOnExit ?? true; return { log: vi.fn(), error: vi.fn(), + writeStdout: vi.fn(), + writeJson: vi.fn(), exit: throwOnExit ? vi.fn((code: number): never => { throw new Error(`exit ${code}`); @@ -20,7 +22,7 @@ export function createTypedRuntimeEnv(options?: { throwOnExit?: boolea return createRuntimeEnv(options) as TRuntime; } -export function createNonExitingRuntimeEnv(): RuntimeEnv { +export function createNonExitingRuntimeEnv(): OutputRuntimeEnv { return createRuntimeEnv({ throwOnExit: false }); }