diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ddf2ec03e..a0060e2e24e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ Docs: https://docs.openclaw.ai - Auto-reply/TUI: keep fallback timeout recovery deliverable after a primary model lifecycle error by emitting fallback progress and deferring terminal TUI errors until recovery has a chance to finish. Fixes #80000. (#80009) Thanks @TurboTheTurtle. - CLI/agent: let `openclaw agent --model` use the backend/admin Gateway scope without cached device-token scopes silently downscoping the request. (#78837) Thanks @VACInc. - CLI/help: keep help and version invocations configless while improving shared port, channel, plugin, task, session, message, pairing, and auth recovery text. +- CLI/config: explain strict JSON parse failures with a valid example and the plain-string escape hatch. +- CLI/secrets: turn offline Gateway reload failures into actionable recovery text. +- CLI/channels: explain missing or ambiguous channel selections with next commands. - Browser/Docker: detect Playwright-managed Chromium from `PLAYWRIGHT_BROWSERS_PATH` and the default Playwright cache on Linux, so Docker installs that persist `/home/node/.cache/ms-playwright` no longer need `browser.executablePath`. - Ollama: keep DeepSeek V4 cloud models thinking-capable even when Ollama Cloud `/api/show` omits the `thinking` capability, so `/think high` no longer rejects `ollama/deepseek-v4-*:cloud`. - ACPX/Claude ACP: keep foreground prompts waiting for their own result when autonomous task-notification results arrive during the same session, and retarget the patch for Claude Agent ACP `0.33.1`. diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index c0ddc248926..654b242d473 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -734,6 +734,12 @@ describe("config cli", () => { expect(mockWriteConfigFile).not.toHaveBeenCalled(); expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled(); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining('Could not parse "{bad" as JSON for --strict-json.'), + ); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("For plain strings, omit --strict-json."), + ); }); it("keeps --json as a strict parsing alias", async () => { diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 7267059c68f..2805fdc7557 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -60,6 +60,7 @@ import { type ConfigSetOptions, } from "./config-set-input.js"; import { resolveConfigSetMode } from "./config-set-parser.js"; +import { formatStrictJsonParseFailure } from "./error-format.js"; import { setCommandJsonMode } from "./program/json-mode.js"; type PathSegment = string; @@ -266,7 +267,7 @@ function parseValue(raw: string, opts: ConfigSetParseOpts): unknown { try { return JSON.parse(trimmed); } catch (err) { - throw new Error(`Failed to parse JSON value: ${String(err)}`, { cause: err }); + throw new Error(formatStrictJsonParseFailure({ value: raw, cause: err }), { cause: err }); } } diff --git a/src/cli/error-format.ts b/src/cli/error-format.ts index 5824888eff8..ae4b7b51bd5 100644 --- a/src/cli/error-format.ts +++ b/src/cli/error-format.ts @@ -48,6 +48,39 @@ export function formatUnsupportedChannelActionMessage(params: { )} to inspect supported actions.`; } +export function formatStrictJsonParseFailure(params: { value: string; cause: unknown }): string { + const rawCause = params.cause instanceof Error ? params.cause.message : String(params.cause); + const cause = rawCause.trim().replace(/[.。]+$/u, ""); + const preview = + params.value.length > 48 ? `${params.value.slice(0, 45).trimEnd()}...` : params.value; + return [ + `Could not parse ${JSON.stringify(preview)} as JSON for --strict-json.`, + `${cause}.`, + `Use valid JSON, for example ${formatInlineCliCommand( + "openclaw config set gateway.port 18789 --strict-json", + )}.`, + "For plain strings, omit --strict-json.", + ].join(" "); +} + +export function formatGatewayCommandFailure(params: { + action: string; + error: unknown; + inspectCommand?: string; +}): string { + const raw = params.error instanceof Error ? params.error.message : String(params.error); + const message = raw + .replace(/\s*Run [`"]?openclaw doctor[`"]? for diagnostics\.?/gi, "") + .replace(/\s+/g, " ") + .trim() + .replace(/[.。]+$/u, ""); + const inspectCommand = params.inspectCommand ?? "openclaw gateway status --deep"; + const detail = message ? `: ${message}` : ""; + return `Could not ${params.action} because the Gateway did not respond${detail}. Run ${formatInlineCliCommand( + inspectCommand, + )} to inspect the active Gateway.`; +} + export function formatLookupMiss(params: { noun: string; value: string; diff --git a/src/cli/secrets-cli.test.ts b/src/cli/secrets-cli.test.ts index 70db46b554a..456b6a51362 100644 --- a/src/cli/secrets-cli.test.ts +++ b/src/cli/secrets-cli.test.ts @@ -175,6 +175,22 @@ describe("secrets CLI", () => { expect(runtimeLogs.at(-1)).toContain('"ok": true'); }); + it("explains Gateway reload failures without duplicate doctor noise", async () => { + callGatewayFromCli.mockRejectedValue( + new Error("gateway closed (1006 abnormal closure). Run `openclaw doctor` for diagnostics."), + ); + + await expect( + createProgram().parseAsync(["secrets", "reload"], { from: "user" }), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain( + "Could not reload secrets because the Gateway did not respond: gateway closed (1006 abnormal closure).", + ); + expect(runtimeErrors.at(-1)).toContain("openclaw gateway status --deep"); + expect(runtimeErrors.at(-1)).not.toContain("diagnostics.."); + }); + it("runs secrets audit and exits via check code", async () => { runSecretsAudit.mockResolvedValue({ version: 1, diff --git a/src/cli/secrets-cli.ts b/src/cli/secrets-cli.ts index 7b6616aa2f1..4baefe8b926 100644 --- a/src/cli/secrets-cli.ts +++ b/src/cli/secrets-cli.ts @@ -11,6 +11,7 @@ import { isSecretsApplyPlan, type SecretsApplyPlan } from "../secrets/plan.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { formatCliCommand } from "./command-format.js"; +import { formatGatewayCommandFailure } from "./error-format.js"; import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js"; type SecretsReloadOptions = GatewayRpcOpts & { json?: boolean }; @@ -82,7 +83,11 @@ export function registerSecretsCli(program: Command) { } catch (err) { defaultRuntime.error( danger( - `Secrets reload failed: ${formatErrorMessage(err)}. Run ${formatCliCommand("openclaw gateway status --deep")} to inspect the active gateway.`, + formatGatewayCommandFailure({ + action: "reload secrets", + error: err, + inspectCommand: "openclaw gateway status --deep", + }), ), ); defaultRuntime.exit(1); diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index e83c48bd930..d2a3ef7cbfa 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -271,7 +271,8 @@ describe("resolveMessageChannelSelection", () => { }, { params: { cfg: {} as never }, - expectedMessage: "Channel is required (no configured channels detected).", + expectedMessage: + "Channel is required (no configured channels detected). Run openclaw channels add to configure one", }, { setup: () => { @@ -292,7 +293,8 @@ describe("resolveMessageChannelSelection", () => { ]); }, params: { cfg: { channels: { whatsapp: { enabled: true } } } as never }, - expectedMessage: "Channel is required (no configured channels detected).", + expectedMessage: + "Channel is required (no configured channels detected). Run openclaw channels add to configure one", }, { setup: () => { @@ -302,7 +304,8 @@ describe("resolveMessageChannelSelection", () => { ]); }, params: { cfg: {} as never }, - expectedMessage: "Channel is required when multiple channels are configured: beta, gamma", + expectedMessage: + "Channel is required when multiple channels are configured: beta, gamma. Pass --channel to choose one.", }, ])("rejects invalid channel selection for %j", async ({ setup, params, expectedMessage }) => { setup?.(); diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index a6c3b132699..a7414703339 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -102,6 +102,21 @@ function formatMissingOfficialExternalChannelsMessage( return `Configured official external channels ${labels} are missing their plugins. Run: openclaw doctor --fix, or install individually: ${installCommands}.`; } +function formatNoConfiguredChannelsMessage(): string { + return [ + "Channel is required (no configured channels detected).", + "Run openclaw channels add to configure one, or pass --channel after enabling a channel.", + "Use openclaw channels list --all to see available channel ids.", + ].join(" "); +} + +function formatMultipleConfiguredChannelsMessage(configured: readonly string[]): string { + return [ + `Channel is required when multiple channels are configured: ${configured.join(", ")}.`, + "Pass --channel to choose one.", + ].join(" "); +} + function isAccountEnabled(account: unknown): boolean { if (!account || typeof account !== "object") { return true; @@ -263,11 +278,9 @@ export async function resolveMessageChannelSelection(params: { `Channel is required (no available channels detected). ${formatMissingOfficialExternalChannelsMessage(repairHints)}`, ); } - throw new Error("Channel is required (no configured channels detected)."); + throw new Error(formatNoConfiguredChannelsMessage()); } - throw new Error( - `Channel is required when multiple channels are configured: ${configured.join(", ")}`, - ); + throw new Error(formatMultipleConfiguredChannelsMessage(configured)); } export const __testing = {