fix(cli): preserve machine-readable stdout (#113654)

Co-authored-by: 1052326311 <65798732+1052326311@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-07-27 05:44:16 -04:00
committed by GitHub
parent fd6e042d87
commit 269bc5c89e
146 changed files with 4038 additions and 454 deletions
@@ -46,6 +46,7 @@ bba5540be7cf9613a163663decdb2affe2af9bbd3ad7914989ab186f9c2abec1 module/channel
fb123c1b557ed2527e335f13c3d6de41ab0c3305151001cf2b1075d1da8034c5 module/channel-setup
47719805c7a1d2623dcecafad50abb6cc674262b250e1f0d7020a79ad41cfd42 module/channel-status
824858dccff862ba6b83f6a478bea73397972a4584f4a427a794224ac680670a module/channel-streaming
14f0103adb14627b662fbe9fdd5ed08596ed702a250853e8beb8bb8dd1361588 module/cli-argv
c89ec1b194b76f67a6f4dd108dccf460da6065646cba31374c8aa748f23a39e4 module/collection-runtime
391e6f0c77da2e17a058fc5aea87fd193830b2a14e12fae77c7f63b0226f0bb5 module/command-auth
7dede491d22f7226d955210befca09b32e955c59436c27500f0c7d1971ba07d5 module/command-auth-native
+7
View File
@@ -234,6 +234,13 @@ CLI registration:
shapes and strips terminal control sequences from descriptions before
rendering help. Cover every top-level command root the registrar exposes.
`commands` alone stays on the eager compatibility path.
- Root descriptors may define a synchronous, pure
`machineOutput({ argv, stdoutIsTTY })` resolver for JSON, JSONL, or other
machine-readable stdout modes that are not selected solely by `--json`.
Parse command tokens with `getRootOptionAwareCommandPath` from
`openclaw/plugin-sdk/cli-argv`. Keep the resolver in lightweight CLI metadata
and share it with full registration. Nested descriptors do not expose this
field.
- Use `api.registerNodeCliFeature(...)` for paired-node feature commands so
they land under `openclaw nodes` (equivalent to
`registerCli(registrar, { parentPath: ["nodes"], ... })`).
+13
View File
@@ -542,6 +542,19 @@ api.registerCli(
);
```
A root descriptor can also declare `machineOutput({ argv, stdoutIsTTY })` when
the command reserves stdout for JSON, JSONL, or another machine-readable format
without relying exclusively on a literal `--json` flag. OpenClaw evaluates this
resolver before plugin activation so startup diagnostics can be routed to
stderr. The resolver must be synchronous, pure, and dependency-light: inspect
only the supplied raw argv and stdout TTY state. Reuse the same resolver in
lightweight CLI metadata and full registration so discovery and execution do
not disagree. Use `getRootOptionAwareCommandPath` from
`openclaw/plugin-sdk/cli-argv` when the resolver needs command-path tokens; it
accepts supported root options before or after the command root. `machineOutput`
is root metadata; nested descriptors cannot use it because their owning root
must already be active before they are visible.
Nested commands receive the resolved parent command as `program`:
```typescript
+1
View File
@@ -224,6 +224,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/lazy-runtime` | Lazy runtime import/binding helpers such as `createLazyRuntimeModule`, `createLazyRuntimeMethod`, and `createLazyRuntimeSurface` |
| `plugin-sdk/process-runtime` | Private-local after July 2026; Process exec helpers |
| `plugin-sdk/node-host` | Private-local after July 2026; Node-host executable resolution and PTY resume helpers |
| `plugin-sdk/cli-argv` | Dependency-light root-option parsing for CLI metadata, including `getRootOptionAwareCommandPath` and `consumeRootOptionToken` |
| `plugin-sdk/cli-runtime` | Private-local after July 2026; Deprecated broad barrel for CLI formatting, wait, version, argument-invocation, and lazy command-group helpers; prefer focused CLI/runtime subpaths |
| `plugin-sdk/qa-runner-runtime` | Private-local after July 2026; Supported facade exposing plugin QA scenarios through the CLI command surface |
| `plugin-sdk/tts-runtime` | Private-local after July 2026; Supported facade for text-to-speech config schemas and runtime helpers |
+12 -1
View File
@@ -3,6 +3,7 @@
* so command discovery does not load the full browser runtime.
*/
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { isBrowserMachineOutput } from "./cli-output-mode.js";
/** Plugin entry that contributes Browser CLI commands. */
export default definePluginEntry({
@@ -15,7 +16,17 @@ export default definePluginEntry({
const { registerBrowserCli } = await import("./src/cli/browser-cli.js");
registerBrowserCli(program, process.argv, api.rootDir);
},
{ commands: ["browser"] },
{
commands: ["browser"],
descriptors: [
{
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
machineOutput: isBrowserMachineOutput,
},
],
},
);
},
});
+91
View File
@@ -0,0 +1,91 @@
import { consumeRootOptionToken } from "openclaw/plugin-sdk/cli-argv";
const BROWSER_BOOLEAN_OPTIONS = new Set(["--json", "--expect-final"]);
const BROWSER_VALUE_OPTIONS = new Set([
"--browser-profile",
"--url",
"--token",
"--timeout",
"--gateway-url",
]);
function isValueToken(arg: string | undefined): boolean {
return Boolean(arg && arg !== "--" && (!arg.startsWith("-") || /^-\d+(?:\.\d+)?$/.test(arg)));
}
function consumeOption(
args: readonly string[],
index: number,
booleanOptions: ReadonlySet<string>,
valueOptions: ReadonlySet<string>,
): number {
const arg = args[index];
if (!arg || arg === "--" || !arg.startsWith("-")) {
return 0;
}
const equalsIndex = arg.indexOf("=");
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
if (booleanOptions.has(flag)) {
return equalsIndex === -1 ? 1 : 0;
}
if (!valueOptions.has(flag)) {
return 0;
}
if (equalsIndex !== -1) {
return arg.slice(equalsIndex + 1).trim() ? 1 : 0;
}
return isValueToken(args[index + 1]) ? 2 : 1;
}
function resolveBrowserCommandPath(argv: readonly string[]): string[] {
const args = argv.slice(2);
let sawBrowser = false;
const commandPath: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg || arg === "--") {
break;
}
if (!sawBrowser) {
const consumed = consumeRootOptionToken(args, index);
if (consumed > 0) {
index += consumed - 1;
continue;
}
if (arg === "browser") {
sawBrowser = true;
}
continue;
}
const rootConsumed = consumeRootOptionToken(args, index);
if (rootConsumed > 0) {
index += rootConsumed - 1;
continue;
}
const consumed = consumeOption(args, index, BROWSER_BOOLEAN_OPTIONS, BROWSER_VALUE_OPTIONS);
if (consumed > 0) {
index += consumed - 1;
continue;
}
if (arg.startsWith("-")) {
break;
}
commandPath.push(arg);
}
return commandPath;
}
export function resolveBrowserLazySubcommand(argv: readonly string[]): string | null {
return resolveBrowserCommandPath(argv)[0] ?? null;
}
/** Browser inspection commands with JSON as their default presentation own machine stdout. */
export function isBrowserMachineOutput(params: { argv: readonly string[] }): boolean {
const path = resolveBrowserCommandPath(params.argv);
return (
path[0] === "evaluate" ||
path[0] === "console" ||
(path[0] === "cookies" && path.length === 1) ||
(path[0] === "storage" && ["local", "session"].includes(path[1] ?? "") && path[2] === "get")
);
}
@@ -14,6 +14,7 @@ import type {
OpenClawPluginToolContext,
OpenClawPluginToolFactory,
} from "openclaw/plugin-sdk/plugin-entry";
import { isBrowserMachineOutput } from "./cli-output-mode.js";
import {
BROWSER_REQUEST_GATEWAY_METHOD,
BROWSER_REQUEST_GATEWAY_SCOPE,
@@ -57,6 +58,7 @@ const BROWSER_CLI_DESCRIPTOR = {
name: "browser",
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
hasSubcommands: true,
machineOutput: isBrowserMachineOutput,
};
function createLazyBrowserTool(opts?: {
@@ -1,7 +1,24 @@
import { describe, expect, it } from "vitest";
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCliRuntimeCapture } from "../../test-support.js";
import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js";
import * as cliCoreApiModule from "./core-api.js";
const relayMocks = vi.hoisted(() => ({ ensureExtensionRelayToken: vi.fn(() => "pair-token") }));
vi.mock("../browser/extension-relay/relay-auth.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../browser/extension-relay/relay-auth.js")>()),
ensureExtensionRelayToken: relayMocks.ensureExtensionRelayToken,
}));
const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture();
describe("browser extension pairing Gateway URL", () => {
afterEach(() => {
vi.restoreAllMocks();
resetRuntimeCapture();
});
it("uses loopback only for a plaintext local Gateway", () => {
expect(resolveLocalPairingGatewayUrl({ gatewayPort: 18789, tlsEnabled: false })).toBe(
"ws://127.0.0.1:18789",
@@ -20,4 +37,25 @@ describe("browser extension pairing Gateway URL", () => {
}),
).toBe("wss://gateway.example");
});
it("writes explicit JSON output through the raw machine-output sink", async () => {
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
const logSpy = vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log);
const writeJsonSpy = vi
.spyOn(cliCoreApiModule.defaultRuntime, "writeJson")
.mockImplementation(runtime.writeJson);
const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js");
const program = new Command();
const browser = program.command("browser");
registerBrowserExtensionCommands(browser, () => ({}));
await program.parseAsync(["browser", "extension", "pair", "--json"], { from: "user" });
expect(writeJsonSpy).toHaveBeenCalledWith({
pairingString: expect.stringContaining("#pair-token"),
relayPort: 18799,
remote: false,
});
expect(logSpy).not.toHaveBeenCalled();
});
});
@@ -138,13 +138,11 @@ export function registerBrowserExtensionCommands(
async () => {
const result = buildPairingString(opts.gatewayUrl);
if (opts.json === true) {
defaultRuntime.log(
JSON.stringify({
pairingString: result.pairing,
relayPort: result.relayPort,
remote: result.remote,
}),
);
defaultRuntime.writeJson({
pairingString: result.pairing,
relayPort: result.relayPort,
remote: result.remote,
});
return;
}
const setupLine = result.remote
@@ -1,6 +1,7 @@
// Browser tests cover browser cli.lazy plugin behavior.
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isBrowserMachineOutput } from "../../cli-output-mode.js";
const manageMocks = vi.hoisted(() => {
const doctorAction = vi.fn();
@@ -81,6 +82,31 @@ function requireTrailingCommand(args: unknown[], label: string): Command {
}
describe("registerBrowserCli lazy browser subcommands", () => {
it.each([
["evaluate", ["browser", "evaluate", "--fn", "return 1"]],
["console", ["browser", "console"]],
["cookies", ["browser", "cookies"]],
["local storage", ["browser", "storage", "local", "get"]],
["session storage", ["browser", "storage", "session", "get", "key"]],
])("declares default JSON output for %s", (_name, args) => {
expect(isBrowserMachineOutput({ argv: ["node", "openclaw", ...args] })).toBe(true);
});
it("keeps human browser commands out of machine-output mode", () => {
expect(isBrowserMachineOutput({ argv: ["node", "openclaw", "browser", "status"] })).toBe(false);
expect(
isBrowserMachineOutput({ argv: ["node", "openclaw", "browser", "cookies", "set"] }),
).toBe(false);
});
it("accepts supported root options after browser", () => {
expect(
isBrowserMachineOutput({
argv: ["node", "openclaw", "browser", "--log-level", "debug", "evaluate"],
}),
).toBe(true);
});
beforeEach(() => {
vi.unstubAllEnvs();
manageMocks.registerBrowserManageCommands.mockClear();
+1 -83
View File
@@ -4,11 +4,11 @@
import type { Command } from "commander";
import {
registerCommandGroups,
resolveCliArgvInvocation,
shouldEagerRegisterSubcommands,
type CommandGroupEntry,
type CommandGroupPlaceholder,
} from "openclaw/plugin-sdk/cli-runtime";
import { resolveBrowserLazySubcommand } from "../../cli-output-mode.js";
import { browserActionExamples, browserCoreExamples } from "./browser-cli-examples.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import {
@@ -32,17 +32,6 @@ type BrowserCommandGroupDefinition = {
register: BrowserCommandRegistrar;
};
const ROOT_BOOLEAN_OPTIONS = new Set(["--dev", "--no-color"]);
const ROOT_VALUE_OPTIONS = new Set(["--profile", "--log-level", "--container"]);
const BROWSER_BOOLEAN_OPTIONS = new Set(["--json", "--expect-final"]);
const BROWSER_VALUE_OPTIONS = new Set([
"--browser-profile",
"--url",
"--token",
"--timeout",
"--gateway-url",
]);
const command = (
name: string,
description: string,
@@ -170,77 +159,6 @@ function buildBrowserCommandGroups(params: {
}));
}
function isValueToken(arg: string | undefined): boolean {
return Boolean(arg && arg !== "--" && (!arg.startsWith("-") || /^-\d+(?:\.\d+)?$/.test(arg)));
}
function consumeOption(
args: readonly string[],
index: number,
booleanOptions: ReadonlySet<string>,
valueOptions: ReadonlySet<string>,
): number {
const arg = args[index];
if (!arg || arg === "--" || !arg.startsWith("-")) {
return 0;
}
const equalsIndex = arg.indexOf("=");
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
if (booleanOptions.has(flag)) {
return equalsIndex === -1 ? 1 : 0;
}
if (!valueOptions.has(flag)) {
return 0;
}
if (equalsIndex !== -1) {
return arg.slice(equalsIndex + 1).trim() ? 1 : 0;
}
return isValueToken(args[index + 1]) ? 2 : 1;
}
function resolveBrowserLazySubcommand(argv: string[]): string | null {
const { primary } = resolveCliArgvInvocation(argv);
if (primary !== "browser") {
return null;
}
const args = argv.slice(2);
let sawBrowser = false;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg || arg === "--") {
break;
}
if (!sawBrowser) {
const consumed = consumeOption(args, i, ROOT_BOOLEAN_OPTIONS, ROOT_VALUE_OPTIONS);
if (consumed > 0) {
i += consumed - 1;
continue;
}
if (arg.startsWith("-")) {
continue;
}
if (arg === "browser") {
sawBrowser = true;
continue;
}
return null;
}
const consumed = consumeOption(args, i, BROWSER_BOOLEAN_OPTIONS, BROWSER_VALUE_OPTIONS);
if (consumed > 0) {
i += consumed - 1;
continue;
}
if (arg.startsWith("-")) {
continue;
}
return arg;
}
return null;
}
function resolveBrowserParentOpts(cmd: Command): BrowserParentOpts {
return cmd.optsWithGlobals<BrowserParentOpts>();
}
@@ -0,0 +1,32 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { appendFileTransferAudit } from "./audit.js";
describe("file-transfer audit diagnostics", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("routes append failures through captured console diagnostics", async () => {
await withTempDir("openclaw-file-transfer-audit-", async (root) => {
const homeFile = path.join(root, "home-file");
await fs.writeFile(homeFile, "not a directory", "utf8");
vi.spyOn(os, "homedir").mockReturnValue(homeFile);
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
await appendFileTransferAudit({
op: "file.fetch",
nodeId: "test-node",
requestedPath: "/tmp/example",
decision: "error",
});
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("[file-transfer:audit] append failed"),
);
});
});
});
+2 -2
View File
@@ -74,7 +74,7 @@ function auditFilePath(dir: string): string {
}
/**
* Append an audit record. Best-effort — failures are logged to stderr and
* Append an audit record. Best-effort — failures are logged through console capture and
* never propagated to the caller (the caller's operation is the source of
* truth, not the audit write).
*/
@@ -93,6 +93,6 @@ export async function appendFileTransferAudit(
rejectSymlinkParents: true,
});
} catch (e) {
process.stderr.write(`[file-transfer:audit] append failed: ${String(e)}\n`);
console.warn(`[file-transfer:audit] append failed: ${String(e)}`);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { GOOGLE_MEET_CLI_DESCRIPTOR } from "./src/cli-output-mode.js";
export default definePluginEntry({
id: "google-meet",
name: "Google Meet",
description: "Google Meet CLI metadata",
register(api) {
api.registerCli(() => {}, { descriptors: [GOOGLE_MEET_CLI_DESCRIPTOR] });
},
});
+2 -7
View File
@@ -7,6 +7,7 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
import { createMeetingTranscriptSourceProvider } from "openclaw/plugin-sdk/transcripts";
import { buildGoogleMeetCalendarDayWindow, listGoogleMeetCalendarEvents } from "./src/calendar.js";
import { GOOGLE_MEET_CLI_DESCRIPTOR } from "./src/cli-output-mode.js";
import {
buildGoogleMeetPreflightReport,
endGoogleMeetActiveConference,
@@ -628,13 +629,7 @@ export default definePluginEntry({
},
{
commands: ["googlemeet"],
descriptors: [
{
name: "googlemeet",
description: "Join and manage Google Meet calls",
hasSubcommands: true,
},
],
descriptors: [GOOGLE_MEET_CLI_DESCRIPTOR],
},
);
},
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { GOOGLE_MEET_CLI_DESCRIPTOR } from "./cli-output-mode.js";
const isMachineOutput = GOOGLE_MEET_CLI_DESCRIPTOR.machineOutput;
describe("Google Meet CLI output mode", () => {
it.each(["join", "status", "test-listen", "test-speech"])(
"detects %s as default JSON output",
(command) => {
expect(isMachineOutput({ argv: ["node", "openclaw", "googlemeet", command] })).toBe(true);
},
);
it("does not classify human setup output", () => {
expect(isMachineOutput({ argv: ["node", "openclaw", "googlemeet", "setup"] })).toBe(false);
});
it("accepts post-root options and detects dry-run exports", () => {
expect(
isMachineOutput({
argv: ["node", "openclaw", "googlemeet", "--log-level", "debug", "join"],
}),
).toBe(true);
expect(
isMachineOutput({
argv: ["node", "openclaw", "googlemeet", "export", "--dry-run"],
}),
).toBe(true);
});
});
@@ -0,0 +1,31 @@
import { getRootOptionAwareCommandPath } from "openclaw/plugin-sdk/cli-argv";
const DEFAULT_JSON_COMMANDS = new Set(["join", "status", "test-listen", "test-speech"]);
function hasOption(argv: readonly string[], flag: string): boolean {
for (const arg of argv.slice(2)) {
if (arg === "--") {
return false;
}
if (arg === flag || arg.startsWith(`${flag}=`)) {
return true;
}
}
return false;
}
/** Runtime probe commands emit JSON without requiring the shared `--json` option. */
function isGoogleMeetMachineOutput(params: { argv: readonly string[] }): boolean {
const [, command] = getRootOptionAwareCommandPath(params.argv, 2);
return (
DEFAULT_JSON_COMMANDS.has(command ?? "") ||
(command === "export" && hasOption(params.argv, "--dry-run"))
);
}
export const GOOGLE_MEET_CLI_DESCRIPTOR = {
name: "googlemeet",
description: "Join and manage Google Meet calls",
hasSubcommands: true,
machineOutput: isGoogleMeetMachineOutput,
} as const;
@@ -1,5 +1,6 @@
// Memory Lancedb plugin module implements cli metadata behavior.
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { isMemoryMachineOutput } from "./cli-output-mode.js";
export default definePluginEntry({
id: "memory-lancedb",
@@ -12,6 +13,7 @@ export default definePluginEntry({
name: "ltm",
description: "Inspect and query LanceDB-backed memory",
hasSubcommands: true,
machineOutput: isMemoryMachineOutput,
},
],
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { isMemoryMachineOutput } from "./cli-output-mode.js";
describe("LanceDB CLI output mode", () => {
it.each(["list", "query", "search"])("detects ltm %s as machine output", (command) => {
expect(isMemoryMachineOutput({ argv: ["node", "openclaw", "ltm", command] })).toBe(true);
});
it("leaves stats human-readable", () => {
expect(isMemoryMachineOutput({ argv: ["node", "openclaw", "ltm", "stats"] })).toBe(false);
});
it("accepts a post-root log level", () => {
expect(
isMemoryMachineOutput({
argv: ["node", "openclaw", "ltm", "--log-level", "debug", "list"],
}),
).toBe(true);
});
});
@@ -0,0 +1,7 @@
import { getRootOptionAwareCommandPath } from "openclaw/plugin-sdk/cli-argv";
/** LanceDB inspection commands emit JSON as their only presentation. */
export function isMemoryMachineOutput(params: { argv: readonly string[] }): boolean {
const [, command] = getRootOptionAwareCommandPath(params.argv, 2);
return ["list", "query", "search"].includes(command ?? "");
}
+5 -2
View File
@@ -1084,7 +1084,9 @@ describe("memory plugin e2e", () => {
on: vi.fn(),
resolvePath: (filePath: string) => filePath,
};
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true as unknown as ReturnType<typeof process.stdout.write>);
try {
registerTestPlugin(dynamicMemoryPlugin, mockApi);
const registrar = firstMockArg(registerCli as unknown as MockCallSource, "cli registrar");
@@ -1094,8 +1096,9 @@ describe("memory plugin e2e", () => {
await program.parseAsync(["node", "openclaw", "ltm", "list", "--limit", "+03"]);
expect(limit).toHaveBeenCalledWith(3);
expect(stdoutWrite).toHaveBeenCalledWith("[]\n");
} finally {
log.mockRestore();
stdoutWrite.mockRestore();
}
},
});
+16 -4
View File
@@ -1,5 +1,7 @@
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime";
import type { OpenClawPluginApi } from "./api.js";
import { isMemoryMachineOutput } from "./cli-output-mode.js";
import type { Embeddings } from "./embeddings.js";
import {
MEMORY_QUERY_COLUMNS,
@@ -124,7 +126,7 @@ export function registerMemoryCli(
const entries = await db.list(agentId, limit, {
orderByCreatedAt: Boolean(opts.orderByCreatedAt),
});
console.log(JSON.stringify(entries, null, 2));
defaultRuntime.writeJson(entries);
});
memory
@@ -148,7 +150,7 @@ export function registerMemoryCli(
importance: r.entry.importance,
score: r.score,
}));
console.log(JSON.stringify(output, null, 2));
defaultRuntime.writeJson(output);
} catch (err) {
operationError = err;
operationFailed = true;
@@ -210,7 +212,7 @@ export function registerMemoryCli(
}
}
}
console.log(JSON.stringify(rows, null, 2));
defaultRuntime.writeJson(rows);
});
memory
@@ -223,6 +225,16 @@ export function registerMemoryCli(
console.log(`Total memories: ${count}`);
});
},
{ commands: ["ltm"] },
{
commands: ["ltm"],
descriptors: [
{
name: "ltm",
description: "LanceDB memory plugin commands",
hasSubcommands: true,
machineOutput: isMemoryMachineOutput,
},
],
},
);
}
+20
View File
@@ -1,6 +1,25 @@
// OC Path module implements cli registration behavior.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
function hasCliFlag(argv: readonly string[], flag: "--human" | "--json"): boolean {
for (const arg of argv.slice(2)) {
if (arg === "--") {
return false;
}
if (arg === flag) {
return true;
}
}
return false;
}
function isPathMachineOutput(params: { argv: readonly string[]; stdoutIsTTY: boolean }): boolean {
if (hasCliFlag(params.argv, "--json")) {
return true;
}
return !hasCliFlag(params.argv, "--human") && !params.stdoutIsTTY;
}
export function registerOcPathCli(api: OpenClawPluginApi): void {
api.registerCli(
async ({ program }) => {
@@ -13,6 +32,7 @@ export function registerOcPathCli(api: OpenClawPluginApi): void {
name: "path",
description: "Inspect and edit workspace files via oc:// paths",
hasSubcommands: true,
machineOutput: isPathMachineOutput,
},
],
},
+30
View File
@@ -9,9 +9,31 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command, CommanderError } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerOcPathCli } from "../cli-registration.js";
import { registerPathCli } from "./cli.js";
const JSONC_INPUT_LIMIT_BYTES = 16 * 1024 * 1024;
type RegisterCli = Parameters<typeof registerOcPathCli>[0]["registerCli"];
type CliRegistrar = Parameters<RegisterCli>[0];
type CliRegistrationOptions = Parameters<RegisterCli>[1];
function resolvePathMachineOutput() {
let resolver:
| ((params: { argv: readonly string[]; stdoutIsTTY: boolean }) => boolean)
| undefined;
registerOcPathCli({
registerCli(_registrar: CliRegistrar, options?: CliRegistrationOptions) {
const descriptor = options?.descriptors?.[0];
resolver = descriptor && "machineOutput" in descriptor ? descriptor.machineOutput : undefined;
},
} as unknown as Parameters<typeof registerOcPathCli>[0]);
if (!resolver) {
throw new Error("oc-path CLI descriptor is missing its machine-output resolver");
}
return resolver;
}
const isPathMachineOutput = resolvePathMachineOutput();
type PathCommandOptions = {
readonly json?: boolean;
@@ -176,6 +198,14 @@ async function pathEmitCommand(
}
describe("openclaw path CLI", () => {
it("reports its TTY-aware machine-output mode to the CLI", () => {
const argv = ["node", "openclaw", "path", "validate", "oc://AGENTS.md"];
expect(isPathMachineOutput({ argv, stdoutIsTTY: false })).toBe(true);
expect(isPathMachineOutput({ argv, stdoutIsTTY: true })).toBe(false);
expect(isPathMachineOutput({ argv: [...argv, "--json"], stdoutIsTTY: true })).toBe(true);
expect(isPathMachineOutput({ argv: [...argv, "--human"], stdoutIsTTY: false })).toBe(false);
});
let workspaceDir: string;
beforeEach(() => {
+11
View File
@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { POLICY_CLI_DESCRIPTOR } from "./src/cli-output-mode.js";
export default definePluginEntry({
id: "policy",
name: "Policy",
description: "Policy CLI metadata",
register(api) {
api.registerCli(() => {}, { descriptors: [POLICY_CLI_DESCRIPTOR] });
},
});
+2 -7
View File
@@ -1,5 +1,6 @@
// Policy plugin entrypoint registers its OpenClaw integration.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { POLICY_CLI_DESCRIPTOR } from "./src/cli-output-mode.js";
import { registerPolicyCli } from "./src/cli.js";
import { registerPolicyDoctorChecks } from "./src/doctor/register.js";
@@ -13,13 +14,7 @@ export default definePluginEntry({
registerPolicyCli(program);
},
{
descriptors: [
{
name: "policy",
description: "Check policy requirements and emit audit evidence",
hasSubcommands: true,
},
],
descriptors: [POLICY_CLI_DESCRIPTOR],
},
);
registerPolicyDoctorChecks();
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { POLICY_CLI_DESCRIPTOR } from "./cli-output-mode.js";
const isMachineOutput = POLICY_CLI_DESCRIPTOR.machineOutput;
describe("policy CLI output mode", () => {
it.each(["check", "compare", "watch"])("detects piped %s output", (command) => {
expect(
isMachineOutput({
argv: ["node", "openclaw", "policy", command],
stdoutIsTTY: false,
}),
).toBe(true);
});
it("keeps terminal output human-readable", () => {
expect(
isMachineOutput({
argv: ["node", "openclaw", "policy", "check"],
stdoutIsTTY: true,
}),
).toBe(false);
});
it("accepts a post-root log level", () => {
expect(
isMachineOutput({
argv: ["node", "openclaw", "policy", "--log-level", "debug", "check"],
stdoutIsTTY: false,
}),
).toBe(true);
});
});
+14
View File
@@ -0,0 +1,14 @@
import { getRootOptionAwareCommandPath } from "openclaw/plugin-sdk/cli-argv";
/** Policy commands follow Unix convention and switch to JSON when stdout is not a terminal. */
function isPolicyMachineOutput(params: { argv: readonly string[]; stdoutIsTTY: boolean }): boolean {
const [, command] = getRootOptionAwareCommandPath(params.argv, 2);
return ["check", "compare", "watch"].includes(command ?? "") && !params.stdoutIsTTY;
}
export const POLICY_CLI_DESCRIPTOR = {
name: "policy",
description: "Check policy requirements and emit audit evidence",
hasSubcommands: true,
machineOutput: isPolicyMachineOutput,
} as const;
+18 -5
View File
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join } from "node:path";
import { Command } from "commander";
import { defaultRuntime as cliRuntime } from "openclaw/plugin-sdk/runtime";
import { clearConfigCache } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerPolicyCli } from "./cli.js";
@@ -33,6 +34,9 @@ async function runPolicyCli(args: readonly string[]) {
output.push(String(chunk));
return true;
}) as typeof process.stderr.write);
const consoleError = vi.spyOn(console, "error").mockImplementation((...values: unknown[]) => {
output.push(`${values.map(String).join(" ")}\n`);
});
const previousExitCode = process.exitCode;
process.exitCode = undefined;
try {
@@ -46,6 +50,7 @@ async function runPolicyCli(args: readonly string[]) {
process.exitCode = previousExitCode;
stdout.mockRestore();
stderr.mockRestore();
consoleError.mockRestore();
}
}
@@ -474,12 +479,20 @@ describe("policy commands", () => {
});
it("rejects invalid severity thresholds", async () => {
const { exitCode, output } = await runPolicyCheckJson({ severityMin: "warnng" });
const errorSpy = vi.spyOn(cliRuntime, "error");
try {
const { exitCode, output } = await runPolicyCheckJson({ severityMin: "warnng" });
expect(exitCode).toBe(2);
expect(output).toEqual([
"Invalid --severity-min value. Expected one of: info, warning, error.\n",
]);
expect(exitCode).toBe(2);
expect(errorSpy).toHaveBeenCalledWith(
"Invalid --severity-min value. Expected one of: info, warning, error.",
);
expect(output).toEqual([
"Invalid --severity-min value. Expected one of: info, warning, error.\n",
]);
} finally {
errorSpy.mockRestore();
}
});
it("fails closed when the OpenClaw config is invalid", async () => {
+2 -1
View File
@@ -12,6 +12,7 @@ import {
type HealthCheckContext,
type HealthFinding,
} from "openclaw/plugin-sdk/health";
import { defaultRuntime as cliRuntime } from "openclaw/plugin-sdk/runtime";
import { POLICY_FIX_METADATA_BY_CHECK_ID } from "./doctor/fix-metadata.js";
import { POLICY_CHECK_IDS, evaluatePolicy } from "./doctor/register.js";
import {
@@ -60,7 +61,7 @@ const defaultRuntime: PolicyCommandRuntime = {
process.stdout.write(value);
},
error(value) {
process.stderr.write(`${value}\n`);
cliRuntime.error(value);
},
sleep(ms) {
return sleep(ms);
+11
View File
@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { TEAMS_MEETINGS_CLI_DESCRIPTOR } from "./src/cli-output-mode.js";
export default definePluginEntry({
id: "teams-meetings",
name: "Microsoft Teams meetings",
description: "Microsoft Teams meetings CLI metadata",
register(api) {
api.registerCli(() => {}, { descriptors: [TEAMS_MEETINGS_CLI_DESCRIPTOR] });
},
});
+2 -7
View File
@@ -3,6 +3,7 @@ import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { Type } from "typebox";
import { TEAMS_MEETINGS_CLI_DESCRIPTOR } from "./src/cli-output-mode.js";
import {
resolveTeamsMeetingsConfig,
resolveTeamsMeetingsGatewayOperationTimeoutMs,
@@ -120,13 +121,7 @@ export default definePluginEntry(
},
{
commands: ["teamsmeetings"],
descriptors: [
{
name: "teamsmeetings",
description: "Join and manage Microsoft Teams meeting guests",
hasSubcommands: true,
},
],
descriptors: [TEAMS_MEETINGS_CLI_DESCRIPTOR],
},
);
},
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { TEAMS_MEETINGS_CLI_DESCRIPTOR } from "./cli-output-mode.js";
const isMachineOutput = TEAMS_MEETINGS_CLI_DESCRIPTOR.machineOutput;
describe("Teams meetings CLI output mode", () => {
it("detects action output and ignores the bare root", () => {
expect(
isMachineOutput({
argv: ["node", "openclaw", "teamsmeetings", "status"],
}),
).toBe(true);
expect(isMachineOutput({ argv: ["node", "openclaw", "teamsmeetings"] })).toBe(false);
expect(
isMachineOutput({
argv: ["node", "openclaw", "teamsmeetings", "--log-level", "debug", "status"],
}),
).toBe(true);
});
});
@@ -0,0 +1,13 @@
import { getRootOptionAwareCommandPath } from "openclaw/plugin-sdk/cli-argv";
/** Every Teams meetings action emits one JSON result on stdout. */
function isTeamsMeetingsMachineOutput(params: { argv: readonly string[] }): boolean {
return getRootOptionAwareCommandPath(params.argv, 2).length === 2;
}
export const TEAMS_MEETINGS_CLI_DESCRIPTOR = {
name: "teamsmeetings",
description: "Join and manage Microsoft Teams meeting guests",
hasSubcommands: true,
machineOutput: isTeamsMeetingsMachineOutput,
} as const;
+5 -1
View File
@@ -1,5 +1,6 @@
// Voice Call plugin module implements cli metadata behavior.
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
// Lightweight CLI metadata entry for exposing the voicecall command.
@@ -8,6 +9,9 @@ export default definePluginEntry({
name: "Voice Call",
description: "Voice call channel plugin",
register(api) {
api.registerCli(() => {}, { commands: ["voicecall"] });
api.registerCli(() => {}, {
commands: ["voicecall"],
descriptors: [VOICE_CALL_CLI_DESCRIPTOR],
});
},
});
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
const isMachineOutput = VOICE_CALL_CLI_DESCRIPTOR.machineOutput;
describe("voice-call CLI output mode", () => {
it.each([
"call",
"continue",
"dtmf",
"end",
"expose",
"latency",
"speak",
"start",
"status",
"tail",
])("detects %s as machine output", (command) => {
expect(isMachineOutput({ argv: ["node", "openclaw", "voicecall", command] })).toBe(true);
});
it("leaves setup human-readable without --json", () => {
expect(isMachineOutput({ argv: ["node", "openclaw", "voicecall", "setup"] })).toBe(false);
});
it("accepts a post-root log level", () => {
expect(
isMachineOutput({
argv: ["node", "openclaw", "voicecall", "--log-level", "debug", "status"],
}),
).toBe(true);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { getRootOptionAwareCommandPath } from "openclaw/plugin-sdk/cli-argv";
const MACHINE_OUTPUT_COMMANDS = new Set([
"call",
"continue",
"dtmf",
"end",
"expose",
"latency",
"speak",
"start",
"status",
"tail",
]);
/** Voice-call result actions emit JSON, while tail reserves stdout for JSONL. */
function isVoiceCallMachineOutput(params: { argv: readonly string[] }): boolean {
const [, command] = getRootOptionAwareCommandPath(params.argv, 2);
return MACHINE_OUTPUT_COMMANDS.has(command ?? "");
}
export const VOICE_CALL_CLI_DESCRIPTOR = {
name: "voicecall",
description: "Voice call utilities",
hasSubcommands: true,
machineOutput: isVoiceCallMachineOutput,
} as const;
+2 -1
View File
@@ -14,6 +14,7 @@ import {
type GatewayRequestHandlerOptions,
type OpenClawPluginApi,
} from "./api.js";
import { VOICE_CALL_CLI_DESCRIPTOR } from "./cli-output-mode.js";
import { createVoiceCallRuntime, type VoiceCallRuntime } from "./runtime-entry.js";
import { registerVoiceCallCli } from "./src/cli.js";
import {
@@ -858,7 +859,7 @@ export default definePluginEntry({
stateRuntime: api.runtime.state,
logger: api.logger,
}),
{ commands: ["voicecall"] },
{ commands: ["voicecall"], descriptors: [VOICE_CALL_CLI_DESCRIPTOR] },
);
api.registerService({
+11
View File
@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { ZOOM_MEETINGS_CLI_DESCRIPTOR } from "./src/cli-output-mode.js";
export default definePluginEntry({
id: "zoom-meetings",
name: "Zoom meetings",
description: "Zoom meetings CLI metadata",
register(api) {
api.registerCli(() => {}, { descriptors: [ZOOM_MEETINGS_CLI_DESCRIPTOR] });
},
});
+2 -7
View File
@@ -3,6 +3,7 @@ import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { Type } from "typebox";
import { ZOOM_MEETINGS_CLI_DESCRIPTOR } from "./src/cli-output-mode.js";
import {
resolveZoomMeetingsConfig,
resolveZoomMeetingsGatewayOperationTimeoutMs,
@@ -115,13 +116,7 @@ export default definePluginEntry(
},
{
commands: ["zoommeetings"],
descriptors: [
{
name: "zoommeetings",
description: "Join and manage Zoom meeting guests",
hasSubcommands: true,
},
],
descriptors: [ZOOM_MEETINGS_CLI_DESCRIPTOR],
},
);
},
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { ZOOM_MEETINGS_CLI_DESCRIPTOR } from "./cli-output-mode.js";
const isMachineOutput = ZOOM_MEETINGS_CLI_DESCRIPTOR.machineOutput;
describe("Zoom meetings CLI output mode", () => {
it("detects action output and ignores the bare root", () => {
expect(isMachineOutput({ argv: ["node", "openclaw", "zoommeetings", "status"] })).toBe(true);
expect(isMachineOutput({ argv: ["node", "openclaw", "zoommeetings"] })).toBe(false);
expect(
isMachineOutput({
argv: ["node", "openclaw", "zoommeetings", "--log-level", "debug", "status"],
}),
).toBe(true);
});
});
@@ -0,0 +1,13 @@
import { getRootOptionAwareCommandPath } from "openclaw/plugin-sdk/cli-argv";
/** Every Zoom meetings action emits one JSON result on stdout. */
function isZoomMeetingsMachineOutput(params: { argv: readonly string[] }): boolean {
return getRootOptionAwareCommandPath(params.argv, 2).length === 2;
}
export const ZOOM_MEETINGS_CLI_DESCRIPTOR = {
name: "zoommeetings",
description: "Join and manage Zoom meeting guests",
hasSubcommands: true,
machineOutput: isZoomMeetingsMachineOutput,
} as const;
+4
View File
@@ -692,6 +692,10 @@
"types": "./dist/plugin-sdk/gateway-runtime.d.ts",
"default": "./dist/plugin-sdk/gateway-runtime.js"
},
"./plugin-sdk/cli-argv": {
"types": "./dist/plugin-sdk/cli-argv.d.ts",
"default": "./dist/plugin-sdk/cli-argv.js"
},
"./plugin-sdk/cli-runtime": {
"default": "./dist/plugin-sdk/cli-runtime.js"
},
@@ -1,7 +1,7 @@
// Memory Host SDK module implements embeddings debug behavior.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
// Lightweight stderr debug logging for memory embedding internals.
// Lightweight debug logging for memory embedding internals.
const debugEmbeddings = isTruthyEnvValue(process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS);
@@ -11,7 +11,7 @@ export function debugEmbeddingsLog(message: string, meta?: Record<string, unknow
return;
}
const suffix = meta ? ` ${JSON.stringify(meta)}` : "";
process.stderr.write(`${message}${suffix}\n`);
console.warn(`${message}${suffix}`);
}
/** Parse common truthy env values for debug toggles. */
@@ -1,5 +1,5 @@
// Memory Host SDK tests cover qmd query parser behavior.
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { parseQmdQueryJson } from "./qmd-query-parser.js";
describe("parseQmdQueryJson", () => {
@@ -80,4 +80,22 @@ complete`,
/qmd query returned invalid JSON/i,
);
});
it("routes invalid-output diagnostics through console capture", () => {
vi.stubEnv("VITEST", "");
vi.stubEnv("NODE_ENV", "production");
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
expect(() => parseQmdQueryJson("this is not json", "")).toThrow(
/qmd query returned invalid JSON/i,
);
expect(warn).toHaveBeenCalledWith(
"qmd query returned invalid JSON: qmd query JSON response was not an array",
);
} finally {
warn.mockRestore();
vi.unstubAllEnvs();
}
});
});
@@ -58,7 +58,7 @@ function warnQmdQueryParseError(message: string): void {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return;
}
process.stderr.write(`qmd query returned invalid JSON: ${message}\n`);
console.warn(`qmd query returned invalid JSON: ${message}`);
}
/** Detect qmd no-result marker output on stdout or stderr. */
+1
View File
@@ -94,6 +94,7 @@
"security-runtime",
"gateway-method-runtime",
"gateway-runtime",
"cli-argv",
"cli-runtime",
"cli-backend",
"codex-mcp-projection",
+6 -3
View File
@@ -151,7 +151,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: session-discussion binds one external discussion provider to sessions.
// +1: focused media-local-roots replacement for the legacy agent-media facade.
// +1: account-aware channel DM policy setup descriptors.
142,
// +1: dependency-light CLI argv parsing for machine-output metadata.
143,
env,
),
publicExports: readPluginSdkSurfaceBudgetEnv(
@@ -182,7 +183,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: shared plugin SecretRef setup plan helper.
// +1: shared multi-claim ingress lifecycle fan-in.
// +3: channel prompt-context entry/compat types and channel metadata builder.
4727,
// +4: focused CLI root-option constants and parsers.
4731,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -208,7 +210,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: native approval messaging target resolver.
// +1: shared multi-claim ingress lifecycle fan-in.
// +1: channel metadata builder.
2863,
// +3: focused CLI root-option parsers.
2866,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
+5 -2
View File
@@ -652,7 +652,7 @@ describe("acquireSessionWriteLock", () => {
it("watchdog releases stale in-process locks", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-"));
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
const sessionFile = path.join(root, "session.jsonl");
const lockPath = `${sessionFile}.lock`;
@@ -664,6 +664,9 @@ describe("acquireSessionWriteLock", () => {
const released = await testing.runLockWatchdogCheck(Date.now() + 1000);
expect(released).toBe(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("[session-write-lock] releasing lock held for"),
);
await expectPathMissing(lockPath);
const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
@@ -676,7 +679,7 @@ describe("acquireSessionWriteLock", () => {
secondLock: lockB,
});
} finally {
stderrSpy.mockRestore();
warnSpy.mockRestore();
await fs.rm(root, { recursive: true, force: true });
}
});
+2 -2
View File
@@ -286,8 +286,8 @@ async function runLockWatchdogCheck(nowMs = Date.now()): Promise<number> {
continue;
}
process.stderr.write(
`[session-write-lock] releasing lock held for ${heldForMs}ms (max=${maxHoldMs}ms): ${held.lockPath}\n`,
console.warn(
`[session-write-lock] releasing lock held for ${heldForMs}ms (max=${maxHoldMs}ms): ${held.lockPath}`,
);
const didRelease = await held.forceRelease();
+2 -34
View File
@@ -4,6 +4,7 @@ import { isBunRuntime, isNodeRuntime } from "../daemon/runtime-binary.js";
import {
consumeRootOptionToken,
FLAG_TERMINATOR,
getRootOptionAwareCommandPath,
isValueToken,
} from "../infra/cli-root-options.js";
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
@@ -464,40 +465,7 @@ export function getPositiveIntFlagValue(argv: string[], name: string): number |
}
export function getCommandPathWithRootOptions(argv: string[], depth = 2): string[] {
return getCommandPathInternal(argv, depth, { skipRootOptions: true });
}
function getCommandPathInternal(
argv: string[],
depth: number,
opts: { skipRootOptions: boolean },
): string[] {
const args = argv.slice(2);
const path: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg) {
continue;
}
if (arg === "--") {
break;
}
if (opts.skipRootOptions) {
const consumed = consumeRootOptionToken(args, i);
if (consumed > 0) {
i += consumed - 1;
continue;
}
}
if (arg.startsWith("-")) {
continue;
}
path.push(arg);
if (path.length >= depth) {
break;
}
}
return path;
return getRootOptionAwareCommandPath(argv, depth);
}
export function getPrimaryCommand(argv: string[]): string | null {
+1 -1
View File
@@ -208,7 +208,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["sessions"],
exact: true,
policy: { ensureCliPath: false, networkProxy: "bypass" },
policy: { ensureCliPath: false, ownsProtocolStdout: true, networkProxy: "bypass" },
route: { id: "sessions" },
},
{
+3 -1
View File
@@ -5,6 +5,7 @@ import { Command, Option } from "commander";
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { routeLogsToStderr } from "../logging/console.js";
import { formatConsoleDiagnosticLine } from "../logging/json-console-line.js";
import {
buildFishOptionCompletionLine,
buildFishSubcommandCompletionLine,
@@ -166,7 +167,8 @@ async function writeCompletionCache(params: {
}
function writeCompletionRegistrationWarning(message: string): void {
process.stderr.write(`[completion] ${message}\n`);
const diagnostic = `[completion] ${message}`;
process.stderr.write(`${formatConsoleDiagnosticLine({ level: "warn", message: diagnostic })}\n`);
}
async function registerSubcommandsForCompletion(program: Command): Promise<void> {
@@ -105,6 +105,36 @@ describe("completion-cli write-state", () => {
}
});
it("structures completion registration warnings for JSON console output", async () => {
const [{ registerCompletionCli }, logging] = await Promise.all([
import("./completion-cli.js"),
import("../logging.js"),
]);
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-state-json-"));
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-home-json-"));
try {
logging.setLoggerOverride({ level: "silent", consoleLevel: "info", consoleStyle: "json" });
await withEnvAsync({ HOME: homeDir, OPENCLAW_STATE_DIR: stateDir }, async () => {
const program = new Command();
program.name("openclaw");
registerCompletionCli(program);
await program.parseAsync(["completion", "--write-state"], { from: "user" });
expect(stderrWrites).toHaveBeenCalledTimes(1);
expect(JSON.parse(String(stderrWrites.mock.calls[0]?.[0]))).toMatchObject({
level: "warn",
message: expect.stringContaining("skipping subcommand `qa`"),
});
});
} finally {
logging.resetLogger();
await fs.rm(stateDir, { recursive: true, force: true });
await fs.rm(homeDir, { recursive: true, force: true });
}
});
it("can skip plugin command registration for update-triggered cache writes", async () => {
const [{ COMPLETION_SKIP_PLUGIN_COMMANDS_ENV }, { registerCompletionCli }] = await Promise.all([
import("./completion-runtime.js"),
+4 -3
View File
@@ -111,6 +111,7 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
const { defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture();
const mockLog = defaultRuntime.log;
const mockWriteStdout = defaultRuntime.writeStdout;
const mockError = defaultRuntime.error;
const mockExit = defaultRuntime.exit;
@@ -1191,7 +1192,7 @@ describe("config cli", () => {
await runConfigCommand(["config", "get", "gateway.auth.token"]);
expect(mockLog).toHaveBeenCalledWith("__OPENCLAW_REDACTED__");
expect(mockWriteStdout).toHaveBeenCalledWith("__OPENCLAW_REDACTED__\n");
});
it("prints materialized subagent archive default", async () => {
@@ -1211,7 +1212,7 @@ describe("config cli", () => {
await runConfigCommand(["config", "get", "agents.defaults.subagents.archiveAfterMinutes"]);
expect(mockLog).toHaveBeenCalledWith("60");
expect(mockWriteStdout).toHaveBeenCalledWith("60\n");
});
it("outputs JSON error to stdout when path is not found and --json is set", async () => {
@@ -3575,7 +3576,7 @@ describe("config cli", () => {
await runConfigCommand(["config", "get", aliasPath]);
expect(mockLog).toHaveBeenCalledWith("gpt");
expect(mockWriteStdout).toHaveBeenCalledWith("gpt\n");
mockLog.mockClear();
setSnapshot(resolved, runtimeMerged);
+11 -3
View File
@@ -10,7 +10,13 @@ import { redactConfigObject } from "../config/redact-snapshot.js";
import { readBestEffortRuntimeConfigSchema } from "../config/runtime-schema.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { danger, info, success, warn } from "../globals.js";
import { ExitError, type RuntimeEnv, defaultRuntime, writeRuntimeJson } from "../runtime.js";
import {
ExitError,
type RuntimeEnv,
defaultRuntime,
writeRuntimeJson,
writeRuntimeStdout,
} from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
import {
@@ -38,6 +44,7 @@ import {
} from "./config-cli-runner.js";
import { formatInvalidConfigRepairHint, loadValidConfig } from "./config-cli-validation.js";
import { checkTouchedTextModelRefs } from "./config-model-validation.js";
import { isConfigMachineOutput, isConfigSetJsonParseOnly } from "./config-output-mode.js";
import {
hasBatchMode,
hasProviderBuilderOptions,
@@ -166,7 +173,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime
typeof res.value === "number" ||
typeof res.value === "boolean"
) {
runtime.log(String(res.value));
writeRuntimeStdout(runtime, `${String(res.value)}\n`);
} else {
writeRuntimeJson(runtime, res.value ?? null);
}
@@ -384,6 +391,7 @@ export function registerConfigCli(program: Command) {
const { configureCommandFromSectionsArg } = await import("../commands/configure.js");
await configureCommandFromSectionsArg(opts.section, defaultRuntime);
});
setCommandJsonMode(cmd, "output", ({ argv }) => isConfigMachineOutput(argv));
cmd
.command("get")
@@ -394,7 +402,7 @@ export function registerConfigCli(program: Command) {
await runConfigGet({ path, json: Boolean(opts.json) });
});
setCommandJsonMode(cmd.command("set"), "parse-only")
setCommandJsonMode(cmd.command("set"), "parse-only", ({ argv }) => isConfigSetJsonParseOnly(argv))
.description(CONFIG_SET_DESCRIPTION)
.argument("[path]", "Config path (dot or bracket notation)")
.argument("[value]", "Value (JSON/JSON5 or raw string)")
+54
View File
@@ -0,0 +1,54 @@
import { consumeRootOptionToken } from "../infra/cli-root-options.js";
import { findMachineOutputRootCommandIndex } from "./machine-output-argv.js";
function hasFlag(argv: readonly string[], flag: string): boolean {
for (const arg of argv.slice(2)) {
if (arg === "--") {
return false;
}
if (arg === flag) {
return true;
}
}
return false;
}
function resolveConfigSubcommand(argv: readonly string[]): string | null {
const rootIndex = findMachineOutputRootCommandIndex(argv);
if (rootIndex === null) {
return null;
}
const args = argv.slice(2);
for (let index = rootIndex - 1; index < args.length; index += 1) {
const arg = args[index];
if (!arg || arg === "--") {
return null;
}
const rootConsumed = consumeRootOptionToken(args, index);
if (rootConsumed > 0) {
index += rootConsumed - 1;
continue;
}
if (arg === "--section") {
index += 1;
continue;
}
if (arg.startsWith("--section=")) {
continue;
}
if (!arg.startsWith("-")) {
return arg;
}
}
return null;
}
/** Config get reserves stdout for the requested value, including bare scalar output. */
export function isConfigMachineOutput(argv: readonly string[]): boolean {
return resolveConfigSubcommand(argv) === "get";
}
/** Config set uses --json as a parser alias except when dry-run emits a JSON report. */
export function isConfigSetJsonParseOnly(argv: readonly string[]): boolean {
return hasFlag(argv, "--json") && !hasFlag(argv, "--dry-run");
}
+27
View File
@@ -0,0 +1,27 @@
import { getMachineOutputCommandPath } from "../machine-output-argv.js";
const MACHINE_OUTPUT_COMMANDS = new Set([
"add",
"create",
"delete",
"disable",
"edit",
"enable",
"get",
"remove",
"rm",
"run",
"runs",
"status",
]);
export function isCronMachineOutput(argv: readonly string[]): boolean {
const [, command] = getMachineOutputCommandPath(argv, 2);
if (!command) {
return false;
}
if (MACHINE_OUTPUT_COMMANDS.has(command)) {
return true;
}
return command === "scratch";
}
+3
View File
@@ -2,7 +2,9 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { setCommandJsonMode } from "../program/json-mode.js";
import { applyParentDefaultHelpAction } from "../program/parent-default-help.js";
import { isCronMachineOutput } from "./output-mode.js";
import {
registerCronAddCommand,
registerCronListCommand,
@@ -28,6 +30,7 @@ export function registerCronCli(program: Command) {
registerCronSimpleCommands(cron);
registerCronScratchCommand(cron);
registerCronEditCommand(cron);
setCommandJsonMode(cron, "output", ({ argv }) => isCronMachineOutput(argv));
applyParentDefaultHelpAction(cron);
}
+4
View File
@@ -1,6 +1,8 @@
// Commander registration for device pairing and auth-token commands.
import type { Command } from "commander";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { isDevicesMachineOutput } from "./devices-output-mode.js";
import { setCommandJsonMode } from "./program/json-mode.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
type DevicesRpcOpts = {
@@ -130,5 +132,7 @@ export function registerDevicesCli(program: Command) {
}),
);
setCommandJsonMode(devices, "output", ({ argv }) => isDevicesMachineOutput(argv));
applyParentDefaultHelpAction(devices);
}
+6
View File
@@ -0,0 +1,6 @@
import { getMachineOutputCommandPath } from "./machine-output-argv.js";
export function isDevicesMachineOutput(argv: readonly string[]): boolean {
const [, command] = getMachineOutputCommandPath(argv, 2);
return command === "rotate" || command === "revoke";
}
+10
View File
@@ -0,0 +1,10 @@
import type { MachineOutputResolverParams } from "./machine-output-argv.js";
import { hasMachineOutputOption } from "./machine-output-argv.js";
/** Doctor lint follows Unix convention and emits JSON when stdout is not a terminal. */
export function isDoctorMachineOutput(params: MachineOutputResolverParams): boolean {
return (
hasMachineOutputOption(params.argv, "--lint") &&
(hasMachineOutputOption(params.argv, "--json") || !params.stdoutIsTTY)
);
}
+27
View File
@@ -3,6 +3,9 @@ import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { loadGlobalRuntimeDotEnvFiles, loadWorkspaceDotEnvFile } from "../infra/dotenv.js";
import { tryProcessCwd } from "../infra/safe-cwd.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
import { resolveCliContainerTarget } from "./container-target.js";
import { resolveGatewayCatalogCommandPath } from "./gateway-run-argv.js";
/** Load `.env` files for normal CLI commands without overriding existing process env. */
export function loadCliDotEnv(opts?: { loadGlobalEnv?: boolean; quiet?: boolean }) {
@@ -23,3 +26,27 @@ export function loadCliDotEnv(opts?: { loadGlobalEnv?: boolean; quiet?: boolean
stateEnvPath: path.join(resolveStateDir(process.env), ".env"),
});
}
/** Load only the dotenv scope an early CLI failure may consult for diagnostic formatting. */
export async function loadCliDotEnvForEarlyDiagnostic(
argv: string[],
env: NodeJS.ProcessEnv = process.env,
): Promise<void> {
if (resolveCliContainerTarget(argv, env)) {
return;
}
const invocation = resolveCliArgvInvocation(argv);
if (invocation.commandPath[0] === "agent" && invocation.commandPath[1] === "exec") {
return;
}
if (invocation.primary === "agent" && !argv.includes("--local")) {
const { loadGatewayDispatchCliDotEnv } = await import("./gateway-dispatch-dotenv.js");
await loadGatewayDispatchCliDotEnv({ quiet: true });
return;
}
const gatewayPath = resolveGatewayCatalogCommandPath(argv);
const isGatewayRun =
!invocation.hasHelpOrVersion &&
(gatewayPath?.length === 1 || (gatewayPath?.length === 2 && gatewayPath[1] === "run"));
loadCliDotEnv({ loadGlobalEnv: !isGatewayRun, quiet: true });
}
+6
View File
@@ -0,0 +1,6 @@
import { resolveGatewayCommandPath } from "../gateway-run-argv.js";
export function isGatewayMachineOutput(argv: readonly string[]): boolean {
const [, command, action] = resolveGatewayCommandPath([...argv], 3) ?? [];
return command === "restart-handoff" && (action === "capabilities" || action === "consume");
}
+3
View File
@@ -23,8 +23,10 @@ import { addGatewayServiceCommands } from "../daemon-cli/register-service-comman
import { parseGatewayPortOption } from "../gateway-port-option.js";
import { formatHelpExamples } from "../help-format.js";
import { parseTimeoutMsWithFallback } from "../parse-timeout.js";
import { setCommandJsonMode } from "../program/json-mode.js";
import type { GatewayRpcOpts } from "./call.js";
import type { GatewayDiscoverOpts } from "./discover.js";
import { isGatewayMachineOutput } from "./output-mode.js";
import { addGatewayRestartHandoffCommands } from "./register-restart-handoff.js";
import { addGatewayRunCommand } from "./run-command.js";
@@ -583,6 +585,7 @@ export function registerGatewayCli(program: Command) {
statusDescription: "Show gateway service status + probe connectivity/capability",
});
addGatewayRestartHandoffCommands(gateway);
setCommandJsonMode(gateway, "output", ({ argv }) => isGatewayMachineOutput(argv));
gatewayCallOpts(
gateway
+22 -4
View File
@@ -51,6 +51,10 @@ function consumeGatewayRunPreBootstrapOptionToken(
args: ReadonlyArray<string>,
index: number,
): number {
const rootConsumed = consumeRootOptionToken(args, index);
if (rootConsumed > 0) {
return rootConsumed;
}
const consumed = consumeGatewayRunOptionToken(args, index);
if (consumed > 0) {
return consumed;
@@ -108,17 +112,23 @@ function resolveGatewayCommandStart(argv: string[]): {
return null;
}
/** Resolve the gateway command path from raw argv for catalog/policy lookups. */
export function resolveGatewayCatalogCommandPath(argv: string[]): string[] | null {
/** Resolve the gateway command path from raw argv without full Commander registration. */
export function resolveGatewayCommandPath(argv: string[], depth = 2): string[] | null {
const gateway = resolveGatewayCommandStart(argv);
if (!gateway) {
return null;
}
const commandPath = ["gateway"];
for (let index = gateway.startIndex; index < gateway.args.length; index += 1) {
const arg = gateway.args[index];
if (!arg || arg === "--") {
break;
}
const rootConsumed = consumeRootOptionToken(gateway.args, index);
if (rootConsumed > 0) {
index += rootConsumed - 1;
continue;
}
const consumed = consumeGatewayRunOptionToken(gateway.args, index);
if (consumed > 0) {
index += consumed - 1;
@@ -127,10 +137,18 @@ export function resolveGatewayCatalogCommandPath(argv: string[]): string[] | nul
if (arg.startsWith("-")) {
continue;
}
return ["gateway", arg];
commandPath.push(arg);
if (commandPath.length >= depth) {
return commandPath;
}
}
return ["gateway"];
return commandPath;
}
/** Resolve the gateway command path used by catalog and startup-policy lookups. */
export function resolveGatewayCatalogCommandPath(argv: string[]): string[] | null {
return resolveGatewayCommandPath(argv, 2);
}
/** Resolve destructive gateway-run flags before Commander registration. */
+803 -8
View File
@@ -24,17 +24,53 @@ const LAZY_GROUP_HELP_CASES = [
{ group: "update", usageCommand: "update" },
] as const;
async function createHelpProcessFixture() {
async function createHelpProcessFixture(
config?: Record<string, unknown>,
loggingViaInclude = false,
loggingViaRootInclude = false,
) {
const root = tempDirs.make("openclaw-help-exit-");
const stateDir = path.join(root, "state");
const configPath = path.join(stateDir, "openclaw.json");
const tlsImportGuardPath = path.join(root, "forbid-tls-import.mjs");
const keepAlivePath = path.join(root, "keep-alive.mjs");
const forceExitPath = path.join(root, "force-exit.mjs");
const unsupportedRuntimePath = path.join(root, "unsupported-runtime.mjs");
const failRunMainImportPath = path.join(root, "fail-run-main-import.mjs");
await fs.mkdir(stateDir, { recursive: true });
const profileConfigPath = path.join(root, ".openclaw-work", "openclaw.json");
await fs.mkdir(path.dirname(profileConfigPath), { recursive: true });
const configWithLoggingInclude = config
? { ...config, logging: { $include: "./logging.json5" } }
: undefined;
const configWithRootLoggingInclude = config
? { $include: "./base.json5", plugins: { $include: "./missing-plugins.json5" } }
: undefined;
const writtenConfig = loggingViaRootInclude
? configWithRootLoggingInclude
: loggingViaInclude
? configWithLoggingInclude
: config;
await fs.writeFile(
configPath,
JSON.stringify({ plugins: { entries: { "oc-path": { enabled: true } } } }),
JSON.stringify(writtenConfig ?? { plugins: { entries: { "oc-path": { enabled: true } } } }),
);
await fs.writeFile(profileConfigPath, JSON.stringify(writtenConfig ?? {}));
if (loggingViaInclude) {
const logging = config?.logging ?? {};
await fs.writeFile(path.join(stateDir, "logging.json5"), JSON.stringify(logging));
await fs.writeFile(
path.join(path.dirname(profileConfigPath), "logging.json5"),
JSON.stringify(logging),
);
}
if (loggingViaRootInclude) {
await fs.writeFile(path.join(stateDir, "base.json5"), JSON.stringify(config ?? {}));
await fs.writeFile(
path.join(path.dirname(profileConfigPath), "base.json5"),
JSON.stringify(config ?? {}),
);
}
await fs.writeFile(
tlsImportGuardPath,
`import { registerHooks } from "node:module";
@@ -49,22 +85,80 @@ registerHooks({
`,
);
await fs.writeFile(keepAlivePath, "setInterval(() => {}, 60_000);\n");
return { root, stateDir, configPath, tlsImportGuardPath, keepAlivePath };
await fs.writeFile(
forceExitPath,
"setTimeout(() => process.exit(typeof process.exitCode === 'number' ? process.exitCode : 0), Number(process.env.OPENCLAW_TEST_FORCE_EXIT_MS));\n",
);
await fs.writeFile(
unsupportedRuntimePath,
'Object.defineProperty(process.versions, "node", { value: "22.0.0" });\n',
);
await fs.writeFile(
failRunMainImportPath,
`import { registerHooks } from "node:module";
registerHooks({
resolve(specifier, context, nextResolve) {
const resolved = nextResolve(specifier, context);
if (/\\/cli\\/run-main\\.(?:js|ts)$/.test(resolved.url)) {
throw new Error("forced run-main import failure");
}
return resolved;
},
});
`,
);
return {
root,
stateDir,
configPath,
tlsImportGuardPath,
keepAlivePath,
forceExitPath,
failRunMainImportPath,
unsupportedRuntimePath,
};
}
async function runCliProcess(params: {
args: string[];
config?: Record<string, unknown>;
env?: NodeJS.ProcessEnv;
useDefaultConfigPaths?: boolean;
forbidTlsImport?: boolean;
keepAlive?: boolean;
forceExitMs?: number;
failRunMainImport?: boolean;
unsupportedRuntime?: boolean;
allowRespawn?: boolean;
loggingViaInclude?: boolean;
loggingViaRootInclude?: boolean;
stateEnv?: (stateDir: string) => Record<string, string>;
}) {
const fixture = await createHelpProcessFixture();
return await execFileAsync(
const fixture = await createHelpProcessFixture(
params.config,
params.loggingViaInclude,
params.loggingViaRootInclude,
);
if (params.stateEnv) {
const lines = Object.entries(params.stateEnv(fixture.stateDir)).map(
([key, value]) => `${key}=${value}`,
);
await fs.writeFile(path.join(fixture.stateDir, ".env"), `${lines.join("\n")}\n`);
}
const result = await execFileAsync(
process.execPath,
[
...(params.forbidTlsImport
? ["--import", pathToFileURL(fixture.tlsImportGuardPath).href]
: []),
...(params.keepAlive ? ["--import", pathToFileURL(fixture.keepAlivePath).href] : []),
...(params.forceExitMs ? ["--import", pathToFileURL(fixture.forceExitPath).href] : []),
...(params.failRunMainImport
? ["--import", pathToFileURL(fixture.failRunMainImportPath).href]
: []),
...(params.unsupportedRuntime
? ["--import", pathToFileURL(fixture.unsupportedRuntimePath).href]
: []),
"--import",
"tsx",
"src/entry.ts",
@@ -79,20 +173,31 @@ async function runCliProcess(params: {
NODE_ENV: undefined,
NODE_OPTIONS: undefined,
NODE_USE_SYSTEM_CA: "1",
OPENCLAW_CONFIG_PATH: fixture.configPath,
OPENCLAW_NO_RESPAWN: "1",
OPENCLAW_STATE_DIR: fixture.stateDir,
OPENCLAW_CONFIG_PATH: params.useDefaultConfigPaths ? undefined : fixture.configPath,
OPENCLAW_NO_RESPAWN: params.allowRespawn ? undefined : "1",
OPENCLAW_STATE_DIR: params.useDefaultConfigPaths ? undefined : fixture.stateDir,
OPENCLAW_TEST_FORCE_EXIT_MS: params.forceExitMs ? String(params.forceExitMs) : undefined,
VITEST: undefined,
...params.env,
},
killSignal: "SIGKILL",
timeout: CHILD_PROCESS_TIMEOUT_MS,
},
);
return { ...result, fixture };
}
function parseJsonLines(stdout: string): Array<Record<string, unknown>> {
return stdout
.split(/\r?\n/u)
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
type CliProcessFailure = Error & {
code?: number | string;
stderr?: string;
stdout?: string;
};
async function runCliProcessExpectFailure(args: string[]): Promise<CliProcessFailure> {
@@ -122,6 +227,23 @@ describe("CLI help process exit", () => {
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`Usage: openclaw ${usageCommand} [options] [command]`);
});
it("flushes explicitly requested entry traces on precomputed help", async () => {
const result = await runCliProcess({
args: ["gateway", "--help"],
config: { logging: { consoleStyle: "json", level: "silent" } },
env: { OPENCLAW_GATEWAY_STARTUP_TRACE: "1" },
});
expect(parseJsonLines(result.stderr)).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "info",
message: expect.stringContaining("startup trace: entry.bootstrap"),
}),
]),
);
});
});
describe("route-first CLI process rejection", () => {
@@ -138,3 +260,676 @@ describe("route-first CLI process rejection", () => {
expect(failure.stderr).toContain(`does not recognize option "${option}"`);
});
});
describe("JSON console style process output", () => {
const loggingConfig = {
logging: {
consoleLevel: "info",
consoleStyle: "json",
level: "silent",
},
};
it.each([
{ name: "routed", env: {} },
{ name: "Commander", env: { OPENCLAW_DISABLE_ROUTE_FIRST: "1" } },
])("emits JSONL for $name text output", async ({ env }) => {
const result = await runCliProcess({
args: ["status", "--timeout", "1000"],
config: loggingConfig,
env,
});
const stdoutRecords = parseJsonLines(result.stdout);
const stderrRecords = parseJsonLines(result.stderr);
expect(stdoutRecords.length).toBeGreaterThan(0);
expect([...stdoutRecords, ...stderrRecords]).toEqual(
expect.arrayContaining([
expect.objectContaining({ level: "info", message: "OpenClaw status" }),
]),
);
expect([...stdoutRecords, ...stderrRecords]).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("tslog: minLevel") }),
]),
);
});
it("keeps writeJson machine output as one raw object", async () => {
const result = await runCliProcess({
args: ["status", "--json", "--timeout", "1000"],
config: loggingConfig,
});
expect(result.stderr).toBe("");
const output = JSON.parse(result.stdout) as Record<string, unknown>;
expect(output).toHaveProperty("gateway");
expect(output).not.toHaveProperty("level");
expect(output).not.toHaveProperty("message");
});
it("keeps typed recommendation machine output as a raw array", async () => {
const result = await runCliProcess({
args: ["onboard", "recommendations", "--json"],
config: loggingConfig,
});
expect(result.stderr).toBe("");
expect(JSON.parse(result.stdout)).toEqual([]);
});
it("structures invalid log-level environment warnings", async () => {
const result = await runCliProcess({
args: ["status", "--timeout", "1000"],
config: loggingConfig,
env: { OPENCLAW_LOG_LEVEL: "bogus" },
});
const records = [...parseJsonLines(result.stdout), ...parseJsonLines(result.stderr)];
expect(records).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "warn",
message: expect.stringContaining('Ignoring invalid OPENCLAW_LOG_LEVEL="bogus"'),
}),
]),
);
});
it("structures gateway safety errors emitted before command routing", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["gateway", "--force"],
config: {
...loggingConfig,
meta: { lastTouchedVersion: "9999.1.1" },
},
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(failure?.stdout ?? "").toBe("");
const records = parseJsonLines(failure?.stderr ?? "");
expect(records.length).toBeGreaterThan(0);
const messages = records
.map((record) => (typeof record.message === "string" ? record.message : ""))
.join("\n");
expect(messages).toContain("written by version 9999.1.1");
expect(messages).toContain("Refusing to force-kill gateway port listeners");
expect(messages).not.toContain("tslog: minLevel");
});
it.each([
{ name: "plain", modifier: [] },
{ name: "help-shaped", modifier: ["--help"] },
{ name: "version-shaped", modifier: ["--version"] },
])(
"structures $name container dispatch errors emitted before command routing",
async ({ modifier }) => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["--container", "openclaw-json-console-missing", "status", ...modifier],
config: loggingConfig,
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(failure?.stdout ?? "").toBe("");
const records = parseJsonLines(failure?.stderr ?? "");
expect(records.length).toBeGreaterThan(0);
expect(records).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("No running container matched"),
}),
]),
);
},
);
it("flushes explicitly requested traces before a container dispatch failure", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["--container", "openclaw-json-console-missing", "gateway", "status"],
config: loggingConfig,
env: { OPENCLAW_GATEWAY_STARTUP_TRACE: "1" },
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "info",
message: expect.stringContaining("startup trace: entry.bootstrap"),
}),
expect.objectContaining({
level: "error",
message: expect.stringContaining("No running container matched"),
}),
]),
);
});
it.each(["--help", "--version"])(
"structures unknown-command validation with %s",
async (modifier) => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["openclaw-json-console-missing-command", modifier],
config: loggingConfig,
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(failure?.stdout ?? "").toBe("");
const records = parseJsonLines(failure?.stderr ?? "");
expect(records).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("Unknown command"),
}),
]),
);
},
);
it("keeps pure help output on the lightweight human-formatted path", async () => {
const result = await runCliProcess({ args: ["--help"], config: loggingConfig });
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Usage: openclaw [options] [command]");
expect(() => parseJsonLines(result.stdout)).toThrow();
});
it.each([
{
name: "missing container value",
args: ["--container"],
message: "--container requires a value",
},
{
name: "missing profile value",
args: ["--profile"],
message: "--profile requires a value",
},
{
name: "container/profile conflict",
args: ["--container", "demo", "--profile", "work", "status"],
message: "--container cannot be combined with --profile/--dev",
},
])("structures entry validation for $name", async ({ args, message }) => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({ args, config: loggingConfig });
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(2);
expect(failure?.stdout ?? "").toBe("");
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({ level: "error", message: expect.stringContaining(message) }),
]),
);
});
it("uses named-profile logging style for entry validation", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["--profile", "work", "--container", "demo", "status"],
config: loggingConfig,
useDefaultConfigPaths: true,
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(2);
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("--container cannot be combined with --profile/--dev"),
}),
]),
);
});
it("uses named-profile logging style when container parsing fails", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["--profile", "work", "--container"],
config: loggingConfig,
useDefaultConfigPaths: true,
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(2);
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("--container requires a value"),
}),
]),
);
});
it("loads dotenv before formatting entry validation diagnostics", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["--container"],
config: {
logging: {
consoleStyle: "${OPENCLAW_TEST_CONSOLE_STYLE}",
level: "silent",
},
},
env: { OPENCLAW_TEST_CONSOLE_STYLE: undefined },
stateEnv: () => ({ OPENCLAW_TEST_CONSOLE_STYLE: "json" }),
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(2);
expect(parseJsonLines(failure?.stderr ?? "")).toEqual([
expect.objectContaining({
level: "error",
message: expect.stringContaining("--container requires a value"),
}),
]);
});
it("loads eligible dotenv before formatting a run-main import failure", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["gateway", "status"],
config: {
logging: {
consoleStyle: "${OPENCLAW_TEST_CONSOLE_STYLE}",
level: "silent",
},
},
env: {
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
OPENCLAW_TEST_CONSOLE_STYLE: undefined,
},
failRunMainImport: true,
stateEnv: () => ({ OPENCLAW_TEST_CONSOLE_STYLE: "json" }),
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "info",
message: expect.stringContaining("startup trace: entry.bootstrap"),
}),
expect.objectContaining({
level: "error",
message: expect.stringContaining("forced run-main import failure"),
}),
]),
);
});
it("keeps valid container dispatch ahead of host dotenv loading", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["--container", "openclaw-json-console-missing", "status"],
config: {
logging: {
consoleStyle: "${OPENCLAW_TEST_CONSOLE_STYLE}",
level: "silent",
},
},
env: { OPENCLAW_TEST_CONSOLE_STYLE: undefined },
stateEnv: () => ({ OPENCLAW_TEST_CONSOLE_STYLE: "json" }),
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.stderr).toContain("No running container matched");
expect(() => parseJsonLines(failure?.stderr ?? "")).toThrow();
});
it.each([
{ name: "default config", args: ["status"], useDefaultConfigPaths: false },
{ name: "named profile", args: ["--profile", "work", "status"], useDefaultConfigPaths: true },
{
name: "included logging config",
args: ["status"],
useDefaultConfigPaths: false,
loggingViaInclude: true,
},
])(
"structures unsupported-runtime diagnostics from $name",
async ({ args, useDefaultConfigPaths, loggingViaInclude }) => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args,
config: loggingConfig,
unsupportedRuntime: true,
useDefaultConfigPaths,
loggingViaInclude,
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(failure?.stdout ?? "").toBe("");
expect(parseJsonLines(failure?.stderr ?? "")).toEqual([
expect.objectContaining({
level: "error",
message: expect.stringContaining("Detected: node 22.0.0"),
}),
]);
},
);
it("structures gateway startup tracing", async () => {
const result = await runCliProcess({
args: ["gateway", "status"],
config: loggingConfig,
env: { OPENCLAW_GATEWAY_STARTUP_TRACE: "1" },
});
const records = parseJsonLines(result.stderr);
expect(records).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "info",
message: expect.stringContaining("[gateway] startup trace:"),
}),
]),
);
});
it("preserves structured entry startup tracing across a normal respawn", async () => {
const result = await runCliProcess({
args: ["gateway", "status"],
allowRespawn: true,
config: loggingConfig,
env: { OPENCLAW_GATEWAY_STARTUP_TRACE: "1" },
});
const bootstrapRecords = parseJsonLines(result.stderr).filter(
(record) =>
typeof record.message === "string" &&
record.message.includes("startup trace: entry.bootstrap"),
);
expect(bootstrapRecords.length).toBeGreaterThanOrEqual(2);
});
it("loads dotenv before formatting and caching startup trace logging settings", async () => {
const logFileName = "startup-trace.jsonl";
const result = await runCliProcess({
args: ["gateway", "status"],
config: {
logging: {
consoleLevel: "info",
consoleStyle: "${OPENCLAW_TEST_CONSOLE_STYLE}",
file: "${OPENCLAW_TEST_LOG_FILE}",
level: "info",
},
},
env: {
OPENCLAW_GATEWAY_STARTUP_TRACE: "1",
OPENCLAW_TEST_CONSOLE_STYLE: undefined,
OPENCLAW_TEST_LOG_FILE: undefined,
},
stateEnv: (stateDir) => ({
OPENCLAW_TEST_CONSOLE_STYLE: "json",
OPENCLAW_TEST_LOG_FILE: path.join(stateDir, logFileName),
}),
});
const records = [...parseJsonLines(result.stdout), ...parseJsonLines(result.stderr)];
expect(records).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "info",
message: expect.stringContaining("startup trace: entry.bootstrap"),
}),
]),
);
expect(await fs.readFile(path.join(result.fixture.stateDir, logFileName), "utf8")).toContain(
'"message":"Service:',
);
});
it.each([
{ name: "routed fallback", env: {} },
{ name: "Commander", env: { OPENCLAW_DISABLE_ROUTE_FIRST: "1" } },
])("structures $name unknown-option validation", async ({ env }) => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["status", "--definitely-invalid"],
config: loggingConfig,
env,
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(failure?.stdout ?? "").toBe("");
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("does not recognize option"),
}),
]),
);
});
it("structures Commander missing-argument validation", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["plugins", "install"],
config: loggingConfig,
env: { OPENCLAW_DISABLE_ROUTE_FIRST: "1" },
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("Missing required argument"),
}),
]),
);
});
it.each(["schema", "validate"])(
"structures config %s Commander validation without loading mutable config",
async (command) => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["config", command, "--definitely-invalid"],
config: loggingConfig,
env: { OPENCLAW_DISABLE_ROUTE_FIRST: "1" },
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(failure?.stdout ?? "").toBe("");
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("does not recognize option"),
}),
]),
);
},
);
it.each(["schema", "validate"])(
"structures config %s validation with logging style from an include",
async (command) => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["config", command, "--definitely-invalid"],
config: {
...loggingConfig,
logging: {
...loggingConfig.logging,
file: "${MISSING_LOG_FILE}",
},
},
env: {
MISSING_LOG_FILE: undefined,
OPENCLAW_DISABLE_ROUTE_FIRST: "1",
},
loggingViaInclude: true,
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(failure?.stdout ?? "").toBe("");
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("does not recognize option"),
}),
]),
);
},
);
it("structures config validation when an unrelated include is missing", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["config", "validate", "--definitely-invalid"],
config: {
...loggingConfig,
logging: {
...loggingConfig.logging,
file: "${MISSING_LOG_FILE}",
},
plugins: { $include: "./missing-plugins.json5" },
},
env: {
MISSING_LOG_FILE: undefined,
OPENCLAW_DISABLE_ROUTE_FIRST: "1",
},
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("does not recognize option"),
}),
]),
);
});
it("structures config validation with root-included logging and a broken sibling include", async () => {
let failure: CliProcessFailure | undefined;
try {
await runCliProcess({
args: ["config", "validate", "--definitely-invalid"],
config: {
...loggingConfig,
logging: {
...loggingConfig.logging,
file: "${MISSING_LOG_FILE}",
},
},
env: {
MISSING_LOG_FILE: undefined,
OPENCLAW_DISABLE_ROUTE_FIRST: "1",
},
loggingViaRootInclude: true,
});
} catch (error) {
failure = error as CliProcessFailure;
}
expect(failure?.code).toBe(1);
expect(parseJsonLines(failure?.stderr ?? "")).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "error",
message: expect.stringContaining("does not recognize option"),
}),
]),
);
});
it("structures required debug-proxy coverage diagnostics", async () => {
const result = await runCliProcess({
args: ["onboard", "recommendations", "--json"],
config: loggingConfig,
forceExitMs: 5_000,
env: {
OPENCLAW_DEBUG_PROXY_ENABLED: "1",
OPENCLAW_DEBUG_PROXY_REQUIRE: "1",
},
});
const records = [...parseJsonLines(result.stdout), ...parseJsonLines(result.stderr)];
expect(records).toEqual(
expect.arrayContaining([
expect.objectContaining({
level: "warn",
message: expect.stringContaining("debug proxy coverage"),
}),
expect.objectContaining({
level: "warn",
message: expect.stringContaining("remaining gaps"),
}),
]),
);
});
});
+32 -1
View File
@@ -1,22 +1,30 @@
// JSON output mode tests cover CLI JSON mode detection and output handling.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loggingState } from "../logging/state.js";
import { hasJsonOutputFlag, withConsoleLogsRoutedToStderrForJson } from "./json-output-mode.js";
import {
applyResolvedCommandOutputMode,
hasJsonOutputFlag,
withConsoleLogsRoutedToStderrForJson,
} from "./json-output-mode.js";
describe("json output mode", () => {
const originalForceStderr = loggingState.forceConsoleToStderr;
const originalEarlyRestore = loggingState.earlyConsoleRoutingRestore;
beforeEach(() => {
loggingState.forceConsoleToStderr = false;
loggingState.earlyConsoleRoutingRestore = null;
});
afterEach(() => {
loggingState.forceConsoleToStderr = originalForceStderr;
loggingState.earlyConsoleRoutingRestore = originalEarlyRestore;
});
it("detects json output flags before argv terminators", () => {
expect(hasJsonOutputFlag(["node", "openclaw", "nodes", "list", "--json"])).toBe(true);
expect(hasJsonOutputFlag(["node", "openclaw", "nodes", "list", "--json=true"])).toBe(true);
expect(hasJsonOutputFlag(["node", "openclaw", "models", "--status-json"])).toBe(false);
expect(hasJsonOutputFlag(["node", "openclaw", "nodes", "--", "--json"])).toBe(false);
});
@@ -46,4 +54,27 @@ describe("json output mode", () => {
expect(loggingState.forceConsoleToStderr).toBe(true);
});
it("restores stdout routing when command metadata marks --json as parse-only", async () => {
await withConsoleLogsRoutedToStderrForJson(
["node", "openclaw", "config", "set", "gateway.port", "18789", "--json"],
async () => {
expect(loggingState.forceConsoleToStderr).toBe(true);
applyResolvedCommandOutputMode(false);
expect(loggingState.forceConsoleToStderr).toBe(false);
},
);
});
it("preserves inherited stderr routing when resolved metadata is parse-only", async () => {
loggingState.forceConsoleToStderr = true;
await withConsoleLogsRoutedToStderrForJson(
["node", "openclaw", "config", "set", "gateway.port", "18789", "--json"],
async () => {
applyResolvedCommandOutputMode(false);
expect(loggingState.forceConsoleToStderr).toBe(true);
},
);
});
});
+28 -2
View File
@@ -18,16 +18,42 @@ export function hasJsonOutputFlag(argv: readonly string[]): boolean {
export async function withConsoleLogsRoutedToStderrForJson<T>(
argv: readonly string[],
run: () => Promise<T>,
options: { machineOutput?: boolean; restoreChanges?: boolean } = {},
): Promise<T> {
if (!hasJsonOutputFlag(argv)) {
const forceStderr = hasJsonOutputFlag(argv) || options.machineOutput;
if (!forceStderr && !options.restoreChanges) {
return run();
}
const previousForceStderr = loggingState.forceConsoleToStderr;
loggingState.forceConsoleToStderr = true;
const previousEarlyRestore = loggingState.earlyConsoleRoutingRestore;
if (forceStderr) {
loggingState.earlyConsoleRoutingRestore = previousForceStderr;
loggingState.forceConsoleToStderr = true;
}
try {
return await run();
} finally {
// Restore the process-wide logging switch so nested/serial CLI calls keep their own output mode.
loggingState.forceConsoleToStderr = previousForceStderr;
loggingState.earlyConsoleRoutingRestore = previousEarlyRestore;
}
}
/** Let resolved command metadata override conservative early literal-flag routing. */
export function applyResolvedCommandOutputMode(machineOutput: boolean): void {
const restore = loggingState.earlyConsoleRoutingRestore;
if (!machineOutput && restore !== null) {
loggingState.forceConsoleToStderr = restore;
}
}
/** Route startup diagnostics to stderr while a command's output mode is still being discovered. */
export async function withConsoleLogsRoutedToStderr<T>(run: () => Promise<T>): Promise<T> {
const previousForceStderr = loggingState.forceConsoleToStderr;
loggingState.forceConsoleToStderr = true;
try {
return await run();
} finally {
loggingState.forceConsoleToStderr = previousForceStderr;
}
}
+55
View File
@@ -0,0 +1,55 @@
import {
consumeRootOptionToken,
getRootOptionAwareCommandPath,
} from "../infra/cli-root-options.js";
export type MachineOutputResolverParams = {
argv: readonly string[];
stdoutIsTTY: boolean;
};
export type MachineOutputResolver = (params: MachineOutputResolverParams) => boolean;
/** Normalize Node's absent `isTTY` property to the public resolver's boolean contract. */
export function isMachineOutputStdoutTTY(stdout: object = process.stdout): boolean {
return Reflect.get(stdout, "isTTY") === true;
}
/** Locate the root command after supported root options without loading descriptor catalogs. */
export function findMachineOutputRootCommandIndex(argv: readonly string[]): number | null {
const args = argv.slice(2);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg || arg === "--") {
return null;
}
const consumed = consumeRootOptionToken(args, index);
if (consumed > 0) {
index += consumed - 1;
continue;
}
if (arg.startsWith("-")) {
continue;
}
return index + 2;
}
return null;
}
/** Read positional command tokens after supported root options, without importing CLI catalogs. */
export function getMachineOutputCommandPath(argv: readonly string[], depth: number): string[] {
return getRootOptionAwareCommandPath(argv, depth);
}
/** Match a boolean or value option before the argv terminator, including `--flag=value`. */
export function hasMachineOutputOption(argv: readonly string[], flag: string): boolean {
for (const arg of argv.slice(2)) {
if (arg === "--") {
return false;
}
if (arg === flag || arg.startsWith(`${flag}=`)) {
return true;
}
}
return false;
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { isConfigMachineOutput, isConfigSetJsonParseOnly } from "./config-output-mode.js";
import { isCronMachineOutput } from "./cron-cli/output-mode.js";
import { isDoctorMachineOutput } from "./doctor-output-mode.js";
import { isGatewayMachineOutput } from "./gateway-cli/output-mode.js";
import { isMachineOutputStdoutTTY } from "./machine-output-argv.js";
import { isProxyMachineOutput } from "./proxy-output-mode.js";
import { isSkillsMachineOutput } from "./skills-output-mode.js";
import { isSystemMachineOutput } from "./system-output-mode.js";
describe("built-in machine-output resolvers", () => {
it("normalizes missing stdout TTY metadata to false", () => {
expect(isMachineOutputStdoutTTY({})).toBe(false);
expect(isMachineOutputStdoutTTY({ isTTY: false })).toBe(false);
expect(isMachineOutputStdoutTTY({ isTTY: true })).toBe(true);
});
it.each([
["heartbeat last", ["system", "heartbeat", "last"]],
["heartbeat enable", ["system", "heartbeat", "enable"]],
["heartbeat disable", ["system", "heartbeat", "disable"]],
["presence", ["system", "presence"]],
])("detects system %s", (_name, path) => {
expect(isSystemMachineOutput(["node", "openclaw", ...path])).toBe(true);
});
it("detects non-TTY doctor lint without changing terminal output", () => {
const argv = ["node", "openclaw", "doctor", "--lint"];
expect(isDoctorMachineOutput({ argv, stdoutIsTTY: false })).toBe(true);
expect(isDoctorMachineOutput({ argv, stdoutIsTTY: true })).toBe(false);
});
it.each(["blob", "coverage", "purge", "query", "sessions"])(
"detects proxy %s output",
(command) => {
expect(isProxyMachineOutput(["node", "openclaw", "proxy", command])).toBe(true);
},
);
it("accepts supported root options after the command root", () => {
expect(
isProxyMachineOutput(["node", "openclaw", "proxy", "--log-level", "debug", "sessions"]),
).toBe(true);
expect(
isSkillsMachineOutput(["node", "openclaw", "skills", "--log-level", "debug", "verify", "x"]),
).toBe(true);
expect(
isGatewayMachineOutput([
"node",
"openclaw",
"gateway",
"--log-level",
"debug",
"restart-handoff",
"capabilities",
]),
).toBe(true);
expect(
isGatewayMachineOutput([
"node",
"openclaw",
"gateway",
"--log-level=debug",
"restart-handoff",
"consume",
]),
).toBe(true);
});
it("reserves raw cron scratch and config get output", () => {
expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job"])).toBe(true);
expect(isConfigMachineOutput(["node", "openclaw", "config", "get", "gateway.port"])).toBe(true);
expect(
isConfigMachineOutput([
"node",
"openclaw",
"config",
"--section",
"agents",
"get",
"gateway.port",
]),
).toBe(true);
});
it("treats config set --json as parse-only except for JSON dry-run reports", () => {
expect(
isConfigSetJsonParseOnly([
"node",
"openclaw",
"config",
"set",
"gateway.port",
"18789",
"--json",
]),
).toBe(true);
expect(
isConfigSetJsonParseOnly([
"node",
"openclaw",
"config",
"set",
"gateway.port",
"18789",
"--dry-run",
"--json",
]),
).toBe(false);
});
it("finds agent-scoped skill verification", () => {
expect(
isSkillsMachineOutput([
"node",
"openclaw",
"skills",
"--agent",
"main",
"verify",
"@owner/skill",
]),
).toBe(true);
});
});
+19
View File
@@ -3,6 +3,7 @@ import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runRegisteredCli } from "../test-utils/command-runner.js";
import { registerModelsCli } from "./models-cli.js";
import { isCommandJsonOutputMode } from "./program/json-mode.js";
const mocks = vi.hoisted(() => ({
modelsStatusCommand: vi.fn().mockResolvedValue(undefined),
@@ -132,6 +133,24 @@ describe("models cli", () => {
}
}
it("declares --status-json as machine output", async () => {
const program = createProgram();
let detected = false;
program.hook("preAction", (_command, actionCommand) => {
detected = isCommandJsonOutputMode(actionCommand, process.argv);
});
const originalArgv = process.argv;
process.argv = ["node", "openclaw", "models", "--status-json"];
try {
await program.parseAsync(["models", "--status-json"], { from: "user" });
} finally {
process.argv = originalArgv;
}
expect(detected).toBe(true);
});
it("registers github-copilot login command", async () => {
const program = createProgram();
const models = requireCommand(program, "models");
+3
View File
@@ -2,6 +2,8 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { isModelsStatusJsonOutput } from "./models-output-mode.js";
import { setCommandJsonMode } from "./program/json-mode.js";
type ModelsCliRuntime = typeof import("./models-cli.runtime.js");
@@ -48,6 +50,7 @@ export function registerModelsCli(program: Command) {
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/models", "docs.openclaw.ai/cli/models")}\n`,
);
setCommandJsonMode(models, "output", ({ argv }) => isModelsStatusJsonOutput(argv));
models
.command("list")
+6
View File
@@ -0,0 +1,6 @@
import { hasMachineOutputOption } from "./machine-output-argv.js";
/** Resolve the parent-command alias for `models status --json`. */
export function isModelsStatusJsonOutput(argv: readonly string[]): boolean {
return hasMachineOutputOption(argv, "--status-json");
}
+6
View File
@@ -0,0 +1,6 @@
import { getMachineOutputCommandPath } from "../machine-output-argv.js";
export function isNodesMachineOutput(argv: readonly string[]): boolean {
const [, command] = getMachineOutputCommandPath(argv, 2);
return command === "invoke" || command === "approve" || command === "reject";
}
+3
View File
@@ -5,6 +5,8 @@ import { theme } from "../../../packages/terminal-core/src/theme.js";
import { resolveCliArgvInvocation } from "../argv-invocation.js";
import { formatHelpExamples } from "../help-format.js";
import { withConsoleLogsRoutedToStderrForJson } from "../json-output-mode.js";
import { setCommandJsonMode } from "../program/json-mode.js";
import { isNodesMachineOutput } from "./output-mode.js";
import { registerNodesCameraCommands } from "./register.camera.js";
import { registerNodesInvokeCommands } from "./register.invoke.js";
import { registerNodesLocationCommands } from "./register.location.js";
@@ -42,6 +44,7 @@ export async function registerNodesCli(program: Command, argv: readonly string[]
registerNodesCameraCommands(nodes);
registerNodesScreenCommands(nodes);
registerNodesLocationCommands(nodes);
setCommandJsonMode(nodes, "output", ({ argv: commandArgv }) => isNodesMachineOutput(commandArgv));
// Built-in `nodes` subcommands (status/list/pairing/invoke/...) must stay on the lightweight
// path: loading plugin CLI/runtime to resolve them only adds startup cost. Plugin-provided node
@@ -1,11 +1,13 @@
// Descriptor-to-lazy-command-group adapters used by core and sub-CLI registration.
import type { Command } from "commander";
import type { MachineOutputResolver } from "../machine-output-argv.js";
/** Descriptor for one root command placeholder. */
export type NamedCommandDescriptor = {
name: string;
description: string;
hasSubcommands: boolean;
machineOutput?: MachineOutputResolver;
hidden?: boolean;
parentDefaultHelp?: boolean;
};
@@ -1,5 +1,7 @@
// Core root-command descriptor catalog used for help placeholders and lazy registration.
import { isExperimentalClawsEnabled } from "../../claws/experimental.js";
import { isConfigMachineOutput } from "../config-output-mode.js";
import { isDoctorMachineOutput } from "../doctor-output-mode.js";
import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js";
import type { NamedCommandDescriptor } from "./command-group-descriptors.js";
@@ -33,6 +35,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
description:
"Non-interactive config helpers (get/set/patch/unset/file/schema/validate). Run without subcommand for guided setup.",
hasSubcommands: true,
machineOutput: ({ argv }) => isConfigMachineOutput(argv),
},
{
name: "claws",
@@ -54,6 +57,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
name: "doctor",
description: "Health checks + quick fixes for the gateway and channels",
hasSubcommands: false,
machineOutput: isDoctorMachineOutput,
},
{
name: "dashboard",
+3 -1
View File
@@ -3,6 +3,7 @@ import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { isRich, theme } from "../../../packages/terminal-core/src/theme.js";
import { resolveCommitHash } from "../../infra/git-commit.js";
import { formatConsoleDiagnosticBlock } from "../../logging/json-console-line.js";
import { escapeRegExp } from "../../utils.js";
import { isRootVersionInvocation } from "../argv.js";
import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js";
@@ -114,7 +115,8 @@ export function configureProgramHelp(
process.stdout.write(formatProgramHelpOutput(str));
},
writeErr: (str) => {
process.stderr.write(formatProgramHelpOutput(str));
const message = formatProgramHelpOutput(str);
process.stderr.write(formatConsoleDiagnosticBlock({ level: "error", message }));
},
outputError: (str, write) => write(formatCliParseErrorOutput(str, { argv: process.argv })),
});
+36 -17
View File
@@ -1,45 +1,64 @@
// JSON-mode metadata for Commander commands; distinguishes JSON output from parse-only flags.
import type { Command } from "commander";
import { hasFlag } from "../argv.js";
import {
isMachineOutputStdoutTTY,
type MachineOutputResolverParams,
} from "../machine-output-argv.js";
const jsonModeSymbol = Symbol("openclaw.cli.jsonMode");
type JsonMode = "output" | "parse-only";
type CommandJsonMode = "output" | "parse-only";
type CommandJsonModeResolver = (
params: {
command: Command;
} & MachineOutputResolverParams,
) => boolean;
type CommandJsonModeDeclaration = {
mode: CommandJsonMode;
resolve?: CommandJsonModeResolver;
};
type JsonModeCommand = Command & {
[jsonModeSymbol]?: JsonMode;
[jsonModeSymbol]?: CommandJsonModeDeclaration;
};
function commandDefinesJsonOption(command: Command): boolean {
return command.options.some((option) => option.long === "--json");
}
function getDeclaredCommandJsonMode(command: Command): JsonMode | null {
function getCommandJsonMode(
command: Command,
argv: string[] = process.argv,
): CommandJsonMode | null {
const literalJsonMode =
command.optsWithGlobals<{ json?: unknown }>().json === true || hasFlag(argv, "--json");
for (let current: Command | null = command; current; current = current.parent ?? null) {
const metadata = (current as JsonModeCommand)[jsonModeSymbol];
if (metadata) {
return metadata;
if (metadata?.resolve?.({ command, argv, stdoutIsTTY: isMachineOutputStdoutTTY() })) {
return metadata.mode;
}
if (commandDefinesJsonOption(current)) {
if (metadata && !metadata.resolve && literalJsonMode) {
return metadata.mode;
}
if (literalJsonMode && commandDefinesJsonOption(current)) {
return "output";
}
}
return null;
}
/** Mark a command as having a special JSON mode beyond ordinary JSON output. */
export function setCommandJsonMode(command: Command, mode: JsonMode): Command {
(command as JsonModeCommand)[jsonModeSymbol] = mode;
/** Mark a command as having a special JSON mode beyond ordinary `--json` output. */
export function setCommandJsonMode(
command: Command,
mode: CommandJsonMode,
resolve?: CommandJsonModeResolver,
): Command {
(command as JsonModeCommand)[jsonModeSymbol] = { mode, ...(resolve ? { resolve } : {}) };
return command;
}
function getCommandJsonMode(command: Command, argv: string[] = process.argv): JsonMode | null {
if (command.optsWithGlobals<{ json?: unknown }>().json !== true && !hasFlag(argv, "--json")) {
return null;
}
return getDeclaredCommandJsonMode(command);
}
/** Return true only when `--json` selects machine-readable command output. */
/** Return true when the command's active mode owns machine-readable JSON stdout. */
export function isCommandJsonOutputMode(command: Command, argv: string[] = process.argv): boolean {
return getCommandJsonMode(command, argv) === "output";
}
+64 -2
View File
@@ -3,6 +3,7 @@ import { Command } from "commander";
import { repoInstallSpec } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { loggingState } from "../../logging/state.js";
import { isConfigSetJsonParseOnly } from "../config-output-mode.js";
import { setCommandJsonMode } from "./json-mode.js";
import { applyParentDefaultHelpAction } from "./parent-default-help.js";
@@ -81,6 +82,8 @@ let observedProcessTitle: string;
let originalNodeNoWarnings: string | undefined;
let originalHideBanner: string | undefined;
let originalForceStderr: boolean;
let originalEarlyConsoleRoutingRestore: boolean | null;
let observedMachineOutputStdoutIsTTY: boolean | undefined;
beforeAll(async () => {
({ registerPreActionHooks } = await import("./preaction.js"));
@@ -95,6 +98,8 @@ beforeEach(() => {
originalNodeNoWarnings = process.env.NODE_NO_WARNINGS;
originalHideBanner = process.env.OPENCLAW_HIDE_BANNER;
originalForceStderr = loggingState.forceConsoleToStderr;
originalEarlyConsoleRoutingRestore = loggingState.earlyConsoleRoutingRestore;
observedMachineOutputStdoutIsTTY = undefined;
// Worker-thread Vitest runs do not reliably mutate the real process title,
// so capture writes at the property boundary instead.
Object.defineProperty(process, "title", {
@@ -106,6 +111,7 @@ beforeEach(() => {
},
});
loggingState.forceConsoleToStderr = false;
loggingState.earlyConsoleRoutingRestore = null;
delete process.env.NODE_NO_WARNINGS;
delete process.env.OPENCLAW_HIDE_BANNER;
});
@@ -123,6 +129,7 @@ afterEach(() => {
process.title = originalProcessTitle;
}
loggingState.forceConsoleToStderr = originalForceStderr;
loggingState.earlyConsoleRoutingRestore = originalEarlyConsoleRoutingRestore;
if (originalNodeNoWarnings === undefined) {
delete process.env.NODE_NO_WARNINGS;
} else {
@@ -228,9 +235,15 @@ describe("registerPreActionHooks", () => {
.command("send")
.option("--json")
.action(() => {});
setCommandJsonMode(programLocal.command("machine"), "output", ({ argv, stdoutIsTTY }) => {
observedMachineOutputStdoutIsTTY = stdoutIsTTY;
return argv.includes("--machine-output");
}).action(() => {});
const config = programLocal.command("config");
config.option("--section <section>");
setCommandJsonMode(config.command("set"), "parse-only")
setCommandJsonMode(config.command("set"), "parse-only", ({ argv }) =>
isConfigSetJsonParseOnly(argv),
)
.argument("<path>")
.argument("<value>")
.option("--json")
@@ -690,13 +703,37 @@ describe("registerPreActionHooks", () => {
vi.clearAllMocks();
// config set --json is parse-only (not JSON output mode), should not route
// Early argv detection routes literal --json conservatively until Commander metadata resolves.
loggingState.forceConsoleToStderr = true;
loggingState.earlyConsoleRoutingRestore = false;
await runPreAction({
parseArgv: ["config", "set", "gateway.auth.mode", "local", "--json"],
processArgv: ["node", "openclaw", "config", "set", "gateway.auth.mode", "local", "--json"],
});
expect(routeLogsToStderrMock).not.toHaveBeenCalled();
expect(loggingState.forceConsoleToStderr).toBe(false);
vi.clearAllMocks();
loggingState.forceConsoleToStderr = true;
loggingState.earlyConsoleRoutingRestore = false;
await runPreAction({
parseArgv: ["config", "set", "gateway.auth.mode", "local", "--dry-run", "--json"],
processArgv: [
"node",
"openclaw",
"config",
"set",
"gateway.auth.mode",
"local",
"--dry-run",
"--json",
],
});
expect(routeLogsToStderrMock).toHaveBeenCalledOnce();
expect(loggingState.forceConsoleToStderr).toBe(true);
vi.clearAllMocks();
@@ -709,6 +746,31 @@ describe("registerPreActionHooks", () => {
expect(routeLogsToStderrMock).not.toHaveBeenCalled();
});
it("routes logs when command-owned metadata selects machine output", async () => {
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: undefined });
try {
await runPreAction({
parseArgv: ["machine"],
processArgv: ["node", "openclaw", "machine", "--machine-output"],
});
} finally {
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
expect(routeLogsToStderrMock).toHaveBeenCalledOnce();
expect(observedMachineOutputStdoutIsTTY).toBe(false);
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["machine"],
suppressDoctorStdout: true,
});
});
it("uses the Commander action path for protocol stdout ownership", async () => {
await runPreAction({
parseArgv: ["acp"],
+2
View File
@@ -13,6 +13,7 @@ import {
resolveCliExecutionStartupContext,
} from "../command-execution-startup.js";
import { shouldBypassConfigGuardForCommandPath } from "../command-startup-policy.js";
import { applyResolvedCommandOutputMode } from "../json-output-mode.js";
import {
resolvePluginInstallInvalidConfigPolicy,
resolvePluginInstallPreactionRequest,
@@ -116,6 +117,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
return;
}
const jsonOutputMode = isCommandJsonOutputMode(actionCommand, argv);
applyResolvedCommandOutputMode(jsonOutputMode);
const { commandPath, startupPolicy } = resolveCliExecutionStartupContext({
argv,
protocolCommandPath: getActionCommandPath(actionCommand),
+4 -1
View File
@@ -5,6 +5,8 @@ import { theme } from "../../../packages/terminal-core/src/theme.js";
import { defaultRuntime } from "../../runtime.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { hasExplicitOptions } from "../command-options.js";
import { isDoctorMachineOutput } from "../doctor-output-mode.js";
import { setCommandJsonMode } from "./json-mode.js";
const STATE_SQLITE_CONFLICTING_OPTION_NAMES = [
"workspaceSuggestions",
@@ -31,7 +33,7 @@ const STATE_SQLITE_CONFLICTING_OPTION_NAMES = [
/** Register maintenance commands that inspect or mutate local OpenClaw state. */
export function registerMaintenanceCommands(program: Command) {
program
const doctor = program
.command("doctor")
.description("Health checks + quick fixes for the gateway and channels")
.addHelpText(
@@ -174,6 +176,7 @@ export function registerMaintenanceCommands(program: Command) {
defaultRuntime.exit(0);
});
});
setCommandJsonMode(doctor, "output", isDoctorMachineOutput);
program
.command("dashboard")
@@ -1,6 +1,10 @@
// SubCLI descriptor tests cover metadata for registered nested command groups.
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
const execFileAsync = promisify(execFile);
async function importSubCliDescriptors() {
vi.resetModules();
return import("./subcli-descriptors.js");
@@ -22,6 +26,22 @@ describe("sub-cli descriptors", () => {
vi.resetModules();
});
it("cold-imports without an ESM initialization cycle", async () => {
await expect(
execFileAsync(
process.execPath,
[
"--import",
"tsx",
"--input-type=module",
"--eval",
"await import('./src/cli/program/subcli-descriptors.ts')",
],
{ cwd: process.cwd(), timeout: 30_000 },
),
).resolves.toMatchObject({ stderr: "" });
});
it("keeps the exported descriptor list aligned with private QA visibility when disabled (#83927)", async () => {
delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
+16
View File
@@ -1,4 +1,12 @@
// Sub-CLI descriptor catalog used for root help placeholders and lazy registration.
import { isCronMachineOutput } from "../cron-cli/output-mode.js";
import { isDevicesMachineOutput } from "../devices-output-mode.js";
import { isGatewayMachineOutput } from "../gateway-cli/output-mode.js";
import { isModelsStatusJsonOutput } from "../models-output-mode.js";
import { isNodesMachineOutput } from "../nodes-cli/output-mode.js";
import { isProxyMachineOutput } from "../proxy-output-mode.js";
import { isSkillsMachineOutput } from "../skills-output-mode.js";
import { isSystemMachineOutput } from "../system-output-mode.js";
import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js";
import type { NamedCommandDescriptor } from "./command-group-descriptors.js";
import { isPrivateQaCliEnabled } from "./private-qa-cli.js";
@@ -12,6 +20,7 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
name: "gateway",
description: "Run, inspect, and query the WebSocket Gateway",
hasSubcommands: true,
machineOutput: ({ argv }) => isGatewayMachineOutput(argv),
},
{
name: "daemon",
@@ -23,11 +32,13 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
name: "system",
description: "System tools (events, heartbeat, presence)",
hasSubcommands: true,
machineOutput: ({ argv }) => isSystemMachineOutput(argv),
},
{
name: "models",
description: "Model discovery, scanning, and configuration",
hasSubcommands: true,
machineOutput: ({ argv }) => isModelsStatusJsonOutput(argv),
},
{
name: "promos",
@@ -64,11 +75,13 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
name: "nodes",
description: "Manage gateway-owned nodes (pairing, status, invoke, and media)",
hasSubcommands: true,
machineOutput: ({ argv }) => isNodesMachineOutput(argv),
},
{
name: "devices",
description: "Device pairing and auth tokens",
hasSubcommands: true,
machineOutput: ({ argv }) => isDevicesMachineOutput(argv),
parentDefaultHelp: true,
},
{
@@ -127,6 +140,7 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
name: "cron",
description: "Manage cron jobs (via Gateway)",
hasSubcommands: true,
machineOutput: ({ argv }) => isCronMachineOutput(argv),
parentDefaultHelp: true,
},
{
@@ -148,6 +162,7 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
name: "proxy",
description: "Run the OpenClaw debug proxy and inspect captured traffic",
hasSubcommands: true,
machineOutput: ({ argv }) => isProxyMachineOutput(argv),
},
{
name: "hooks",
@@ -205,6 +220,7 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
name: "skills",
description: "List and inspect available skills",
hasSubcommands: true,
machineOutput: ({ argv }) => isSkillsMachineOutput(argv),
},
{
name: "update",
+3
View File
@@ -3,6 +3,8 @@ import { InvalidArgumentError, type Command } from "commander";
import { parseStrictInteger } from "../infra/parse-finite-number.js";
import type { CaptureQueryPreset } from "../proxy-capture/types.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { setCommandJsonMode } from "./program/json-mode.js";
import { isProxyMachineOutput } from "./proxy-output-mode.js";
type ProxyCliRuntime = typeof import("./proxy-cli.runtime.js");
@@ -47,6 +49,7 @@ export function registerProxyCli(program: Command) {
const proxy = program
.command("proxy")
.description("Run the OpenClaw debug proxy and inspect captured traffic");
setCommandJsonMode(proxy, "output", ({ argv }) => isProxyMachineOutput(argv));
proxy
.command("start")
+9
View File
@@ -0,0 +1,9 @@
import { getMachineOutputCommandPath } from "./machine-output-argv.js";
const MACHINE_OUTPUT_COMMANDS = new Set(["blob", "coverage", "purge", "query", "sessions"]);
/** Proxy inspection commands reserve stdout for JSON or raw captured content. */
export function isProxyMachineOutput(argv: readonly string[]): boolean {
const [, command] = getMachineOutputCommandPath(argv, 2);
return MACHINE_OUTPUT_COMMANDS.has(command ?? "");
}
+11 -1
View File
@@ -37,7 +37,7 @@ vi.mock("../runtime.js", () => ({
function firstConfigReadyCall() {
return ensureConfigReadyMock.mock.calls[0]?.[0] as
| { runtime?: unknown; commandPath?: unknown }
| { runtime?: unknown; commandPath?: unknown; suppressDoctorStdout?: boolean }
| undefined;
}
@@ -120,6 +120,16 @@ describe("tryRouteCli", () => {
});
});
it("suppresses startup output for bare config get machine output", async () => {
await expect(
tryRouteCli(["node", "openclaw", "config", "get", "gateway.port"], {
machineOutput: true,
}),
).resolves.toBe(true);
expect(firstConfigReadyCall()?.suppressDoctorStdout).toBe(true);
});
it("keeps logs routed to stderr for routed --json commands", async () => {
findRoutedCommandMock.mockReturnValue({
loadPlugins: true,
+7 -2
View File
@@ -53,10 +53,11 @@ async function prepareRoutedCommand(params: {
argv: string[];
commandPath: string[];
loadPlugins?: boolean | ((argv: string[]) => boolean);
machineOutput?: boolean;
}) {
const { startupPolicy } = resolveCliExecutionStartupContext({
argv: params.argv,
jsonOutputMode: hasFlag(params.argv, "--json"),
jsonOutputMode: params.machineOutput === true || hasFlag(params.argv, "--json"),
env: process.env,
routeMode: true,
});
@@ -79,7 +80,10 @@ async function prepareRoutedCommand(params: {
}
/** Try a lightweight route-first command before falling back to the full CLI program. */
export async function tryRouteCli(argv: string[]): Promise<boolean> {
export async function tryRouteCli(
argv: string[],
options: { machineOutput?: boolean } = {},
): Promise<boolean> {
if (isTruthyEnvValue(process.env.OPENCLAW_DISABLE_ROUTE_FIRST)) {
return false;
}
@@ -110,6 +114,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
argv,
commandPath: invocation.commandPath,
loadPlugins: route.loadPlugins,
machineOutput: options.machineOutput,
});
return route.run(argv);
}
+159 -2
View File
@@ -63,6 +63,18 @@ const registerCoreCliByNameMock = vi.hoisted(() => vi.fn());
const registerSubCliByNameMock = vi.hoisted(() => vi.fn());
const registerPluginCliCommandsFromValidatedConfigMock = vi.hoisted(() => vi.fn(async () => ({})));
const resolvePluginCliRootOwnerIdsMock = vi.hoisted(() => vi.fn());
const loadPluginCliDescriptorsMock = vi.hoisted(() =>
vi.fn<
() => Promise<
Array<{
name: string;
description: string;
hasSubcommands: boolean;
machineOutput?: (params: { argv: readonly string[]; stdoutIsTTY: boolean }) => boolean;
}>
>
>(async () => []),
);
const resolveManifestCommandAliasOwnerMock = vi.hoisted(() => vi.fn());
const resolveManifestToolOwnerMock = vi.hoisted(() => vi.fn());
const resolveManifestCliCommandSurfaceOwnerMock = vi.hoisted(() => vi.fn());
@@ -315,6 +327,7 @@ vi.mock("../plugins/cli.js", () => ({
}));
vi.mock("../plugins/cli-registry-loader.js", () => ({
loadPluginCliDescriptors: loadPluginCliDescriptorsMock,
resolvePluginCliRootOwnerIds: resolvePluginCliRootOwnerIdsMock,
}));
@@ -442,6 +455,7 @@ describe("runCli exit behavior", () => {
startProxyMock.mockResolvedValue(null);
stopProxyMock.mockResolvedValue(undefined);
getProgramContextMock.mockReturnValue(null);
loadPluginCliDescriptorsMock.mockReset().mockResolvedValue([]);
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "googlemeet" ? ["google-meet"] : [],
@@ -463,7 +477,12 @@ describe("runCli exit behavior", () => {
await runCli(["node", "openclaw", "status"]);
expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
expect(enableConsoleCaptureMock).toHaveBeenCalledTimes(1);
expect(tryRouteCliMock).toHaveBeenCalledWith(["node", "openclaw", "status"]);
const captureOrder = enableConsoleCaptureMock.mock.invocationCallOrder[0] ?? 0;
const routeOrder = tryRouteCliMock.mock.invocationCallOrder[0] ?? 0;
expect(captureOrder).toBeGreaterThan(0);
expect(routeOrder).toBeGreaterThan(captureOrder);
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
expect(disposeRegisteredAgentHarnessesMock).not.toHaveBeenCalled();
expect(ensureTaskRegistryReadyMock).not.toHaveBeenCalled();
@@ -472,6 +491,17 @@ describe("runCli exit behavior", () => {
exitSpy.mockRestore();
});
it("passes config get machine ownership into route-first startup", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "config", "get", "gateway.port"]);
expect(tryRouteCliMock).toHaveBeenCalledWith(
["node", "openclaw", "config", "get", "gateway.port"],
{ machineOutput: true },
);
});
it("disposes registered harnesses after full CLI command completion", async () => {
listRegisteredAgentHarnessesMock.mockReturnValueOnce([{ harness: { id: "codex" } }]);
tryRouteCliMock.mockResolvedValueOnce(false);
@@ -2181,6 +2211,94 @@ describe("runCli exit behavior", () => {
expect(startProxyMock).toHaveBeenCalledWith(undefined);
});
it.each([
["JSON flag", ["node", "openclaw", "plugins", "marketplace", "list", "--json"]],
["models status JSON alias", ["node", "openclaw", "models", "--status-json"]],
])("routes managed-proxy startup logs away for the %s", async (_name, argv) => {
tryRouteCliMock.mockResolvedValueOnce(true);
startProxyMock.mockImplementationOnce(async () => {
expect(loggingState.forceConsoleToStderr).toBe(true);
return null;
});
await runCli(argv);
expect(startProxyMock).toHaveBeenCalledWith(undefined);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it.each([
["cron", ["node", "openclaw", "cron", "status"]],
["cron alias", ["node", "openclaw", "cron", "create", "daily", "message"]],
["cron removal alias", ["node", "openclaw", "cron", "delete", "job"]],
["cron scratch equals", ["node", "openclaw", "cron", "scratch", "job", "--set=text"]],
["device token", ["node", "openclaw", "devices", "rotate", "--device", "one"]],
[
"gateway handoff",
["node", "openclaw", "gateway", "--port", "18789", "restart-handoff", "capabilities"],
],
["node pairing", ["node", "openclaw", "nodes", "approve", "request-one"]],
["node invoke", ["node", "openclaw", "nodes", "invoke", "--node", "one"]],
["skill verification", ["node", "openclaw", "skills", "verify", "@owner/skill"]],
[
"agent-scoped skill verification",
["node", "openclaw", "skills", "--agent", "main", "verify", "@owner/skill"],
],
["system heartbeat", ["node", "openclaw", "system", "heartbeat", "last"]],
["system presence", ["node", "openclaw", "system", "presence"]],
["doctor lint", ["node", "openclaw", "doctor", "--lint"]],
["proxy coverage", ["node", "openclaw", "proxy", "coverage"]],
])("routes startup diagnostics for default-machine %s output", async (_name, argv) => {
tryRouteCliMock.mockImplementationOnce(async () => {
expect(loggingState.forceConsoleToStderr).toBe(true);
return true;
});
await runCli(argv);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it("routes managed-proxy startup logs for plugin-declared machine output", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
let observedStdoutIsTTY: boolean | undefined;
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "path" ? ["oc-path"] : [],
);
loadPluginCliDescriptorsMock.mockResolvedValueOnce([
{
name: "path",
description: "OC path",
hasSubcommands: true,
machineOutput: ({ stdoutIsTTY }: { stdoutIsTTY: boolean }) => {
observedStdoutIsTTY = stdoutIsTTY;
return !stdoutIsTTY;
},
},
]);
startProxyMock.mockImplementationOnce(async () => {
expect(loggingState.forceConsoleToStderr).toBe(true);
return null;
});
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: undefined });
try {
await runCli(["node", "openclaw", "path", "validate", "oc://AGENTS.md"]);
} finally {
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
expect(startProxyMock).toHaveBeenCalledWith(undefined);
expect(observedStdoutIsTTY).toBe(false);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it.each([
["fast path", ["node", "openclaw", "gateway", "run"]],
[
@@ -2198,6 +2316,13 @@ describe("runCli exit behavior", () => {
expect(startProxyMock).toHaveBeenCalledWith(undefined);
});
it("keeps agent exec outside the CLI dotenv loader", async () => {
buildProgramMock.mockReturnValueOnce({ commands: [], parseAsync: vi.fn() });
await runCli(["node", "openclaw", "agent", "exec", "test prompt"]);
expect(loadDotEnvMock).not.toHaveBeenCalled();
});
it("validates the runtime before selecting gateway config", async () => {
await runCli(["node", "openclaw", "gateway", "run"]);
@@ -2465,7 +2590,7 @@ describe("runCli exit behavior", () => {
expect(parseAsync).toHaveBeenCalledWith(argv);
});
it("routes lazy plugin registration logs to stderr only during --json registration", async () => {
it("routes incidental logs to stderr throughout --json startup and dispatch", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
@@ -2494,7 +2619,34 @@ describe("runCli exit behavior", () => {
{ mode: "lazy", primary: "memory" },
);
expect(stderrDuringPluginRegistration).toBe(true);
expect(stderrDuringParse).toBe(false);
expect(stderrDuringParse).toBe(true);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
it("routes plugin registration logs for descriptor-declared machine output", async () => {
tryRouteCliMock.mockResolvedValueOnce(false);
resolvePluginCliRootOwnerIdsMock.mockImplementation(
({ primaryCommand }: { primaryCommand?: string }) =>
primaryCommand === "path" ? ["oc-path"] : [],
);
loadPluginCliDescriptorsMock.mockResolvedValueOnce([
{
name: "path",
description: "OC path",
hasSubcommands: true,
machineOutput: ({ stdoutIsTTY }: { stdoutIsTTY: boolean }) => !stdoutIsTTY,
},
]);
let stderrDuringPluginRegistration = false;
registerPluginCliCommandsFromValidatedConfigMock.mockImplementationOnce(async () => {
stderrDuringPluginRegistration = loggingState.forceConsoleToStderr;
return {};
});
buildProgramMock.mockReturnValueOnce({ commands: [], parseAsync: vi.fn() });
await runCli(["node", "openclaw", "path", "validate", "oc://AGENTS.md"]);
expect(stderrDuringPluginRegistration).toBe(true);
expect(loggingState.forceConsoleToStderr).toBe(false);
});
@@ -3706,6 +3858,11 @@ describe("runCli exit behavior", () => {
"demo",
"status",
]);
expect(enableConsoleCaptureMock).toHaveBeenCalledTimes(1);
const captureOrder = enableConsoleCaptureMock.mock.invocationCallOrder[0] ?? 0;
const containerOrder = maybeRunCliInContainerMock.mock.invocationCallOrder[0] ?? 0;
expect(captureOrder).toBeGreaterThan(0);
expect(containerOrder).toBeGreaterThan(captureOrder);
expect(loadDotEnvMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(closeActiveMemorySearchManagersMock).not.toHaveBeenCalled();
+33 -1
View File
@@ -1,7 +1,10 @@
// Run main tests cover CLI main entrypoint behavior and process error handling.
import { describe, expect, it } from "vitest";
import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js";
import { resolveGatewayRunPreBootstrapOptions } from "./gateway-run-argv.js";
import {
resolveGatewayCatalogCommandPath,
resolveGatewayRunPreBootstrapOptions,
} from "./gateway-run-argv.js";
import {
rewriteUpdateFlagArgv,
resolveMissingPluginCommandMessage,
@@ -70,6 +73,12 @@ describe("isGatewayRunFastPathArgv", () => {
isGatewayRunFastPathArgv(["node", "openclaw", "--no-color", "gateway", "--bind", "loopback"]),
).toBe(true);
expect(isGatewayRunFastPathArgv(["node", "openclaw", "gateway", "run"])).toBe(true);
expect(
isGatewayRunFastPathArgv(["node", "openclaw", "gateway", "--log-level", "debug", "run"]),
).toBe(true);
expect(
isGatewayRunFastPathArgv(["node", "openclaw", "gateway", "--log-level=debug", "run"]),
).toBe(true);
expect(
isGatewayRunFastPathArgv(["node", "openclaw", "gateway", "run", "--raw-stream-path", "x"]),
).toBe(true);
@@ -78,6 +87,29 @@ describe("isGatewayRunFastPathArgv", () => {
expect(isGatewayRunFastPathArgv(["node", "openclaw", "gateway", "--port"])).toBe(false);
expect(isGatewayRunFastPathArgv(["node", "openclaw", "gateway", "--unknown"])).toBe(false);
});
it("keeps post-root log levels out of the gateway command path", () => {
expect(
resolveGatewayCatalogCommandPath([
"node",
"openclaw",
"gateway",
"--log-level",
"debug",
"run",
]),
).toEqual(["gateway", "run"]);
expect(
resolveGatewayCatalogCommandPath([
"node",
"openclaw",
"gateway",
"--log-level=debug",
"restart-handoff",
"capabilities",
]),
).toEqual(["gateway", "restart-handoff"]);
});
});
describe("resolveGatewayRunPreBootstrapOptions", () => {
+160 -24
View File
@@ -7,7 +7,11 @@ import type { Command as CommanderCommand, Option as CommanderOption } from "com
import { resolveStateDir } from "../config/paths.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import { isLoopbackAddress, isSecureWebSocketUrl } from "../gateway/net.js";
import { FLAG_TERMINATOR, isValueToken } from "../infra/cli-root-options.js";
import {
consumeRootOptionToken,
FLAG_TERMINATOR,
isValueToken,
} from "../infra/cli-root-options.js";
import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js";
import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
@@ -34,12 +38,20 @@ import {
resolveGatewayCatalogCommandPath,
resolveGatewayRunPreBootstrapOptions,
} from "./gateway-run-argv.js";
import { hasJsonOutputFlag, withConsoleLogsRoutedToStderrForJson } from "./json-output-mode.js";
import {
hasJsonOutputFlag,
withConsoleLogsRoutedToStderr,
withConsoleLogsRoutedToStderrForJson,
} from "./json-output-mode.js";
import { isMachineOutputStdoutTTY } from "./machine-output-argv.js";
import { requestExitAfterOneShotOutput } from "./one-shot-exit.js";
import { tryOutputPrecomputedCommandHelp } from "./precomputed-help.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { formatCliCommandSuggestions } from "./program/command-suggestions.js";
import { getCoreCliCommandNames } from "./program/core-command-descriptors.js";
import {
getCoreCliCommandDescriptors,
getCoreCliCommandNames,
} from "./program/core-command-descriptors.js";
import { getSubCliEntries } from "./program/subcli-descriptors.js";
import {
resolveMissingPluginCommandMessage as resolveMissingPluginCommandMessageFromPolicy,
@@ -51,7 +63,10 @@ import {
shouldUseSetupOnboardConfigureHelpFastPath,
} from "./run-main-policy.js";
import { registerSignalExitBarrier, waitForSignalExitBarriers } from "./signal-exit-barrier.js";
import { createGatewayStartupTrace } from "./startup-trace.js";
import {
configureGatewayStartupTraceConsoleFormatting,
createGatewayStartupTrace,
} from "./startup-trace.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
export {
@@ -112,6 +127,11 @@ export function isGatewayRunFastPathArgv(argv: string[]): boolean {
continue;
}
const rootConsumed = consumeRootOptionToken(args, index);
if (rootConsumed > 0) {
index += rootConsumed - 1;
continue;
}
const consumed = consumeGatewayRunOptionToken(args, index);
if (consumed > 0) {
index += consumed - 1;
@@ -148,7 +168,6 @@ async function tryRunGatewayRunFastPath(
{ VERSION },
{ emitCliBanner },
{ resolveCliStartupPolicy },
{ enableConsoleCapture },
{ ensureCliExecutionBootstrap },
{ defaultRuntime },
] = await startupTrace.measure("gateway-run-imports", () =>
@@ -158,7 +177,6 @@ async function tryRunGatewayRunFastPath(
import("../version.js"),
import("./banner.js"),
import("./command-startup-policy.js"),
loadLoggingModule(),
import("./command-execution-startup.js"),
import("../runtime.js"),
]),
@@ -230,7 +248,6 @@ async function tryRunGatewayRunFastPath(
gateway.command("run").description("Run the WebSocket Gateway (foreground)"),
{ beforeRun },
);
enableConsoleCapture();
try {
await startupTrace.measure("gateway-run-parse", () => program.parseAsync(argv));
} catch (error) {
@@ -833,6 +850,44 @@ function isKnownBuiltInCommandRoot(primary: string): boolean {
);
}
function resolvesMachineOutput(
descriptor: {
machineOutput?: (params: { argv: readonly string[]; stdoutIsTTY: boolean }) => boolean;
},
argv: readonly string[],
): boolean {
return descriptor.machineOutput?.({ argv, stdoutIsTTY: isMachineOutputStdoutTTY() }) ?? false;
}
function resolveBuiltInMachineOutput(argv: string[]): boolean {
const { primary } = resolveCliArgvInvocation(argv);
if (!primary) {
return false;
}
const descriptor = [...getCoreCliCommandDescriptors(), ...getSubCliEntries()].find(
(entry) => entry.name === primary,
);
return descriptor ? resolvesMachineOutput(descriptor, argv) : false;
}
async function resolvePluginMachineOutput(params: {
argv: string[];
config: OpenClawConfig;
}): Promise<boolean> {
const { primary } = resolveCliArgvInvocation(params.argv);
if (!primary || isKnownBuiltInCommandRoot(primary)) {
return false;
}
const { loadPluginCliDescriptors } = await loadCliRegistryLoaderModule();
const descriptors = await loadPluginCliDescriptors({
cfg: params.config,
env: process.env,
primaryCommand: primary,
});
const descriptor = descriptors.find((entry) => entry.name === primary);
return descriptor ? resolvesMachineOutput(descriptor, params.argv) : false;
}
async function isPluginCliRoot(params: {
primary: string;
config: OpenClawConfig;
@@ -964,31 +1019,93 @@ async function bootstrapCliProxyCaptureAndDispatcher(
if (options.ensureDispatcher !== false) {
await startupTrace.measure("proxy-dispatcher", () => ensureCliEnvProxyDispatcher());
}
maybeWarnAboutDebugProxyCoverage();
maybeWarnAboutDebugProxyCoverage(undefined, (message) => console.warn(message));
}
export async function runCli(argv: string[] = process.argv) {
export async function runCli(
argv: string[] = process.argv,
options: {
additionalStartupTrace?: ReturnType<typeof createGatewayStartupTrace>;
} = {},
) {
const originalArgv = normalizeWindowsArgv(argv);
const builtInMachineOutput = resolveBuiltInMachineOutput(originalArgv);
return await withConsoleLogsRoutedToStderrForJson(
originalArgv,
() => runCliWithPreparedOutputMode(originalArgv, { ...options, builtInMachineOutput }),
{ machineOutput: builtInMachineOutput, restoreChanges: true },
);
}
async function runCliWithPreparedOutputMode(
originalArgv: string[],
options: {
additionalStartupTrace?: ReturnType<typeof createGatewayStartupTrace>;
builtInMachineOutput: boolean;
},
) {
const startupTrace = createGatewayStartupTrace(originalArgv, "cli.main");
const earlyProfile = parseCliProfileArgs(originalArgv);
if (earlyProfile.ok && earlyProfile.profile) {
applyCliProfileEnv({ profile: earlyProfile.profile });
}
const originalInvocation = resolveCliArgvInvocation(originalArgv);
let consoleCaptureInstalled = false;
const installConsoleCapture = async () => {
if (consoleCaptureInstalled) {
return;
}
const { enableConsoleCapture } = await loadLoggingModule();
enableConsoleCapture();
consoleCaptureInstalled = true;
};
const configureStartupTraces = async () => {
await configureGatewayStartupTraceConsoleFormatting(startupTrace);
if (options.additionalStartupTrace) {
await configureGatewayStartupTraceConsoleFormatting(options.additionalStartupTrace);
}
};
const parsedContainer = parseCliContainerArgs(originalArgv);
if (!parsedContainer.ok) {
await installConsoleCapture();
await configureStartupTraces();
throw new Error(parsedContainer.error);
}
const parsedProfile = parseCliProfileArgs(parsedContainer.argv);
const containerTargetName =
parsedContainer.container ?? normalizeOptionalString(process.env.OPENCLAW_CONTAINER) ?? null;
const hasPreHelpValidationError =
!parsedProfile.ok || (containerTargetName !== null && parsedProfile.profile !== null);
// Console formatting is a process-wide invariant. Install capture before
// container dispatch or validation can bypass the pure help/version path.
if (
!originalInvocation.hasHelpOrVersion ||
containerTargetName !== null ||
hasPreHelpValidationError
) {
await installConsoleCapture();
}
if (!parsedProfile.ok) {
await configureStartupTraces();
throw new Error(parsedProfile.error);
}
if (parsedProfile.profile) {
applyCliProfileEnv({ profile: parsedProfile.profile });
}
const containerTargetName =
parsedContainer.container ?? normalizeOptionalString(process.env.OPENCLAW_CONTAINER) ?? null;
if (containerTargetName && parsedProfile.profile) {
await configureStartupTraces();
throw new Error("--container cannot be combined with --profile/--dev");
}
const containerTarget = maybeRunCliInContainer(originalArgv);
let containerTarget: ReturnType<typeof maybeRunCliInContainer>;
try {
containerTarget = maybeRunCliInContainer(originalArgv);
} catch (error) {
await configureStartupTraces();
throw error;
}
if (containerTarget.handled) {
await configureStartupTraces();
if (containerTarget.exitCode !== 0) {
process.exitCode = containerTarget.exitCode;
}
@@ -1018,6 +1135,7 @@ export async function runCli(argv: string[] = process.argv) {
}
});
}
await configureStartupTraces();
if (!isHelpOrVersionInvocation && isGatewayRunInvocation) {
await startupTrace.measure("gateway-run-select-environment", async () => {
const [{ selectGatewayRunEnvironment }, { defaultRuntime }] = await Promise.all([
@@ -1053,6 +1171,21 @@ export async function runCli(argv: string[] = process.argv) {
}
return await bestEffortConfigPromise;
};
if (
!isHelpOrVersionInvocation &&
normalizedInvocation.primary &&
!isKnownBuiltInCommandRoot(normalizedInvocation.primary)
) {
const config = await withConsoleLogsRoutedToStderr(readBestEffortCliConfig);
if (
await withConsoleLogsRoutedToStderr(() =>
resolvePluginMachineOutput({ argv: normalizedArgv, config }),
)
) {
const { routeLogsToStderr } = await loadLoggingModule();
routeLogsToStderr();
}
}
const uninstallProxySignalHandlers = () => {
if (onSigterm) {
process.off("SIGTERM", onSigterm);
@@ -1107,7 +1240,7 @@ export async function runCli(argv: string[] = process.argv) {
installProxySignalHandlers();
};
if (!isHelpOrVersionInvocation && shouldStartProxyForCli(normalizedArgv)) {
const config = await readBestEffortCliConfig();
const config = await withConsoleLogsRoutedToStderr(readBestEffortCliConfig);
const unownedPrimary = await resolveUnownedCliPrimary({ argv: normalizedArgv, config });
if (unownedPrimary) {
throw new Error(await resolveUnownedCliPrimaryMessage({ primary: unownedPrimary, config }));
@@ -1154,6 +1287,10 @@ export async function runCli(argv: string[] = process.argv) {
}
}
// Genuine help fast paths have returned. Any remaining help/version-shaped
// invocation can still fail validation and must honor the console style.
await installConsoleCapture();
// Reject unowned command roots before help/version routing, so that
// `openclaw <typo> --help` surfaces the same Unknown command error as
// `openclaw <typo>` instead of silently showing generic top-level help.
@@ -1262,7 +1399,12 @@ export async function runCli(argv: string[] = process.argv) {
}
const { tryRouteCli } = await startupTrace.measure("route-import", () => import("./route.js"));
if (await startupTrace.measure("route", () => tryRouteCli(normalizedArgv))) {
const routed = await startupTrace.measure("route", () =>
options.builtInMachineOutput
? tryRouteCli(normalizedArgv, { machineOutput: true })
: tryRouteCli(normalizedArgv),
);
if (routed) {
return;
}
@@ -1285,10 +1427,6 @@ export async function runCli(argv: string[] = process.argv) {
};
try {
// Capture all console output into structured logs while keeping stdout/stderr behavior.
const { enableConsoleCapture } = await loadLoggingModule();
enableConsoleCapture();
const [
{ buildProgram },
{ formatUncaughtError },
@@ -1372,12 +1510,10 @@ export async function runCli(argv: string[] = process.argv) {
const config = await startupTrace.measure("register-plugin-commands", async () => {
const { registerPluginCliCommandsFromValidatedConfig } =
await import("../plugins/cli.js");
return await withConsoleLogsRoutedToStderrForJson(parseArgv, () =>
registerPluginCliCommandsFromValidatedConfig(program, undefined, undefined, {
mode: "lazy",
primary,
}),
);
return await registerPluginCliCommandsFromValidatedConfig(program, undefined, undefined, {
mode: "lazy",
primary,
});
});
if (config) {
if (
+3
View File
@@ -63,7 +63,9 @@ import { CONFIG_DIR } from "../utils.js";
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
import { resolveOptionFromCommand } from "./cli-utils.js";
import { parseStrictPositiveIntOption } from "./program/helpers.js";
import { setCommandJsonMode } from "./program/json-mode.js";
import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js";
import { isSkillsMachineOutput } from "./skills-output-mode.js";
export type {
SkillInfoOptions,
@@ -424,6 +426,7 @@ export function registerSkillsCli(program: Command) {
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/skills", "docs.openclaw.ai/cli/skills")}\n`,
);
setCommandJsonMode(skills, "output", ({ argv }) => isSkillsMachineOutput(argv));
skills
.command("search")
+39
View File
@@ -0,0 +1,39 @@
import { consumeRootOptionToken } from "../infra/cli-root-options.js";
import {
findMachineOutputRootCommandIndex,
hasMachineOutputOption,
} from "./machine-output-argv.js";
function resolveSkillsSubcommand(argv: readonly string[]): string | null {
const rootIndex = findMachineOutputRootCommandIndex(argv);
if (rootIndex === null) {
return null;
}
for (let index = rootIndex + 1; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg || arg === "--") {
return null;
}
const rootConsumed = consumeRootOptionToken(argv.slice(2), index - 2);
if (rootConsumed > 0) {
index += rootConsumed - 1;
continue;
}
if (arg === "--agent") {
index += 1;
continue;
}
if (arg.startsWith("--agent=")) {
continue;
}
if (!arg.startsWith("-")) {
return arg;
}
}
return null;
}
/** Skill verification emits JSON unless the caller explicitly requests the Markdown card. */
export function isSkillsMachineOutput(argv: readonly string[]): boolean {
return resolveSkillsSubcommand(argv) === "verify" && !hasMachineOutputOption(argv, "--card");
}
+47 -2
View File
@@ -3,11 +3,14 @@ import process from "node:process";
import { isTruthyEnvValue } from "../infra/env.js";
type GatewayStartupTraceSource = "entry" | "cli.main";
type GatewayStartupTraceLineFormatter = (message: string) => string;
export function createGatewayStartupTrace(
argv: string[],
source: GatewayStartupTraceSource,
): {
enabled: boolean;
setLineFormatter(formatter: GatewayStartupTraceLineFormatter): void;
mark(name: string): void;
measure<T>(name: string, run: () => T | PromiseLike<T>): Promise<T>;
} {
@@ -16,15 +19,47 @@ export function createGatewayStartupTrace(
argv.slice(2).includes("gateway");
const started = performance.now();
let last = started;
let lineFormatter: GatewayStartupTraceLineFormatter | null = null;
let pendingMessages: string[] = [];
const flushPending = (formatter: GatewayStartupTraceLineFormatter) => {
const queued = pendingMessages;
pendingMessages = [];
for (const message of queued) {
process.stderr.write(`${formatter(message)}\n`);
}
};
const flushPendingPlainOnExit = () => {
if (!lineFormatter) {
flushPending((message) => message);
}
};
if (enabled) {
// Direct process.exit paths cannot await config-backed formatting. Never
// silently lose explicitly requested trace records on an unknown early exit.
process.once("exit", flushPendingPlainOnExit);
}
const writeMessage = (message: string) => {
if (!lineFormatter) {
pendingMessages.push(message);
return;
}
process.stderr.write(`${lineFormatter(message)}\n`);
};
const emit = (name: string, durationMs: number, totalMs: number) => {
if (!enabled) {
return;
}
process.stderr.write(
`[gateway] startup trace: ${source}.${name} ${durationMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms\n`,
writeMessage(
`[gateway] startup trace: ${source}.${name} ${durationMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms`,
);
};
return {
enabled,
setLineFormatter(formatter) {
lineFormatter = formatter;
process.off("exit", flushPendingPlainOnExit);
flushPending(formatter);
},
mark(name: string) {
const now = performance.now();
emit(name, now - last, now - started);
@@ -42,3 +77,13 @@ export function createGatewayStartupTrace(
},
};
}
export async function configureGatewayStartupTraceConsoleFormatting(
trace: ReturnType<typeof createGatewayStartupTrace>,
): Promise<void> {
if (!trace.enabled) {
return;
}
const { formatConsoleDiagnosticLine } = await import("../logging/json-console-line.js");
trace.setLineFormatter((message) => formatConsoleDiagnosticLine({ level: "info", message }));
}
+3
View File
@@ -8,6 +8,8 @@ import { defaultRuntime } from "../runtime.js";
import { formatCliCommand } from "./command-format.js";
import type { GatewayRpcOpts } from "./gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
import { setCommandJsonMode } from "./program/json-mode.js";
import { isSystemMachineOutput } from "./system-output-mode.js";
type SystemEventOpts = GatewayRpcOpts & {
text?: string;
@@ -56,6 +58,7 @@ export function registerSystemCli(program: Command) {
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/system", "docs.openclaw.ai/cli/system")}\n`,
);
setCommandJsonMode(system, "output", ({ argv }) => isSystemMachineOutput(argv));
addGatewayClientOptions(
system
+13
View File
@@ -0,0 +1,13 @@
import { getMachineOutputCommandPath } from "./machine-output-argv.js";
const DEFAULT_JSON_PATHS = new Set([
"heartbeat disable",
"heartbeat enable",
"heartbeat last",
"presence",
]);
/** System query/control commands emit JSON even when `--json` is omitted. */
export function isSystemMachineOutput(argv: readonly string[]): boolean {
return DEFAULT_JSON_PATHS.has(getMachineOutputCommandPath(argv, 3).slice(1).join(" "));
}
+3 -3
View File
@@ -68,9 +68,9 @@ async function measureStartupPreflightStep<T>(name: string, run: () => T | Promi
} finally {
const durationMs = performance.now() - startedAt;
const totalMs = performance.now() - startupPreflightTraceStartedAt;
process.stderr.write(
`[gateway] startup trace: cli.bootstrap.${name} ${durationMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms\n`,
);
const { formatConsoleDiagnosticLine } = await import("../logging/json-console-line.js");
const message = `[gateway] startup trace: cli.bootstrap.${name} ${durationMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms`;
process.stderr.write(`${formatConsoleDiagnosticLine({ level: "info", message })}\n`);
}
}
+41 -1
View File
@@ -3,7 +3,8 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../logging.js";
import { writePersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "../plugins/installed-plugin-index.js";
import { runPostUpgradeProbes } from "./doctor-post-upgrade.js";
@@ -75,6 +76,45 @@ describe("runPostUpgradeProbes — plugin.index_unavailable", () => {
});
describe("runPostUpgradeProbes — plugin.entry_unresolved", () => {
it("structures unreadable package diagnostics for JSON console output", async () => {
const root = await makeFixtureRoot("entry-unreadable-json");
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true as unknown as ReturnType<typeof process.stderr.write>);
try {
const installsPath = path.join(root, "plugins", "installs.json");
await fs.mkdir(path.dirname(installsPath), { recursive: true });
await fs.writeFile(
installsPath,
JSON.stringify({
plugins: [
{
pluginId: "broken",
rootDir: path.join(root, "broken"),
enabled: true,
packageJson: { path: "missing-package.json" },
},
],
}),
"utf-8",
);
setLoggerOverride({ level: "silent", consoleLevel: "info", consoleStyle: "json" });
const report = await runPostUpgradeProbes({ installsPath });
expect(report.findings).toEqual([]);
const line = stderrSpy.mock.calls.map(([value]) => String(value)).join("");
expect(JSON.parse(line)).toMatchObject({
level: "warn",
message: expect.stringContaining("could not read package.json for broken"),
});
} finally {
stderrSpy.mockRestore();
resetLogger();
await fs.rm(root, { recursive: true, force: true });
}
});
it("reads the canonical SQLite plugin index by default", async () => {
const root = await makeFixtureRoot("entry-sqlite");
try {
+3 -3
View File
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { formatConsoleDiagnosticLine } from "../logging/json-console-line.js";
import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js";
import type { PackageManifest } from "../plugins/manifest.js";
import { validatePackageExtensionEntriesForInstall } from "../plugins/package-entry-resolution.js";
@@ -175,9 +176,8 @@ export async function runPostUpgradeProbes(params: {
try {
pkg = await readInstalledPackageJson(record.rootDir, pkgRelPath);
} catch (err) {
process.stderr.write(
`[doctor-post-upgrade] could not read package.json for ${record.pluginId} at ${record.rootDir}: ${err instanceof Error ? err.message : String(err)}\n`,
);
const message = `[doctor-post-upgrade] could not read package.json for ${record.pluginId} at ${record.rootDir}: ${err instanceof Error ? err.message : String(err)}`;
process.stderr.write(`${formatConsoleDiagnosticLine({ level: "warn", message })}\n`);
continue;
}
const entries = pkg.openclaw?.extensions ?? [];

Some files were not shown because too many files have changed in this diff Show More