From ca2f0466686d5ff39ef75d1e77d0c88f07ca5383 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 15 Mar 2026 20:56:44 -0700 Subject: [PATCH] Status: route JSON through lean command --- src/cli/program/routes.test.ts | 25 +++++++++ src/cli/program/routes.ts | 5 ++ src/commands/status-json.ts | 100 +++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/commands/status-json.ts diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 896dcb6757a..65cba06e299 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -6,6 +6,7 @@ const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {})); const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {})); const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); const gatewayStatusCommandMock = vi.hoisted(() => vi.fn(async () => {})); +const statusJsonCommandMock = vi.hoisted(() => vi.fn(async () => {})); vi.mock("../config-cli.js", () => ({ runConfigGet: runConfigGetMock, @@ -21,6 +22,10 @@ vi.mock("../../commands/gateway-status.js", () => ({ gatewayStatusCommand: gatewayStatusCommandMock, })); +vi.mock("../../commands/status-json.js", () => ({ + statusJsonCommand: statusJsonCommandMock, +})); + describe("program routes", () => { beforeEach(() => { vi.clearAllMocks(); @@ -124,6 +129,26 @@ describe("program routes", () => { await expectRunFalse(["status"], ["node", "openclaw", "status", "--timeout"]); }); + it("routes status --json through the lean JSON command", async () => { + const route = expectRoute(["status"]); + await expect( + route?.run([ + "node", + "openclaw", + "status", + "--json", + "--deep", + "--usage", + "--timeout", + "5000", + ]), + ).resolves.toBe(true); + expect(statusJsonCommandMock).toHaveBeenCalledWith( + { deep: true, all: false, usage: true, timeoutMs: 5000 }, + expect.any(Object), + ); + }); + it("returns false for sessions route when --store value is missing", async () => { await expectRunFalse(["sessions"], ["node", "openclaw", "sessions", "--store"]); }); diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 353c9b8f11d..913f84dd2e4 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -47,6 +47,11 @@ const routeStatus: RouteSpec = { if (timeoutMs === null) { return false; } + if (json) { + const { statusJsonCommand } = await import("../../commands/status-json.js"); + await statusJsonCommand({ deep, all, usage, timeoutMs }, defaultRuntime); + return true; + } const { statusCommand } = await import("../../commands/status.js"); await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime); return true; diff --git a/src/commands/status-json.ts b/src/commands/status-json.ts new file mode 100644 index 00000000000..035f2c71245 --- /dev/null +++ b/src/commands/status-json.ts @@ -0,0 +1,100 @@ +import { callGateway } from "../gateway/call.js"; +import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; +import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { runSecurityAudit } from "../security/audit.js"; +import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; +import { scanStatus } from "./status.scan.js"; + +let providerUsagePromise: Promise | undefined; + +function loadProviderUsage() { + providerUsagePromise ??= import("../infra/provider-usage.js"); + return providerUsagePromise; +} + +export async function statusJsonCommand( + opts: { + deep?: boolean; + usage?: boolean; + timeoutMs?: number; + all?: boolean; + }, + runtime: RuntimeEnv, +) { + const scan = await scanStatus({ json: true, timeoutMs: opts.timeoutMs, all: opts.all }, runtime); + const securityAudit = await runSecurityAudit({ + config: scan.cfg, + sourceConfig: scan.sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }); + + const usage = opts.usage + ? await loadProviderUsage().then(({ loadProviderUsageSummary }) => + loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), + ) + : undefined; + const health = opts.deep + ? await callGateway({ + method: "health", + params: { probe: true }, + timeoutMs: opts.timeoutMs, + config: scan.cfg, + }).catch(() => undefined) + : undefined; + const lastHeartbeat = + opts.deep && scan.gatewayReachable + ? await callGateway({ + method: "last-heartbeat", + params: {}, + timeoutMs: opts.timeoutMs, + config: scan.cfg, + }).catch(() => null) + : null; + + const [daemon, nodeDaemon] = await Promise.all([ + getDaemonStatusSummary(), + getNodeDaemonStatusSummary(), + ]); + const channelInfo = resolveUpdateChannelDisplay({ + configChannel: normalizeUpdateChannel(scan.cfg.update?.channel), + installKind: scan.update.installKind, + gitTag: scan.update.git?.tag ?? null, + gitBranch: scan.update.git?.branch ?? null, + }); + + runtime.log( + JSON.stringify( + { + ...scan.summary, + os: scan.osSummary, + update: scan.update, + updateChannel: channelInfo.channel, + updateChannelSource: channelInfo.source, + memory: scan.memory, + memoryPlugin: scan.memoryPlugin, + gateway: { + mode: scan.gatewayMode, + url: scan.gatewayConnection.url, + urlSource: scan.gatewayConnection.urlSource, + misconfigured: scan.remoteUrlMissing, + reachable: scan.gatewayReachable, + connectLatencyMs: scan.gatewayProbe?.connectLatencyMs ?? null, + self: scan.gatewaySelf, + error: scan.gatewayProbe?.error ?? null, + authWarning: scan.gatewayProbeAuthWarning ?? null, + }, + gatewayService: daemon, + nodeService: nodeDaemon, + agents: scan.agentStatus, + securityAudit, + secretDiagnostics: scan.secretDiagnostics, + ...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}), + }, + null, + 2, + ), + ); +}