Files
openclaw/extensions/workspaces/src/cli.ts
Peter Steinberger 2237196871 refactor(extensions): remove dead QA and utility exports (#105914)
* refactor(extensions): remove dead QA and utility exports

* refactor(extensions): trim Matrix QA internal exports

* chore(deadcode): refresh extension export baseline

* chore(ci): reconcile deadcode and LOC baselines

* chore(deadcode): refresh baseline after main advance

* chore(deadcode): refresh baseline after main advance

* refactor(plugins): keep channel snapshot type private

* fix(ci): align Linux deadcode baseline
2026-07-13 00:34:46 -07:00

494 lines
17 KiB
TypeScript

import fs from "node:fs/promises";
import type { Command } from "commander";
import { addGatewayClientOptions, callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
import {
validateWorkspaceDoc,
type WorkspaceBinding,
type WorkspaceGrid,
type WorkspaceTab,
type WorkspaceWidget,
type JsonValue,
type WorkspaceDoc,
} from "./schema.js";
type JsonOptions = {
json?: boolean;
};
type GatewayOptions = JsonOptions & {
url?: string;
token?: string;
timeout?: string;
expectFinal?: boolean;
};
type RegisterWorkspaceCliOptions = {
program: Command;
stateDir?: string;
};
function writeJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}
function writeLine(value: string): void {
process.stdout.write(`${value}\n`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseJson(value: string, label: string): JsonValue {
try {
return JSON.parse(value) as JsonValue;
} catch (error) {
throw new Error(`invalid ${label} JSON: ${(error as Error).message}`, { cause: error });
}
}
function parseOptionalBoolean(value: string): boolean {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "off"].includes(normalized)) {
return false;
}
throw new Error(`invalid boolean: ${value}`);
}
function parseWorkspaceGrid(value: string): WorkspaceGrid {
const parts = value.split(",").map((entry) => Number(entry.trim()));
if (parts.length !== 4 || parts.some((entry) => !Number.isInteger(entry))) {
throw new Error("grid must be x,y,w,h");
}
const [x, y, w, h] = parts as [number, number, number, number];
return { x, y, w, h };
}
export function parseWorkspaceBindingShorthand(value: string): [string, WorkspaceBinding] {
const eqIndex = value.indexOf("=");
if (eqIndex <= 0) {
throw new Error("binding must be id=file:<path>, id=rpc:<method>, or id=static:<json>");
}
const id = value.slice(0, eqIndex).trim();
const body = value.slice(eqIndex + 1).trim();
if (!/^[A-Za-z0-9._-]{1,64}$/.test(id)) {
throw new Error("binding id is invalid");
}
if (body.startsWith("file:")) {
const fileSpec = body.slice("file:".length);
const hashIndex = fileSpec.indexOf("#");
const bindingPath = hashIndex >= 0 ? fileSpec.slice(0, hashIndex) : fileSpec;
const pointer = hashIndex >= 0 ? fileSpec.slice(hashIndex + 1) : undefined;
if (!bindingPath) {
throw new Error("file binding path is required");
}
return [
id,
{
source: "file",
path: bindingPath,
...(pointer !== undefined ? { pointer } : {}),
},
];
}
if (body.startsWith("rpc:")) {
const method = body.slice("rpc:".length).trim();
if (!method) {
throw new Error("rpc binding method is required");
}
return [id, { source: "rpc", method }];
}
if (body.startsWith("static:")) {
return [id, { source: "static", value: parseJson(body.slice("static:".length), "static") }];
}
throw new Error("binding source must be file, rpc, or static");
}
function collectBinding(value: string, previous: string[] = []): string[] {
return [...previous, value];
}
function parseBindings(values: string[] | undefined): Record<string, WorkspaceBinding> | undefined {
if (!values?.length) {
return undefined;
}
return Object.fromEntries(values.map(parseWorkspaceBindingShorthand));
}
async function callWorkspaceGateway(
method: string,
options: GatewayOptions,
params?: unknown,
): Promise<unknown> {
// `workspaces.widget.approve` is the only approvals-scoped method here: approving
// agent-authored widget code is a separate decision from editing a layout, so the
// connection asks for that scope only when it is about to make it.
// Match `src/gateway/operator-approvals-client.ts`: ask for the approvals scope
// alone. A paired device approves scope sets, so requesting a superset here is
// rejected as "asking for more scopes than currently approved".
const scopes =
method === "workspaces.widget.approve"
? (["operator.approvals"] as const)
: (["operator.write", "operator.read"] as const);
return await callGatewayFromCli(method, options, params, { mode: "cli", scopes: [...scopes] });
}
function readWorkspaceResult(value: unknown): { doc: WorkspaceDoc; workspaceVersion: number } {
if (!isRecord(value)) {
throw new Error("workspace gateway response must be an object");
}
const doc = validateWorkspaceDoc(value.doc);
return {
doc,
workspaceVersion:
typeof value.workspaceVersion === "number" ? value.workspaceVersion : doc.workspaceVersion,
};
}
async function readWorkspace(options: GatewayOptions): Promise<WorkspaceDoc> {
return readWorkspaceResult(await callWorkspaceGateway("workspaces.get", options)).doc;
}
function orderedTabs(doc: WorkspaceDoc): WorkspaceTab[] {
const bySlug = new Map(doc.tabs.map((tab) => [tab.slug, tab]));
const ordered = doc.prefs.tabOrder.flatMap((slug) => {
const tab = bySlug.get(slug);
return tab ? [tab] : [];
});
const seen = new Set(ordered.map((tab) => tab.slug));
return [...ordered, ...doc.tabs.filter((tab) => !seen.has(tab.slug))];
}
function formatTabLine(tab: WorkspaceTab): string {
const hidden = tab.hidden ? "hidden" : "visible";
return `${tab.slug.padEnd(18)} ${hidden.padEnd(8)} ${tab.title}`;
}
function formatWidgetLine(tab: string, widget: WorkspaceWidget): string {
const grid = `${widget.grid.x},${widget.grid.y},${widget.grid.w},${widget.grid.h}`;
const state = [widget.hidden ? "hidden" : "visible", widget.collapsed ? "collapsed" : ""]
.filter(Boolean)
.join(",");
return `${tab.padEnd(14)} ${widget.id.padEnd(18)} ${widget.kind.padEnd(20)} ${grid.padEnd(9)} ${state.padEnd(10)} ${widget.title ?? ""}`;
}
function writeTabs(doc: WorkspaceDoc, options: JsonOptions): void {
const tabs = orderedTabs(doc);
if (options.json) {
writeJson({ tabs });
return;
}
for (const tab of tabs) {
writeLine(formatTabLine(tab));
}
}
function widgetRows(
doc: WorkspaceDoc,
tabSlug?: string,
): Array<{ tab: string; widget: WorkspaceWidget }> {
const tabs = tabSlug ? doc.tabs.filter((tab) => tab.slug === tabSlug) : orderedTabs(doc);
if (tabSlug && tabs.length === 0) {
throw new Error(`workspace tab not found: ${tabSlug}`);
}
return tabs.flatMap((tab) => tab.widgets.map((widget) => ({ tab: tab.slug, widget })));
}
function writeWidgets(doc: WorkspaceDoc, options: JsonOptions & { tab?: string }): void {
const widgets = widgetRows(doc, options.tab);
if (options.json) {
writeJson({ widgets: widgets.map(({ tab, widget }) => ({ tab, ...widget })) });
return;
}
for (const { tab, widget } of widgets) {
writeLine(formatWidgetLine(tab, widget));
}
}
function requirePatch(patch: Record<string, unknown>): void {
if (Object.keys(patch).length === 0) {
throw new Error("at least one patch option is required");
}
}
function addGatewayOptions(command: Command): Command {
return addGatewayClientOptions(command);
}
export function registerWorkspaceCli(options: RegisterWorkspaceCliOptions): void {
const workspace = options.program
.command("workspaces")
.description("Manage Workspaces tabs and widgets");
const tabs = workspace.command("tabs").description("Manage workspace tabs");
const widgets = workspace.command("widgets").description("Manage workspace widgets");
const layout = workspace.command("layout").description("Manage workspace layout documents");
addGatewayOptions(
tabs.command("list").description("List workspace tabs").option("--json", "Print JSON", false),
).action(async (commandOptions: GatewayOptions) => {
writeTabs(await readWorkspace(commandOptions), commandOptions);
});
addGatewayOptions(
tabs
.command("create")
.description("Create a workspace tab")
.requiredOption("--title <title>", "Tab title")
.option("--slug <slug>", "Tab slug")
.option("--icon <icon>", "Icon name"),
).action(
async (commandOptions: GatewayOptions & { title: string; slug?: string; icon?: string }) => {
const result = await callWorkspaceGateway("workspaces.tab.create", commandOptions, {
title: commandOptions.title,
...(commandOptions.slug ? { slug: commandOptions.slug } : {}),
...(commandOptions.icon ? { icon: commandOptions.icon } : {}),
});
writeTabs(readWorkspaceResult(result).doc, commandOptions);
},
);
addGatewayOptions(
tabs.command("delete").argument("<slug>", "Tab slug").description("Delete a workspace tab"),
).action(async (slug: string, commandOptions: GatewayOptions) => {
const result = await callWorkspaceGateway("workspaces.tab.delete", commandOptions, {
slug,
});
writeTabs(readWorkspaceResult(result).doc, commandOptions);
});
addGatewayOptions(
tabs
.command("reorder")
.argument("<slug...>", "Tab slugs")
.description("Set workspace tab order"),
).action(async (order: string[], commandOptions: GatewayOptions) => {
const result = await callWorkspaceGateway("workspaces.tab.reorder", commandOptions, {
order,
});
writeTabs(readWorkspaceResult(result).doc, commandOptions);
});
for (const [verb, hidden] of [
["hide", true],
["show", false],
] as const) {
addGatewayOptions(
tabs.command(verb).argument("<slug>", "Tab slug").description(`${verb} a workspace tab`),
).action(async (slug: string, commandOptions: GatewayOptions) => {
const result = await callWorkspaceGateway("workspaces.tab.update", commandOptions, {
slug,
patch: { hidden },
});
writeTabs(readWorkspaceResult(result).doc, commandOptions);
});
}
addGatewayOptions(
widgets
.command("list")
.description("List workspace widgets")
.option("--tab <slug>", "Tab slug")
.option("--json", "Print JSON", false),
).action(async (commandOptions: GatewayOptions & { tab?: string }) => {
writeWidgets(await readWorkspace(commandOptions), commandOptions);
});
addGatewayOptions(
widgets
.command("add")
.description("Add a workspace widget")
.requiredOption("--tab <slug>", "Tab slug")
.requiredOption("--kind <kind>", "Widget kind")
.option("--id <id>", "Widget id")
.option("--title <title>", "Widget title")
.option("--grid <x,y,w,h>", "Widget grid", "0,0,4,2")
.option("--binding <id=source>", "Binding shorthand", collectBinding, [])
.option("--props <json>", "Widget props JSON"),
).action(
async (
commandOptions: GatewayOptions & {
tab: string;
id?: string;
kind: string;
title?: string;
grid?: string;
binding?: string[];
props?: string;
},
) => {
const bindings = parseBindings(commandOptions.binding);
const result = await callWorkspaceGateway("workspaces.widget.add", commandOptions, {
tab: commandOptions.tab,
widget: {
...(commandOptions.id ? { id: commandOptions.id } : {}),
kind: commandOptions.kind,
...(commandOptions.title ? { title: commandOptions.title } : {}),
grid: parseWorkspaceGrid(commandOptions.grid ?? "0,0,4,2"),
...(bindings ? { bindings } : {}),
...(commandOptions.props ? { props: parseJson(commandOptions.props, "props") } : {}),
},
});
writeWidgets(readWorkspaceResult(result).doc, { ...commandOptions, tab: commandOptions.tab });
},
);
addGatewayOptions(
widgets
.command("update")
.description("Update a workspace widget")
.requiredOption("--tab <slug>", "Tab slug")
.requiredOption("--id <id>", "Widget id")
.option("--title <title>", "Widget title")
.option("--collapsed <bool>", "Collapsed state", parseOptionalBoolean)
.option("--hidden <bool>", "Hidden state", parseOptionalBoolean),
).action(
async (
commandOptions: GatewayOptions & {
tab: string;
id: string;
title?: string;
collapsed?: boolean;
hidden?: boolean;
},
) => {
const patch = {
...(commandOptions.title !== undefined ? { title: commandOptions.title } : {}),
...(commandOptions.collapsed !== undefined ? { collapsed: commandOptions.collapsed } : {}),
...(commandOptions.hidden !== undefined ? { hidden: commandOptions.hidden } : {}),
};
requirePatch(patch);
const result = await callWorkspaceGateway("workspaces.widget.update", commandOptions, {
tab: commandOptions.tab,
id: commandOptions.id,
patch,
});
writeWidgets(readWorkspaceResult(result).doc, { ...commandOptions, tab: commandOptions.tab });
},
);
addGatewayOptions(
widgets
.command("move")
.description("Move a workspace widget")
.option("--tab <slug>", "Current tab slug")
.requiredOption("--id <id>", "Widget id")
.option("--grid <x,y,w,h>", "New grid")
.option("--to-tab <slug>", "Destination tab slug"),
).action(
async (
commandOptions: GatewayOptions & { tab?: string; id: string; grid?: string; toTab?: string },
) => {
const result = await callWorkspaceGateway("workspaces.widget.move", commandOptions, {
...(commandOptions.tab ? { tab: commandOptions.tab } : {}),
id: commandOptions.id,
...(commandOptions.grid ? { grid: parseWorkspaceGrid(commandOptions.grid) } : {}),
...(commandOptions.toTab ? { toTab: commandOptions.toTab } : {}),
});
writeWidgets(readWorkspaceResult(result).doc, { ...commandOptions, tab: commandOptions.tab });
},
);
addGatewayOptions(
widgets
.command("remove")
.description("Remove a workspace widget")
.requiredOption("--tab <slug>", "Tab slug")
.requiredOption("--id <id>", "Widget id"),
).action(async (commandOptions: GatewayOptions & { tab: string; id: string }) => {
const result = await callWorkspaceGateway("workspaces.widget.remove", commandOptions, {
tab: commandOptions.tab,
id: commandOptions.id,
});
writeWidgets(readWorkspaceResult(result).doc, { ...commandOptions, tab: commandOptions.tab });
});
addGatewayOptions(
layout
.command("get")
.description("Read the Workspaces layout")
.option("--json", "Print JSON", false),
).action(async (commandOptions: GatewayOptions) => {
const doc = await readWorkspace(commandOptions);
if (commandOptions.json) {
writeJson({ doc, workspaceVersion: doc.workspaceVersion });
} else {
writeLine(`workspaceVersion ${doc.workspaceVersion}`);
writeTabs(doc, commandOptions);
}
});
addGatewayOptions(
layout
.command("set")
.description("Replace the Workspaces layout")
.requiredOption("--file <path>", "Workspace JSON file"),
).action(async (commandOptions: GatewayOptions & { file: string }) => {
const doc = validateWorkspaceDoc(JSON.parse(await fs.readFile(commandOptions.file, "utf8")));
const result = await callWorkspaceGateway("workspaces.replace", commandOptions, {
doc,
});
const next = readWorkspaceResult(result);
if (commandOptions.json) {
writeJson(next);
} else {
writeLine(`workspaceVersion ${next.workspaceVersion}`);
}
});
addGatewayOptions(
layout.command("undo").description("Restore the newest workspace undo snapshot"),
).action(async (commandOptions: GatewayOptions) => {
const result = await callWorkspaceGateway("workspaces.undo", commandOptions, {});
const next = readWorkspaceResult(result);
if (commandOptions.json) {
writeJson(next);
} else {
writeLine(`workspaceVersion ${next.workspaceVersion}`);
}
});
addGatewayOptions(
workspace
.command("widget-scaffold")
.argument("<name>", "Custom widget name")
.description("Create a custom widget scaffold (starts pending approval)")
.option("--title <title>", "Widget title"),
).action(async (name: string, commandOptions: GatewayOptions & { title?: string }) => {
const result = await callWorkspaceGateway("workspaces.widget.scaffold", commandOptions, {
name,
...(commandOptions.title !== undefined ? { title: commandOptions.title } : {}),
});
if (commandOptions.json) {
writeJson(result);
return;
}
const dir = isRecord(result) && typeof result.dir === "string" ? result.dir : name;
writeLine(`created ${dir}`);
writeLine(`pending approval; run: openclaw workspaces widget-approve ${name}`);
});
addGatewayOptions(
workspace
.command("widget-approve")
.argument("<name>", "Custom widget name")
.description("Approve or reject a pending custom widget")
.option("--reject", "Reject instead of approving"),
).action(async (name: string, commandOptions: GatewayOptions & { reject?: boolean }) => {
const decision = commandOptions.reject ? "rejected" : "approved";
// Approve responds with the registry entry only: the approvals scope is not a
// door onto the workspace document.
const result = await callWorkspaceGateway("workspaces.widget.approve", commandOptions, {
name,
decision,
});
if (commandOptions.json) {
writeJson(result);
} else {
writeLine(`${name} ${decision}`);
}
});
}