diff --git a/docs/cli/index.md b/docs/cli/index.md index 5198177e76c6..3a9dd6e777a8 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -65,6 +65,26 @@ state directories and config paths remain unchanged. report to return. - Long-running commands show a progress indicator (OSC 9;4 when supported). +### JSON failures + +Successful JSON payloads remain command-specific. When a command in JSON output +mode fails, it exits nonzero and writes one JSON document to stdout with this +envelope: + +```json +{ + "ok": false, + "error": { + "type": "cli_error", + "message": "Description of the failure" + } +} +``` + +A command may add domain-specific fields, such as per-item results, beside this +envelope. Failure messages are sanitized. Human-readable diagnostics may also be +written to stderr, so scripts should parse stdout and still check the exit status. + ## Color palette OpenClaw uses a lobster palette for CLI output: diff --git a/src/cli/cli-utils.test.ts b/src/cli/cli-utils.test.ts index 15728d481ec5..d226a1bddc8c 100644 --- a/src/cli/cli-utils.test.ts +++ b/src/cli/cli-utils.test.ts @@ -4,6 +4,10 @@ import { describe, expect, it, vi } from "vitest"; import { defaultRuntime } from "../runtime.js"; import { runCommandWithRuntime } from "./cli-utils.js"; import { registerDnsCli } from "./dns-cli.js"; +import { + applyResolvedCommandOutputMode, + withConsoleLogsRoutedToStderrForJson, +} from "./json-output-mode.js"; import { parseByteSize } from "./parse-bytes.js"; import { parseDurationMs } from "./parse-duration.js"; import { @@ -61,6 +65,27 @@ describe("runCommandWithRuntime", () => { expect(messages[0]).toContain("UND_ERR_INVALID_ARG"); expect(exits).toEqual([1]); }); + + it("bubbles JSON-mode failures to the process-level owner", async () => { + const originalArgv = process.argv; + const runtime = { error: vi.fn(), exit: vi.fn() }; + process.argv = ["node", "openclaw", "backup", "verify", "missing.tgz", "--json"]; + try { + await withConsoleLogsRoutedToStderrForJson(process.argv, async () => { + applyResolvedCommandOutputMode(true); + await expect( + runCommandWithRuntime(runtime, async () => { + throw new Error("archive missing"); + }), + ).rejects.toThrow("archive missing"); + }); + } finally { + process.argv = originalArgv; + } + + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); }); describe("shouldSkipRespawnForArgv", () => { diff --git a/src/cli/cli-utils.ts b/src/cli/cli-utils.ts index 407ce57840c8..92d15047cced 100644 --- a/src/cli/cli-utils.ts +++ b/src/cli/cli-utils.ts @@ -1,6 +1,7 @@ // Shared CLI execution wrappers and inherited Commander option lookup. import type { Command } from "commander"; import { formatErrorMessage } from "../infra/errors.js"; +import { isJsonOutputModeActive } from "./json-output-mode.js"; export { formatErrorMessage }; @@ -40,6 +41,9 @@ export async function runCommandWithRuntime( try { await action(); } catch (err) { + if (isJsonOutputModeActive(process.argv)) { + throw err; + } if (onError) { onError(err); return; diff --git a/src/cli/config-cli-validation.ts b/src/cli/config-cli-validation.ts index 9a8f684aebaa..0fc88cbade6a 100644 --- a/src/cli/config-cli-validation.ts +++ b/src/cli/config-cli-validation.ts @@ -31,6 +31,7 @@ import { formatCliCommand } from "./command-format.js"; import type { ConfigSetOperation } from "./config-cli-input.js"; import { formatPluginPackagingRuntimeOutputRecoveryHint } from "./config-recovery-hints.js"; import type { ConfigSetDryRunError } from "./config-set-dryrun.js"; +import { formatCliJsonFailure } from "./failure-output.js"; function formatInvalidConfigRepairHint( snapshot: Pick, @@ -54,7 +55,7 @@ export async function loadValidConfig( } if (options.json) { writeRuntimeJson(runtime, { - error: `OpenClaw config is invalid: ${shortenHomePath(snapshot.path)}`, + ...formatCliJsonFailure(`OpenClaw config is invalid: ${shortenHomePath(snapshot.path)}`), issues: normalizeConfigIssues(snapshot.issues), }); runtime.exit(1); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index e09817de9271..120a1dd5f91e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1355,8 +1355,11 @@ describe("config cli", () => { ).rejects.toThrow(ExitError); expect(mockError).not.toHaveBeenCalled(); - const payload = parseLastLogPayload() as { error: string }; - expect(payload.error).toBe("Config path not found: nonexistent.path"); + const payload = parseLastLogPayload() as { error: { type: string; message: string } }; + expect(payload.error).toEqual({ + type: "cli_error", + message: "Config path not found: nonexistent.path", + }); }); it.each([ @@ -1386,7 +1389,11 @@ describe("config cli", () => { expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); expect(mockError).not.toHaveBeenCalled(); expect(parseLastLogPayload()).toMatchObject({ - error: expect.stringContaining(testCase.error), + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining(testCase.error), + }, }); }, ); @@ -1405,7 +1412,11 @@ describe("config cli", () => { expect(mockReadConfigFileSnapshot).toHaveBeenCalledWith({ observe: false }); expect(mockError).not.toHaveBeenCalled(); expect(parseLastLogPayload()).toMatchObject({ - error: expect.stringContaining("OpenClaw config is invalid"), + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining("OpenClaw config is invalid"), + }, issues: [{ path: "gateway.bind", message: "Invalid enum value" }], }); }); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 18bed6560346..207ded381225 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -54,6 +54,7 @@ import { type ConfigSetOptions, } from "./config-set-input.js"; import { resolveConfigSetMode } from "./config-set-parser.js"; +import { formatCliJsonFailure } from "./failure-output.js"; import { setCommandJsonMode } from "./program/json-mode.js"; export { parseConfigSetPath } from "./config-cli-path.js"; @@ -155,7 +156,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime const res = getAtPath(redactConfigObject(snapshot.config), parsedPath); if (!res.found) { if (opts.json) { - writeRuntimeJson(runtime, { error: `Config path not found: ${opts.path}` }); + writeRuntimeJson(runtime, formatCliJsonFailure(`Config path not found: ${opts.path}`)); runtime.exit(1); return; } @@ -183,7 +184,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime throw err; } if (opts.json) { - writeRuntimeJson(runtime, { error: formatErrorMessage(err) }); + writeRuntimeJson(runtime, formatCliJsonFailure(err)); runtime.exit(1); return; } @@ -317,7 +318,11 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } const shortPath = shortenHomePath(outputPath); if (!snapshot.exists) { if (opts.json) { - writeRuntimeJson(runtime, { valid: false, path: outputPath, error: "file not found" }, 0); + writeRuntimeJson( + runtime, + { ...formatCliJsonFailure("file not found"), valid: false, path: outputPath }, + 0, + ); } else { runtime.error(danger(`Config file not found: ${shortPath}`)); runtime.error( @@ -330,7 +335,12 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } if (!snapshot.valid) { const issues = normalizeConfigIssues(snapshot.issues); if (opts.json) { - writeRuntimeJson(runtime, { valid: false, path: outputPath, issues }); + writeRuntimeJson(runtime, { + ...formatCliJsonFailure(`OpenClaw config is invalid: ${shortPath}`), + valid: false, + path: outputPath, + issues, + }); } else { runtime.error(danger(`OpenClaw config is invalid: ${shortPath}`)); for (const line of renderConfigValidationIssueLines(snapshot, danger("×"))) { @@ -361,7 +371,7 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } if (opts.json) { writeRuntimeJson( runtime, - { valid: false, path: outputPath, error: formatErrorMessage(err) }, + { ...formatCliJsonFailure(err), valid: false, path: outputPath }, 0, ); } else { diff --git a/src/cli/cron-cli/cron-pagination.gateway.test.ts b/src/cli/cron-cli/cron-pagination.gateway.test.ts index 50e01c2ecc56..261c41c2ce08 100644 --- a/src/cli/cron-cli/cron-pagination.gateway.test.ts +++ b/src/cli/cron-cli/cron-pagination.gateway.test.ts @@ -6,6 +6,7 @@ import { createMockCronStateForJobs } from "../../cron/service.test-harness.js"; import { listPage } from "../../cron/service/ops-read.js"; import type { CronJob } from "../../cron/types.js"; import { cronHandlers } from "../../gateway/server-methods/cron.js"; +import { withConsoleLogsRoutedToStderrForJson } from "../json-output-mode.js"; const mocks = vi.hoisted(() => { const runtime = { @@ -131,6 +132,16 @@ async function runCron(args: string[]): Promise { await program.parseAsync(["cron", ...args], { from: "user" }); } +async function runCronWithJsonOwner(args: string[]): Promise { + const originalArgv = process.argv; + process.argv = ["node", "openclaw", "cron", ...args]; + try { + await withConsoleLogsRoutedToStderrForJson(process.argv, () => runCron(args)); + } finally { + process.argv = originalArgv; + } +} + afterEach(() => { vi.clearAllMocks(); }); @@ -278,11 +289,11 @@ describe("cron CLI with the real Gateway pagination contract", () => { ); disableCronGetForProtocolV4Gateway(); - await expect(runCron(["list", "--json"])).rejects.toThrow("exit 1"); - - expect(mocks.runtime.error).toHaveBeenCalledWith( - expect.stringContaining("inventory changed repeatedly"), + await expect(runCronWithJsonOwner(["list", "--json"])).rejects.toThrow( + "inventory changed repeatedly", ); + + expect(mocks.runtime.error).not.toHaveBeenCalled(); expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); expect( mocks.callGatewayFromCli.mock.calls.filter(([method]) => method === "cron.list"), diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index dac94ca3e366..72cf96526f77 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -25,6 +25,7 @@ import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { formatLookupMiss } from "../error-format.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js"; +import { isJsonOutputModeActive } from "../json-output-mode.js"; import { parseDurationMs as parseSharedDurationMs } from "../parse-duration.js"; function parseCronArgv(value: unknown, flag: string): string[] | undefined { @@ -213,6 +214,9 @@ export function handleCronCliError(err: unknown) { valueLabel: "automation id", }) : formatErrorMessage(err); + if (isJsonOutputModeActive(process.argv)) { + throw new Error(message); + } defaultRuntime.error(danger(message)); defaultRuntime.exit(1); } diff --git a/src/cli/daemon-cli/status.test.ts b/src/cli/daemon-cli/status.test.ts index abae75d2938a..297e99c09b6a 100644 --- a/src/cli/daemon-cli/status.test.ts +++ b/src/cli/daemon-cli/status.test.ts @@ -131,8 +131,11 @@ describe("runDaemonStatus", () => { expect(gatherDaemonStatus).not.toHaveBeenCalled(); expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ ok: false, - error: - "Gateway status failed: --require-rpc needs probing enabled. Remove --no-probe or drop --require-rpc.", + error: { + type: "cli_error", + message: + "Gateway status failed: --require-rpc needs probing enabled. Remove --no-probe or drop --require-rpc.", + }, }); expect(defaultRuntime.error).not.toHaveBeenCalled(); expect(defaultRuntime.exit).toHaveBeenCalledTimes(1); @@ -156,7 +159,10 @@ describe("runDaemonStatus", () => { expect(printDaemonStatus).not.toHaveBeenCalled(); expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ ok: false, - error: expect.stringContaining("Gateway status failed: service manager unavailable"), + error: { + type: "cli_error", + message: expect.stringContaining("Gateway status failed: service manager unavailable"), + }, }); expect(JSON.stringify(defaultRuntime.writeJson.mock.calls)).not.toContain(error.name); expect(JSON.stringify(defaultRuntime.writeJson.mock.calls)).not.toContain(secret); diff --git a/src/cli/daemon-cli/status.ts b/src/cli/daemon-cli/status.ts index f694474bb439..11d544349dd2 100644 --- a/src/cli/daemon-cli/status.ts +++ b/src/cli/daemon-cli/status.ts @@ -2,13 +2,14 @@ import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; +import { formatCliJsonFailure } from "../failure-output.js"; import { gatherDaemonStatus } from "./status.gather.js"; import { printDaemonStatus } from "./status.print.js"; import type { DaemonStatusOptions } from "./types.js"; function failDaemonStatus(opts: DaemonStatusOptions, message: string): void { if (opts.json) { - defaultRuntime.writeJson({ ok: false, error: message }); + defaultRuntime.writeJson(formatCliJsonFailure(message)); } else { defaultRuntime.error(colorize(isRich(), theme.error, message)); } diff --git a/src/cli/directory-cli.test.ts b/src/cli/directory-cli.test.ts index c585cfc08256..8c25f1559c6d 100644 --- a/src/cli/directory-cli.test.ts +++ b/src/cli/directory-cli.test.ts @@ -499,7 +499,7 @@ describe("registerDirectoryCli", () => { ], "Channel demo-directory does not support group members listing", ], - ])("writes JSON errors for unsupported directory %s", async (_label, args, expectedError) => { + ])("bubbles JSON errors for unsupported directory %s", async (_label, args, expectedError) => { mocks.resolveInstallableChannelPlugin.mockResolvedValue({ cfg: { channels: { "demo-directory": {} } }, channelId: "demo-directory", @@ -513,12 +513,11 @@ describe("registerDirectoryCli", () => { const program = new Command().name("openclaw"); registerDirectoryCli(program); - await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("exit:1"); + await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(expectedError); - expect(runtimeState.defaultRuntime.writeJson).toHaveBeenCalledOnce(); - expect(runtimeState.defaultRuntime.writeJson).toHaveBeenCalledWith({ error: expectedError }); + expect(runtimeState.defaultRuntime.writeJson).not.toHaveBeenCalled(); expect(runtimeState.defaultRuntime.error).not.toHaveBeenCalled(); - expect(runtimeState.defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(runtimeState.defaultRuntime.exit).not.toHaveBeenCalled(); }); it.each([ @@ -541,18 +540,18 @@ describe("registerDirectoryCli", () => { const program = new Command().name("openclaw"); registerDirectoryCli(program); - await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("exit:1"); - if (mode === "JSON") { - const payload = JSON.parse(runtimeState.runtimeLogs.at(-1) ?? ""); - expect(payload).toEqual({ error: error.message }); + await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow(error.message); + expect(runtimeState.defaultRuntime.writeJson).not.toHaveBeenCalled(); expect(runtimeState.defaultRuntime.error).not.toHaveBeenCalled(); + expect(runtimeState.defaultRuntime.exit).not.toHaveBeenCalled(); } else { + await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("exit:1"); expect(runtimeErrors()).toEqual([error.message]); expect(runtimeState.defaultRuntime.writeJson).not.toHaveBeenCalled(); + expect(runtimeState.defaultRuntime.exit).toHaveBeenCalledWith(1); } expect([...runtimeState.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(error.name); - expect(runtimeState.defaultRuntime.exit).toHaveBeenCalledWith(1); }); it.each([ diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index 236bab34bdda..a898b574eab1 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -206,12 +206,10 @@ export function registerDirectoryCli(program: Command) { try { await action(); } catch (err) { - const message = formatErrorMessage(err); if (opts.json) { - defaultRuntime.writeJson({ error: message }); - } else { - defaultRuntime.error(danger(message)); + throw err; } + defaultRuntime.error(danger(formatErrorMessage(err))); defaultRuntime.exit(1); } }; diff --git a/src/cli/exec-approvals-cli.pending-resolve.test.ts b/src/cli/exec-approvals-cli.pending-resolve.test.ts index 02086858b19f..325a7a773ae9 100644 --- a/src/cli/exec-approvals-cli.pending-resolve.test.ts +++ b/src/cli/exec-approvals-cli.pending-resolve.test.ts @@ -273,17 +273,16 @@ describe("exec approvals pending and resolve CLI", () => { }); }); - it("writes pending approval failures as JSON", async () => { + it("bubbles pending approval failures to the JSON owner", async () => { callGatewayFromCli.mockRejectedValue(new Error("gateway unavailable")); await expect(runApprovalsCommand(["approvals", "pending", "--json"])).rejects.toThrow( - "__exit__:1", + "gateway unavailable", ); - expect(defaultRuntime.writeJson).toHaveBeenCalledOnce(); - expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ error: "gateway unavailable" }, 0); + expect(defaultRuntime.writeJson).not.toHaveBeenCalled(); expect(defaultRuntime.error).not.toHaveBeenCalled(); - expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + expect(defaultRuntime.exit).not.toHaveBeenCalled(); }); it("preserves whitespace-bearing ids verbatim and keeps them distinct", async () => { diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index 96646a695a72..50122409cc75 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -943,9 +943,11 @@ describe("exec approvals CLI", () => { const filePath = path.join(dir, "oversized.json"); fs.writeFileSync(filePath, Buffer.alloc(1024 * 1024 + 1, "x")); - await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow("__exit__:1"); + await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow( + "File exceeds 1048576 bytes", + ); - expect(writtenJson().error).toContain("File exceeds 1048576 bytes"); + expect(defaultRuntime.writeJson).not.toHaveBeenCalled(); expect(runtimeErrors).toHaveLength(0); expect(callGatewayFromCli).toHaveBeenCalledTimes(1); }); @@ -953,9 +955,9 @@ describe("exec approvals CLI", () => { it("preserves the directory read error", async () => { const dir = tempDirs.make("openclaw-approvals-file-directory-"); - await expect(runNativeApprovalsFileCommand(dir)).rejects.toThrow("__exit__:1"); + await expect(runNativeApprovalsFileCommand(dir)).rejects.toThrow(/EISDIR|directory/i); - expect(writtenJson().error).toMatch(/EISDIR|directory/i); + expect(defaultRuntime.writeJson).not.toHaveBeenCalled(); expect(runtimeErrors).toHaveLength(0); expect(callGatewayFromCli).toHaveBeenCalledTimes(1); }); @@ -987,12 +989,14 @@ describe("exec approvals CLI", () => { }); try { - await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow("__exit__:1"); + await expect(runNativeApprovalsFileCommand(filePath)).rejects.toThrow( + "File exceeds 1048576 bytes", + ); } finally { openSpy.mockRestore(); } - expect(writtenJson().error).toContain("File exceeds 1048576 bytes"); + expect(defaultRuntime.writeJson).not.toHaveBeenCalled(); expect(runtimeErrors).toHaveLength(0); expect(callGatewayFromCli).toHaveBeenCalledTimes(1); }); diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index e3a481ba9877..5d3c9cf19add 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -383,10 +383,9 @@ function formatCliError(err: unknown): string { function failApprovalsCommand(err: unknown, opts: ExecApprovalsCliOpts): void { const message = formatCliError(err); if (opts.json) { - defaultRuntime.writeJson({ error: message }, 0); - } else { - defaultRuntime.error(message); + throw new Error(message); } + defaultRuntime.error(message); defaultRuntime.exit(1); } diff --git a/src/cli/failure-output.test.ts b/src/cli/failure-output.test.ts index 817e5bee0dc2..0aeb9377b0e7 100644 --- a/src/cli/failure-output.test.ts +++ b/src/cli/failure-output.test.ts @@ -1,6 +1,22 @@ // Failure output tests cover CLI error formatting and failure summaries. import { describe, expect, it } from "vitest"; -import { formatCliFailureLines } from "./failure-output.js"; +import { formatCliFailureLines, formatCliJsonFailure } from "./failure-output.js"; + +describe("formatCliJsonFailure", () => { + it("uses the canonical typed envelope and redacts the message", () => { + const token = "sk-abcdefghijklmnopqrstuv"; + const payload = formatCliJsonFailure(new Error(`Authorization: Bearer ${token}`)); + + expect(payload).toEqual({ + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining("Authorization: Bearer"), + }, + }); + expect(payload.error.message).not.toContain(token); + }); +}); describe("formatCliFailureLines", () => { it("shows a concise reason and recovery commands by default", () => { diff --git a/src/cli/failure-output.ts b/src/cli/failure-output.ts index eed27bf839b7..3dfc2741c69c 100644 --- a/src/cli/failure-output.ts +++ b/src/cli/failure-output.ts @@ -11,6 +11,25 @@ type FormatCliFailureOptions = { includeDoctorHint?: boolean; }; +export type CliJsonFailure = { + ok: false; + error: { + type: "cli_error"; + message: string; + }; +}; + +/** Canonical machine-readable failure envelope for CLI-owned errors. */ +export function formatCliJsonFailure(error: unknown): CliJsonFailure { + return { + ok: false, + error: { + type: "cli_error", + message: formatErrorMessage(error), + }, + }; +} + function hasDebugArg(argv: string[] | undefined): boolean { for (const arg of argv ?? []) { // Arguments after the terminator belong to the child, not root stack-trace policy. diff --git a/src/cli/json-output-mode.test.ts b/src/cli/json-output-mode.test.ts index 700a62dc5828..7ba21573ffe8 100644 --- a/src/cli/json-output-mode.test.ts +++ b/src/cli/json-output-mode.test.ts @@ -4,6 +4,7 @@ import { loggingState } from "../logging/state.js"; import { applyResolvedCommandOutputMode, hasJsonOutputFlag, + isJsonOutputModeActive, withConsoleLogsRoutedToStderrForJson, } from "./json-output-mode.js"; @@ -62,10 +63,19 @@ describe("json output mode", () => { expect(loggingState.forceConsoleToStderr).toBe(true); applyResolvedCommandOutputMode(false); expect(loggingState.forceConsoleToStderr).toBe(false); + expect( + isJsonOutputModeActive(["node", "openclaw", "config", "set", "x", "1", "--json"]), + ).toBe(false); }, ); }); + it("does not treat config set's parser alias as JSON output before Commander resolves it", () => { + expect(isJsonOutputModeActive(["node", "openclaw", "config", "set", "x", "1", "--json"])).toBe( + false, + ); + }); + it("preserves inherited stderr routing when resolved metadata is parse-only", async () => { loggingState.forceConsoleToStderr = true; diff --git a/src/cli/json-output-mode.ts b/src/cli/json-output-mode.ts index 227dec21426f..4b8c3bc70af3 100644 --- a/src/cli/json-output-mode.ts +++ b/src/cli/json-output-mode.ts @@ -1,5 +1,9 @@ // Early JSON-output detection and console-log routing for parseable CLI stdout. import { loggingState } from "../logging/state.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; +import { isConfigSetJsonParseOnly } from "./config-output-mode.js"; + +let resolvedJsonOutputMode: boolean | null = null; /** Detects CLI JSON mode before Commander parses options, stopping at the argv sentinel. */ export function hasJsonOutputFlag(argv: readonly string[]): boolean { @@ -14,6 +18,14 @@ export function hasJsonOutputFlag(argv: readonly string[]): boolean { return false; } +/** Uses Commander-resolved output ownership when available, then falls back to argv. */ +export function isJsonOutputModeActive(argv: readonly string[]): boolean { + const commandPath = resolveCliArgvInvocation([...argv]).commandPath; + const parseOnlyJson = + commandPath[0] === "config" && commandPath[1] === "set" && isConfigSetJsonParseOnly(argv); + return resolvedJsonOutputMode ?? (hasJsonOutputFlag(argv) && !parseOnlyJson); +} + /** Keeps structured JSON stdout clean by routing incidental console logs to stderr. */ export async function withConsoleLogsRoutedToStderrForJson( argv: readonly string[], @@ -30,6 +42,8 @@ export async function withConsoleLogsRoutedToStderrForJson( } const previousForceStderr = loggingState.forceConsoleToStderr; const previousEarlyRestore = loggingState.earlyConsoleRoutingRestore; + const previousJsonOutputMode = resolvedJsonOutputMode; + resolvedJsonOutputMode = null; if (forceStderr) { loggingState.earlyConsoleRoutingRestore = previousForceStderr; loggingState.forceConsoleToStderr = true; @@ -41,12 +55,14 @@ export async function withConsoleLogsRoutedToStderrForJson( // Restore the process-wide logging switch so nested/serial CLI calls keep their own output mode. loggingState.forceConsoleToStderr = previousForceStderr; loggingState.earlyConsoleRoutingRestore = previousEarlyRestore; + resolvedJsonOutputMode = previousJsonOutputMode; } } } /** Let resolved command metadata override conservative early literal-flag routing. */ export function applyResolvedCommandOutputMode(machineOutput: boolean): void { + resolvedJsonOutputMode = machineOutput; const restore = loggingState.earlyConsoleRoutingRestore; if (!machineOutput && restore !== null) { loggingState.forceConsoleToStderr = restore; diff --git a/src/cli/node-cli/daemon.test.ts b/src/cli/node-cli/daemon.test.ts index 631c2c135cad..ebf222832cc1 100644 --- a/src/cli/node-cli/daemon.test.ts +++ b/src/cli/node-cli/daemon.test.ts @@ -440,14 +440,12 @@ describe("runNodeDaemonStatus", () => { error.name = "ServiceManagerError"; mocks.service.isLoaded.mockRejectedValue(error); - await runNodeDaemonStatus({ json: true }); + await expect(runNodeDaemonStatus({ json: true })).rejects.toThrow( + "Node service check failed: systemd unavailable", + ); - expect(mocks.runtime.writeJson).toHaveBeenCalledWith({ - error: expect.stringContaining("Node service check failed: systemd unavailable"), - }); - expect(JSON.stringify(mocks.runtime.writeJson.mock.calls)).not.toContain(error.name); - expect(JSON.stringify(mocks.runtime.writeJson.mock.calls)).not.toContain(secret); - expect(mocks.runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); + expect(mocks.runtime.exit).not.toHaveBeenCalled(); expect(mocks.runtime.error).not.toHaveBeenCalled(); }); diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 3be8450ab95c..0755e4bc8614 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -262,10 +262,9 @@ export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) { } catch (error) { const message = `Node service check failed: ${formatErrorMessage(error)}`; if (json) { - defaultRuntime.writeJson({ error: message }); - } else { - defaultRuntime.error(message); + throw new Error(message, { cause: error }); } + defaultRuntime.error(message); defaultRuntime.exit(1); return; } diff --git a/src/cli/plugins-inspect-command.ts b/src/cli/plugins-inspect-command.ts index fa67027c0c49..abe93a10fdab 100644 --- a/src/cli/plugins-inspect-command.ts +++ b/src/cli/plugins-inspect-command.ts @@ -10,6 +10,7 @@ import { import { defaultRuntime } from "../runtime.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatMissingPluginMessage } from "./error-format.js"; +import { formatCliJsonFailure } from "./failure-output.js"; import { quietPluginJsonLogger } from "./plugins-json-logger.js"; import { formatPluginBundleFormat } from "./plugins-list-format.js"; @@ -20,6 +21,15 @@ export type PluginInspectOptions = { runtime?: boolean; }; +function failPluginInspect(message: string, json: boolean | undefined): void { + if (json) { + defaultRuntime.writeJson(formatCliJsonFailure(message)); + } else { + defaultRuntime.error(message); + } + defaultRuntime.exit(1); +} + function formatInspectSection(title: string, lines: string[]): string[] { if (lines.length === 0) { return []; @@ -131,8 +141,8 @@ export async function runPluginsInspectCommand( const runtimeInspect = opts.runtime === true; if (opts.all) { if (id) { - defaultRuntime.error("Pass either a plugin id or --all, not both."); - return defaultRuntime.exit(1); + failPluginInspect("Pass either a plugin id or --all, not both.", opts.json); + return; } const report = runtimeInspect ? tracePluginLifecyclePhase( @@ -212,8 +222,8 @@ export async function runPluginsInspectCommand( } if (!id) { - defaultRuntime.error("Provide a plugin id or use --all."); - return defaultRuntime.exit(1); + failPluginInspect("Provide a plugin id or use --all.", opts.json); + return; } const snapshotReport = tracePluginLifecyclePhase( @@ -242,11 +252,11 @@ export async function runPluginsInspectCommand( if (diagnostic) { lines.push(diagnostic.message); } - defaultRuntime.error(lines.join("\n")); - return defaultRuntime.exit(1); + failPluginInspect(lines.join("\n"), opts.json); + return; } - defaultRuntime.error(formatMissingPluginMessage({ id, includeSearch: true })); - return defaultRuntime.exit(1); + failPluginInspect(formatMissingPluginMessage({ id, includeSearch: true }), opts.json); + return; } const report = runtimeInspect ? tracePluginLifecyclePhase( @@ -267,10 +277,11 @@ export async function runPluginsInspectCommand( report, }); if (!inspect) { - defaultRuntime.error( + failPluginInspect( formatMissingPluginMessage({ id, listCommand: "openclaw plugins list --json" }), + opts.json, ); - return defaultRuntime.exit(1); + return; } const install = installRecords[inspect.plugin.id]; diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index 75f2bbbd40a3..346ebc70b736 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -58,6 +58,10 @@ function commandCall(mock: ReturnType): [typeof runtime, Record { async function runMaintenanceCli(args: string[]) { const program = new Command(); @@ -121,7 +125,11 @@ describe("registerMaintenanceCommands doctor action", () => { await runMaintenanceCli(["doctor", "--state-sqlite", "compact", "--json"]); expect(runtime.writeJson).toHaveBeenCalledWith({ - error: expect.stringContaining("maintenance failed: Authorization: Bearer"), + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining("maintenance failed: Authorization: Bearer"), + }, }); expect(JSON.stringify(runtime.writeJson.mock.calls)).not.toContain(token); expect(runtime.error).not.toHaveBeenCalled(); @@ -225,7 +233,7 @@ describe("registerMaintenanceCommands doctor action", () => { "--json", ]); - expect(runtime.writeJson).toHaveBeenCalledWith({ error: message }); + expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure(message)); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).toHaveBeenCalledWith(2); }); @@ -279,7 +287,7 @@ describe("registerMaintenanceCommands doctor action", () => { expect(doctorCommand).not.toHaveBeenCalled(); expect(runDoctorLintCli).not.toHaveBeenCalled(); if (json) { - expect(runtime.writeJson).toHaveBeenCalledWith({ error: message }); + expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure(message)); expect(runtime.error).not.toHaveBeenCalled(); } else { expect(runtime.error).toHaveBeenCalledWith(message); @@ -372,7 +380,7 @@ describe("registerMaintenanceCommands doctor action", () => { expect(doctorCommand).not.toHaveBeenCalled(); expect(runDoctorLintCli).not.toHaveBeenCalled(); - expect(runtime.writeJson).toHaveBeenCalledWith({ error: message }); + expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure(message)); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).toHaveBeenCalledWith(2); }); @@ -394,7 +402,7 @@ describe("registerMaintenanceCommands doctor action", () => { expect(doctorCommand).not.toHaveBeenCalled(); expect(runDoctorLintCli).not.toHaveBeenCalled(); - expect(runtime.writeJson).toHaveBeenCalledWith({ error: message }); + expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure(message)); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).toHaveBeenCalledWith(2); }); @@ -415,7 +423,7 @@ describe("registerMaintenanceCommands doctor action", () => { await runMaintenanceCli(["doctor", "--json"]); - expect(runtime.writeJson).toHaveBeenCalledWith({ error: "lint failed" }); + expect(runtime.writeJson).toHaveBeenCalledWith(jsonFailure("lint failed")); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).toHaveBeenCalledWith(2); }); diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index 836e9ebdb907..88a392ea92b3 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -6,6 +6,7 @@ import { defaultRuntime } from "../../runtime.js"; import { formatErrorMessage as formatError, runCommandWithRuntime } from "../cli-utils.js"; import { hasExplicitOptions } from "../command-options.js"; import { isDoctorMachineOutput } from "../doctor-output-mode.js"; +import { formatCliJsonFailure } from "../failure-output.js"; import { setCommandJsonMode } from "./json-mode.js"; const STATE_SQLITE_CONFLICTING_OPTION_NAMES = [ @@ -33,7 +34,7 @@ const STATE_SQLITE_CONFLICTING_OPTION_NAMES = [ function exitDoctorError(message: string, json: boolean): void { if (json) { - defaultRuntime.writeJson({ error: message }); + defaultRuntime.writeJson(formatCliJsonFailure(message)); } else { defaultRuntime.error(message); } diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 567c44970f0d..733c4feb1166 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cliCommandCatalog } from "../command-catalog.js"; import { isReservedNonPluginCommandRoot } from "../command-registration-policy.js"; import { collectShellCompletionCommandTree } from "../completion-command-tree.js"; +import { formatCliJsonFailure } from "../failure-output.js"; +import { runCliWithExitFinalization } from "../one-shot-exit.js"; import { getCoreCliCommandNames, registerCoreCliByName } from "./command-registry-core.js"; import { createProgramContext } from "./context.js"; import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; @@ -248,6 +250,29 @@ function supportsJsonOutput(path: string, command: Command): boolean { return hasOwnJsonOption(command) || JSON_OUTPUT_ROUTE_FIRST.has(path); } +function requiredCommandArgs(command: Command): string[] { + const args = command.registeredArguments.flatMap((argument) => { + if (!argument.required) { + return []; + } + return argument.variadic ? ["guard-value"] : ["guard-value"]; + }); + for (const option of command.options) { + if (!option.mandatory) { + continue; + } + const flag = option.long ?? option.short; + if (!flag) { + continue; + } + args.push(flag); + if (option.required || option.optional) { + args.push(option.argChoices?.[0] ?? "guard-value"); + } + } + return args; +} + function collectRegisteredCommandPaths(...programs: Command[]): Set { return new Set( programs.flatMap((program) => @@ -403,4 +428,39 @@ describe("root command descriptions", () => { "route-first JSON entries must exist and remain absent from Commander options", ).toEqual([]); }); + + it("routes every registered JSON command failure through the canonical envelope", async () => { + const program = await registerAllBuiltInCommands(); + const contexts = collectShellCompletionCommandTree(program).descendants.filter((context) => { + const path = context.pathVariants[0]?.join(" ") ?? ""; + return supportsJsonOutput(path, context.command); + }); + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + + expect(contexts.length).toBeGreaterThan(0); + for (const context of contexts) { + const path = context.pathVariants[0]?.join(" ") ?? ""; + const failure = new Error(`synthetic failure for ${path}`); + const payloads: unknown[] = []; + context.command.action(async () => { + throw failure; + }); + const args = requiredCommandArgs(context.command); + if (hasOwnJsonOption(context.command)) { + args.push("--json"); + } + + await runCliWithExitFinalization({ + runtime, + run: async () => { + await context.command.parseAsync(args, { from: "user" }); + }, + onError: (error) => { + payloads.push(formatCliJsonFailure(error)); + }, + }); + + expect(payloads, path).toEqual([formatCliJsonFailure(failure)]); + } + }); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 8b4244b8f37d..6751df269192 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -44,6 +44,7 @@ import { } from "./gateway-run-argv.js"; import { hasJsonOutputFlag, + isJsonOutputModeActive, withConsoleLogsRoutedToStderr, withConsoleLogsRoutedToStderrForJson, } from "./json-output-mode.js"; @@ -1520,14 +1521,14 @@ async function runCliWithPreparedOutputMode( const [ { buildProgram }, { formatUncaughtError }, - { formatCliFailureLines }, + { formatCliFailureLines, formatCliJsonFailure }, { runFatalErrorHooks }, { installUnhandledRejectionHandler, isBenignUncaughtExceptionError, isUncaughtExceptionHandled, }, - { restoreRuntimeTerminalState }, + { defaultRuntime, restoreRuntimeTerminalState }, ] = await startupTrace.measure("core-imports", () => Promise.all([ import("./program.js"), @@ -1555,6 +1556,9 @@ async function runCliWithPreparedOutputMode( ); return; } + if (isJsonOutputModeActive(normalizedArgv)) { + defaultRuntime.writeJson(formatCliJsonFailure(error)); + } for (const line of formatCliFailureLines({ title: "OpenClaw hit an unexpected runtime error.", error, @@ -1656,6 +1660,9 @@ async function runCliWithPreparedOutputMode( if (!isCommanderParseExit(error)) { throw error; } + if (isJsonOutputModeActive(parseArgv) && error.exitCode !== 0) { + throw error; + } process.exitCode = error.exitCode; completedHelpOrVersion = isHelpOrVersionInvocation && error.exitCode === 0; } diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index 9dd6e72f291f..79af794ecbaa 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -1343,7 +1343,11 @@ describe("skills cli commands", () => { await runCommand(["skills", "verify", "agentreceipt", "--global", "--agent", "main"]); expect(JSON.parse(runtimeStdout.at(-1) ?? "{}")).toEqual({ - error: "Use either --global or --agent, not both.", + ok: false, + error: { + type: "cli_error", + message: "Use either --global or --agent, not both.", + }, }); expect(runtimeErrors).toStrictEqual([]); expect(defaultRuntime.exit).toHaveBeenCalledWith(1); @@ -1426,7 +1430,15 @@ describe("skills cli commands", () => { { label: "JSON", argv: ["skills", "info", "missing-skill", "--json"], - expected: JSON.stringify({ error: "not found", skill: "missing-skill" }, null, 2), + expected: JSON.stringify( + { + ok: false, + error: { type: "cli_error", message: 'Skill "missing-skill" not found.' }, + skill: "missing-skill", + }, + null, + 2, + ), }, ])("exits nonzero for missing skill info in $label mode", async ({ argv, expected }) => { vi.stubEnv("OPENCLAW_PROFILE", ""); diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index b1fe75918b90..25dd88cd694b 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -13,6 +13,7 @@ import { } from "../skills/discovery/status.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { formatCliJsonFailure } from "./failure-output.js"; /** Options for rendering the skill list command. */ export type SkillsListOptions = { @@ -204,7 +205,10 @@ export function formatSkillInfo( if (!skill) { if (opts.json) { return JSON.stringify( - sanitizeJsonValue({ error: "not found", skill: requestedName }), + sanitizeJsonValue({ + ...formatCliJsonFailure(`Skill "${requestedName}" not found.`), + skill: requestedName, + }), null, 2, ); diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index a52f6b24c439..2f59849f1ca2 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -584,9 +584,17 @@ describe("skills-cli", () => { it("sanitizes user-supplied skill name in not-found JSON output", () => { const report = createMockReport([]); const output = formatSkillInfo(report, "evil\u001b[31m\u009f", { json: true }); - const parsed = JSON.parse(output) as { error: string; skill: string }; + const parsed = JSON.parse(output) as { + ok: boolean; + error: { type: string; message: string }; + skill: string; + }; - expect(parsed.error).toBe("not found"); + expect(parsed.ok).toBe(false); + expect(parsed.error).toEqual({ + type: "cli_error", + message: 'Skill "evil" not found.', + }); expect(parsed.skill).toBe("evil"); expect(output).not.toContain("\u001b"); }); diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 704927573095..f4d76995bc3a 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -69,6 +69,7 @@ import { CONFIG_DIR } from "../utils.js"; import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js"; import { resolveOptionFromCommand } from "./cli-utils.js"; import { inheritOptionFromParent } from "./command-options.js"; +import { formatCliJsonFailure } from "./failure-output.js"; import { resolveInstallPolicyWarningAcknowledgementCliOptions } from "./install-policy-warning-acknowledgement.js"; import { parseStrictPositiveIntOption } from "./program/helpers.js"; import { setCommandJsonMode } from "./program/json-mode.js"; @@ -875,7 +876,7 @@ export function registerSkillsCli(program: Command) { let exitCode: number | undefined; const reportError = hasJsonOutput(opts) || opts.card !== true - ? (message: string) => defaultRuntime.writeJson({ error: message }) + ? (message: string) => defaultRuntime.writeJson(formatCliJsonFailure(message)) : defaultRuntime.error; try { const workspace = resolveClawHubTargetWorkspace(command, opts, reportError); diff --git a/src/cli/skills-cli.verify.test.ts b/src/cli/skills-cli.verify.test.ts index 131d5b720541..8ecde427d373 100644 --- a/src/cli/skills-cli.verify.test.ts +++ b/src/cli/skills-cli.verify.test.ts @@ -266,7 +266,11 @@ describe("skills verify CLI", () => { ).rejects.toThrow("__exit__:1"); expect(JSON.parse(mocks.runtimeStdout.at(-1) ?? "{}")).toEqual({ - error: 'Skill "html" is not tracked from skills-sh:owner-b/repo-b/html.', + ok: false, + error: { + type: "cli_error", + message: 'Skill "html" is not tracked from skills-sh:owner-b/repo-b/html.', + }, }); expect(mocks.runtimeErrors).toStrictEqual([]); expect(mocks.fetchClawHubSkillVerificationMock).not.toHaveBeenCalled(); @@ -285,7 +289,11 @@ describe("skills verify CLI", () => { ).rejects.toThrow("__exit__:1"); expect(JSON.parse(mocks.runtimeStdout.at(-1) ?? "{}")).toEqual({ - error: "ClawHub verification unavailable", + ok: false, + error: { + type: "cli_error", + message: "ClawHub verification unavailable", + }, }); expect(mocks.runtimeErrors).toStrictEqual([]); }); diff --git a/src/cli/system-cli.test.ts b/src/cli/system-cli.test.ts index 6f08185d1859..edd03f4050fb 100644 --- a/src/cli/system-cli.test.ts +++ b/src/cli/system-cli.test.ts @@ -31,6 +31,10 @@ function gatewayCall(callIndex = 0): ReadonlyArray { return call; } +function jsonFailure(message: string) { + return { ok: false, error: { type: "cli_error", message } }; +} + describe("system-cli", () => { async function runCli(args: string[]) { const program = new Command(); @@ -114,9 +118,9 @@ describe("system-cli", () => { await runCli(args); - expect(runtimeLogs).toEqual([JSON.stringify({ error: expectedError }, null, 2)]); + expect(runtimeLogs).toEqual([JSON.stringify(jsonFailure(expectedError), null, 2)]); expect(runtimeErrors).toEqual([]); - expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ error: expectedError }); + expect(defaultRuntime.writeJson).toHaveBeenCalledWith(jsonFailure(expectedError)); expect(defaultRuntime.exit).toHaveBeenCalledWith(1); expect(callGatewayFromCli).toHaveBeenCalledTimes(gatewayCalls); }, @@ -134,7 +138,7 @@ describe("system-cli", () => { if (mode === "JSON") { const payload = JSON.parse(runtimeLogs.at(-1) ?? ""); - expect(payload).toEqual({ error: error.message }); + expect(payload).toEqual(jsonFailure(error.message)); expect(runtimeErrors).toEqual([]); } else { expect(runtimeErrors).toEqual([error.message]); @@ -194,9 +198,9 @@ describe("system-cli", () => { expect(params).toBeUndefined(); expect(requestOptions).toEqual({ expectFinal: false }); const expectedError = "Gateway unavailable"; - expect(runtimeLogs).toEqual([JSON.stringify({ error: expectedError }, null, 2)]); + expect(runtimeLogs).toEqual([JSON.stringify(jsonFailure(expectedError), null, 2)]); expect(runtimeErrors).toEqual([]); - expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ error: expectedError }); + expect(defaultRuntime.writeJson).toHaveBeenCalledWith(jsonFailure(expectedError)); expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); diff --git a/src/cli/system-cli.ts b/src/cli/system-cli.ts index a3cf4a249342..c66aeb9ed44a 100644 --- a/src/cli/system-cli.ts +++ b/src/cli/system-cli.ts @@ -7,6 +7,7 @@ import { danger } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; import { formatCliCommand } from "./command-format.js"; +import { formatCliJsonFailure } from "./failure-output.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; import { setCommandJsonMode } from "./program/json-mode.js"; @@ -47,7 +48,7 @@ async function runSystemGatewayCommand( } catch (err) { const message = formatErrorMessage(err); if (machineOutput) { - defaultRuntime.writeJson({ error: message }); + defaultRuntime.writeJson(formatCliJsonFailure(message)); } else { defaultRuntime.error(danger(message)); } diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 672bfe7093b7..ba0cc39647ce 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -6,6 +6,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { defaultRuntime } from "../runtime.js"; import { inheritOptionFromParent } from "./command-options.js"; import { formatHelpExamples } from "./help-format.js"; +import { isJsonOutputModeActive } from "./json-output-mode.js"; import type { UpdateCommandOptions, UpdateFinalizeOptions, @@ -28,6 +29,14 @@ function inheritedUpdateJson(command?: Command): boolean { return Boolean(inheritOptionFromParent(command, "json")); } +function handleUpdateCommandError(error: unknown): void { + if (isJsonOutputModeActive(process.argv)) { + throw error; + } + defaultRuntime.error(formatErrorMessage(error)); + defaultRuntime.exit(1); +} + function inheritedUpdateTimeout( opts: { timeout?: unknown }, command?: Command, @@ -67,10 +76,11 @@ function rejectUnsupportedInheritedUpdateDryRun(command: Command): boolean { return false; } - defaultRuntime.error( - `--dry-run is not supported for \`openclaw update ${command.name()}\`. Run \`openclaw update --dry-run\` instead.`, + handleUpdateCommandError( + new Error( + `--dry-run is not supported for \`openclaw update ${command.name()}\`. Run \`openclaw update --dry-run\` instead.`, + ), ); - defaultRuntime.exit(1); return true; } @@ -119,8 +129,7 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden normalizeCommanderClawHubRiskOption(opts) || inheritedUpdateClawHubRisk(actionCommand), }); } catch (err) { - defaultRuntime.error(formatErrorMessage(err)); - defaultRuntime.exit(1); + handleUpdateCommandError(err); } }); } @@ -210,8 +219,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts), }); } catch (err) { - defaultRuntime.error(formatErrorMessage(err)); - defaultRuntime.exit(1); + handleUpdateCommandError(err); } }); @@ -236,8 +244,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up timeout: inheritedUpdateTimeout(opts, command), }); } catch (err) { - defaultRuntime.error(formatErrorMessage(err)); - defaultRuntime.exit(1); + handleUpdateCommandError(err); } }); @@ -266,8 +273,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/up timeout: inheritedUpdateTimeout(opts, command), }); } catch (err) { - defaultRuntime.error(formatErrorMessage(err)); - defaultRuntime.exit(1); + handleUpdateCommandError(err); } }); } diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 8807852ca3ea..6bae9a9659d6 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -26,6 +26,7 @@ import { runCommandWithTimeout } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; import { pathExists } from "../../utils.js"; import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../completion-runtime.js"; +import { isJsonOutputModeActive } from "../json-output-mode.js"; export type UpdateCommandOptions = { json?: boolean; @@ -67,6 +68,9 @@ export function parseTimeoutMsOrExit(timeout?: string): number | undefined | nul const trimmed = timeout.trim(); const seconds = parseStrictPositiveInteger(trimmed); if (seconds === undefined || seconds > MAX_SAFE_TIMEOUT_SECONDS) { + if (isJsonOutputModeActive(process.argv)) { + throw new Error(INVALID_TIMEOUT_ERROR); + } defaultRuntime.error(INVALID_TIMEOUT_ERROR); defaultRuntime.exit(1); return null; diff --git a/src/cli/webhooks-cli.test.ts b/src/cli/webhooks-cli.test.ts index 49e610bef6f7..1f584e65691f 100644 --- a/src/cli/webhooks-cli.test.ts +++ b/src/cli/webhooks-cli.test.ts @@ -60,14 +60,15 @@ describe("webhooks cli", () => { args.push("--json"); } - await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1"); - if (json) { - expect(mocks.defaultRuntime.writeJson).toHaveBeenCalledWith({ - error: `${flag} must be a positive integer.`, - }); + await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow( + `${flag} must be a positive integer.`, + ); + expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled(); expect(mocks.defaultRuntime.error).not.toHaveBeenCalled(); + expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); } else { + await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1"); expect(runtimeErrors().join("\n")).toContain(`${flag} must be a positive integer.`); } expect(mocks.runGmailSetup).not.toHaveBeenCalled(); @@ -93,24 +94,24 @@ describe("webhooks cli", () => { args.push("--json"); } - await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1"); - - expect(runner).toHaveBeenCalledOnce(); if (json) { - const payload = JSON.parse(mocks.runtimeLogs.at(-1) ?? ""); - expect(payload).toEqual({ - error: expect.stringContaining("Gmail failed: Authorization: Bearer"), - }); + await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow( + "Gmail failed: Authorization: Bearer", + ); + expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled(); expect(mocks.defaultRuntime.error).not.toHaveBeenCalled(); + expect(mocks.defaultRuntime.exit).not.toHaveBeenCalled(); } else { + await expect(program.parseAsync(args, { from: "user" })).rejects.toThrow("__exit__:1"); expect(runtimeErrors()).toEqual([ expect.stringContaining("Gmail failed: Authorization: Bearer"), ]); expect(mocks.defaultRuntime.writeJson).not.toHaveBeenCalled(); + expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1); } + expect(runner).toHaveBeenCalledOnce(); expect([...mocks.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(error.name); expect([...mocks.runtimeLogs, ...runtimeErrors()].join("\n")).not.toContain(secret); - expect(mocks.defaultRuntime.exit).toHaveBeenCalledWith(1); }); it.each([ diff --git a/src/cli/webhooks-cli.ts b/src/cli/webhooks-cli.ts index 8e92a6212da5..e590fe880c9b 100644 --- a/src/cli/webhooks-cli.ts +++ b/src/cli/webhooks-cli.ts @@ -72,12 +72,10 @@ export function registerWebhooksCli(program: Command) { const parsed = parseGmailSetupOptions(opts); await runGmailSetup(parsed); } catch (err) { - const message = formatErrorMessage(err); if (opts.json) { - defaultRuntime.writeJson({ error: message }); - } else { - defaultRuntime.error(danger(message)); + throw new Error(formatErrorMessage(err), { cause: err }); } + defaultRuntime.error(danger(formatErrorMessage(err))); defaultRuntime.exit(1); } }); diff --git a/src/commands/agents.commands.delete.ts b/src/commands/agents.commands.delete.ts index f14cfc193b12..58274fd7b015 100644 --- a/src/commands/agents.commands.delete.ts +++ b/src/commands/agents.commands.delete.ts @@ -28,6 +28,7 @@ import { prepareWorkspaceStateDeletion, } from "../agents/workspace-state-store.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { formatCliJsonFailure } from "../cli/failure-output.js"; import { replaceConfigFile } from "../config/config.js"; import { logConfigUpdated } from "../config/logging.js"; import { @@ -66,7 +67,7 @@ type AgentsDeleteGatewayResult = { function failAgentsDelete(opts: AgentsDeleteOptions, runtime: RuntimeEnv, message: string): void { if (opts.json) { - writeRuntimeJson(runtime, { error: message }); + writeRuntimeJson(runtime, formatCliJsonFailure(message)); runtime.exit(1, { resetStream: process.stderr }); } else { runtime.error(message); @@ -202,8 +203,7 @@ export async function agentsDeleteCommand( if (!opts.force) { if (!process.stdin.isTTY) { - runtime.error("Non-interactive session. Re-run with --force."); - runtime.exit(1); + failAgentsDelete(opts, runtime, "Non-interactive session. Re-run with --force."); return; } const prompter = createClackPrompter(); diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index 7b0865bbf366..5c9bcb248858 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -240,8 +240,12 @@ describe("agents delete command", () => { expect(runtime.error).not.toHaveBeenCalled(); expect(readJsonLogs()).toEqual([ { - error: - 'Agent "main" owns the legacy shared auth store and cannot be deleted. Run openclaw doctor --fix to migrate shared auth, then retry.', + ok: false, + error: { + type: "cli_error", + message: + 'Agent "main" owns the legacy shared auth store and cannot be deleted. Run openclaw doctor --fix to migrate shared auth, then retry.', + }, }, ]); expect(runtime.exit).toHaveBeenCalledWith(1, { resetStream: process.stderr }); @@ -645,7 +649,13 @@ describe("agents delete command", () => { expect(runtime.error).not.toHaveBeenCalled(); expect(readJsonLogs()).toEqual([ - { error: 'Agent "ops" is the only configured agent and cannot be deleted.' }, + { + ok: false, + error: { + type: "cli_error", + message: 'Agent "ops" is the only configured agent and cannot be deleted.', + }, + }, ]); expect(runtime.exit).toHaveBeenCalledWith(1, { resetStream: process.stderr }); expectSessionStore(cfg, { diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index a1db871b2ecf..5c541e87a3f3 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -433,6 +433,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { expect(announceRequest?.config?.secretResolved).toBe(true); expect(announceRequest?.activationSourceConfig?.secretResolved).toBe(false); const payload = JSON.parse(logs.at(-1) ?? "{}"); + expect(errors).toEqual([]); expect(errors.join("\n")).not.toContain("user:pass"); expect(errors.join("\n")).not.toContain("secret-token"); expect(errors.join("\n")).not.toContain("fallback-user:fallback-pass"); diff --git a/src/commands/channels/status.runtime.ts b/src/commands/channels/status.runtime.ts index 917f41c54056..0d423c455a8c 100644 --- a/src/commands/channels/status.runtime.ts +++ b/src/commands/channels/status.runtime.ts @@ -228,9 +228,11 @@ export async function renderChannelsStatusFallback(params: { const fallbackReason = gatewayAuthUnavailable ? "Gateway auth unavailable; showing config-only status." : "Gateway not reachable; showing config-only status."; - runtime.error( - `${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`, - ); + if (!opts.json) { + runtime.error( + `${gatewayAuthUnavailable ? "Gateway auth unavailable" : "Gateway not reachable"}: ${safeError}`, + ); + } const cfg = await requireValidConfig(runtime, { observe: false }); if (!cfg) { return; diff --git a/src/commands/sessions-lifecycle.test.ts b/src/commands/sessions-lifecycle.test.ts index a81f5b3c88b7..a31e68cff66a 100644 --- a/src/commands/sessions-lifecycle.test.ts +++ b/src/commands/sessions-lifecycle.test.ts @@ -153,6 +153,10 @@ describe("sessions lifecycle commands", () => { expect(runtime.writeJson).toHaveBeenCalledWith( { ok: false, + error: { + type: "cli_error", + message: `Session ${_operation} did not complete for every requested key.`, + }, operation: _operation, dryRun: false, results: [ @@ -315,6 +319,10 @@ describe("sessions lifecycle commands", () => { expect(runtime.writeJson).toHaveBeenCalledWith( { ok: false, + error: { + type: "cli_error", + message: "Session delete did not complete for every requested key.", + }, operation: "delete", dryRun: false, results: [ @@ -354,6 +362,10 @@ describe("sessions lifecycle commands", () => { expect(runtime.writeJson).toHaveBeenCalledWith( { ok: false, + error: { + type: "cli_error", + message: "Session delete did not complete for every requested key.", + }, operation: "delete", dryRun: false, results: [ diff --git a/src/commands/sessions-lifecycle.ts b/src/commands/sessions-lifecycle.ts index cb5d5bfc4440..41b6a13a8e40 100644 --- a/src/commands/sessions-lifecycle.ts +++ b/src/commands/sessions-lifecycle.ts @@ -1,5 +1,6 @@ /** Gateway-backed archive and delete commands for stored sessions. */ import { formatCliCommand } from "../cli/command-format.js"; +import { formatCliJsonFailure } from "../cli/failure-output.js"; import { callGatewayFromCliWithTransport } from "../cli/gateway-rpc.js"; import { formatErrorMessage } from "../infra/errors.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -138,7 +139,19 @@ function outputLifecycleResults( ): void { const ok = results.every((result) => result.ok); if (json) { - writeRuntimeJson(runtime, { ok, operation, dryRun, results }); + writeRuntimeJson( + runtime, + ok + ? { ok, operation, dryRun, results } + : { + ...formatCliJsonFailure( + `Session ${operation} did not complete for every requested key.`, + ), + operation, + dryRun, + results, + }, + ); } else { for (const result of results) { switch (result.status) { diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 24c5214a17ba..4e4f7dc015e3 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -801,6 +801,16 @@ describe("tasks commands", () => { const lookupRuntime = createRuntime(); await tasksShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime); expectSafeTaskOutput(lookupRuntime, "error"); + + const jsonLookupRuntime = createRuntime(); + await tasksShowCommand({ lookup: `missing${unsafe}`, json: true }, jsonLookupRuntime); + expect(readFirstJsonLog(jsonLookupRuntime)).toMatchObject({ + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining("Task not found: missing"), + }, + }); }); }); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 8ae6fa5d0c01..129f60027443 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -9,6 +9,7 @@ import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; import { parseCliEnumFilter } from "../cli/enum-filter.js"; import { formatLookupMiss } from "../cli/error-format.js"; +import { formatCliJsonFailure } from "../cli/failure-output.js"; import { getRuntimeConfig } from "../config/config.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; import { getTaskById, updateTaskNotifyPolicyById } from "../tasks/runtime-internal.js"; @@ -323,7 +324,12 @@ export async function tasksShowCommand( ) { const task = reconcileTaskLookupToken(opts.lookup); if (!task) { - runtime.error(formatTaskLookupMiss(opts.lookup)); + const message = formatTaskLookupMiss(opts.lookup); + if (opts.json) { + writeRuntimeJson(runtime, formatCliJsonFailure(message)); + } else { + runtime.error(message); + } runtime.exit(1); return; } diff --git a/src/entry.ts b/src/entry.ts index b29749754b0b..29eb6a293cce 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -47,6 +47,13 @@ async function writeCapturedCliArgumentError(message: string): Promise { await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace); const { enableConsoleCapture } = await import("./logging.js"); enableConsoleCapture(); + const [{ formatCliJsonFailure }, { isJsonOutputModeActive }] = await Promise.all([ + import("./cli/failure-output.js"), + import("./cli/json-output-mode.js"), + ]); + if (isJsonOutputModeActive(process.argv)) { + defaultRuntime.writeJson(formatCliJsonFailure(message)); + } console.error(`[openclaw] ${message}`); } @@ -307,7 +314,11 @@ export async function runMainOrRootHelp( await configureGatewayStartupTraceConsoleFormatting(gatewayEntryStartupTrace); const { enableConsoleCapture } = await import("./logging.js"); enableConsoleCapture(); - const { formatCliFailureLines } = await import("./cli/failure-output.js"); + const [{ formatCliFailureLines, formatCliJsonFailure }, { isJsonOutputModeActive }] = + await Promise.all([import("./cli/failure-output.js"), import("./cli/json-output-mode.js")]); + if (isJsonOutputModeActive(argv)) { + defaultRuntime.writeJson(formatCliJsonFailure(error)); + } for (const line of formatCliFailureLines({ title: "Could not start the CLI.", error, diff --git a/src/index.ts b/src/index.ts index c5f05a94a24d..d8c90d6e34ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,8 @@ // Package executable entrypoint that forwards to the CLI bootstrap. import process from "node:process"; import { fileURLToPath } from "node:url"; -import { formatCliFailureLines } from "./cli/failure-output.js"; +import { formatCliFailureLines, formatCliJsonFailure } from "./cli/failure-output.js"; +import { isJsonOutputModeActive } from "./cli/json-output-mode.js"; import { runCliWithExitFinalization } from "./cli/one-shot-exit.js"; import { tryHandleRootVersionFastPath } from "./entry.version-fast-path.js"; import { formatUncaughtError } from "./infra/errors.js"; @@ -99,7 +100,7 @@ if (!isMain) { } if (isMain && !handledRootVersion) { - const { restoreRuntimeTerminalState } = await import("./runtime.js"); + const { defaultRuntime, restoreRuntimeTerminalState } = await import("./runtime.js"); // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. // These log the error and exit gracefully instead of crashing without trace. @@ -116,6 +117,9 @@ if (isMain && !handledRootVersion) { ); return; } + if (isJsonOutputModeActive(process.argv)) { + defaultRuntime.writeJson(formatCliJsonFailure(error)); + } for (const line of formatCliFailureLines({ title: "OpenClaw hit an unexpected runtime error.", error, @@ -137,6 +141,9 @@ if (isMain && !handledRootVersion) { retainConsoleRoutingUntilProcessExit: true, }), onError: (err) => { + if (isJsonOutputModeActive(process.argv)) { + defaultRuntime.writeJson(formatCliJsonFailure(err)); + } for (const line of formatCliFailureLines({ title: "The CLI command failed.", error: err, diff --git a/test/cli-json-stdout.e2e.test.ts b/test/cli-json-stdout.e2e.test.ts index 7fa23ecf4f39..de1622585552 100644 --- a/test/cli-json-stdout.e2e.test.ts +++ b/test/cli-json-stdout.e2e.test.ts @@ -111,7 +111,11 @@ describe("cli json stdout contract", () => { expect(result.status, result.stderr).toBe(1); expect(JSON.parse(result.stdout)).toMatchObject({ - error: expect.stringContaining("Invalid path segment: __proto__"), + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining("Invalid path segment: __proto__"), + }, }); expect(result.stderr).toBe(""); await expect( @@ -149,7 +153,11 @@ describe("cli json stdout contract", () => { expect(result.status, result.stderr).toBe(1); expect(JSON.parse(result.stdout)).toMatchObject({ - error: expect.stringContaining("OpenClaw config is invalid"), + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining("OpenClaw config is invalid"), + }, issues: expect.arrayContaining([ expect.objectContaining({ path: "gateway.bind", message: expect.any(String) }), ]), @@ -255,13 +263,84 @@ describe("cli json stdout contract", () => { const result = runSourceCli(tempHome, ["update", "status", "--json", "--timeout", ""]); expect(result.status, result.stderr).toBe(1); - expect(result.stdout).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: { + type: "cli_error", + message: "--timeout must be a positive integer (seconds)", + }, + }); expect(result.stderr).toContain("--timeout must be a positive integer (seconds)"); }, { prefix: "openclaw-update-empty-timeout-e2e-" }, ); }); + it("returns one canonical document for a command that previously failed on stderr only", async () => { + await withTempHome( + async (tempHome) => { + const missingArchive = path.join(tempHome, "missing-backup.tar.gz"); + const result = runSourceCli(tempHome, ["backup", "verify", missingArchive, "--json"]); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining("missing-backup.tar.gz"), + }, + }); + }, + { prefix: "openclaw-json-failure-e2e-" }, + ); + }); + + it("keeps Commander parse failures machine-readable in JSON mode", async () => { + await withTempHome( + async (tempHome) => { + const result = runSourceCli(tempHome, [ + "config", + "get", + "gateway.port", + "--json", + "--not-a-real-option", + ]); + + expect(result.status).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { + type: "cli_error", + message: expect.stringContaining("--not-a-real-option"), + }, + }); + expect(result.stderr).toContain("--not-a-real-option"); + }, + { prefix: "openclaw-json-parse-failure-e2e-" }, + ); + }); + + it("keeps representative success payload bytes unchanged", async () => { + await withTempHome( + async (tempHome) => { + const configPath = path.join(tempHome, "openclaw.json"); + await fs.writeFile(configPath, '{"gateway":{"port":28789}}\n', "utf8"); + const env = { OPENCLAW_CONFIG_PATH: configPath }; + + const getResult = runSourceCli(tempHome, ["config", "get", "gateway.port", "--json"], env); + const validateResult = runSourceCli(tempHome, ["config", "validate", "--json"], env); + + expect(getResult.status, getResult.stderr).toBe(0); + expect(getResult.stdout).toBe("28789\n"); + expect(validateResult.status, validateResult.stderr).toBe(0); + expect(validateResult.stdout).toBe( + `${JSON.stringify({ valid: true, path: configPath, warnings: [] })}\n`, + ); + }, + { prefix: "openclaw-json-success-bytes-e2e-" }, + ); + }); + it("keeps `config schema` stdout parseable at debug log level", async () => { await withTempHome( async (tempHome) => {