fix(cli): emit one JSON failure contract for --json invocations (#124849)

* fix(cli): unify JSON failure output

* test(cli): update skills verify failure envelope
This commit is contained in:
Peter Steinberger
2026-08-16 15:09:58 -07:00
committed by GitHub
parent 0b5bb09510
commit 6c66f48a7c
48 changed files with 570 additions and 135 deletions
+20
View File
@@ -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:
+25
View File
@@ -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", () => {
+4
View File
@@ -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;
+2 -1
View File
@@ -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<ConfigFileSnapshot, "valid" | "issues" | "warnings" | "legacyIssues">,
@@ -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);
+15 -4
View File
@@ -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" }],
});
});
+15 -5
View File
@@ -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 {
@@ -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<void> {
await program.parseAsync(["cron", ...args], { from: "user" });
}
async function runCronWithJsonOwner(args: string[]): Promise<void> {
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"),
+4
View File
@@ -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);
}
+9 -3
View File
@@ -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);
+2 -1
View File
@@ -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));
}
+9 -10
View File
@@ -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([
+2 -4
View File
@@ -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);
}
};
@@ -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 () => {
+10 -6
View File
@@ -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);
});
+2 -3
View File
@@ -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);
}
+17 -1
View File
@@ -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", () => {
+19
View File
@@ -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.
+10
View File
@@ -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;
+16
View File
@@ -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<T>(
argv: readonly string[],
@@ -30,6 +42,8 @@ export async function withConsoleLogsRoutedToStderrForJson<T>(
}
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<T>(
// 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;
+5 -7
View File
@@ -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();
});
+2 -3
View File
@@ -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;
}
+21 -10
View File
@@ -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];
+14 -6
View File
@@ -58,6 +58,10 @@ function commandCall(mock: ReturnType<typeof vi.fn>): [typeof runtime, Record<st
return call;
}
function jsonFailure(message: string) {
return { ok: false, error: { type: "cli_error", message } };
}
describe("registerMaintenanceCommands doctor action", () => {
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);
});
+2 -1
View File
@@ -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);
}
@@ -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<string> {
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)]);
}
});
});
+9 -2
View File
@@ -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;
}
+14 -2
View File
@@ -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", "");
+5 -1
View File
@@ -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,
);
+10 -2
View File
@@ -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");
});
+2 -1
View File
@@ -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);
+10 -2
View File
@@ -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([]);
});
+9 -5
View File
@@ -31,6 +31,10 @@ function gatewayCall(callIndex = 0): ReadonlyArray<unknown> {
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);
});
+2 -1
View File
@@ -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));
}
+17 -11
View File
@@ -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<boolean>(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);
}
});
}
+4
View File
@@ -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;
+14 -13
View File
@@ -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([
+2 -4
View File
@@ -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);
}
});
+3 -3
View File
@@ -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();
+13 -3
View File
@@ -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, {
@@ -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");
+5 -3
View File
@@ -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;
+12
View File
@@ -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: [
+14 -1
View File
@@ -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) {
+10
View File
@@ -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"),
},
});
});
});
+7 -1
View File
@@ -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;
}
+12 -1
View File
@@ -47,6 +47,13 @@ async function writeCapturedCliArgumentError(message: string): Promise<void> {
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,
+9 -2
View File
@@ -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,
+82 -3
View File
@@ -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) => {