openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
973 lines (972 loc) • 41.6 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString, s as normalizeOptionalLowercaseString } from "./string-coerce-mnp54Vah.js";
import { i as formatErrorMessage } from "./errors-BXgSefBE.js";
import { t as formatCliCommand } from "./command-format-CKGmlpAQ.js";
import { t as formatDocsLink } from "./links-CsLBrRff.js";
import { n as isRich, r as theme } from "./theme-vjDs9tao.js";
import { g as shortenHomePath, h as shortenHomeInString } from "./utils-CCC-BEJH.js";
import { n as defaultRuntime } from "./runtime-B4lgFmsS.js";
import { t as sanitizeTerminalText } from "./safe-text-BkQYdJi1.js";
import { t as parseDurationMs } from "./parse-duration-oxu_S07c.js";
import { h as randomIdempotencyKey } from "./call-B5-GYOlf.js";
import { t as resolveNodePairApprovalScopes } from "./node-pairing-authz-BV3lN8MO.js";
import { a as parseCameraClipPayload, c as writeCameraPayloadToFile, i as cameraTempPath, n as screenRecordTempPath, o as parseCameraSnapPayload, r as writeScreenRecordToFile, s as writeCameraClipPayloadToFile, t as parseScreenRecordPayload } from "./nodes-screen-C6sdhNwf.js";
import { n as parsePairingList, t as parseNodeList } from "./node-list-parse-BSOtZszw.js";
import { n as formatTimeAgo } from "./format-relative-Bjc3l98W.js";
import { n as withConsoleLogsRoutedToStderrForJson } from "./json-output-mode-uwhtsLhD.js";
import { n as renderTable, t as getTerminalTableWidth } from "./table-CpFtGZut.js";
import { n as runCommandWithRuntime } from "./cli-utils-Cuzzfy1Q.js";
import { t as formatHelpExamples } from "./help-format-CAcwboTs.js";
import { a as parseOptionalNodeFiniteNumber, c as resolveNode, d as formatPermissions, i as nodesCallOpts, l as resolveNodeId, n as callGatewayCli, o as parseOptionalNodeNonNegativeInteger, r as callNodePairApprovalGatewayCli, s as parseOptionalNodePositiveInteger, t as buildNodeInvokeParams, u as unauthorizedHintForMessage } from "./rpc-DrF6iZuE.js";
//#region src/cli/nodes-cli/cli-utils.ts
/** Return color helpers that degrade to plain text in non-rich terminals. */
function getNodesTheme() {
const rich = isRich();
const color = (fn) => (value) => rich ? fn(value) : value;
return {
rich,
heading: color(theme.heading),
ok: color(theme.success),
warn: color(theme.warn),
muted: color(theme.muted),
error: color(theme.error)
};
}
/** Run a node CLI action with standard failure text and authorization hints. */
function runNodesCommand(label, action) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
const message = String(err);
const { error, warn } = getNodesTheme();
defaultRuntime.error(error(`nodes ${label} failed: ${message}`));
const hint = unauthorizedHintForMessage(message);
if (hint) defaultRuntime.error(warn(hint));
defaultRuntime.exit(1);
});
}
//#endregion
//#region src/cli/nodes-cli/register.camera.ts
const parseFacing = (value) => {
const v = normalizeLowercaseStringOrEmpty(normalizeOptionalString(value) ?? "");
if (v === "front" || v === "back") return v;
throw new Error(`invalid facing: ${value} (expected front|back)`);
};
function getGatewayInvokePayload(raw) {
return typeof raw === "object" && raw !== null ? raw.payload : void 0;
}
/** Register node camera list/snap/clip commands. */
function registerNodesCameraCommands(nodes) {
const camera = nodes.command("camera").description("Capture camera media from a paired node");
nodesCallOpts(camera.command("list").description("List available cameras on a node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").action(async (opts) => {
await runNodesCommand("camera list", async () => {
const raw = await callGatewayCli("node.invoke", opts, buildNodeInvokeParams({
nodeId: await resolveNodeId(opts, opts.node ?? ""),
command: "camera.list",
params: {}
}));
const res = typeof raw === "object" && raw !== null ? raw : {};
const payload = typeof res.payload === "object" && res.payload !== null ? res.payload : {};
const devices = Array.isArray(payload.devices) ? payload.devices : [];
if (opts.json) {
defaultRuntime.writeJson(devices);
return;
}
if (devices.length === 0) {
const { muted } = getNodesTheme();
defaultRuntime.log(muted("No cameras reported."));
return;
}
const { heading, muted } = getNodesTheme();
const tableWidth = getTerminalTableWidth();
const rows = devices.map((device) => ({
Name: typeof device.name === "string" ? device.name : "Unknown Camera",
Position: typeof device.position === "string" ? device.position : muted("unspecified"),
ID: typeof device.id === "string" ? device.id : ""
}));
defaultRuntime.log(heading("Cameras"));
defaultRuntime.log(renderTable({
width: tableWidth,
columns: [
{
key: "Name",
header: "Name",
minWidth: 14,
flex: true
},
{
key: "Position",
header: "Position",
minWidth: 10
},
{
key: "ID",
header: "ID",
minWidth: 10,
flex: true
}
],
rows
}).trimEnd());
});
}), { timeoutMs: 6e4 });
nodesCallOpts(camera.command("snap").description("Capture a photo from a node camera (prints the saved path)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--facing <front|back|both>", "Camera facing", "both").option("--device-id <id>", "Camera device id (from nodes camera list)").option("--max-width <px>", "Max width in px (optional)").option("--quality <0-1>", "JPEG quality (default 0.9)").option("--delay-ms <ms>", "Delay before capture in ms (macOS default 2000)").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000").action(async (opts) => {
await runNodesCommand("camera snap", async () => {
const node = await resolveNode(opts, normalizeOptionalString(opts.node) ?? "");
const nodeId = node.nodeId;
const facingOpt = normalizeLowercaseStringOrEmpty(normalizeOptionalString(opts.facing) ?? "both");
const facings = facingOpt === "both" ? ["front", "back"] : facingOpt === "front" || facingOpt === "back" ? [facingOpt] : (() => {
throw new Error(`invalid facing: ${String(opts.facing)} (expected front|back|both)`);
})();
const maxWidth = parseOptionalNodePositiveInteger(opts.maxWidth, "--max-width");
const quality = parseOptionalNodeFiniteNumber(opts.quality, "--quality", {
minInclusive: 0,
maxInclusive: 1
});
const delayMs = parseOptionalNodeNonNegativeInteger(opts.delayMs, "--delay-ms");
const deviceId = normalizeOptionalString(opts.deviceId);
if (deviceId && facings.length > 1) throw new Error("facing=both is not allowed when --device-id is set");
const timeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout");
const results = [];
for (const facing of facings) {
const payload = parseCameraSnapPayload(getGatewayInvokePayload(await callGatewayCli("node.invoke", opts, buildNodeInvokeParams({
nodeId,
command: "camera.snap",
params: {
facing,
maxWidth: Number.isFinite(maxWidth) ? maxWidth : void 0,
quality: Number.isFinite(quality) ? quality : void 0,
format: "jpg",
delayMs: Number.isFinite(delayMs) ? delayMs : void 0,
deviceId: deviceId || void 0
},
timeoutMs
}))));
const filePath = cameraTempPath({
kind: "snap",
facing,
ext: payload.format === "jpeg" ? "jpg" : payload.format
});
await writeCameraPayloadToFile({
filePath,
payload,
expectedHost: node.remoteIp,
invalidPayloadMessage: "invalid camera.snap payload"
});
results.push({
facing,
path: filePath,
width: payload.width,
height: payload.height
});
}
if (opts.json) {
defaultRuntime.writeJson({ files: results });
return;
}
defaultRuntime.log(results.map((r) => shortenHomePath(r.path)).join("\n"));
});
}), { timeoutMs: 6e4 });
nodesCallOpts(camera.command("clip").description("Capture a short video clip from a node camera (prints the saved path)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--facing <front|back>", "Camera facing", "front").option("--device-id <id>", "Camera device id (from nodes camera list)").option("--duration <ms|10s|1m>", "Duration (default 3000ms; supports ms/s/m, e.g. 10s)", "3000").option("--no-audio", "Disable audio capture").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 90000)", "90000").action(async (opts) => {
await runNodesCommand("camera clip", async () => {
const node = await resolveNode(opts, normalizeOptionalString(opts.node) ?? "");
const nodeId = node.nodeId;
const facing = parseFacing(opts.facing ?? "front");
const durationMs = parseDurationMs(opts.duration ?? "3000");
const includeAudio = opts.audio !== false;
const timeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout");
const deviceId = normalizeOptionalString(opts.deviceId);
const payload = parseCameraClipPayload(getGatewayInvokePayload(await callGatewayCli("node.invoke", opts, buildNodeInvokeParams({
nodeId,
command: "camera.clip",
params: {
facing,
durationMs: Number.isFinite(durationMs) ? durationMs : void 0,
includeAudio,
format: "mp4",
deviceId: deviceId || void 0
},
timeoutMs
}))));
const filePath = await writeCameraClipPayloadToFile({
payload,
facing,
expectedHost: node.remoteIp
});
if (opts.json) {
defaultRuntime.writeJson({ file: {
facing,
path: filePath,
durationMs: payload.durationMs,
hasAudio: payload.hasAudio
} });
return;
}
defaultRuntime.log(shortenHomePath(filePath));
});
}), { timeoutMs: 9e4 });
}
//#endregion
//#region src/cli/nodes-cli/register.invoke.ts
const BLOCKED_NODE_INVOKE_COMMANDS = new Set(["system.run", "system.run.prepare"]);
/** Register direct node command invocation. */
function registerNodesInvokeCommands(nodes) {
nodesCallOpts(nodes.command("invoke").description("Invoke a command on a paired node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").requiredOption("--command <command>", "Command (e.g. canvas.eval)").option("--params <json>", "JSON object string for params", "{}").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 15000)", "15000").option("--idempotency-key <key>", "Idempotency key (optional)").action(async (opts) => {
await runNodesCommand("invoke", async () => {
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const command = normalizeOptionalString(opts.command) ?? "";
if (!nodeId || !command) {
const { error } = getNodesTheme();
defaultRuntime.error(error("--node and --command required"));
defaultRuntime.exit(1);
return;
}
if (BLOCKED_NODE_INVOKE_COMMANDS.has(normalizeLowercaseStringOrEmpty(command))) throw new Error(`command "${command}" is reserved for shell execution; use the exec tool with host=node instead`);
const params = JSON.parse(opts.params ?? "{}");
const timeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout");
const invokeParams = {
nodeId,
command,
params,
idempotencyKey: opts.idempotencyKey ?? randomIdempotencyKey()
};
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) invokeParams.timeoutMs = timeoutMs;
const result = await callGatewayCli("node.invoke", opts, invokeParams);
defaultRuntime.writeJson(result);
});
}), { timeoutMs: 3e4 });
}
//#endregion
//#region src/cli/nodes-cli/register.location.ts
/** Register node location lookup commands. */
function registerNodesLocationCommands(nodes) {
nodesCallOpts(nodes.command("location").description("Fetch location from a paired node").command("get").description("Fetch the current location from a node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--max-age <ms>", "Use cached location newer than this (ms)").option("--accuracy <coarse|balanced|precise>", "Desired accuracy (default: balanced/precise depending on node setting)").option("--location-timeout <ms>", "Location fix timeout (ms)", "10000").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 20000)", "20000").action(async (opts) => {
await runNodesCommand("location get", async () => {
const nodeId = await resolveNodeId(opts, opts.node ?? "");
const maxAgeMs = parseOptionalNodeNonNegativeInteger(opts.maxAge, "--max-age");
const desiredAccuracyRaw = normalizeOptionalLowercaseString(opts.accuracy);
const desiredAccuracy = desiredAccuracyRaw === "coarse" || desiredAccuracyRaw === "balanced" || desiredAccuracyRaw === "precise" ? desiredAccuracyRaw : void 0;
const timeoutMs = parseOptionalNodePositiveInteger(opts.locationTimeout, "--location-timeout");
const invokeTimeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout");
const invokeParams = {
nodeId,
command: "location.get",
params: {
maxAgeMs: Number.isFinite(maxAgeMs) ? maxAgeMs : void 0,
desiredAccuracy,
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : void 0
},
idempotencyKey: randomIdempotencyKey()
};
if (typeof invokeTimeoutMs === "number" && Number.isFinite(invokeTimeoutMs)) invokeParams.timeoutMs = invokeTimeoutMs;
const raw = await callGatewayCli("node.invoke", opts, invokeParams);
const res = typeof raw === "object" && raw !== null ? raw : {};
const payload = res.payload && typeof res.payload === "object" ? res.payload : {};
if (opts.json) {
defaultRuntime.writeJson(payload);
return;
}
const lat = payload.lat;
const lon = payload.lon;
const acc = payload.accuracyMeters;
if (typeof lat === "number" && typeof lon === "number") {
const accText = typeof acc === "number" ? ` ±${acc.toFixed(1)}m` : "";
defaultRuntime.log(`${lat},${lon}${accText}`);
return;
}
defaultRuntime.writeJson(payload, 0);
});
}), { timeoutMs: 3e4 });
}
//#endregion
//#region src/cli/nodes-cli/register.notify.ts
/** Register node notification command. */
function registerNodesNotifyCommand(nodes) {
nodesCallOpts(nodes.command("notify").description("Send a local notification on a node (mac only)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--title <text>", "Notification title").option("--body <text>", "Notification body").option("--sound <name>", "Notification sound").option("--priority <passive|active|timeSensitive>", "Notification priority").option("--delivery <system|overlay|auto>", "Delivery mode", "system").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 15000)", "15000").action(async (opts) => {
await runNodesCommand("notify", async () => {
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const title = normalizeOptionalString(opts.title) ?? "";
const body = normalizeOptionalString(opts.body) ?? "";
if (!title && !body) throw new Error("missing --title or --body");
const invokeTimeout = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout");
const invokeParams = {
nodeId,
command: "system.notify",
params: {
title,
body,
sound: opts.sound,
priority: opts.priority,
delivery: opts.delivery
},
idempotencyKey: opts.idempotencyKey ?? randomIdempotencyKey()
};
if (typeof invokeTimeout === "number" && Number.isFinite(invokeTimeout)) invokeParams.timeoutMs = invokeTimeout;
const result = await callGatewayCli("node.invoke", opts, invokeParams);
if (opts.json) {
defaultRuntime.writeJson(result);
return;
}
const { ok } = getNodesTheme();
defaultRuntime.log(ok("notify ok"));
});
}));
}
//#endregion
//#region src/cli/nodes-cli/pairing-render.ts
/** Render pending pairing requests with sanitized labels and relative request age. */
function renderPendingPairingRequestsTable(params) {
const { pending, now, tableWidth, theme } = params;
const rows = pending.map((r) => {
const nodeLabel = r.displayName?.trim() ? r.displayName.trim() : r.nodeId;
return {
Request: sanitizeTerminalText(r.requestId),
Node: sanitizeTerminalText(nodeLabel),
IP: sanitizeTerminalText(r.remoteIp ?? ""),
Requested: typeof r.ts === "number" ? formatTimeAgo(Math.max(0, now - r.ts)) : theme.muted("unknown")
};
});
return {
heading: theme.heading("Pending"),
table: renderTable({
width: tableWidth,
columns: [
{
key: "Request",
header: "Request",
minWidth: 8
},
{
key: "Node",
header: "Node",
minWidth: 14,
flex: true
},
{
key: "IP",
header: "IP",
minWidth: 10
},
{
key: "Requested",
header: "Requested",
minWidth: 12
}
],
rows
}).trimEnd()
};
}
//#endregion
//#region src/cli/nodes-cli/register.pairing.ts
const DEFAULT_NODE_PAIR_APPROVE_SCOPES = ["operator.pairing"];
const NODE_PAIR_APPROVE_SCOPE_SET = new Set([
"operator.pairing",
"operator.write",
"operator.admin"
]);
function normalizeNodePairApproveScopes(scopes) {
const normalized = new Set(DEFAULT_NODE_PAIR_APPROVE_SCOPES);
if (!Array.isArray(scopes)) return [...normalized];
for (const scope of scopes) {
if (typeof scope !== "string") continue;
if (!NODE_PAIR_APPROVE_SCOPE_SET.has(scope)) continue;
normalized.add(scope);
}
return [...normalized];
}
async function resolveApproveScopesForRequest(opts, requestId) {
try {
const { pending } = parsePairingList(await callNodePairApprovalGatewayCli("node.pair.list", opts, {}, { scopes: DEFAULT_NODE_PAIR_APPROVE_SCOPES }));
const request = pending.find((candidate) => candidate.requestId === requestId);
const scopes = normalizeNodePairApproveScopes(request?.requiredApproveScopes);
if (scopes.length > DEFAULT_NODE_PAIR_APPROVE_SCOPES.length) return scopes;
return resolveNodePairApprovalScopes(request?.commands);
} catch {
return [...DEFAULT_NODE_PAIR_APPROVE_SCOPES];
}
}
/** Register node pairing management commands. */
function registerNodesPairingCommands(nodes) {
nodesCallOpts(nodes.command("pending").description("List pending pairing requests").action(async (opts) => {
await runNodesCommand("pending", async () => {
const { pending } = parsePairingList(await callGatewayCli("node.pair.list", opts, {}));
if (opts.json) {
defaultRuntime.writeJson(pending);
return;
}
if (pending.length === 0) {
const { muted } = getNodesTheme();
defaultRuntime.log(muted("No pending pairing requests."));
return;
}
const { heading, warn, muted } = getNodesTheme();
const tableWidth = getTerminalTableWidth();
const rendered = renderPendingPairingRequestsTable({
pending,
now: Date.now(),
tableWidth,
theme: {
heading,
warn,
muted
}
});
defaultRuntime.log(rendered.heading);
defaultRuntime.log(rendered.table);
});
}));
nodesCallOpts(nodes.command("approve").description("Approve a pending pairing request").argument("<requestId>", "Pending request id").action(async (requestId, opts) => {
await runNodesCommand("approve", async () => {
const scopes = await resolveApproveScopesForRequest(opts, requestId);
const result = await callNodePairApprovalGatewayCli("node.pair.approve", opts, { requestId }, { scopes });
defaultRuntime.writeJson(result);
});
}));
nodesCallOpts(nodes.command("reject").description("Reject a pending pairing request").argument("<requestId>", "Pending request id").action(async (requestId, opts) => {
await runNodesCommand("reject", async () => {
const result = await callGatewayCli("node.pair.reject", opts, { requestId });
defaultRuntime.writeJson(result);
});
}));
nodesCallOpts(nodes.command("remove").description("Remove a paired node entry").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").action(async (opts) => {
await runNodesCommand("remove", async () => {
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
if (!nodeId) {
defaultRuntime.error(`--node is required. Run ${formatCliCommand("openclaw nodes pairing pending")} to choose a node request.`);
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("node.pair.remove", opts, { nodeId });
if (opts.json) {
defaultRuntime.writeJson(result);
return;
}
const { warn } = getNodesTheme();
defaultRuntime.log(warn(`Removed paired node ${nodeId}`));
});
}));
nodesCallOpts(nodes.command("rename").description("Rename a paired node (display name override)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").requiredOption("--name <displayName>", "New display name").action(async (opts) => {
await runNodesCommand("rename", async () => {
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const name = normalizeOptionalString(opts.name) ?? "";
if (!nodeId || !name) {
defaultRuntime.error(`--node and --name are required. Run ${formatCliCommand("openclaw nodes pairing pending")} to choose a node, then rerun with --name <displayName>.`);
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("node.rename", opts, {
nodeId,
displayName: name
});
if (opts.json) {
defaultRuntime.writeJson(result);
return;
}
const { ok } = getNodesTheme();
defaultRuntime.log(ok(`node rename ok: ${nodeId} -> ${name}`));
});
}));
}
//#endregion
//#region src/cli/nodes-cli/register.push.ts
function normalizeEnvironment(value) {
if (typeof value !== "string") return null;
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "sandbox" || normalized === "production") return normalized;
return null;
}
/** Register the node push-test command. */
function registerNodesPushCommand(nodes) {
nodesCallOpts(nodes.command("push").description("Send an APNs test push to an iOS node").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--title <text>", "Push title", "OpenClaw").option("--body <text>", "Push body").option("--environment <sandbox|production>", "Override APNs environment").action(async (opts) => {
await runNodesCommand("push", async () => {
const nodeId = await resolveNodeId(opts, normalizeOptionalString(opts.node) ?? "");
const title = normalizeOptionalString(opts.title) || "OpenClaw";
const body = normalizeOptionalString(opts.body) || `Push test for node ${nodeId}`;
const environment = normalizeEnvironment(opts.environment);
if (opts.environment && !environment) throw new Error("invalid --environment (use sandbox|production)");
const params = {
nodeId,
title,
body
};
if (environment) params.environment = environment;
const result = await callGatewayCli("push.test", opts, params);
if (opts.json) {
defaultRuntime.writeJson(result);
return;
}
const parsed = typeof result === "object" && result !== null ? result : {};
const ok = parsed.ok === true;
const status = typeof parsed.status === "number" ? parsed.status : 0;
const reason = typeof parsed.reason === "string" ? normalizeOptionalString(parsed.reason) : void 0;
const env = typeof parsed.environment === "string" ? normalizeOptionalString(parsed.environment) ?? "unknown" : "unknown";
const { ok: okLabel, error: errorLabel } = getNodesTheme();
const label = ok ? okLabel : errorLabel;
defaultRuntime.log(label(`push.test status=${status} ok=${ok} env=${env}`));
if (reason) defaultRuntime.log(`reason: ${reason}`);
});
}), { timeoutMs: 25e3 });
}
//#endregion
//#region src/cli/nodes-cli/register.screen.ts
/** Register node screen recording commands. */
function registerNodesScreenCommands(nodes) {
nodesCallOpts(nodes.command("screen").description("Capture screen recordings from a paired node").command("record").description("Capture a short screen recording from a node (prints the saved path)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").option("--screen <index>", "Screen index (0 = primary)", "0").option("--duration <ms|10s>", "Clip duration (ms or 10s)", "10000").option("--fps <fps>", "Frames per second", "10").option("--no-audio", "Disable microphone audio capture").option("--out <path>", "Output path").option("--invoke-timeout <ms>", "Node invoke timeout in ms (default 120000)", "120000").action(async (opts) => {
await runNodesCommand("screen record", async () => {
const nodeId = await resolveNodeId(opts, opts.node ?? "");
const durationMs = parseDurationMs(opts.duration ?? "");
const screenIndex = parseOptionalNodeNonNegativeInteger(opts.screen ?? "0", "--screen");
const fps = parseOptionalNodeFiniteNumber(opts.fps ?? "10", "--fps", { minExclusive: 0 });
const timeoutMs = parseOptionalNodePositiveInteger(opts.invokeTimeout, "--invoke-timeout");
const raw = await callGatewayCli("node.invoke", opts, buildNodeInvokeParams({
nodeId,
command: "screen.record",
params: {
durationMs: Number.isFinite(durationMs) ? durationMs : void 0,
screenIndex: Number.isFinite(screenIndex) ? screenIndex : void 0,
fps: Number.isFinite(fps) ? fps : void 0,
format: "mp4",
includeAudio: opts.audio !== false
},
timeoutMs
}));
const parsed = parseScreenRecordPayload((typeof raw === "object" && raw !== null ? raw : {}).payload);
const written = await writeScreenRecordToFile(opts.out ?? screenRecordTempPath({ ext: parsed.format || "mp4" }), parsed.base64);
if (opts.json) {
defaultRuntime.writeJson({ file: {
path: written.path,
durationMs: parsed.durationMs,
fps: parsed.fps,
screenIndex: parsed.screenIndex,
hasAudio: parsed.hasAudio
} });
return;
}
defaultRuntime.log(shortenHomePath(written.path));
});
}), { timeoutMs: 18e4 });
}
//#endregion
//#region src/cli/nodes-cli/register.status.ts
function formatVersionLabel(raw) {
const trimmed = raw.trim();
if (!trimmed) return raw;
if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("v")) return trimmed;
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
}
function resolveNodeVersions(node) {
const core = normalizeOptionalString(node.coreVersion);
const ui = normalizeOptionalString(node.uiVersion);
if (core || ui) return {
core,
ui
};
const legacy = node.version?.trim();
if (!legacy) return {
core: void 0,
ui: void 0
};
const platform = normalizeOptionalLowercaseString(node.platform) ?? "";
return platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows" ? {
core: legacy,
ui: void 0
} : {
core: void 0,
ui: legacy
};
}
function formatNodeVersions(node) {
const { core, ui } = resolveNodeVersions(node);
const parts = [];
if (core) parts.push(`core ${formatVersionLabel(core)}`);
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
return parts.length > 0 ? parts.join(" · ") : null;
}
function formatPathEnv(raw) {
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
if (!trimmed) return null;
const parts = trimmed.split(":").filter(Boolean);
return shortenHomeInString(parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`);
}
function formatClientLabel(node) {
const clientId = node.clientId?.trim();
const clientMode = node.clientMode?.trim();
if (clientId && clientMode) return `${clientId}/${clientMode}`;
return clientId || clientMode || null;
}
function formatNodeTerminalLabel(node) {
return sanitizeTerminalText(node.displayName?.trim() ? node.displayName.trim() : node.nodeId);
}
function parseSinceMs(raw, label) {
if (raw === void 0 || raw === null) return;
const value = normalizeOptionalString(raw) ?? (typeof raw === "number" ? String(raw) : null);
if (value === null) {
defaultRuntime.error(`${label}: invalid duration value`);
defaultRuntime.exit(1);
return;
}
if (!value) return;
try {
return parseDurationMs(value);
} catch (err) {
const message = formatErrorMessage(err);
defaultRuntime.error(`${label}: ${message}`);
defaultRuntime.exit(1);
return;
}
}
function mergePairedNodeWithEffectiveNode(paired, effective) {
return {
...paired,
...effective,
token: paired?.token,
createdAtMs: paired?.createdAtMs,
lastConnectedAtMs: paired?.lastConnectedAtMs ?? effective.connectedAtMs,
displayName: effective.displayName ?? paired?.displayName,
platform: effective.platform ?? paired?.platform,
version: effective.version ?? paired?.version,
coreVersion: effective.coreVersion ?? paired?.coreVersion,
uiVersion: effective.uiVersion ?? paired?.uiVersion,
remoteIp: effective.remoteIp ?? paired?.remoteIp,
permissions: effective.permissions ?? paired?.permissions,
approvedAtMs: effective.approvedAtMs ?? paired?.approvedAtMs
};
}
function mergePairedNodesWithEffectiveNodes(paired, effectiveNodes) {
if (effectiveNodes === null) return paired;
const pairedById = new Map(paired.map((node) => [node.nodeId, node]));
const seen = /* @__PURE__ */ new Set();
const rows = [];
for (const effective of effectiveNodes) {
const pairedNode = pairedById.get(effective.nodeId);
if (!pairedNode && effective.paired !== true) continue;
seen.add(effective.nodeId);
rows.push(mergePairedNodeWithEffectiveNode(pairedNode, effective));
}
for (const node of paired) if (!seen.has(node.nodeId)) rows.push(node);
return rows;
}
async function tryReadNodeList(opts) {
try {
return parseNodeList(await callGatewayCli("node.list", opts, {}));
} catch {
return null;
}
}
function sanitizePairedNodeForListJson(node) {
const copy = { ...node };
delete copy.token;
return copy;
}
/** Register node status, describe, and paired-node list commands. */
function registerNodesStatusCommands(nodes) {
nodesCallOpts(nodes.command("status").description("List known nodes with connection status and capabilities").option("--connected", "Only show connected nodes").option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)").action(async (opts) => {
await runNodesCommand("status", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = await callGatewayCli("node.list", opts, {});
const obj = typeof result === "object" && result !== null ? result : {};
const { ok, warn, muted } = getNodesTheme();
const tableWidth = getTerminalTableWidth();
const now = Date.now();
const nodesLocal = parseNodeList(result);
const lastConnectedById = sinceMs !== void 0 ? new Map(parsePairingList(await callGatewayCli("node.pair.list", opts, {})).paired.map((entry) => [entry.nodeId, entry])) : null;
const filtered = nodesLocal.filter((n) => {
if (connectedOnly && !n.connected) return false;
if (sinceMs !== void 0) {
const paired = lastConnectedById?.get(n.nodeId);
const lastConnectedAtMs = typeof paired?.lastConnectedAtMs === "number" ? paired.lastConnectedAtMs : typeof n.connectedAtMs === "number" ? n.connectedAtMs : void 0;
if (typeof lastConnectedAtMs !== "number") return false;
if (now - lastConnectedAtMs > sinceMs) return false;
}
return true;
});
if (opts.json) {
const ts = typeof obj.ts === "number" ? obj.ts : Date.now();
defaultRuntime.writeJson({
...obj,
ts,
nodes: filtered
});
return;
}
const pairedCount = filtered.filter((n) => Boolean(n.paired)).length;
const connectedCount = filtered.filter((n) => Boolean(n.connected)).length;
const filteredLabel = filtered.length !== nodesLocal.length ? ` (of ${nodesLocal.length})` : "";
defaultRuntime.log(`Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`);
if (filtered.length === 0) return;
const rows = filtered.map((n) => {
const perms = formatPermissions(n.permissions);
const versions = formatNodeVersions(n);
const pathEnv = formatPathEnv(n.pathEnv);
const client = formatClientLabel(n);
const detailParts = [
client ? `client: ${client}` : null,
n.deviceFamily ? `device: ${n.deviceFamily}` : null,
n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null,
perms ? `perms: ${perms}` : null,
versions,
pathEnv ? `path: ${pathEnv}` : null
].filter(Boolean).map((part) => sanitizeTerminalText(String(part)));
const caps = Array.isArray(n.caps) ? sanitizeTerminalText(n.caps.map(String).filter(Boolean).toSorted().join(", ")) : "?";
const paired = n.paired ? ok("paired") : warn("unpaired");
const connected = n.connected ? ok("connected") : muted("disconnected");
const since = typeof n.connectedAtMs === "number" ? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})` : "";
return {
Node: formatNodeTerminalLabel(n),
ID: sanitizeTerminalText(n.nodeId),
IP: sanitizeTerminalText(n.remoteIp ?? ""),
Detail: detailParts.join(" · "),
Status: `${paired} · ${connected}${since}`,
Caps: caps
};
});
defaultRuntime.log(renderTable({
width: tableWidth,
columns: [
{
key: "Node",
header: "Node",
minWidth: 14,
flex: true
},
{
key: "ID",
header: "ID",
minWidth: 10
},
{
key: "IP",
header: "IP",
minWidth: 10
},
{
key: "Detail",
header: "Detail",
minWidth: 18,
flex: true
},
{
key: "Status",
header: "Status",
minWidth: 18
},
{
key: "Caps",
header: "Caps",
minWidth: 12,
flex: true
}
],
rows
}).trimEnd());
});
}));
nodesCallOpts(nodes.command("describe").description("Describe a node (capabilities + supported invoke commands)").requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP").action(async (opts) => {
await runNodesCommand("describe", async () => {
const nodeId = await resolveNodeId(opts, opts.node ?? "");
const result = await callGatewayCli("node.describe", opts, { nodeId });
if (opts.json) {
defaultRuntime.writeJson(result);
return;
}
const obj = typeof result === "object" && result !== null ? result : {};
const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId;
const connected = Boolean(obj.connected);
const paired = Boolean(obj.paired);
const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).toSorted() : null;
const commands = Array.isArray(obj.commands) ? obj.commands.map(String).filter(Boolean).toSorted() : [];
const perms = formatPermissions(obj.permissions);
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const client = formatClientLabel(obj);
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null;
const versions = formatNodeVersions(obj);
const { heading, ok, warn, muted } = getNodesTheme();
const status = `${paired ? ok("paired") : warn("unpaired")} · ${connected ? ok("connected") : muted("disconnected")}`;
const tableWidth = getTerminalTableWidth();
const rows = [
{
Field: "ID",
Value: sanitizeTerminalText(nodeId)
},
displayName ? {
Field: "Name",
Value: sanitizeTerminalText(displayName)
} : null,
client ? {
Field: "Client",
Value: sanitizeTerminalText(client)
} : null,
ip ? {
Field: "IP",
Value: sanitizeTerminalText(ip)
} : null,
family ? {
Field: "Device",
Value: sanitizeTerminalText(family)
} : null,
model ? {
Field: "Model",
Value: sanitizeTerminalText(model)
} : null,
perms ? {
Field: "Perms",
Value: sanitizeTerminalText(perms)
} : null,
versions ? {
Field: "Version",
Value: sanitizeTerminalText(versions)
} : null,
pathEnv ? {
Field: "PATH",
Value: sanitizeTerminalText(pathEnv)
} : null,
{
Field: "Status",
Value: status
},
{
Field: "Caps",
Value: caps ? sanitizeTerminalText(caps.join(", ")) : "?"
}
].filter(Boolean);
defaultRuntime.log(heading("Node"));
defaultRuntime.log(renderTable({
width: tableWidth,
columns: [{
key: "Field",
header: "Field",
minWidth: 8
}, {
key: "Value",
header: "Value",
minWidth: 24,
flex: true
}],
rows
}).trimEnd());
defaultRuntime.log("");
defaultRuntime.log(heading("Commands"));
if (commands.length === 0) {
defaultRuntime.log(muted("- (none reported)"));
return;
}
for (const c of commands) defaultRuntime.log(`- ${c}`);
});
}));
nodesCallOpts(nodes.command("list").description("List pending and paired nodes").option("--connected", "Only show connected nodes").option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)").action(async (opts) => {
await runNodesCommand("list", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const { pending, paired } = parsePairingList(await callGatewayCli("node.pair.list", opts, {}));
const { heading, muted, warn } = getNodesTheme();
const tableWidth = getTerminalTableWidth();
const now = Date.now();
const hasFilters = connectedOnly || sinceMs !== void 0;
const pendingRows = hasFilters ? [] : pending;
const filteredPaired = mergePairedNodesWithEffectiveNodes(paired, hasFilters ? parseNodeList(await callGatewayCli("node.list", opts, {})) : await tryReadNodeList(opts)).filter((node) => {
if (connectedOnly) {
if (!node.connected) return false;
}
if (sinceMs !== void 0) {
const lastConnectedAtMs = typeof node.lastConnectedAtMs === "number" ? node.lastConnectedAtMs : typeof node.connectedAtMs === "number" ? node.connectedAtMs : void 0;
if (typeof lastConnectedAtMs !== "number") return false;
if (now - lastConnectedAtMs > sinceMs) return false;
}
return true;
});
const filteredLabel = hasFilters && filteredPaired.length !== paired.length ? ` (of ${paired.length})` : "";
if (opts.json) {
defaultRuntime.writeJson({
pending: pendingRows,
paired: filteredPaired.map(sanitizePairedNodeForListJson)
});
return;
}
defaultRuntime.log(`Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`);
if (pendingRows.length > 0) {
const rendered = renderPendingPairingRequestsTable({
pending: pendingRows,
now,
tableWidth,
theme: {
heading,
warn,
muted
}
});
defaultRuntime.log("");
defaultRuntime.log(rendered.heading);
defaultRuntime.log(rendered.table);
}
if (filteredPaired.length > 0) {
const pairedTableRows = filteredPaired.map((n) => {
const lastConnectedAtMs = typeof n.lastConnectedAtMs === "number" ? n.lastConnectedAtMs : typeof n.connectedAtMs === "number" ? n.connectedAtMs : void 0;
return {
Node: formatNodeTerminalLabel(n),
Id: sanitizeTerminalText(n.nodeId),
IP: sanitizeTerminalText(n.remoteIp ?? ""),
LastConnect: typeof lastConnectedAtMs === "number" ? formatTimeAgo(Math.max(0, now - lastConnectedAtMs)) : muted("unknown")
};
});
defaultRuntime.log("");
defaultRuntime.log(heading("Paired"));
defaultRuntime.log(renderTable({
width: tableWidth,
columns: [
{
key: "Node",
header: "Node",
minWidth: 14,
flex: true
},
{
key: "Id",
header: "ID",
minWidth: 10
},
{
key: "IP",
header: "IP",
minWidth: 10
},
{
key: "LastConnect",
header: "Last Connect",
minWidth: 14
}
],
rows: pairedTableRows
}).trimEnd());
}
});
}));
}
//#endregion
//#region src/cli/nodes-cli/register.ts
/** Register the `nodes` command group and lazy plugin-provided node commands. */
async function registerNodesCli(program, argv = process.argv) {
const nodes = program.command("nodes").description("Manage gateway-owned nodes (pairing, status, invoke, and media)").addHelpText("after", () => `\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw nodes status", "List known nodes with live status."],
["openclaw nodes pairing pending", "Show pending node pairing requests."],
["openclaw nodes remove --node <id|name|ip>", "Remove a stale paired node entry."],
["openclaw nodes invoke --node <id> --command system.which --params '{\"name\":\"uname\"}'", "Invoke a node command directly."],
["openclaw nodes camera snap --node <id>", "Capture a photo from a node camera."]
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/nodes", "docs.openclaw.ai/cli/nodes")}\n`);
registerNodesStatusCommands(nodes);
registerNodesPairingCommands(nodes);
registerNodesInvokeCommands(nodes);
registerNodesNotifyCommand(nodes);
registerNodesPushCommand(nodes);
registerNodesCameraCommands(nodes);
registerNodesScreenCommands(nodes);
registerNodesLocationCommands(nodes);
const { registerPluginCliCommandsFromValidatedConfig } = await import("./cli-DNuRZvyd.js");
await withConsoleLogsRoutedToStderrForJson(argv, async () => await registerPluginCliCommandsFromValidatedConfig(program, void 0, void 0, {
mode: "lazy",
primary: "nodes"
}));
}
//#endregion
export { registerNodesCli };