import fs from "node:fs/promises"; import type { Command } from "commander"; import { randomIdempotencyKey } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; import { writeBase64ToFile } from "../nodes-camera.js"; import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js"; import { parseTimeoutMs } from "../nodes-run.js"; import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import type { NodesRpcOpts } from "./types.js"; import { shortenHomePath } from "../../utils.js"; async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record) { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const invokeParams: Record = { nodeId, command, params, idempotencyKey: randomIdempotencyKey(), }; const timeoutMs = parseTimeoutMs(opts.invokeTimeout); if (typeof timeoutMs === "number") { invokeParams.timeoutMs = timeoutMs; } return await callGatewayCli("node.invoke", opts, invokeParams); } export function registerNodesCanvasCommands(nodes: Command) { const canvas = nodes .command("canvas") .description("Capture or render canvas content from a paired node"); nodesCallOpts( canvas .command("snapshot") .description("Capture a canvas snapshot (prints MEDIA:)") .requiredOption("--node ", "Node id, name, or IP") .option("--format ", "Image format", "jpg") .option("--max-width ", "Max width in px (optional)") .option("--quality <0-1>", "JPEG quality (optional)") .option("--invoke-timeout ", "Node invoke timeout in ms (default 20000)", "20000") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas snapshot", async () => { const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const formatOpt = String(opts.format ?? "jpg") .trim() .toLowerCase(); const formatForParams = formatOpt === "jpg" ? "jpeg" : formatOpt === "jpeg" ? "jpeg" : "png"; if (formatForParams !== "png" && formatForParams !== "jpeg") { throw new Error(`invalid format: ${String(opts.format)} (expected png|jpg|jpeg)`); } const maxWidth = opts.maxWidth ? Number.parseInt(String(opts.maxWidth), 10) : undefined; const quality = opts.quality ? Number.parseFloat(String(opts.quality)) : undefined; const timeoutMs = opts.invokeTimeout ? Number.parseInt(String(opts.invokeTimeout), 10) : undefined; const invokeParams: Record = { nodeId, command: "canvas.snapshot", params: { format: formatForParams, maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, quality: Number.isFinite(quality) ? quality : undefined, }, idempotencyKey: randomIdempotencyKey(), }; if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) { invokeParams.timeoutMs = timeoutMs; } const raw = (await callGatewayCli("node.invoke", opts, invokeParams)) as unknown; const res = typeof raw === "object" && raw !== null ? (raw as { payload?: unknown }) : {}; const payload = parseCanvasSnapshotPayload(res.payload); const filePath = canvasSnapshotTempPath({ ext: payload.format === "jpeg" ? "jpg" : payload.format, }); await writeBase64ToFile(filePath, payload.base64); if (opts.json) { defaultRuntime.log( JSON.stringify({ file: { path: filePath, format: payload.format } }, null, 2), ); return; } defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`); }); }), { timeoutMs: 60_000 }, ); nodesCallOpts( canvas .command("present") .description("Show the canvas (optionally with a target URL/path)") .requiredOption("--node ", "Node id, name, or IP") .option("--target ", "Target URL/path (optional)") .option("--x ", "Placement x coordinate") .option("--y ", "Placement y coordinate") .option("--width ", "Placement width") .option("--height ", "Placement height") .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas present", async () => { const placement = { x: opts.x ? Number.parseFloat(opts.x) : undefined, y: opts.y ? Number.parseFloat(opts.y) : undefined, width: opts.width ? Number.parseFloat(opts.width) : undefined, height: opts.height ? Number.parseFloat(opts.height) : undefined, }; const params: Record = {}; if (opts.target) params.url = String(opts.target); if ( Number.isFinite(placement.x) || Number.isFinite(placement.y) || Number.isFinite(placement.width) || Number.isFinite(placement.height) ) { params.placement = placement; } await invokeCanvas(opts, "canvas.present", params); if (!opts.json) { const { ok } = getNodesTheme(); defaultRuntime.log(ok("canvas present ok")); } }); }), ); nodesCallOpts( canvas .command("hide") .description("Hide the canvas") .requiredOption("--node ", "Node id, name, or IP") .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas hide", async () => { await invokeCanvas(opts, "canvas.hide", undefined); if (!opts.json) { const { ok } = getNodesTheme(); defaultRuntime.log(ok("canvas hide ok")); } }); }), ); nodesCallOpts( canvas .command("navigate") .description("Navigate the canvas to a URL") .argument("", "Target URL/path") .requiredOption("--node ", "Node id, name, or IP") .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (url: string, opts: NodesRpcOpts) => { await runNodesCommand("canvas navigate", async () => { await invokeCanvas(opts, "canvas.navigate", { url }); if (!opts.json) { const { ok } = getNodesTheme(); defaultRuntime.log(ok("canvas navigate ok")); } }); }), ); nodesCallOpts( canvas .command("eval") .description("Evaluate JavaScript in the canvas") .argument("[js]", "JavaScript to evaluate") .option("--js ", "JavaScript to evaluate") .requiredOption("--node ", "Node id, name, or IP") .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (jsArg: string | undefined, opts: NodesRpcOpts) => { await runNodesCommand("canvas eval", async () => { const js = opts.js ?? jsArg; if (!js) throw new Error("missing --js or "); const raw = await invokeCanvas(opts, "canvas.eval", { javaScript: js, }); if (opts.json) { defaultRuntime.log(JSON.stringify(raw, null, 2)); return; } const payload = typeof raw === "object" && raw !== null ? (raw as { payload?: { result?: string } }).payload : undefined; if (payload?.result) defaultRuntime.log(payload.result); else { const { ok } = getNodesTheme(); defaultRuntime.log(ok("canvas eval ok")); } }); }), ); const a2ui = canvas.command("a2ui").description("Render A2UI content on the canvas"); nodesCallOpts( a2ui .command("push") .description("Push A2UI JSONL to the canvas") .option("--jsonl ", "Path to JSONL payload") .option("--text ", "Render a quick A2UI text payload") .requiredOption("--node ", "Node id, name, or IP") .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas a2ui push", async () => { const hasJsonl = Boolean(opts.jsonl); const hasText = typeof opts.text === "string"; if (hasJsonl === hasText) { throw new Error("provide exactly one of --jsonl or --text"); } const jsonl = hasText ? buildA2UITextJsonl(String(opts.text ?? "")) : await fs.readFile(String(opts.jsonl), "utf8"); const { version, messageCount } = validateA2UIJsonl(jsonl); if (version === "v0.9") { throw new Error( "Detected A2UI v0.9 JSONL (createSurface). Moltbot currently supports v0.8 only.", ); } await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl }); if (!opts.json) { const { ok } = getNodesTheme(); defaultRuntime.log( ok( `canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`, ), ); } }); }), ); nodesCallOpts( a2ui .command("reset") .description("Reset A2UI renderer state") .requiredOption("--node ", "Node id, name, or IP") .option("--invoke-timeout ", "Node invoke timeout in ms") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("canvas a2ui reset", async () => { await invokeCanvas(opts, "canvas.a2ui.reset", undefined); if (!opts.json) { const { ok } = getNodesTheme(); defaultRuntime.log(ok("canvas a2ui reset ok")); } }); }), ); }