| |
| import { formatByteSize } from "@openclaw/normalization-core"; |
| import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion"; |
| import type { Command } from "commander"; |
| import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; |
| import { colorize, isRich, theme } from "../../../packages/terminal-core/src/theme.js"; |
| import type { HealthSummary } from "../../commands/health.js"; |
| import { formatErrorMessage } from "../../infra/errors.js"; |
| import type { CostUsageSummary } from "../../infra/session-cost-usage.js"; |
| import type { |
| DiagnosticStabilityBundle, |
| ReadDiagnosticStabilityBundleResult, |
| } from "../../logging/diagnostic-stability-bundle.js"; |
| import type { |
| DiagnosticStabilityEventRecord, |
| DiagnosticStabilitySnapshot, |
| } from "../../logging/diagnostic-stability.js"; |
| import type { WriteDiagnosticSupportExportResult } from "../../logging/diagnostic-support-export.js"; |
| import { defaultRuntime } from "../../runtime.js"; |
| import { createLazyPromise } from "../../shared/lazy-promise.js"; |
| import { inheritOptionFromParent } from "../command-options.js"; |
| import { addGatewayServiceCommands } from "../daemon-cli/register-service-commands.js"; |
| import { formatCliJsonFailure, rethrowExpectedCliError } from "../failure-output.js"; |
| import { |
| addGatewayClientOptions, |
| callGatewayFromCliWithTransport, |
| resolveGatewayRpcOptions, |
| resolveGatewayRpcOptionsWithLocalPort, |
| } from "../gateway-rpc.js"; |
| import { formatHelpExamples } from "../help-format.js"; |
| import { parseTimeoutMsWithFallback } from "../parse-timeout.js"; |
| import { setCommandJsonMode } from "../program/json-mode.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"; |
| import { runGatewayResume, runGatewaySuspend } from "./suspend-cli.js"; |
|
|
| type GatewayRpcOpts = Parameters<typeof callGatewayFromCliWithTransport>[1]; |
|
|
| const loadConfigModule = createLazyPromise( |
| () => import("../../config/read-best-effort-config.runtime.js"), |
| ); |
| const loadGatewayStatusModule = createLazyPromise(() => import("../../commands/gateway-status.js")); |
| const loadGatewayHealthModule = createLazyPromise(() => import("../../commands/health.js")); |
| const loadBonjourDiscoveryModule = createLazyPromise( |
| () => import("../../infra/bonjour-discovery.js"), |
| ); |
| const loadWideAreaDnsModule = createLazyPromise(() => import("../../infra/widearea-dns.js")); |
| const loadHealthStyleModule = createLazyPromise( |
| () => import("../../../packages/terminal-core/src/health-style.js"), |
| ); |
| const loadUsageFormatModule = createLazyPromise(() => import("../../utils/usage-format.js")); |
| const loadStabilityBundleModule = createLazyPromise( |
| () => import("../../logging/diagnostic-stability-bundle.js"), |
| ); |
| const loadSupportExportModule = createLazyPromise( |
| () => import("../../logging/diagnostic-support-export.js"), |
| ); |
| const loadDaemonStatusGatherModule = createLazyPromise( |
| () => import("../daemon-cli/status.gather.js"), |
| ); |
|
|
| const DEFAULT_GATEWAY_RPC_TIMEOUT_MS = 10_000; |
| const SETUP_INFERENCE_DETECT_RPC_TIMEOUT_MS = 40_000; |
| type GatewayCliDependencies = { |
| loadGatewayHealthModule?: typeof loadGatewayHealthModule; |
| loadHealthStyleModule?: typeof loadHealthStyleModule; |
| }; |
|
|
| function gatewayCallOpts(cmd: Command, defaultTimeoutMs = DEFAULT_GATEWAY_RPC_TIMEOUT_MS): Command { |
| return addGatewayClientOptions(cmd, { timeoutMs: defaultTimeoutMs }).option( |
| "--json", |
| "Output JSON", |
| false, |
| ); |
| } |
|
|
| async function callGatewayReadOnlyCli(method: string, opts: GatewayRpcOpts, params?: unknown) { |
| return await callGatewayFromCliWithTransport(method, opts, params, { |
| defaultTimeoutMs: DEFAULT_GATEWAY_RPC_TIMEOUT_MS, |
| sharedStateMode: "read-only", |
| }); |
| } |
|
|
| function parseGatewayCallParams(value = "{}"): unknown { |
| try { |
| return JSON.parse(value) as unknown; |
| } catch { |
| throw new Error("--params must be valid JSON."); |
| } |
| } |
|
|
| async function runGatewayCommand( |
| action: () => Promise<void>, |
| label?: string, |
| opts?: { json?: boolean }, |
| ) { |
| |
| try { |
| await action(); |
| } catch (err) { |
| if (!opts?.json) { |
| rethrowExpectedCliError(err); |
| } |
| if (opts?.json) { |
| const { |
| formatGatewayAuthErrorJson, |
| formatGatewayClientRequestErrorJson, |
| formatGatewayTransportErrorJson, |
| } = await import("../../gateway/call.js"); |
| defaultRuntime.writeJson( |
| formatGatewayAuthErrorJson(err) ?? |
| formatGatewayClientRequestErrorJson(err) ?? |
| formatGatewayTransportErrorJson(err) ?? |
| formatCliJsonFailure(err), |
| ); |
| defaultRuntime.exit(1); |
| return; |
| } |
| const message = formatErrorMessage(err); |
| defaultRuntime.error(label ? `${label}: ${message}` : message); |
| defaultRuntime.exit(1); |
| } |
| } |
|
|
| function parseDaysOption(raw: unknown, fallback = 30): number { |
| if (typeof raw === "number" && Number.isFinite(raw)) { |
| return Math.max(1, Math.floor(raw)); |
| } |
| if (typeof raw === "string") { |
| const parsed = parseStrictPositiveInteger(raw); |
| if (parsed !== undefined) { |
| return parsed; |
| } |
| |
| |
| |
| throw new Error(`Invalid --days. Use a positive integer, e.g. --days 30. Received: "${raw}".`); |
| } |
| return fallback; |
| } |
|
|
| async function renderCostUsageSummaryAsync( |
| summary: CostUsageSummary, |
| days: number, |
| rich: boolean, |
| ): Promise<string[]> { |
| const { formatMissingCostEntries } = await import("../../infra/session-cost-usage-totals.js"); |
| const { formatCostUsageCachePrefix, formatTokenCount, formatUsd } = await loadUsageFormatModule(); |
| const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00"; |
| const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0"; |
| const cachePrefix = formatCostUsageCachePrefix(summary.cacheStatus); |
| const lines = [ |
| colorize(rich, theme.heading, `Usage cost (${days} days)`), |
| `${cachePrefix}${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`, |
| ]; |
|
|
| if (summary.totals.missingCostEntries > 0) { |
| lines.push( |
| `${colorize(rich, theme.muted, "Missing cost:")} ${formatMissingCostEntries(summary.totals)}`, |
| ); |
| } |
|
|
| const latest = summary.daily.at(-1); |
| if (latest) { |
| const latestCost = formatUsd(latest.totalCost) ?? "$0.00"; |
| const latestTokens = formatTokenCount(latest.totalTokens) ?? "0"; |
| lines.push( |
| `${colorize(rich, theme.muted, "Latest day:")} ${latest.date} · ${latestCost} · ${latestTokens} tokens`, |
| ); |
| } |
|
|
| return lines; |
| } |
|
|
| function formatBytes(value: number | undefined): string { |
| if (value === undefined) { |
| return "n/a"; |
| } |
| return formatByteSize(value, { |
| style: "iec", |
| maxUnit: "giga", |
| separator: " ", |
| fractionDigits: (amount, unit) => (unit === "byte" || amount >= 100 ? 0 : 1), |
| }); |
| } |
|
|
| function formatStabilityEvent(record: DiagnosticStabilityEventRecord): string { |
| const parts = [ |
| new Date(record.ts).toISOString(), |
| `#${record.seq}`, |
| record.type, |
| record.level ? `level=${record.level}` : "", |
| record.action ? `action=${record.action}` : "", |
| record.outcome ? `outcome=${record.outcome}` : "", |
| record.surface ? `surface=${record.surface}` : "", |
| record.channel ? `channel=${record.channel}` : "", |
| record.pluginId ? `plugin=${record.pluginId}` : "", |
| record.reason ? `reason=${record.reason}` : "", |
| record.bytes !== undefined ? `bytes=${formatBytes(record.bytes)}` : "", |
| record.limitBytes !== undefined ? `limit=${formatBytes(record.limitBytes)}` : "", |
| record.queueDepth !== undefined ? `queueDepth=${record.queueDepth}` : "", |
| record.queueLength !== undefined ? `queueLength=${record.queueLength}` : "", |
| record.droppedEvents !== undefined ? `dropped=${record.droppedEvents}` : "", |
| record.maxQueueLength !== undefined ? `maxQueue=${record.maxQueueLength}` : "", |
| record.queued !== undefined ? `queued=${record.queued}` : "", |
| record.memory ? `rss=${formatBytes(record.memory.rssBytes)}` : "", |
| record.memory ? `heap=${formatBytes(record.memory.heapUsedBytes)}` : "", |
| ].filter(Boolean); |
| return parts.join(" "); |
| } |
|
|
| function renderStabilitySummary(snapshot: DiagnosticStabilitySnapshot, rich: boolean): string[] { |
| const lines = [ |
| colorize(rich, theme.heading, "Gateway Stability"), |
| `${colorize(rich, theme.muted, "Events:")} ${snapshot.count}/${snapshot.capacity}${ |
| snapshot.dropped > 0 ? ` · dropped=${snapshot.dropped}` : "" |
| }`, |
| ]; |
|
|
| const topTypes = Object.entries(snapshot.summary.byType) |
| .toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) |
| .slice(0, 8) |
| .map(([type, count]) => `${type}=${count}`) |
| .join(", "); |
| if (topTypes) { |
| lines.push(`${colorize(rich, theme.muted, "Types:")} ${topTypes}`); |
| } |
|
|
| const memory = snapshot.summary.memory; |
| if (memory) { |
| lines.push( |
| `${colorize(rich, theme.muted, "Memory:")} rss=${formatBytes( |
| memory.latest?.rssBytes, |
| )} heap=${formatBytes(memory.latest?.heapUsedBytes)} maxRss=${formatBytes( |
| memory.maxRssBytes, |
| )} pressure=${memory.pressureCount}`, |
| ); |
| } |
|
|
| const payloadLarge = snapshot.summary.payloadLarge; |
| if (payloadLarge) { |
| const surfaces = Object.entries(payloadLarge.bySurface) |
| .toSorted((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) |
| .map(([surface, count]) => `${surface}=${count}`) |
| .join(", "); |
| lines.push( |
| `${colorize(rich, theme.muted, "Large payloads:")} total=${payloadLarge.count} rejected=${ |
| payloadLarge.rejected |
| } truncated=${payloadLarge.truncated} chunked=${payloadLarge.chunked}${ |
| surfaces ? ` · ${surfaces}` : "" |
| }`, |
| ); |
| } |
|
|
| if (snapshot.events.length > 0) { |
| lines.push(colorize(rich, theme.muted, "Recent:")); |
| for (const event of snapshot.events) { |
| lines.push(` ${formatStabilityEvent(event)}`); |
| } |
| } |
|
|
| return lines; |
| } |
|
|
| function normalizeStabilityBundleTarget(raw: unknown): string | null { |
| if (raw === undefined || raw === false) { |
| return null; |
| } |
| if (raw === true) { |
| return "latest"; |
| } |
| if (typeof raw !== "string") { |
| return "latest"; |
| } |
| const value = raw.trim(); |
| return value === "" ? "latest" : value; |
| } |
|
|
| function formatBundleError(result: ReadDiagnosticStabilityBundleResult): string { |
| if (result.status === "missing") { |
| return `No stability bundles found in ${result.dir}`; |
| } |
| if (result.status === "failed") { |
| return result.error instanceof Error ? result.error.message : String(result.error); |
| } |
| return "Unexpected stability bundle read result"; |
| } |
|
|
| async function readStabilityBundleTarget( |
| bundleTarget: string, |
| ): Promise<ReadDiagnosticStabilityBundleResult> { |
| const { readDiagnosticStabilityBundleFileSync, readLatestDiagnosticStabilityBundleSync } = |
| await loadStabilityBundleModule(); |
| return bundleTarget === "latest" |
| ? readLatestDiagnosticStabilityBundleSync() |
| : readDiagnosticStabilityBundleFileSync(bundleTarget); |
| } |
|
|
| function renderStabilityBundleSummary(params: { |
| bundle: DiagnosticStabilityBundle; |
| path: string; |
| snapshot: DiagnosticStabilitySnapshot; |
| rich: boolean; |
| }): string[] { |
| const { bundle, path, rich, snapshot } = params; |
| const processDetails = [ |
| `pid=${bundle.process.pid}`, |
| `node=${bundle.process.node}`, |
| `${bundle.process.platform}/${bundle.process.arch}`, |
| `uptime=${Math.round(bundle.process.uptimeMs / 1000)}s`, |
| ].join(" "); |
| const lines = [ |
| colorize(rich, theme.heading, "Stability bundle"), |
| `${colorize(rich, theme.muted, "Path:")} ${path}`, |
| `${colorize(rich, theme.muted, "Generated:")} ${bundle.generatedAt}`, |
| `${colorize(rich, theme.muted, "Reason:")} ${bundle.reason}`, |
| `${colorize(rich, theme.muted, "Process:")} ${processDetails}`, |
| `${colorize(rich, theme.muted, "Host:")} ${bundle.host.hostname}`, |
| ]; |
| if (bundle.error) { |
| const errorParts = [ |
| bundle.error.name ? `name=${bundle.error.name}` : "", |
| bundle.error.code ? `code=${bundle.error.code}` : "", |
| ].filter(Boolean); |
| if (errorParts.length > 0) { |
| lines.push(`${colorize(rich, theme.muted, "Error:")} ${errorParts.join(" ")}`); |
| } |
| } |
| const memoryPressure = bundle.evidence?.memoryPressure; |
| if (memoryPressure) { |
| lines.push( |
| `${colorize(rich, theme.muted, "Memory pressure:")} ${memoryPressure.level}/${ |
| memoryPressure.reason |
| } rss=${formatBytes(memoryPressure.memory.rssBytes)} heap=${formatBytes( |
| memoryPressure.memory.heapUsedBytes, |
| )} threshold=${formatBytes(memoryPressure.thresholdBytes)}`, |
| ); |
| if (memoryPressure.heapStatistics) { |
| lines.push( |
| `${colorize(rich, theme.muted, "V8 heap:")} used=${formatBytes( |
| memoryPressure.heapStatistics.usedHeapSizeBytes, |
| )} limit=${formatBytes( |
| memoryPressure.heapStatistics.heapSizeLimitBytes, |
| )} available=${formatBytes(memoryPressure.heapStatistics.totalAvailableSizeBytes)}`, |
| ); |
| } |
| if (memoryPressure.activeResources) { |
| const resources = Object.entries(memoryPressure.activeResources.byType) |
| .map(([type, count]) => `${type}=${count}`) |
| .join(", "); |
| lines.push( |
| `${colorize(rich, theme.muted, "Active resources:")} total=${ |
| memoryPressure.activeResources.total |
| }${resources ? ` · ${resources}` : ""}`, |
| ); |
| } |
| if (memoryPressure.topSessionFiles?.length) { |
| const files = memoryPressure.topSessionFiles |
| .slice(0, 5) |
| .map((file) => `${file.relativePath}=${formatBytes(file.sizeBytes)}`) |
| .join(", "); |
| lines.push(`${colorize(rich, theme.muted, "Largest session files:")} ${files}`); |
| } |
| } |
| lines.push("", ...renderStabilitySummary(snapshot, rich)); |
| return lines; |
| } |
|
|
| function renderSupportExportResult( |
| result: WriteDiagnosticSupportExportResult, |
| rich: boolean, |
| ): string[] { |
| return [ |
| colorize(rich, theme.heading, "Diagnostics export"), |
| `${colorize(rich, theme.muted, "Path:")} ${result.path}`, |
| `${colorize(rich, theme.muted, "Size:")} ${formatBytes(result.bytes)}`, |
| `${colorize(rich, theme.muted, "Files:")} ${result.manifest.contents.length}`, |
| `${colorize(rich, theme.muted, "Privacy:")} payload-free stability, sanitized logs/status/health/config`, |
| ]; |
| } |
|
|
| function resolveSupportExportRpcOptions( |
| rpc?: Pick<GatewayRpcOpts, "url" | "token" | "password" | "timeout">, |
| ): GatewayRpcOpts & { timeout: string } { |
| return { |
| url: rpc?.url, |
| token: rpc?.token, |
| password: rpc?.password, |
| timeout: rpc?.timeout ?? "3000", |
| json: true, |
| }; |
| } |
|
|
| function parseOptionalPositiveIntegerOption(raw: unknown, label: string): number | undefined { |
| if (raw === undefined) { |
| return undefined; |
| } |
| const parsed = parseStrictPositiveInteger(raw); |
| if (parsed === undefined) { |
| throw new Error(`${label} must be a positive integer.`); |
| } |
| return parsed; |
| } |
|
|
| async function writeSupportExportFromCli(opts: { |
| json?: boolean; |
| output?: string; |
| logLines?: string; |
| logBytes?: string; |
| stabilityBundle?: string | false; |
| rpc?: Pick<GatewayRpcOpts, "url" | "token" | "password" | "timeout">; |
| }): Promise<void> { |
| const { writeDiagnosticSupportExport } = await loadSupportExportModule(); |
| const rpc = resolveSupportExportRpcOptions(opts.rpc); |
| const result = await writeDiagnosticSupportExport({ |
| outputPath: opts.output, |
| logLimit: parseOptionalPositiveIntegerOption(opts.logLines, "--log-lines"), |
| logMaxBytes: parseOptionalPositiveIntegerOption(opts.logBytes, "--log-bytes"), |
| stabilityBundle: opts.stabilityBundle, |
| readStatusSnapshot: async () => { |
| const { gatherDaemonStatus } = await loadDaemonStatusGatherModule(); |
| return await gatherDaemonStatus({ |
| rpc, |
| probe: true, |
| requireRpc: false, |
| deep: false, |
| }); |
| }, |
| readHealthSnapshot: async () => await callGatewayReadOnlyCli("health", rpc), |
| }); |
| if (opts.json) { |
| defaultRuntime.writeJson(result); |
| return; |
| } |
| const rich = isRich(); |
| for (const line of renderSupportExportResult(result, rich)) { |
| defaultRuntime.log(line); |
| } |
| } |
|
|
| export function registerGatewayCli(program: Command, deps: GatewayCliDependencies = {}) { |
| const gateway = addGatewayRunCommand( |
| program |
| .command("gateway") |
| .description("Run, inspect, and query the WebSocket Gateway") |
| .addHelpText( |
| "after", |
| () => |
| `\n${theme.heading("Examples:")}\n${formatHelpExamples([ |
| ["openclaw gateway run", "Run the gateway in the foreground."], |
| ["openclaw gateway status", "Show service status plus connectivity/capability."], |
| ["openclaw gateway auth-token --show", "Reveal the shared token interactively."], |
| ["openclaw gateway discover", "Find local and wide-area gateway beacons."], |
| ["openclaw gateway stability", "Show recent stability diagnostics."], |
| ["openclaw gateway call health", "Call a gateway RPC method directly."], |
| ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/gateway", "docs.openclaw.ai/cli/gateway")}\n`, |
| ), |
| ); |
|
|
| addGatewayRunCommand( |
| gateway.command("run").description("Run the WebSocket Gateway (foreground)"), |
| ); |
|
|
| addGatewayServiceCommands(gateway, { |
| statusDescription: "Show gateway service status + probe connectivity/capability", |
| }); |
| addGatewayRestartHandoffCommands(gateway); |
| setCommandJsonMode(gateway, "output", ({ argv }) => isGatewayMachineOutput(argv)); |
|
|
| gateway |
| .command("auth-token") |
| .description("Reveal the configured shared Gateway token") |
| .option("--show", "Print the token to an interactive terminal", false) |
| .action(async (opts) => { |
| await runGatewayCommand(async () => { |
| if (!opts.show) { |
| throw new Error( |
| "Pass --show to confirm that you want to print the Gateway token to this terminal.", |
| ); |
| } |
| const { gatewayAuthTokenCommand } = await import("../../commands/gateway-auth-token.js"); |
| await gatewayAuthTokenCommand(defaultRuntime); |
| }, "Gateway auth token failed"); |
| }); |
|
|
| gatewayCallOpts( |
| gateway |
| .command("call") |
| .description("Call a Gateway method") |
| .argument("<method>", "Method name (health/status/system-presence/cron.*)") |
| .option( |
| "--expect-url <url>", |
| "Fail if the resolved Gateway URL differs; preserves configured authentication", |
| ) |
| .option("--params <json>", "JSON object string for params", "{}") |
| .action(async (method, opts, command) => { |
| await runGatewayCommand( |
| async () => { |
| |
| |
| const callOpts = |
| method === "openclaw.setup.detect" && |
| command.getOptionValueSource("timeout") === "default" |
| ? { ...opts, timeout: String(SETUP_INFERENCE_DETECT_RPC_TIMEOUT_MS) } |
| : opts; |
| const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(callOpts, command); |
| const params = parseGatewayCallParams(String(opts.params ?? "{}")); |
| const result = await callGatewayReadOnlyCli(method, rpcOpts, params); |
| if (rpcOpts.json) { |
| defaultRuntime.writeJson(result); |
| return; |
| } |
| const rich = isRich(); |
| defaultRuntime.log( |
| `${colorize(rich, theme.heading, "Gateway call")}: ${colorize(rich, theme.muted, String(method))}`, |
| ); |
| defaultRuntime.writeJson(result); |
| }, |
| "Gateway call failed", |
| { json: Boolean(opts.json) }, |
| ); |
| }), |
| ); |
|
|
| gatewayCallOpts( |
| gateway |
| .command("suspend") |
| .description("Prepare the Gateway for cooperative host suspension") |
| .option("--request-id <id>", "Stable suspension request id") |
| .option("--wait <seconds>", "Wait up to this many seconds for active work to drain") |
| .action(async (opts, command) => { |
| await runGatewayCommand( |
| async () => { |
| const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(opts, command); |
| await runGatewaySuspend( |
| { |
| rpcOpts, |
| requestId: opts.requestId, |
| waitSeconds: opts.wait, |
| json: Boolean(rpcOpts.json), |
| }, |
| { callGateway: callGatewayReadOnlyCli, runtime: defaultRuntime }, |
| ); |
| }, |
| "Gateway suspend failed", |
| { json: Boolean(opts.json) }, |
| ); |
| }), |
| ); |
|
|
| gatewayCallOpts( |
| gateway |
| .command("resume") |
| .description("Release a cooperative Gateway suspension") |
| .argument("<suspensionId>", "Suspension id returned by gateway suspend") |
| .action(async (suspensionId, opts, command) => { |
| await runGatewayCommand( |
| async () => { |
| const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(opts, command); |
| await runGatewayResume( |
| { rpcOpts, suspensionId: String(suspensionId), json: Boolean(rpcOpts.json) }, |
| { callGateway: callGatewayReadOnlyCli, runtime: defaultRuntime }, |
| ); |
| }, |
| "Gateway resume failed", |
| { json: Boolean(opts.json) }, |
| ); |
| }), |
| ); |
|
|
| gatewayCallOpts( |
| gateway |
| .command("usage-cost") |
| .description("Fetch usage cost summary from session logs") |
| .option("--days <days>", "Number of days to include", "30") |
| .option("--agent <id>", "Scope the cost summary to a specific agent id") |
| .option("--all-agents", "Aggregate the cost summary across all agents", false) |
| .action(async (opts, command) => { |
| await runGatewayCommand( |
| async () => { |
| const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(opts, command); |
| const days = parseDaysOption(opts.days); |
| const agentId = typeof opts.agent === "string" ? opts.agent.trim() : undefined; |
| |
| |
| if (agentId && opts.allAgents) { |
| throw new Error("Use --agent or --all-agents, not both"); |
| } |
| const summary = (await callGatewayReadOnlyCli("usage.cost", rpcOpts, { |
| days, |
| ...(agentId ? { agentId } : {}), |
| ...(opts.allAgents ? { agentScope: "all" } : {}), |
| })) as CostUsageSummary; |
| if (rpcOpts.json) { |
| defaultRuntime.writeJson(summary); |
| return; |
| } |
| const rich = isRich(); |
| for (const line of await renderCostUsageSummaryAsync(summary, days, rich)) { |
| defaultRuntime.log(line); |
| } |
| }, |
| "Gateway usage cost failed", |
| { json: Boolean(opts.json) }, |
| ); |
| }), |
| ); |
|
|
| gatewayCallOpts( |
| gateway |
| .command("health") |
| .description("Fetch Gateway health") |
| .action(async (opts, command) => { |
| await runGatewayCommand( |
| async () => { |
| const rpcOpts = resolveGatewayRpcOptionsWithLocalPort(opts, command); |
| let result: unknown; |
| try { |
| result = await callGatewayReadOnlyCli("health", rpcOpts); |
| } catch (error) { |
| const { emitReachableGatewayAuthDiagnostic, readNonObservingHealthConfig } = await ( |
| deps.loadGatewayHealthModule ?? loadGatewayHealthModule |
| )(); |
| const handled = await emitReachableGatewayAuthDiagnostic({ |
| error, |
| config: rpcOpts.config ?? (await readNonObservingHealthConfig()), |
| runtime: defaultRuntime, |
| timeoutMs: parseTimeoutMsWithFallback(rpcOpts.timeout, 10_000, { |
| invalidType: "error", |
| }), |
| token: rpcOpts.token, |
| password: rpcOpts.password, |
| localPortOverride: rpcOpts.localPortOverride, |
| json: Boolean(rpcOpts.json), |
| }); |
| if (handled) { |
| return; |
| } |
| throw error; |
| } |
| if (rpcOpts.json) { |
| defaultRuntime.writeJson(result); |
| return; |
| } |
| const [{ formatHealthChannelLines }, { styleHealthChannelLine }] = await Promise.all([ |
| (deps.loadGatewayHealthModule ?? loadGatewayHealthModule)(), |
| (deps.loadHealthStyleModule ?? loadHealthStyleModule)(), |
| ]); |
| const rich = isRich(); |
| const obj: Record<string, unknown> = |
| result && typeof result === "object" ? (result as Record<string, unknown>) : {}; |
| const durationMs = typeof obj.durationMs === "number" ? obj.durationMs : null; |
| defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health")); |
| defaultRuntime.log( |
| `${colorize(rich, theme.success, "OK")}${durationMs != null ? ` (${durationMs}ms)` : ""}`, |
| ); |
| if (obj.channels && typeof obj.channels === "object") { |
| for (const line of formatHealthChannelLines(obj as HealthSummary)) { |
| defaultRuntime.log(styleHealthChannelLine(line, rich)); |
| } |
| } |
| }, |
| undefined, |
| { json: Boolean(opts.json) }, |
| ); |
| }), |
| ); |
|
|
| gatewayCallOpts( |
| gateway |
| .command("stability") |
| .description("Fetch payload-free Gateway stability diagnostics") |
| .option("--limit <limit>", "Maximum number of recent events", "25") |
| .option("--type <type>", "Filter by diagnostic event type") |
| .option("--since-seq <seq>", "Only include events after this sequence") |
| .option( |
| "--bundle [path]", |
| 'Read a persisted stability bundle instead of calling Gateway; pass "latest" for newest', |
| ) |
| .option("--export", "Write a shareable support diagnostics export", false) |
| .option("--output <path>", "Diagnostics export output .zip path") |
| .action(async (opts, command) => { |
| await runGatewayCommand( |
| async () => { |
| const { normalizeDiagnosticStabilityQuery, selectDiagnosticStabilitySnapshot } = |
| await import("../../logging/diagnostic-stability.js"); |
| const rpcOpts = resolveGatewayRpcOptions(opts, command); |
| const query = normalizeDiagnosticStabilityQuery( |
| { |
| limit: opts.limit, |
| sinceSeq: opts.sinceSeq, |
| type: opts.type, |
| }, |
| { defaultLimit: 25 }, |
| ); |
| const bundleTarget = normalizeStabilityBundleTarget(opts.bundle); |
| if (opts.export) { |
| await writeSupportExportFromCli({ |
| json: rpcOpts.json, |
| output: opts.output, |
| stabilityBundle: bundleTarget ?? "latest", |
| rpc: rpcOpts, |
| }); |
| return; |
| } |
| if (bundleTarget) { |
| const result = await readStabilityBundleTarget(bundleTarget); |
| if (result.status !== "found") { |
| throw new Error(formatBundleError(result)); |
| } |
| const snapshot = selectDiagnosticStabilitySnapshot(result.bundle.snapshot, query); |
| if (rpcOpts.json) { |
| defaultRuntime.writeJson({ |
| path: result.path, |
| mtimeMs: result.mtimeMs, |
| bundle: { |
| ...result.bundle, |
| snapshot, |
| }, |
| }); |
| return; |
| } |
| const rich = isRich(); |
| for (const line of renderStabilityBundleSummary({ |
| bundle: result.bundle, |
| path: result.path, |
| rich, |
| snapshot, |
| })) { |
| defaultRuntime.log(line); |
| } |
| return; |
| } |
|
|
| const result = await callGatewayReadOnlyCli( |
| "diagnostics.stability", |
| resolveGatewayRpcOptionsWithLocalPort(rpcOpts, command), |
| { |
| limit: query.limit, |
| ...(query.type ? { type: query.type } : {}), |
| ...(query.sinceSeq !== undefined ? { sinceSeq: query.sinceSeq } : {}), |
| }, |
| ); |
| if (rpcOpts.json) { |
| defaultRuntime.writeJson(result); |
| return; |
| } |
| const rich = isRich(); |
| for (const line of renderStabilitySummary( |
| result as DiagnosticStabilitySnapshot, |
| rich, |
| )) { |
| defaultRuntime.log(line); |
| } |
| }, |
| "Gateway stability failed", |
| { json: Boolean(opts.json) }, |
| ); |
| }), |
| ); |
|
|
| const diagnostics = gateway |
| .command("diagnostics") |
| .description("Export local support diagnostics"); |
| diagnostics |
| .command("export") |
| .description("Write a shareable, payload-free diagnostics .zip") |
| .option("--output <path>", "Output .zip path") |
| .option("--log-lines <count>", "Maximum sanitized log lines to include", "5000") |
| .option("--log-bytes <bytes>", "Maximum log bytes to inspect", "1000000") |
| .option("--url <url>", "Gateway WebSocket URL for health snapshot") |
| .option("--token <token>", "Gateway token for health snapshot") |
| .option("--password <password>", "Gateway password for health snapshot") |
| .option("--timeout <ms>", "Status/health snapshot timeout in ms", "3000") |
| .option("--no-stability-bundle", "Skip persisted stability bundle lookup") |
| .option("--json", "Output JSON", false) |
| .action(async (opts, command) => { |
| await runGatewayCommand( |
| async () => { |
| const rpcOpts = resolveGatewayRpcOptions(opts, command); |
| await writeSupportExportFromCli({ |
| json: opts.json, |
| output: opts.output, |
| logLines: opts.logLines, |
| logBytes: opts.logBytes, |
| stabilityBundle: opts.stabilityBundle === false ? false : "latest", |
| rpc: rpcOpts, |
| }); |
| }, |
| "Gateway diagnostics export failed", |
| { json: Boolean(opts.json) }, |
| ); |
| }); |
|
|
| gateway |
| .command("probe") |
| .description( |
| "Show gateway reachability, auth capability, and read-probe summary (local + remote)", |
| ) |
| .option("--url <url>", "Explicit Gateway WebSocket URL (still probes localhost)") |
| .option("--port <port>", "Local Gateway port") |
| .option("--ssh <target>", "SSH target for remote gateway tunnel (user@host or user@host:port)") |
| .option("--ssh-identity <path>", "SSH identity file path") |
| .option("--ssh-auto", "Try to derive an SSH target from Bonjour discovery", false) |
| .option("--token <token>", "Gateway token (applies to all probes)") |
| .option("--password <password>", "Gateway password (applies to all probes)") |
| .option("--timeout <ms>", "Overall probe budget in ms", "3000") |
| .option("--json", "Output JSON", false) |
| .action(async (opts, command) => { |
| await runGatewayCommand( |
| async () => { |
| const rpcOpts = resolveGatewayRpcOptions(opts, command); |
| const { gatewayStatusCommand } = await loadGatewayStatusModule(); |
| await gatewayStatusCommand( |
| { |
| ...rpcOpts, |
| port: opts.port ?? inheritOptionFromParent(command, "port"), |
| }, |
| defaultRuntime, |
| ); |
| }, |
| undefined, |
| { json: Boolean(opts.json) }, |
| ); |
| }); |
|
|
| gateway |
| .command("discover") |
| .description("Discover gateways via Bonjour (local + wide-area if configured)") |
| .option("--timeout <ms>", "Per-command timeout in ms", "2000") |
| .option("--json", "Output JSON", false) |
| .action(async (opts: GatewayDiscoverOpts) => { |
| await runGatewayCommand( |
| async () => { |
| const [ |
| { readSourceConfigBestEffort }, |
| { discoverGatewayBeacons, resolveGatewayDiscoveryEndpoint }, |
| { resolveWideAreaDiscoveryDomain }, |
| { dedupeBeacons, parseDiscoverTimeoutMs, renderBeaconLines }, |
| { withProgress }, |
| ] = await Promise.all([ |
| loadConfigModule(), |
| loadBonjourDiscoveryModule(), |
| loadWideAreaDnsModule(), |
| import("./discover.js"), |
| import("../progress.js"), |
| ]); |
| const cfg = await readSourceConfigBestEffort(); |
| const wideAreaDomain = resolveWideAreaDiscoveryDomain({ |
| configDomain: cfg.discovery?.wideArea?.domain, |
| }); |
| const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000); |
| const domains = ["local.", ...(wideAreaDomain ? [wideAreaDomain] : [])]; |
| const beacons = await withProgress( |
| { |
| label: "Scanning for gateways…", |
| indeterminate: true, |
| enabled: opts.json !== true, |
| delayMs: 0, |
| }, |
| async () => await discoverGatewayBeacons({ timeoutMs, wideAreaDomain }), |
| ); |
|
|
| const deduped = dedupeBeacons(beacons).toSorted((a, b) => |
| (a.displayName || a.instanceName).localeCompare(b.displayName || b.instanceName), |
| ); |
|
|
| if (opts.json) { |
| const enriched = deduped.map((beacon) => ({ |
| ...beacon, |
| wsUrl: resolveGatewayDiscoveryEndpoint(beacon)?.wsUrl ?? null, |
| })); |
| defaultRuntime.writeJson({ |
| timeoutMs, |
| domains, |
| count: enriched.length, |
| beacons: enriched, |
| }); |
| return; |
| } |
|
|
| const rich = isRich(); |
| defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery")); |
| defaultRuntime.log( |
| colorize( |
| rich, |
| theme.muted, |
| `Found ${deduped.length} gateway(s) · domains: ${domains.join(", ")}`, |
| ), |
| ); |
| if (deduped.length === 0) { |
| return; |
| } |
|
|
| for (const beacon of deduped) { |
| for (const line of renderBeaconLines(beacon, rich)) { |
| defaultRuntime.log(line); |
| } |
| } |
| }, |
| "gateway discover failed", |
| { json: Boolean(opts.json) }, |
| ); |
| }); |
| } |
| |
|
|