@todoforai/figma-api 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -6,9 +6,9 @@ Read this before using `figma-api`. It tells you exactly what to set up.
6
6
 
7
7
  - Need to **read** designs, or **write** comments / dev-resources / webhooks?
8
8
  → REST is enough. Just set a token (step 1). Works headless / in CI.
9
- - Need to **create canvas nodes** (frames, text, shapes) or **edit variables**
10
- off-Enterprise? → REST can't. Use the **plugin bridge** (step 2). Requires the
11
- Figma desktop app open.
9
+ - Need to **create canvas nodes** (frames, text, shapes)? REST can't. Use the
10
+ **plugin bridge** (step 2). Requires a plugin connected in the Figma **desktop**
11
+ app (browser can't reach the localhost relay — see gotchas).
12
12
 
13
13
  ## 1. Auth (always required)
14
14
 
@@ -28,54 +28,80 @@ FIGMA_TOKEN=<token> figma-api me
28
28
  - File commands accept a raw file key **or** a Figma URL; `node-id` is parsed from
29
29
  the URL automatically.
30
30
 
31
- ## 2. Plugin bridge (only for canvas writes / variables off-Enterprise)
31
+ ## 2. Plugin bridge (only for canvas writes)
32
32
 
33
33
  The Figma REST API has **no endpoint** to create canvas nodes. The Figma Plugin
34
- API does. The bridge lets the CLI drive a companion plugin:
34
+ API does. The bridge is a **WebSocket relay** that drives a companion plugin. It
35
+ speaks the **cursor-talk-to-figma** protocol, so it's interchangeable with that
36
+ ecosystem (their MCP server can drive our plugin and vice versa).
35
37
 
36
38
  ```
37
- figma-api run '<code>' ──POST──▶ relay ◀──poll── Figma plugin → runs on canvas
39
+ figma-api create-frame ──ws──▶ relay ◀──ws── Figma plugin → runs on canvas
40
+ (join channel, broadcast)
38
41
  ```
39
42
 
40
43
  Setup (do all three, in order):
41
44
 
42
45
  1. **Start the relay** (keep it running, background it):
43
46
  ```bash
44
- figma-api bridge # listens on http://localhost:8917
47
+ figma-api bridge # WebSocket relay on ws://localhost:3055
45
48
  ```
46
- 2. **Load the plugin** — this is a human step, you cannot automate it:
47
- In the **Figma desktop app** (browser can't import dev plugins):
48
- Plugins Development → Import plugin from manifest → pick this package's
49
- `plugin/manifest.json`. Run it, paste the relay URL, click **Connect**.
50
- 3. **Drive it**:
49
+ 2. **Connect a plugin** — a human step, you cannot automate it. Pick ONE:
50
+ - **Easy (recommended):** install **"Cursor Talk To Figma MCP"** from the
51
+ Figma Community (figma.com/community/plugin/1485687494525374295), run it in
52
+ the **desktop** app, set port `3055`, click **Connect**. It auto-picks a
53
+ random channel — that's fine: the relay bridges channels, so you do **not**
54
+ copy it or pass `--channel`.
55
+ - **Advanced:** in the Figma **desktop** app, Plugins → Development → Import
56
+ plugin from manifest → this package's `plugin/manifest.json`. Run it, set URL
57
+ `ws://localhost:3055`, channel `figma-api`, click **Connect**.
58
+ 3. **Drive it** with named commands:
51
59
  ```bash
52
60
  figma-api ping # round-trip check; prints page + selection
53
- figma-api run 'const t=figma.createText(); await figma.loadFontAsync({family:"Inter",style:"Regular"}); t.characters="Hi"; figma.currentPage.appendChild(t); return t'
54
- figma-api run @script.js # or load code from a file
61
+ figma-api create-frame --width 320 --height 200 --fill "#1F6FEB" --name Card
62
+ figma-api create-text "Hi" --size 24 --parent 10:5
63
+ figma-api set-fill-color 10:5 "#FF0044"
64
+ figma-api get-selection
55
65
  ```
56
66
 
57
- `run` executes arbitrary Plugin API code (`figma` in scope, `await` + `return`
58
- supported; returned nodes come back as `{id,type,name}`). It subsumes any
59
- draw/create helper — prefer it over inventing new commands.
67
+ Every canvas command takes `--relay <ws-url>` / `--channel <name>` (defaults
68
+ `ws://localhost:3055` / `figma-api`; or `FIGMA_RELAY` / `FIGMA_CHANNEL` env).
69
+
70
+ **Escape hatch:** for anything the named commands don't cover, `run` executes
71
+ arbitrary Plugin API code (`figma` in scope, `await` + `return` supported;
72
+ returned nodes come back as `{id,type,name}`):
73
+ ```bash
74
+ figma-api run 'return figma.currentPage.selection.map(n => n.name)'
75
+ figma-api run @script.js
76
+ ```
77
+ Prefer the named `create-*`/`set-*`/`get-*` commands; reach for `run` only for
78
+ gaps they don't fill.
60
79
 
61
80
  Cross-machine (CLI and Figma on different hosts): expose the relay publicly with
62
- `cloudflared tunnel --url http://localhost:8917` and paste that https URL into
81
+ `cloudflared tunnel --url http://localhost:3055` and paste that `wss://` URL into
63
82
  the plugin instead of localhost.
64
83
 
65
84
  ## Gotchas an agent will hit
66
85
 
67
86
  - **REST `variables-modify` → 403** on non-Enterprise tokens. Don't retry; switch
68
- to `figma-api run` with `figma.variables.*`.
87
+ to `figma-api run` with `figma.variables.*` (off-Enterprise, via the bridge).
69
88
  - **"create a frame/text/shape via REST"** is impossible — there is no endpoint.
70
- Use the bridge.
71
- - **Browser-only Figma can't load the dev plugin.** Needs the desktop app, or a
72
- published plugin. If the user is browser-only and refuses desktop, canvas
73
- writes are not available say so plainly.
89
+ Use the bridge (`create-frame`/`create-text`/…).
90
+ - **Use the DESKTOP app for canvas writes.** Both plugins can be *opened* in
91
+ browser Figma, but a browser tab can't connect to `ws://localhost:3055`
92
+ (mixed-content: an https page blocks insecure `ws`), so it never reaches
93
+ "Connected" — this is the usual "plugin won't connect on Linux/browser" cause.
94
+ Desktop has no such block. Dev-manifest import is desktop-only regardless. If
95
+ desktop is truly impossible, expose the relay over TLS (`cloudflared tunnel
96
+ --url http://localhost:3055`) and paste the `wss://` URL into the plugin.
97
+ - **Channel names don't need to match.** The relay bridges across channels, so the
98
+ community plugin's random channel and the CLI's `figma-api` default interoperate
99
+ with no `--channel` flag.
74
100
  - **`figma-api run` / the relay have no auth** and execute arbitrary code in the
75
101
  user's document. Only run on a trusted machine; don't expose the tunnel URL
76
102
  publicly; don't run untrusted code.
77
103
  - The bridge handles **one command at a time, one connected plugin**. Don't fan
78
- out parallel `run` calls.
104
+ out parallel canvas calls.
79
105
  - The CLI exits non-zero on plugin error/timeout — check exit codes.
80
106
 
81
107
  ## Quick reference
@@ -83,4 +109,7 @@ the plugin instead of localhost.
83
109
  `figma-api --help` lists every command; `figma-api <cmd> --help` has params,
84
110
  scopes and examples. Read = me/file/nodes/images/comments/components/styles/
85
111
  variables-*/dev-resources/webhooks/… Write (REST) = comment-add/reaction-add/
86
- variables-modify/dev-resource-*/webhook-*. Canvas write = bridge + run.
112
+ variables-modify/dev-resource-*/webhook-*. Canvas (bridge) = create-frame/
113
+ create-rectangle/create-text/set-fill-color/set-text/move-node/resize-node/
114
+ clone-node/delete-node/get-selection/get-node-info/export-image/focus/select/…
115
+ + `run` escape hatch.
package/README.md CHANGED
@@ -53,40 +53,61 @@ Run `figma-api <command> --help` for parameters, scopes and examples.
53
53
 
54
54
  ## Plugin bridge — canvas writes
55
55
 
56
- The REST API **cannot create canvas nodes** (frames/text/shapes) or edit variables
57
- off-Enterprise. The Figma **Plugin API** can. The bridge lets the CLI drive a small
58
- companion plugin:
56
+ The REST API **cannot create canvas nodes** (frames/text/shapes). The Figma
57
+ **Plugin API** can. The bridge lets the CLI drive a small companion plugin over a
58
+ WebSocket relay. It speaks the **[cursor-talk-to-figma](https://github.com/grab/cursor-talk-to-figma-mcp)
59
+ protocol**, so the pieces are interchangeable: that ecosystem's MCP server can
60
+ drive our plugin, and our CLI can drive theirs.
59
61
 
60
62
  ```
61
- figma-api run '...' ──POST──▶ relay (figma-api bridge) ◀──poll── Figma plugin
62
- ──result─▶ (runs on canvas)
63
+ figma-api create-frame ──ws──▶ relay (figma-api bridge) ◀──ws── Figma plugin
64
+ (join channel, broadcast) ──result─▶ (runs on canvas)
63
65
  ```
64
66
 
65
67
  Setup:
66
68
 
67
- 1. `figma-api bridge` — start the relay (keep it running).
68
- 2. Figma **desktop** Plugins Development **Import plugin from manifest**
69
- `plugin/manifest.json`. Run it, paste the relay URL, click **Connect**.
70
- 3. Drive it from the CLI:
69
+ 1. `figma-api bridge` — start the WebSocket relay (keep it running).
70
+ 2. Connect a plugin in the Figma **desktop** app pick one:
71
+ - **Easy (recommended):** install [**Cursor Talk To Figma MCP**](https://www.figma.com/community/plugin/1485687494525374295)
72
+ from the Figma Community, run it, set port `3055`, click **Connect**. It picks
73
+ a random channel — that's fine, the relay bridges channels so you don't copy
74
+ it or pass `--channel`.
75
+ - **Advanced:** Plugins → Development → **Import plugin from manifest** →
76
+ `plugin/manifest.json`. Run it, set URL `ws://localhost:3055`, channel
77
+ `figma-api`, click **Connect**.
78
+ 3. Drive it from the CLI with named commands:
71
79
 
72
80
  ```bash
73
81
  figma-api ping
74
- figma-api run 'const t = figma.createText(); await figma.loadFontAsync({family:"Inter",style:"Regular"}); t.characters="Hi from the CLI"; figma.currentPage.appendChild(t); return t'
75
- figma-api run @make-card.js
82
+ figma-api create-frame --width 320 --height 200 --fill "#1F6FEB" --name Card
83
+ figma-api create-text "Hello" --size 24 --weight 700 --color "#111" --parent 10:5
84
+ figma-api set-fill-color 10:5 "#FF0044"
85
+ figma-api get-selection
76
86
  ```
77
87
 
78
- `run` executes arbitrary Figma Plugin API code (`figma` in scope, `await` and
79
- `return` supported), so it subsumes any draw/create helper.
88
+ All commands take `--relay <ws-url>` and `--channel <name>` (defaults
89
+ `ws://localhost:3055` / `figma-api`; override via `FIGMA_RELAY` / `FIGMA_CHANNEL`).
90
+
91
+ Escape hatch — for anything the named commands don't cover, `run` executes
92
+ arbitrary Plugin API code (`figma` in scope, `await`/`return` supported):
93
+
94
+ ```bash
95
+ figma-api run 'return figma.currentPage.selection.map(n => n.name)'
96
+ figma-api run @make-card.js
97
+ ```
80
98
 
81
- Cross-machine: `cloudflared tunnel --url http://localhost:8917` and paste that
82
- https URL into the plugin.
99
+ Cross-machine: `cloudflared tunnel --url http://localhost:3055` and paste the
100
+ `wss://` URL into the plugin.
83
101
 
84
102
  > ⚠️ **Security:** the relay has no auth and `run` executes arbitrary code in your
85
103
  > Figma document. Only use it on a trusted machine/network; don't expose the tunnel
86
104
  > URL publicly or run code you don't trust.
87
105
 
88
- > Note: development (unpublished) plugins can only be imported in the **Figma
89
- > desktop app**, not the browser. Once published, the browser works too.
106
+ > Note: use the **Figma desktop app**. A browser tab can't reach `ws://localhost:3055`
107
+ > (an https page blocks insecure `ws` as mixed content), so the plugin never reaches
108
+ > "Connected" — the usual "won't connect in the browser" cause. Dev-manifest import
109
+ > is desktop-only regardless. If desktop is impossible, expose the relay over TLS
110
+ > (`cloudflared tunnel --url http://localhost:3055`) and paste the `wss://` URL in.
90
111
 
91
112
  ## Why both REST and a plugin?
92
113
 
@@ -95,7 +116,6 @@ https URL into the plugin.
95
116
  | Files / nodes / images (read) | ✅ | ✅ |
96
117
  | Comments, dev resources, webhooks | ✅ | — |
97
118
  | Create frames / text / shapes | ❌ (no endpoint) | ✅ |
98
- | Edit variables off-Enterprise | ❌ (403) | ✅ |
99
119
  | Works headless / CI | ✅ | needs Figma open |
100
120
 
101
121
  ## Reference
@@ -103,8 +123,8 @@ https URL into the plugin.
103
123
  - [figma/rest-api-spec](https://github.com/figma/rest-api-spec) — authoritative endpoint list.
104
124
  - [GLips/Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP) — read design-context MCP.
105
125
  - [Official Figma MCP](https://help.figma.com/hc/en-us/articles/32132100833559) — `mcp.figma.com`.
106
- - The bridge mirrors the architecture of community plugin bridges (e.g.
107
- southleft/figma-console-mcp) but driven by a plain CLI instead of MCP.
126
+ - [grab/cursor-talk-to-figma-mcp](https://github.com/grab/cursor-talk-to-figma-mcp) —
127
+ the WebSocket bridge protocol this plugin implements (interchangeable clients).
108
128
 
109
129
  ## Dev
110
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@todoforai/figma-api",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Figma CLI — full REST API wrapper (files, comments, variables, webhooks…) plus a plugin bridge for canvas writes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,9 @@
20
20
  "rest",
21
21
  "design",
22
22
  "plugin",
23
- "mcp"
23
+ "mcp",
24
+ "cursor-talk-to-figma",
25
+ "websocket"
24
26
  ],
25
27
  "repository": {
26
28
  "type": "git",
package/plugin/code.js CHANGED
@@ -1,10 +1,14 @@
1
- // figma-api bridge plugin. The UI (ui.html) long-polls the relay, forwards each
2
- // command here, this runs it and returns the result. The REST API can't create
3
- // canvas nodes or edit variables off-Enterprise the Plugin API can, and the
4
- // figma-api CLI drives it through `figma-api run`.
1
+ // figma-api bridge plugin talks the cursor-talk-to-figma WebSocket protocol so
2
+ // it's a drop-in for that ecosystem (its MCP server can drive this plugin), while
3
+ // staying small. The UI (ui.html) joins a WS channel on the relay, forwards each
4
+ // {command,params} here, this runs it and returns the result.
5
+ //
6
+ // Two ways to drive it:
7
+ // • named commands (create_frame, set_fill_color, …) — cursor-talk-to-figma compatible
8
+ // • "eval" — run arbitrary Plugin API code (our extra; droppable, but handy)
5
9
 
6
- // Serialize a value for JSON transport: Figma nodes → {id,type,name}, recurse
7
- // plain objects/arrays, guard against cycles and unstringifiable values.
10
+ // ── serialization ────────────────────────────────────────────────────────────
11
+ // Figma nodes → {id,type,name}; recurse plain objects/arrays; guard cycles/bigints.
8
12
  function serialize(v, seen) {
9
13
  if (v == null || typeof v === "boolean" || typeof v === "number" || typeof v === "string") return v;
10
14
  if (typeof v === "bigint") return v.toString();
@@ -19,29 +23,278 @@ function serialize(v, seen) {
19
23
  return o;
20
24
  }
21
25
 
22
- async function exec(cmd) {
23
- if (cmd.op === "ping") {
24
- return { pong: true, page: figma.currentPage.name, selection: figma.currentPage.selection.map((n) => n.id) };
26
+ // ── small helpers ────────────────────────────────────────────────────────────
27
+ async function byId(id) {
28
+ if (!id) throw new Error("Missing nodeId parameter");
29
+ const n = await figma.getNodeByIdAsync(id);
30
+ if (!n) throw new Error(`Node not found with ID: ${id}`);
31
+ return n;
32
+ }
33
+ async function appendTo(node, parentId) {
34
+ if (parentId) {
35
+ const p = await byId(parentId);
36
+ if (!("appendChild" in p)) throw new Error(`Parent node does not support children: ${parentId}`);
37
+ p.appendChild(node);
38
+ } else figma.currentPage.appendChild(node);
39
+ }
40
+ const solid = (c) => ({ type: "SOLID", color: { r: +c.r || 0, g: +c.g || 0, b: +c.b || 0 }, opacity: c.a === undefined ? 1 : +c.a });
41
+ const box = (n) => ({ id: n.id, name: n.name, x: n.x, y: n.y, width: n.width, height: n.height, parentId: n.parent ? n.parent.id : undefined });
42
+ const fontStyle = (w) => ({ 100: "Thin", 200: "Extra Light", 300: "Light", 400: "Regular", 500: "Medium", 600: "Semi Bold", 700: "Bold", 800: "Extra Bold", 900: "Black" }[w] || "Regular");
43
+
44
+ function b64(bytes) {
45
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
46
+ let out = "";
47
+ for (let i = 0; i < bytes.length; i += 3) {
48
+ const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
49
+ const ng = i + 1 < bytes.length, nh = i + 2 < bytes.length;
50
+ out += chars[a >> 2] + chars[((a & 3) << 4) | (ng ? b >> 4 : 0)] +
51
+ (ng ? chars[((b & 15) << 2) | (nh ? c >> 6 : 0)] : "=") + (nh ? chars[c & 63] : "=");
25
52
  }
26
- if (cmd.op === "eval") {
27
- // Run arbitrary Plugin API code. `figma` is in scope; code may use await
28
- // and `return` a value. WARNING: this executes whatever the relay delivers.
53
+ return out;
54
+ }
55
+
56
+ // ── command handlers (cursor-talk-to-figma compatible) ───────────────────────
57
+ const handlers = {
58
+ async ping() {
59
+ return { pong: true, page: figma.currentPage.name, selection: figma.currentPage.selection.map((n) => n.id) };
60
+ },
61
+
62
+ // arbitrary Plugin API code — `figma` in scope, may await and return a value.
63
+ // WARNING: executes whatever the relay delivers. Run only on trusted machines.
64
+ async eval(p) {
29
65
  const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
30
- const out = await new AsyncFunction("figma", cmd.code || "")(figma);
31
- return { value: serialize(out) };
32
- }
33
- return { error: "unknown op: " + cmd.op };
66
+ return { value: serialize(await new AsyncFunction("figma", p.code || "")(figma)) };
67
+ },
68
+
69
+ async get_document_info() {
70
+ await figma.currentPage.loadAsync();
71
+ const pg = figma.currentPage;
72
+ return { id: pg.id, name: pg.name, type: pg.type, childCount: pg.children.length,
73
+ children: pg.children.map((n) => ({ id: n.id, name: n.name, type: n.type })) };
74
+ },
75
+ async get_selection() {
76
+ return { selectionCount: figma.currentPage.selection.length,
77
+ selection: figma.currentPage.selection.map((n) => ({ id: n.id, name: n.name, type: n.type, visible: n.visible })) };
78
+ },
79
+ async get_node_info(p) {
80
+ const n = await byId(p.nodeId);
81
+ return serialize((await n.exportAsync({ format: "JSON_REST_V1" })).document);
82
+ },
83
+ async get_nodes_info(p) {
84
+ const nodes = await Promise.all((p.nodeIds || []).map((id) => byId(id)));
85
+ return Promise.all(nodes.map(async (n) => ({ nodeId: n.id, document: serialize((await n.exportAsync({ format: "JSON_REST_V1" })).document) })));
86
+ },
87
+ read_my_design() { return handlers.get_selection(); },
88
+
89
+ async create_rectangle(p = {}) {
90
+ const r = figma.createRectangle();
91
+ r.x = p.x ?? 0; r.y = p.y ?? 0; r.resize(p.width ?? 100, p.height ?? 100); r.name = p.name || "Rectangle";
92
+ await appendTo(r, p.parentId);
93
+ return box(r);
94
+ },
95
+ async create_frame(p = {}) {
96
+ const f = figma.createFrame();
97
+ f.x = p.x ?? 0; f.y = p.y ?? 0; f.resize(p.width ?? 100, p.height ?? 100); f.name = p.name || "Frame";
98
+ if (p.layoutMode && p.layoutMode !== "NONE") {
99
+ f.layoutMode = p.layoutMode; f.layoutWrap = p.layoutWrap || "NO_WRAP";
100
+ f.paddingTop = p.paddingTop ?? 10; f.paddingRight = p.paddingRight ?? 10;
101
+ f.paddingBottom = p.paddingBottom ?? 10; f.paddingLeft = p.paddingLeft ?? 10;
102
+ f.primaryAxisAlignItems = p.primaryAxisAlignItems || "MIN";
103
+ f.counterAxisAlignItems = p.counterAxisAlignItems || "MIN";
104
+ f.layoutSizingHorizontal = p.layoutSizingHorizontal || "FIXED";
105
+ f.layoutSizingVertical = p.layoutSizingVertical || "FIXED";
106
+ f.itemSpacing = p.itemSpacing ?? 0;
107
+ }
108
+ if (p.fillColor) f.fills = [solid(p.fillColor)];
109
+ if (p.strokeColor) f.strokes = [solid(p.strokeColor)];
110
+ if (p.strokeWeight !== undefined) f.strokeWeight = p.strokeWeight;
111
+ await appendTo(f, p.parentId);
112
+ return Object.assign(box(f), { fills: f.fills, strokes: f.strokes, layoutMode: f.layoutMode });
113
+ },
114
+ async create_text(p = {}) {
115
+ const t = figma.createText();
116
+ t.x = p.x ?? 0; t.y = p.y ?? 0;
117
+ const text = p.text ?? "Text"; t.name = p.name || text;
118
+ const style = fontStyle(p.fontWeight ?? 400);
119
+ await figma.loadFontAsync({ family: "Inter", style });
120
+ t.fontName = { family: "Inter", style };
121
+ t.fontSize = parseInt(p.fontSize ?? 14);
122
+ t.characters = text;
123
+ t.fills = [solid(p.fontColor || { r: 0, g: 0, b: 0, a: 1 })];
124
+ await appendTo(t, p.parentId);
125
+ return Object.assign(box(t), { characters: t.characters, fontSize: t.fontSize, fontName: t.fontName });
126
+ },
127
+
128
+ async set_fill_color(p = {}) {
129
+ const n = await byId(p.nodeId);
130
+ if (!("fills" in n)) throw new Error(`Node does not support fills: ${p.nodeId}`);
131
+ n.fills = [solid(p.color || {})];
132
+ return { id: n.id, name: n.name, fills: n.fills };
133
+ },
134
+ async set_stroke_color(p = {}) {
135
+ const n = await byId(p.nodeId);
136
+ if (!("strokes" in n)) throw new Error(`Node does not support strokes: ${p.nodeId}`);
137
+ n.strokes = [solid(p.color || {})];
138
+ if ("strokeWeight" in n) n.strokeWeight = p.weight ?? 1;
139
+ return { id: n.id, name: n.name, strokes: n.strokes, strokeWeight: "strokeWeight" in n ? n.strokeWeight : undefined };
140
+ },
141
+ async set_corner_radius(p = {}) {
142
+ const n = await byId(p.nodeId);
143
+ if (p.radius === undefined) throw new Error("Missing radius parameter");
144
+ if (!("cornerRadius" in n)) throw new Error(`Node does not support corner radius: ${p.nodeId}`);
145
+ const c = p.corners;
146
+ if (Array.isArray(c) && c.length === 4 && "topLeftRadius" in n) {
147
+ if (c[0]) n.topLeftRadius = p.radius; if (c[1]) n.topRightRadius = p.radius;
148
+ if (c[2]) n.bottomRightRadius = p.radius; if (c[3]) n.bottomLeftRadius = p.radius;
149
+ } else n.cornerRadius = p.radius;
150
+ return { id: n.id, name: n.name, cornerRadius: n.cornerRadius };
151
+ },
152
+ async set_text_content(p = {}) {
153
+ const n = await byId(p.nodeId);
154
+ if (p.text === undefined) throw new Error("Missing text parameter");
155
+ if (n.type !== "TEXT") throw new Error(`Node is not a text node: ${p.nodeId}`);
156
+ await figma.loadFontAsync(n.fontName);
157
+ n.characters = p.text;
158
+ return { id: n.id, name: n.name, characters: n.characters, fontName: n.fontName };
159
+ },
160
+
161
+ async move_node(p = {}) {
162
+ const n = await byId(p.nodeId);
163
+ if (p.x === undefined || p.y === undefined) throw new Error("Missing x or y parameters");
164
+ if (!("x" in n)) throw new Error(`Node does not support position: ${p.nodeId}`);
165
+ n.x = p.x; n.y = p.y;
166
+ return { id: n.id, name: n.name, x: n.x, y: n.y };
167
+ },
168
+ async resize_node(p = {}) {
169
+ const n = await byId(p.nodeId);
170
+ if (p.width === undefined || p.height === undefined) throw new Error("Missing width or height parameters");
171
+ if (!("resize" in n)) throw new Error(`Node does not support resizing: ${p.nodeId}`);
172
+ n.resize(p.width, p.height);
173
+ return { id: n.id, name: n.name, width: n.width, height: n.height };
174
+ },
175
+ async clone_node(p = {}) {
176
+ const n = await byId(p.nodeId);
177
+ const c = n.clone();
178
+ if (p.x !== undefined) c.x = p.x; if (p.y !== undefined) c.y = p.y;
179
+ await appendTo(c, p.parentId);
180
+ return box(c);
181
+ },
182
+ async delete_node(p = {}) {
183
+ const n = await byId(p.nodeId);
184
+ const info = { id: n.id, name: n.name, type: n.type };
185
+ n.remove();
186
+ return info;
187
+ },
188
+ async delete_multiple_nodes(p = {}) {
189
+ const deleted = [];
190
+ for (const id of p.nodeIds || []) {
191
+ try { const n = await byId(id); deleted.push({ id: n.id, name: n.name, type: n.type }); n.remove(); }
192
+ catch (e) { deleted.push({ id, error: String(e.message || e) }); }
193
+ }
194
+ return { deleted };
195
+ },
196
+
197
+ async set_focus(p = {}) {
198
+ const n = await byId(p.nodeId);
199
+ figma.currentPage.selection = [n];
200
+ figma.viewport.scrollAndZoomIntoView([n]);
201
+ return { id: n.id, name: n.name };
202
+ },
203
+ async set_selections(p = {}) {
204
+ const nodes = await Promise.all((p.nodeIds || []).map((id) => byId(id)));
205
+ figma.currentPage.selection = nodes;
206
+ figma.viewport.scrollAndZoomIntoView(nodes);
207
+ return { selection: nodes.map((n) => ({ id: n.id, name: n.name })) };
208
+ },
209
+
210
+ async get_styles() {
211
+ const [colors, texts, effects, grids] = await Promise.all([
212
+ figma.getLocalPaintStylesAsync(), figma.getLocalTextStylesAsync(),
213
+ figma.getLocalEffectStylesAsync(), figma.getLocalGridStylesAsync()]);
214
+ return {
215
+ colors: colors.map((s) => ({ id: s.id, name: s.name, key: s.key, paint: s.paints[0] })),
216
+ texts: texts.map((s) => ({ id: s.id, name: s.name, key: s.key })),
217
+ effects: effects.map((s) => ({ id: s.id, name: s.name, key: s.key })),
218
+ grids: grids.map((s) => ({ id: s.id, name: s.name, key: s.key })),
219
+ };
220
+ },
221
+ async get_local_components() {
222
+ await figma.loadAllPagesAsync();
223
+ const comps = figma.root.findAllWithCriteria({ types: ["COMPONENT"] });
224
+ return { count: comps.length, components: comps.map((c) => ({ id: c.id, name: c.name, key: c.key })) };
225
+ },
226
+ async create_component_instance(p = {}) {
227
+ if (!p.componentKey) throw new Error("Missing componentKey parameter");
228
+ const comp = await figma.importComponentByKeyAsync(p.componentKey);
229
+ const inst = comp.createInstance();
230
+ inst.x = p.x ?? 0; inst.y = p.y ?? 0;
231
+ await appendTo(inst, p.parentId);
232
+ return box(inst);
233
+ },
234
+ async export_node_as_image(p = {}) {
235
+ const n = await byId(p.nodeId);
236
+ if (!("exportAsync" in n)) throw new Error(`Node does not support exporting: ${p.nodeId}`);
237
+ const scale = p.scale ?? 1;
238
+ const bytes = await n.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: scale } });
239
+ return { nodeId: p.nodeId, format: "PNG", scale, mimeType: "image/png", imageData: b64(bytes) };
240
+ },
241
+
242
+ async set_layout_mode(p = {}) {
243
+ const n = await byId(p.nodeId);
244
+ if (!("layoutMode" in n)) throw new Error(`Node does not support auto layout: ${p.nodeId}`);
245
+ n.layoutMode = p.layoutMode || "NONE";
246
+ if (p.layoutWrap !== undefined) n.layoutWrap = p.layoutWrap;
247
+ return { id: n.id, name: n.name, layoutMode: n.layoutMode };
248
+ },
249
+ async set_padding(p = {}) {
250
+ const n = await byId(p.nodeId);
251
+ if (!("paddingTop" in n)) throw new Error(`Node does not support padding: ${p.nodeId}`);
252
+ if (p.paddingTop !== undefined) n.paddingTop = p.paddingTop;
253
+ if (p.paddingRight !== undefined) n.paddingRight = p.paddingRight;
254
+ if (p.paddingBottom !== undefined) n.paddingBottom = p.paddingBottom;
255
+ if (p.paddingLeft !== undefined) n.paddingLeft = p.paddingLeft;
256
+ return { id: n.id, name: n.name };
257
+ },
258
+ async set_item_spacing(p = {}) {
259
+ const n = await byId(p.nodeId);
260
+ if (!("itemSpacing" in n)) throw new Error(`Node does not support item spacing: ${p.nodeId}`);
261
+ n.itemSpacing = p.itemSpacing ?? 0;
262
+ return { id: n.id, name: n.name, itemSpacing: n.itemSpacing };
263
+ },
264
+ async set_axis_align(p = {}) {
265
+ const n = await byId(p.nodeId);
266
+ if (p.primaryAxisAlignItems !== undefined) n.primaryAxisAlignItems = p.primaryAxisAlignItems;
267
+ if (p.counterAxisAlignItems !== undefined) n.counterAxisAlignItems = p.counterAxisAlignItems;
268
+ return { id: n.id, name: n.name };
269
+ },
270
+ async set_layout_sizing(p = {}) {
271
+ const n = await byId(p.nodeId);
272
+ if (p.layoutSizingHorizontal !== undefined) n.layoutSizingHorizontal = p.layoutSizingHorizontal;
273
+ if (p.layoutSizingVertical !== undefined) n.layoutSizingVertical = p.layoutSizingVertical;
274
+ return { id: n.id, name: n.name };
275
+ },
276
+ };
277
+
278
+ async function exec(command, params) {
279
+ const h = handlers[command];
280
+ if (!h) throw new Error(`Unknown command: ${command}`);
281
+ return h(params || {});
34
282
  }
35
283
 
36
- figma.showUI(__html__, { width: 340, height: 320 });
284
+ // ── plugin UI wiring ───────────────────────────────────────────────────────
285
+ figma.showUI(__html__, { width: 340, height: 360 });
37
286
 
38
287
  figma.ui.onmessage = async (msg) => {
39
- if (msg.type === "exec") {
40
- let result;
41
- try { result = await exec(msg.cmd); }
42
- catch (e) { result = { error: String((e && e.message) || e) }; }
43
- figma.ui.postMessage({ type: "result", id: msg.cmd.id, result });
44
- figma.notify(result.error ? "⚠️ " + result.error : "✅ " + msg.cmd.op);
288
+ if (msg.type === "execute-command") {
289
+ try {
290
+ const result = await exec(msg.command, msg.params);
291
+ figma.ui.postMessage({ type: "command-result", id: msg.id, result });
292
+ figma.notify("✅ " + msg.command);
293
+ } catch (e) {
294
+ const error = String((e && e.message) || e);
295
+ figma.ui.postMessage({ type: "command-error", id: msg.id, error });
296
+ figma.notify("⚠️ " + error);
297
+ }
45
298
  } else if (msg.type === "close") {
46
299
  figma.closePlugin();
47
300
  }
@@ -8,6 +8,6 @@
8
8
  "documentAccess": "dynamic-page",
9
9
  "networkAccess": {
10
10
  "allowedDomains": ["*"],
11
- "reasoning": "Long-polls the local figma-api relay to receive drawing commands from the CLI."
11
+ "reasoning": "Connects to the local figma-api relay over WebSocket to receive drawing commands from the CLI."
12
12
  }
13
13
  }