3. The command registry and the one write path

This section realises SRS §3.1 layer 3 and CMD-13, CMD-17 to CMD-23, CMD-28 to CMD-30.

Esta página está em inglês; a tradução está a caminho.

This section realises SRS §3.1 layer 3 and CMD-13, CMD-17 to CMD-23, CMD-28 to CMD-30.

3.1 The three layers in code#

text
 Figma MCP tool          ud get_metadata --nodeId 1:2       src/mcp/tools/*.ts     (layer 1)
        │                ud use_figma --code-file a.js
        ▼
 Plugin API              node.fills = [...]                 src/plugin-api          (layer 2)
        │                figma.createFrame()
        ▼
 Command registry        registry.run("fill.set", {...})    src/commands            (layer 3)
        │
        ▼
 document core           draft → events → commit group      src/core

A tool handler may read the core directly (get_metadata reads the tree). A tool that changes the document always goes through layer 3, either directly (upload_assets runs asset.add and fill.set) or through layer 2 (use_figma). Layer 2 never writes the core directly (CMD-13).

3.2 The command definition#

Every command is one object in one file under src/commands/<category>/. The registry is built by importing the category index files; there is no dynamic registration.

ts
// src/commands/types.ts
export interface CommandDef<P = unknown, R = unknown> {
	id: string;                       // "node.align" — stable, versioned API (CMD-23)
	title: string;                    // "Align Left" — palette, tooltips, quick actions
	category: CommandCategory;        // "node" | "text" | "layout" | "component" | "variable" | "export" | "view" | …
	summary: string;                  // one line (ud commands, palette description)
	help: string;                     // long text with at least one example (ud help, docs site)
	params: JsonSchema;               // JSON Schema 2020-12 subset (§3.3)
	result?: JsonSchema;
	flags: {
		mutates: boolean;             // produces events
		needsSelection?: boolean;     // uses ctx.targets; headless requires a target (CMD-25)
		uiOnly?: boolean;             // pointer feedback only; must produce no events (CMD-20)
		hostOnly?: boolean;           // needs host services (raster, file system, clipboard): never predicted in the webview
	};
	licence?: "read" | "free" | "paid"; // default: mutates ? "paid" : "read" (§3.4 step 2)
	targets?: TargetSpec;             // which params name nodes, and whether the selection is a fallback
	pluginApi: string[] | "registry-only"; // Plugin API members that map here (CMD-19)
	since: string;                    // "0.1"
	deprecated?: { since: string; replacedBy?: string };
	aliases?: string[];               // old ids kept for one minor version
	prepare?(ctx: PrepareContext, params: P): Promise<void>; // load fonts, decode images, read files
	run(ctx: CommandContext, params: P): R;                  // synchronous, deterministic
}

export interface CommandContext {
	readonly draft: Draft;            // copy-on-write view of the document (§4.4)
	readonly targets: readonly NodeId[];
	readonly ids: IdSource;           // deterministic from the request seed (§3.6)
	readonly layout: LayoutReader;    // derived geometry of the draft, relaid out on demand
	readonly text: TextEngine;
	readonly services: HostServices;  // raster, assets, clipboard; absent members throw "host only"
	readonly actor: string;
	run<Q, S>(id: string, params: Q): S;  // nested command, same transaction
	warn(message: string): void;
	limit(kind: "nodes"): void;       // counts toward transaction limits (CMD-11)
}

run is synchronous on purpose: Plugin API setters are synchronous in Figma (node.x = 10 takes effect before the next statement), so the command behind them must be too. Anything asynchronous (loading a font face, decoding an image to learn its size, reading an SVG file) happens in prepare, which the pipeline awaits before run. In use_figma, prepare is the work that figma.loadFontAsync and figma.createImageAsync do, which is why Figma requires those calls first (CMD-15).

A command reads and writes only through ctx. It never touches the file system, the clock or Math.random. This is what makes it safe to run the same command on the host, in the CLI and, as a prediction, in the webview.

Example:

ts
// src/commands/layout/setAutoLayout.ts
export default defineCommand({
	id: "layout.setAutoLayout",
	title: "Add Auto Layout",
	category: "layout",
	summary: "Turn frames into auto layout frames, or change their auto layout settings.",
	help: `…\nExample:\n  ud run layout.setAutoLayout --node "Home/Card" --direction vertical --gap 16 --padding 24`,
	params: {
		type: "object",
		properties: {
			node: NODE_REF, query: QUERY,
			direction: { enum: ["horizontal", "vertical", "grid", "none"] },
			wrap: { type: "boolean" },
			gap: { oneOf: [{ type: "number" }, { const: "auto" }, VARIABLE_REF] },
			padding: PADDING,
		},
		additionalProperties: false,
	},
	flags: { mutates: true, needsSelection: true },
	targets: { from: ["node", "query"], selection: true, types: ["FRAME", "COMPONENT", "INSTANCE"] },
	pluginApi: ["FrameNode.layoutMode", "FrameNode.layoutWrap", "FrameNode.itemSpacing", "FrameNode.padding*"],
	since: "0.3",
	run(ctx, p) {
		for (const id of ctx.targets) {
			ctx.draft.set(id, autoLayoutProps(ctx.draft.get(id), p));
		}
	},
});

3.3 The run pipeline#

registry.run(id, params, options) and registry.transaction(fn, options) share one pipeline:

# Step What happens Failure
1 Resolve Look up id or an alias; a deprecated id warns. not-found (exit 5)
2 Validate Check params against the schema with src/commands/schema.ts: a JSON Schema 2020-12 subset (type, enum, const, properties, required, additionalProperties, items, oneOf, anyOf, minimum/maximum, pattern, $ref to shared definitions) compiled to closures, no eval. Defaults are filled in. invalid-params (exit 2 on the CLI, with the JSON pointer and a hint)
3 Licence gate read always passes (LIC-5). free passes on every plan (comments, exports, version labels). paid needs the licence to allow editing (trial or paid, LIC-2). The gate asks a LicenceView injected by the environment: the host’s licensing client, the CLI’s ticket (§10.6), or “allow” in the dev server and tests. licence (exit 4, message says how to unlock)
4 Resolve targets Node references in params (nd-…, nd:…, Home/Header/Logo, ud://…?node-id=) and --query selectors become node ids (CMD-3, CMD-24). With none given, a live editor’s selection is used; headless, a target is required (CMD-25). Type restrictions in targets.types are checked. not-found (exit 5) or no-target (exit 2)
5 Prepare await def.prepare(ctx, params): fonts, images, files. font-missing, asset (exit 1)
6 Apply on a draft def.run(ctx, params) mutates ctx.draft. Each mutation records a forward event and, from the value it replaces, an inverse event. thrown CommandError (exit 1)
7 Check Relayout the dirty part of the draft (§5.7); check invariants: no cycles, a component is not inside its own instance, variable aliases are acyclic (VAR-3), limits (CMD-11). invariant (exit 1)
8 Produce events Coalesce the draft’s journal: several sets of one property become one, a node created and deleted inside the transaction disappears, property sets on a node created in the transaction fold into its create event. Order is stable. —
9 Commit Through the controller’s queue (or the headless document): --if-version check (CMD-30) against the document’s state hash (§4.1), assign seq, at, by; append to the WAL; fold into the document (§4.3); update indexes, derived caches and the state hash; push one undo group with its label; notify webviews, bridge watchers and ud watch (CMD-37); mark the VS Code document dirty. conflict (exit 3)

--dry-run stops after step 8 and returns the events. Non-mutating commands return after step 6 with their result and no commit. A failure at any step discards the draft: nothing is applied (CMD-12, CMD-28).

3.4 Transactions#

A transaction is one draft, one commit and one undo group, however many commands run inside it.

ts
const result = await registry.transaction(async (tx) => {
	const card = tx.run("node.create", { type: "FRAME", parent: pageId, name: "Card" });
	tx.run("layout.setAutoLayout", { node: card.id, direction: "vertical", gap: 16 });
	return card.id;
}, { label: "Create card", actor: "Claude (opus)", source: "ud apply", ifVersion: "sha256:3f9a…c1e0" });
Caller What one transaction is
A panel field, a menu item, a shortcut one command
A canvas gesture (drag, resize, rotate) one command at pointer-up; intermediate frames are local previews, not commands (§11.3)
Paste, duplicate, “Wrap in auto layout” one command that runs nested commands
ud apply the whole list; $1, $2 and ref names resolve to ids created earlier in the list (CMD-28)
use_figma the whole script (CMD-12); description becomes the label
An AI turn all changes of the turn on a proposal; Accept commits them as one group (AI-5, §10.7)
Library update acceptance all accepted components and styles (VAR-8)

Nested ctx.run calls do not commit. Only the outermost transaction commits.

3.5 Who calls the registry#

Surface Path to registry.run
Webview (toolbar, menus, panels, gestures, palette) AppModel.runCommand(id, params) → postMessage({type: "run", requestId, id, params, seed}) → DesignController.run. The webview may predict the result on its replica (§11.4). No other webview code changes the document (CMD-18; checked by npm run check:webview).
VS Code commands and keybindings Each non-ui-only command is contributed as ud.<id> (for example ud.node.align) with its title; args in keybindings.json become params (CMD-22). The handler calls the active editor’s controller.
ud run <id> / ud <id> Bridge: {type: "run", …} to the host’s controller. Headless: HeadlessDocument.run. Same pipeline, same events.
ud apply registry.transaction over the list.
use_figma Plugin API members call ctx.run inside the script’s transaction (§8). ud.run(id, params) inside a script calls it directly.
MCP tools Each tool handler in src/mcp/tools/ calls the registry for writes; ud_run and ud_apply are the registry exposed as tools.
LM tools The same tool handlers, invoked by vscode.lm (AI-12).
AI runner Claude Code calls ud mcp, which calls the bridge, which calls the controller on a proposal (§10.7).

3.6 Ids and determinism#

New ids are nd- (or pg-, st-, vc-, vr-, md-, cn-, vs-) plus 12 random lowercase hex digits (FMT-001). Inside a command they come from ctx.ids, which derives them from a 128-bit seed carried by the request: id_n = prefix + "-" + hex12(sha256(seed ‖ n)). The caller makes the seed (the webview, the CLI) with crypto.getRandomValues. Consequences:

  • the webview’s prediction and the host’s commit create the same ids, so the selection and the layers panel do not jump when the confirmation arrives;
  • --dry-run followed by the real run with the same --seed produces the same events;
  • tests are reproducible.

Timestamps (at) and seq are assigned at commit, never by a command. seq is a branch-local counter (max(seq in file) + 1, FMT-001 §5); it orders events within one file and is never rewritten once on disk, but it does not identify a state, because two git branches assign the same numbers to different events. Anything that needs to name a state (--if-version, named versions, library.acceptedHash) uses the state hash (§4.1).

3.7 Conformance tests#

test/conformance/registry.test.ts enumerates the registry and fails the build when:

Check Requirement
a command has no summary, no help, or no example in its help CMD-17, CMD-21
its params schema does not compile, or the example does not validate CMD-17
it has no headless CLI case in test/commands/cases/<id>.json (params, fixture, expected events or result) CMD-19
pluginApi is empty and not "registry-only", or names a member absent from the Plugin API member map CMD-19
a uiOnly command produces events on any fixture CMD-20
an id or parameter present in test/fixtures/registry-snapshot.json disappeared without deprecated and an alias CMD-23
a non-ui-only command is missing from package.json contributes.commands (generated, §15.3) CMD-22

The CLI cases run each command through ud run in-process against a temporary copy of the fixture and compare the events, so the CLI and the registry are tested together.