mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cli): preserve machine-readable stdout (#113654)
Co-authored-by: 1052326311 <65798732+1052326311@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
fd6e042d87
commit
269bc5c89e
@@ -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
|
||||
|
||||
@@ -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"], ... })`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
@@ -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 ?? "");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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. */
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"security-runtime",
|
||||
"gateway-method-runtime",
|
||||
"gateway-runtime",
|
||||
"cli-argv",
|
||||
"cli-runtime",
|
||||
"cli-backend",
|
||||
"codex-mcp-projection",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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)")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,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 })),
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(" "));
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user