@todoforai/figma-api 1.0.4 → 1.0.6

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,8 @@ 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 the Figma desktop app open.
12
11
 
13
12
  ## 1. Auth (always required)
14
13
 
@@ -28,46 +27,61 @@ FIGMA_TOKEN=<token> figma-api me
28
27
  - File commands accept a raw file key **or** a Figma URL; `node-id` is parsed from
29
28
  the URL automatically.
30
29
 
31
- ## 2. Plugin bridge (only for canvas writes / variables off-Enterprise)
30
+ ## 2. Plugin bridge (only for canvas writes)
32
31
 
33
32
  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:
33
+ API does. The bridge is a **WebSocket relay** that drives a companion plugin. It
34
+ speaks the **cursor-talk-to-figma** protocol, so it's interchangeable with that
35
+ ecosystem (their MCP server can drive our plugin and vice versa).
35
36
 
36
37
  ```
37
- figma-api run '<code>' ──POST──▶ relay ◀──poll── Figma plugin → runs on canvas
38
+ figma-api create-frame ──ws──▶ relay ◀──ws── Figma plugin → runs on canvas
39
+ (join channel, broadcast)
38
40
  ```
39
41
 
40
42
  Setup (do all three, in order):
41
43
 
42
44
  1. **Start the relay** (keep it running, background it):
43
45
  ```bash
44
- figma-api bridge # listens on http://localhost:8917
46
+ figma-api bridge # WebSocket relay on ws://localhost:3055
45
47
  ```
46
48
  2. **Load the plugin** — this is a human step, you cannot automate it:
47
49
  In the **Figma desktop app** (browser can't import dev plugins):
48
50
  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**:
51
+ `plugin/manifest.json`. Run it, set URL `ws://localhost:3055` + channel
52
+ `figma-api`, click **Connect**.
53
+ 3. **Drive it** with named commands:
51
54
  ```bash
52
55
  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
56
+ figma-api create-frame --width 320 --height 200 --fill "#1F6FEB" --name Card
57
+ figma-api create-text "Hi" --size 24 --parent 10:5
58
+ figma-api set-fill-color 10:5 "#FF0044"
59
+ figma-api get-selection
55
60
  ```
56
61
 
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.
62
+ Every canvas command takes `--relay <ws-url>` / `--channel <name>` (defaults
63
+ `ws://localhost:3055` / `figma-api`; or `FIGMA_RELAY` / `FIGMA_CHANNEL` env).
64
+
65
+ **Escape hatch:** for anything the named commands don't cover, `run` executes
66
+ arbitrary Plugin API code (`figma` in scope, `await` + `return` supported;
67
+ returned nodes come back as `{id,type,name}`):
68
+ ```bash
69
+ figma-api run 'return figma.currentPage.selection.map(n => n.name)'
70
+ figma-api run @script.js
71
+ ```
72
+ Prefer the named `create-*`/`set-*`/`get-*` commands; reach for `run` only for
73
+ gaps they don't fill.
60
74
 
61
75
  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
76
+ `cloudflared tunnel --url http://localhost:3055` and paste that `wss://` URL into
63
77
  the plugin instead of localhost.
64
78
 
65
79
  ## Gotchas an agent will hit
66
80
 
67
81
  - **REST `variables-modify` → 403** on non-Enterprise tokens. Don't retry; switch
68
- to `figma-api run` with `figma.variables.*`.
82
+ to `figma-api run` with `figma.variables.*` (off-Enterprise, via the bridge).
69
83
  - **"create a frame/text/shape via REST"** is impossible — there is no endpoint.
70
- Use the bridge.
84
+ Use the bridge (`create-frame`/`create-text`/…).
71
85
  - **Browser-only Figma can't load the dev plugin.** Needs the desktop app, or a
72
86
  published plugin. If the user is browser-only and refuses desktop, canvas
73
87
  writes are not available — say so plainly.
@@ -75,7 +89,7 @@ the plugin instead of localhost.
75
89
  user's document. Only run on a trusted machine; don't expose the tunnel URL
76
90
  publicly; don't run untrusted code.
77
91
  - The bridge handles **one command at a time, one connected plugin**. Don't fan
78
- out parallel `run` calls.
92
+ out parallel canvas calls.
79
93
  - The CLI exits non-zero on plugin error/timeout — check exit codes.
80
94
 
81
95
  ## Quick reference
@@ -83,4 +97,7 @@ the plugin instead of localhost.
83
97
  `figma-api --help` lists every command; `figma-api <cmd> --help` has params,
84
98
  scopes and examples. Read = me/file/nodes/images/comments/components/styles/
85
99
  variables-*/dev-resources/webhooks/… Write (REST) = comment-add/reaction-add/
86
- variables-modify/dev-resource-*/webhook-*. Canvas write = bridge + run.
100
+ variables-modify/dev-resource-*/webhook-*. Canvas (bridge) = create-frame/
101
+ create-rectangle/create-text/set-fill-color/set-text/move-node/resize-node/
102
+ clone-node/delete-node/get-selection/get-node-info/export-image/focus/select/…
103
+ + `run` escape hatch.
package/README.md CHANGED
@@ -53,33 +53,46 @@ 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).
69
+ 1. `figma-api bridge` — start the WebSocket relay (keep it running).
68
70
  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:
71
+ `plugin/manifest.json`. Run it, set URL `ws://localhost:3055` + channel
72
+ `figma-api`, click **Connect**.
73
+ 3. Drive it from the CLI with named commands:
71
74
 
72
75
  ```bash
73
76
  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
77
+ figma-api create-frame --width 320 --height 200 --fill "#1F6FEB" --name Card
78
+ figma-api create-text "Hello" --size 24 --weight 700 --color "#111" --parent 10:5
79
+ figma-api set-fill-color 10:5 "#FF0044"
80
+ figma-api get-selection
76
81
  ```
77
82
 
78
- `run` executes arbitrary Figma Plugin API code (`figma` in scope, `await` and
79
- `return` supported), so it subsumes any draw/create helper.
83
+ All commands take `--relay <ws-url>` and `--channel <name>` (defaults
84
+ `ws://localhost:3055` / `figma-api`; override via `FIGMA_RELAY` / `FIGMA_CHANNEL`).
85
+
86
+ Escape hatch — for anything the named commands don't cover, `run` executes
87
+ arbitrary Plugin API code (`figma` in scope, `await`/`return` supported):
88
+
89
+ ```bash
90
+ figma-api run 'return figma.currentPage.selection.map(n => n.name)'
91
+ figma-api run @make-card.js
92
+ ```
80
93
 
81
- Cross-machine: `cloudflared tunnel --url http://localhost:8917` and paste that
82
- https URL into the plugin.
94
+ Cross-machine: `cloudflared tunnel --url http://localhost:3055` and paste the
95
+ `wss://` URL into the plugin.
83
96
 
84
97
  > ⚠️ **Security:** the relay has no auth and `run` executes arbitrary code in your
85
98
  > Figma document. Only use it on a trusted machine/network; don't expose the tunnel
@@ -95,7 +108,6 @@ https URL into the plugin.
95
108
  | Files / nodes / images (read) | ✅ | ✅ |
96
109
  | Comments, dev resources, webhooks | ✅ | — |
97
110
  | Create frames / text / shapes | ❌ (no endpoint) | ✅ |
98
- | Edit variables off-Enterprise | ❌ (403) | ✅ |
99
111
  | Works headless / CI | ✅ | needs Figma open |
100
112
 
101
113
  ## Reference
@@ -103,8 +115,8 @@ https URL into the plugin.
103
115
  - [figma/rest-api-spec](https://github.com/figma/rest-api-spec) — authoritative endpoint list.
104
116
  - [GLips/Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP) — read design-context MCP.
105
117
  - [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.
118
+ - [grab/cursor-talk-to-figma-mcp](https://github.com/grab/cursor-talk-to-figma-mcp) —
119
+ the WebSocket bridge protocol this plugin implements (interchangeable clients).
108
120
 
109
121
  ## Dev
110
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@todoforai/figma-api",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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
  }
package/plugin/ui.html CHANGED
@@ -3,62 +3,74 @@
3
3
  <head><meta charset="utf-8" /></head>
4
4
  <body style="font:13px -apple-system,Segoe UI,Roboto,sans-serif;margin:0;padding:14px;color:#1a1a1a">
5
5
  <h3 style="margin:0 0 6px">figma-api bridge</h3>
6
- <p style="margin:0 0 10px;color:#666">Connect to the relay, then drive drawing from the <code>figma-api</code> CLI.</p>
6
+ <p style="margin:0 0 10px;color:#666">Joins the relay's WebSocket channel, then drive it from the <code>figma-api</code> CLI (or any cursor-talk-to-figma client).</p>
7
7
 
8
- <label style="display:block;margin:6px 0 2px;font-weight:600">Relay URL</label>
9
- <input id="relay" value="http://localhost:8917" style="width:100%;box-sizing:border-box;padding:7px;border:1px solid #ccc;border-radius:6px" />
8
+ <label style="display:block;margin:6px 0 2px;font-weight:600">Relay WebSocket URL</label>
9
+ <input id="url" value="ws://localhost:3055" style="width:100%;box-sizing:border-box;padding:7px;border:1px solid #ccc;border-radius:6px" />
10
+
11
+ <label style="display:block;margin:8px 0 2px;font-weight:600">Channel</label>
12
+ <input id="channel" value="figma-api" style="width:100%;box-sizing:border-box;padding:7px;border:1px solid #ccc;border-radius:6px" />
10
13
 
11
14
  <div style="display:flex;gap:8px;margin-top:10px">
12
15
  <button id="connect" style="flex:1;padding:9px;background:#1F6FEB;color:#fff;border:0;border-radius:8px;font-weight:600;cursor:pointer">Connect</button>
13
- <button id="stop" style="flex:1;padding:9px;background:#eee;color:#333;border:0;border-radius:8px;font-weight:600;cursor:pointer">Stop</button>
16
+ <button id="stop" style="flex:1;padding:9px;background:#eee;color:#333;border:0;border-radius:8px;font-weight:600;cursor:pointer">Disconnect</button>
14
17
  </div>
15
18
 
16
19
  <div id="status" style="margin-top:10px;color:#666;min-height:18px">Idle.</div>
17
- <pre id="log" style="margin-top:8px;background:#0d1117;color:#8b949e;padding:8px;border-radius:6px;height:120px;overflow:auto;font-size:11px"></pre>
20
+ <pre id="log" style="margin-top:8px;background:#0d1117;color:#8b949e;padding:8px;border-radius:6px;height:110px;overflow:auto;font-size:11px"></pre>
18
21
 
19
22
  <script>
20
23
  const $ = (id) => document.getElementById(id);
21
- let running = false;
22
24
  const log = (m) => { const l = $("log"); l.textContent += m + "\n"; l.scrollTop = l.scrollHeight; };
25
+ let socket = null, channel = null;
23
26
 
24
- let resolveResult = null;
25
- // plugin code → result → resolve the pending exec
27
+ // plugin code → command result → relay (cursor-talk-to-figma envelope)
26
28
  onmessage = (e) => {
27
29
  const m = e.data.pluginMessage;
28
- if (m && m.type === "result" && resolveResult) { const r = resolveResult; resolveResult = null; r(m); }
30
+ if (!m || !socket || socket.readyState !== 1) return;
31
+ if (m.type === "command-result") {
32
+ log("▶ " + m.id + " ok");
33
+ socket.send(JSON.stringify({ id: m.id, type: "message", channel, message: { id: m.id, result: m.result } }));
34
+ } else if (m.type === "command-error") {
35
+ log("▶ " + m.id + " ✗ " + m.error);
36
+ socket.send(JSON.stringify({ id: m.id, type: "message", channel, message: { id: m.id, error: m.error } }));
37
+ }
29
38
  };
30
39
 
31
- async function loop() {
32
- const relay = $("relay").value.replace(/\/$/, "");
33
- $("status").textContent = "🟢 Connected — polling " + relay;
34
- while (running) {
35
- let cmd;
36
- try {
37
- cmd = await (await fetch(relay + "/poll")).json();
38
- } catch (_) {
39
- $("status").textContent = "🔴 Relay unreachable — retrying";
40
- await new Promise((res) => setTimeout(res, 1500));
41
- continue;
40
+ function connect() {
41
+ const url = $("url").value.trim();
42
+ channel = $("channel").value.trim();
43
+ $("status").textContent = "Connecting to " + url + " …";
44
+ socket = new WebSocket(url);
45
+
46
+ socket.onopen = () => {
47
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
48
+ socket.send(JSON.stringify({ id, type: "join", channel }));
49
+ log("joining channel: " + channel);
50
+ };
51
+ socket.onmessage = (event) => {
52
+ let data; try { data = JSON.parse(event.data); } catch (_) { return; }
53
+ if (data.type === "system") {
54
+ const sm = data.message;
55
+ // structured join ack, or "Joined channel: …" — not the "please join" welcome
56
+ if ((sm && typeof sm === "object" && sm.result) || (typeof sm === "string" && sm.indexOf("Joined channel") === 0)) {
57
+ $("status").textContent = "🟢 Connected — channel: " + channel;
58
+ } else if (typeof sm === "string") log("· " + sm);
59
+ return;
42
60
  }
43
- if (!cmd || !cmd.op) continue;
44
- log("◀ " + cmd.op + " (" + cmd.id + ")");
45
- // Run one command at a time and wait for its result (no overlap).
46
- const m = await new Promise((resolve) => {
47
- resolveResult = resolve;
48
- parent.postMessage({ pluginMessage: { type: "exec", cmd } }, "*");
49
- });
50
- let bodyResult = m.result;
51
- try { JSON.stringify(bodyResult); } catch (_) { bodyResult = { error: "result not serializable" }; }
52
- log("▶ " + m.id + " " + JSON.stringify(bodyResult).slice(0, 60));
53
- try {
54
- await fetch(relay + "/result", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: m.id, result: bodyResult }) });
55
- } catch (_) { log("⚠ result POST failed"); }
56
- }
57
- $("status").textContent = "⏹ Stopped.";
61
+ // commands arrive as a "broadcast" event from the other peer; data.message is the payload
62
+ const payload = data.message;
63
+ if (payload && payload.command) {
64
+ log("◀ " + payload.command + " (" + payload.id + ")");
65
+ parent.postMessage({ pluginMessage: { type: "execute-command", id: payload.id, command: payload.command, params: payload.params } }, "*");
66
+ }
67
+ };
68
+ socket.onclose = () => { $("status").textContent = "⏹ Disconnected."; socket = null; };
69
+ socket.onerror = () => { $("status").textContent = "🔴 WebSocket error is the relay running? (figma-api bridge)"; };
58
70
  }
59
71
 
60
- $("connect").onclick = () => { if (!running) { running = true; loop(); } };
61
- $("stop").onclick = () => { running = false; };
72
+ $("connect").onclick = () => { if (!socket) connect(); };
73
+ $("stop").onclick = () => { if (socket) socket.close(); };
62
74
  </script>
63
75
  </body>
64
76
  </html>
package/src/bridge.ts CHANGED
@@ -1,15 +1,27 @@
1
- // Relay/bridge between the figma-api CLI and the Figma plugin.
2
- // The plugin can't be addressed directly, but it can poll over HTTP — so the CLI
3
- // enqueues commands here, the plugin long-polls /poll, executes them on the
4
- // canvas, and posts the result back to /result.
1
+ // WebSocket relay + CLI peer for the Figma plugin bridge.
5
2
  //
6
- // figma-api draw-* ──POST /cmd──▶ relay ◀──GET /poll── plugin
7
- // ──POST /result─▶ (draws, returns ids)
3
+ // The Figma REST API can't create canvas nodes (frames/text/shapes) — the Plugin
4
+ // API can. The plugin can't be addressed directly, so a relay sits between it and
5
+ // the CLI. We speak the cursor-talk-to-figma WebSocket protocol, so this relay is
6
+ // a drop-in for that ecosystem: its MCP server can drive our plugin, and our CLI
7
+ // can drive theirs.
8
+ //
9
+ // figma-api create-frame … ──ws──▶ relay (channel broadcast) ◀──ws── plugin
10
+ //
11
+ // Protocol (channel-scoped, broadcast to the *other* peer, never echoed):
12
+ // join: { type:"join", channel } → { type:"system", message:{ id, result } }
13
+ // command: { type:"message", channel, message:{ id, command, params } }
14
+ // result: { type:"message", channel, message:{ id, result } | { id, error } }
15
+ // each is delivered to the other peer wrapped as { type:"broadcast", message }.
8
16
 
9
17
  import { resolve } from "path";
10
18
  import { existsSync } from "fs";
19
+ import type { ServerWebSocket } from "bun";
20
+
21
+ export const DEFAULT_PORT = 3055;
22
+ export const DEFAULT_CHANNEL = "figma-api";
11
23
 
12
- interface Cmd { id: string; op: string; [k: string]: unknown }
24
+ interface WsData { channel?: string }
13
25
 
14
26
  /** Absolute path to the bundled plugin/manifest.json, or a clone hint if missing. */
15
27
  function manifestPath(): string {
@@ -19,109 +31,147 @@ function manifestPath(): string {
19
31
  : `${p} (missing — clone it: git clone https://github.com/todoforai/figma-api)`;
20
32
  }
21
33
 
22
- const queue: Cmd[] = [];
23
- const resultWaiters = new Map<string, (v: unknown) => void>();
24
- const pollWaiters: ((c: Cmd | null) => void)[] = [];
25
- let lastSeen = 0;
26
-
27
- function json(body: unknown, status = 200): Response {
28
- return new Response(JSON.stringify(body), {
29
- status,
30
- headers: { "content-type": "application/json", "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
31
- });
32
- }
33
-
34
34
  export function startBridge(port: number): void {
35
- Bun.serve({
35
+ const channels = new Map<string, Set<ServerWebSocket<WsData>>>();
36
+
37
+ Bun.serve<WsData, undefined>({
36
38
  port,
37
- async fetch(req) {
38
- const url = new URL(req.url);
39
- if (req.method === "OPTIONS") return json({}, 204);
39
+ fetch(req, server) {
40
+ if (server.upgrade(req, { data: {} })) return;
41
+ return new Response("figma-api bridge: WebSocket only", { status: 426 });
42
+ },
43
+ websocket: {
44
+ // Match cursor-talk-to-figma's relay (src/socket.ts) so either side is a
45
+ // drop-in: welcome on open, two-part join ack, peer notices, broadcasts
46
+ // wrapped as {type:"broadcast", sender:"peer", message:<inner>}.
47
+ open(ws) {
48
+ ws.send(JSON.stringify({ type: "system", message: "Please join a channel to start chatting" }));
49
+ },
50
+ message(ws, raw) {
51
+ let msg: any;
52
+ try { msg = JSON.parse(String(raw)); } catch { return; }
40
53
 
41
- // Health / status
42
- if (url.pathname === "/health") {
43
- return json({ ok: true, queued: queue.length, pluginSeenMsAgo: lastSeen ? Date.now() - lastSeen : null });
44
- }
54
+ if (msg.type === "join") {
55
+ const ch = typeof msg.channel === "string" && msg.channel ? msg.channel : DEFAULT_CHANNEL;
56
+ if (ws.data.channel && ws.data.channel !== ch) channels.get(ws.data.channel)?.delete(ws);
57
+ let peers = channels.get(ch);
58
+ if (!peers) channels.set(ch, (peers = new Set()));
59
+ ws.data.channel = ch;
60
+ ws.send(JSON.stringify({ type: "system", message: `Joined channel: ${ch}`, channel: ch }));
61
+ ws.send(JSON.stringify({ type: "system", message: { id: msg.id, result: `Connected to channel: ${ch}` }, channel: ch }));
62
+ for (const peer of peers) if (peer !== ws) peer.send(JSON.stringify({ type: "system", message: "A new user has joined the channel", channel: ch }));
63
+ peers.add(ws);
64
+ console.log(`peer joined channel "${ch}" (${peers.size} in channel)`);
65
+ return;
66
+ }
45
67
 
46
- // CLI enqueue a command and wait for the plugin's result
47
- if (url.pathname === "/cmd" && req.method === "POST") {
48
- const body = (await req.json()) as Record<string, unknown>;
49
- const id = crypto.randomUUID().slice(0, 8);
50
- const cmd: Cmd = { id, op: String(body.op), ...body };
51
- const waiter = pollWaiters.shift();
52
- if (waiter) waiter(cmd);
53
- else queue.push(cmd);
54
- const result = await new Promise((resolve) => {
55
- resultWaiters.set(id, resolve);
56
- setTimeout(() => { if (resultWaiters.delete(id)) resolve({ timeout: true }); }, 30000);
57
- });
58
- return json({ id, result });
59
- }
68
+ const ch = ws.data.channel;
69
+ if (!ch || !channels.get(ch)?.has(ws)) { ws.send(JSON.stringify({ type: "error", message: "You must join the channel first" })); return; }
60
70
 
61
- // Plugin long-poll for the next command
62
- if (url.pathname === "/poll") {
63
- lastSeen = Date.now();
64
- const next = queue.shift();
65
- if (next) return json(next);
66
- const cmd = await new Promise<Cmd | null>((resolve) => {
67
- pollWaiters.push(resolve);
68
- setTimeout(() => {
69
- const i = pollWaiters.indexOf(resolve);
70
- if (i >= 0) { pollWaiters.splice(i, 1); resolve(null); }
71
- }, 25000);
72
- });
73
- return json(cmd ?? {});
74
- }
71
+ // Forward progress updates verbatim to the other peers (upstream parity).
72
+ if (msg.type === "progress_update") {
73
+ for (const peer of channels.get(ch) ?? []) if (peer !== ws) peer.send(JSON.stringify(msg));
74
+ return;
75
+ }
76
+ if (msg.type !== "message") return;
75
77
 
76
- // Plugin post a command result
77
- if (url.pathname === "/result" && req.method === "POST") {
78
- const body = (await req.json()) as { id: string; result: unknown };
79
- const w = resultWaiters.get(body.id);
80
- if (w) { resultWaiters.delete(body.id); w(body.result); }
81
- return json({ ok: true });
82
- }
83
-
84
- return json({ error: "not found" }, 404);
78
+ // Broadcast the inner message to the OTHER peers (sender never gets its own).
79
+ const out = JSON.stringify({ type: "broadcast", sender: "peer", channel: ch, message: msg.message });
80
+ for (const peer of channels.get(ch) ?? []) if (peer !== ws) peer.send(out);
81
+ },
82
+ close(ws) {
83
+ const ch = ws.data.channel;
84
+ if (!ch) return;
85
+ const peers = channels.get(ch);
86
+ if (!peers) return;
87
+ peers.delete(ws);
88
+ for (const peer of peers) peer.send(JSON.stringify({ type: "system", message: "A user has left the channel", channel: ch }));
89
+ if (peers.size === 0) channels.delete(ch);
90
+ },
85
91
  },
86
92
  });
87
- console.log(`figma-api bridge listening on http://localhost:${port}`);
88
- console.log("Endpoints: POST /cmd GET /poll POST /result GET /health");
89
- console.log("Point the plugin at this URL (or a cloudflared tunnel of it), then run draw-* commands.");
93
+
94
+ console.log(`figma-api bridge (WebSocket) listening on ws://localhost:${port}`);
95
+ console.log(`Default channel: "${DEFAULT_CHANNEL}". Open the plugin, click Connect, then run figma-api commands.`);
90
96
  }
91
97
 
92
- /** Setup instructions shown only when a canvas-write command can't reach the plugin. */
93
- const setup = () => `
94
- Canvas writes (create frames/text/shapes, edit variables off-Enterprise) aren't in
95
- the Figma REST API — they need the plugin bridge, which the user must install.
98
+ /** Setup instructions shown only when a canvas command can't reach the plugin. */
99
+ const setup = (port: number) => `
100
+ Canvas writes (create frames/text/shapes) aren't in the Figma REST API — they need
101
+ the plugin bridge, which the user must install.
96
102
 
97
- AGENT: be explicit with the user about this. Explain that the bridge plugin grants
98
- write access to their Figma documents, confirm they want to install it, then walk
99
- them through these steps (the plugin runs in THEIR Figma, you can't do step 2 for
100
- them):
103
+ AGENT: be explicit with the user about this. The bridge plugin grants write access
104
+ to their Figma documents confirm they want it, then walk them through (the plugin
105
+ runs in THEIR Figma, you can't do step 2 for them):
101
106
  1. Start the relay (keep running): figma-api bridge
102
107
  2. In the Figma DESKTOP app: Plugins → Development → Import plugin from manifest
103
108
  → ${manifestPath()}
104
- Run it, paste the relay URL (default http://localhost:8917), click Connect.
109
+ Run it, set the URL to ws://localhost:${port} and channel "${DEFAULT_CHANNEL}", click Connect.
105
110
  3. Retry. Verify the round-trip with: figma-api ping
106
- Cross-machine: expose the relay via 'cloudflared tunnel --url http://localhost:8917'
107
- and paste that https URL into the plugin. Browser-only Figma can't load dev plugins.`;
108
-
109
- /** CLI helper: push a command to a running bridge and return the plugin's result. */
110
- export async function sendCommand(relay: string, op: string, params: Record<string, unknown>): Promise<void> {
111
- const res = await fetch(`${relay.replace(/\/$/, "")}/cmd`, {
112
- method: "POST",
113
- headers: { "content-type": "application/json" },
114
- body: JSON.stringify({ op, ...params }),
115
- }).catch((e) => {
116
- console.error(`Bridge relay not reachable at ${relay} (${e.message}).${setup()}`);
117
- process.exit(1);
111
+ Cross-machine: expose the relay via 'cloudflared tunnel --url http://localhost:${port}'
112
+ and paste the wss:// URL into the plugin. Browser-only Figma can't load dev plugins.`;
113
+
114
+ /** CLI peer: connect to the relay, send one command, return the plugin's result. */
115
+ export function sendCommand(
116
+ wsUrl: string,
117
+ channel: string,
118
+ command: string,
119
+ params: Record<string, unknown>,
120
+ timeoutMs = 30000,
121
+ ): Promise<unknown> {
122
+ const port = Number(new URL(wsUrl).port) || DEFAULT_PORT;
123
+ return new Promise((resolveP, rejectP) => {
124
+ const ws = new WebSocket(wsUrl);
125
+ const id = crypto.randomUUID().slice(0, 8);
126
+ let joined = false;
127
+
128
+ const timer = setTimeout(() => {
129
+ ws.close();
130
+ rejectP(new Error(`Timed out waiting for the plugin — relay is up but no plugin is Connected.${setup(port)}`));
131
+ }, timeoutMs);
132
+
133
+ ws.addEventListener("open", () => ws.send(JSON.stringify({ id, type: "join", channel })));
134
+ ws.addEventListener("error", () => {
135
+ clearTimeout(timer);
136
+ rejectP(new Error(`Bridge relay not reachable at ${wsUrl}.${setup(port)}`));
137
+ });
138
+ ws.addEventListener("message", (ev) => {
139
+ let data: any;
140
+ try { data = JSON.parse(String(ev.data)); } catch { return; }
141
+ // Wait for the structured join ack (ignore string system notices / welcome)
142
+ // before sending the command, else the relay rejects it as "not in channel".
143
+ if (data.type === "system") {
144
+ const sm = data.message;
145
+ if (!joined && sm && typeof sm === "object" && sm.result === `Connected to channel: ${channel}`) {
146
+ joined = true;
147
+ ws.send(JSON.stringify({ id, type: "message", channel, message: { id, command, params } }));
148
+ }
149
+ return;
150
+ }
151
+ // The plugin's result arrives as a "broadcast" event; data.message is the payload.
152
+ const m = data.message;
153
+ if (m && m.id === id) {
154
+ clearTimeout(timer);
155
+ ws.close();
156
+ if (m.error) rejectP(new Error(m.error));
157
+ else resolveP(m.result);
158
+ }
159
+ });
118
160
  });
119
- const data = (await (res as Response).json()) as { result?: { timeout?: boolean; error?: string } };
120
- const result = data.result;
121
- if (result?.timeout) {
122
- console.error(`Timed out waiting for the plugin — the relay is up but no plugin is Connected.${setup()}`);
161
+ }
162
+
163
+ /** Run a command and print its result; exit non-zero on failure. */
164
+ export async function runCommand(
165
+ wsUrl: string,
166
+ channel: string,
167
+ command: string,
168
+ params: Record<string, unknown>,
169
+ ): Promise<void> {
170
+ try {
171
+ const result = await sendCommand(wsUrl, channel, command, params);
172
+ console.log(JSON.stringify(result ?? null, null, 2));
173
+ } catch (e) {
174
+ console.error((e as Error).message);
123
175
  process.exit(1);
124
176
  }
125
- console.log(JSON.stringify(result ?? data, null, 2));
126
- if (result?.error) process.exit(1);
127
177
  }
package/src/index.ts CHANGED
@@ -5,9 +5,44 @@ import {
5
5
  saveCredentials, loadCredentials,
6
6
  get, post, put, del, output, parseFigmaTarget, readJsonArg,
7
7
  } from "./api";
8
- import { startBridge, sendCommand } from "./bridge";
9
-
10
- const DEFAULT_RELAY = process.env.FIGMA_RELAY || "http://localhost:8917";
8
+ import { startBridge, runCommand, DEFAULT_PORT, DEFAULT_CHANNEL } from "./bridge";
9
+
10
+ const DEFAULT_RELAY = process.env.FIGMA_RELAY || `ws://localhost:${DEFAULT_PORT}`;
11
+ const DEFAULT_CH = process.env.FIGMA_CHANNEL || DEFAULT_CHANNEL;
12
+
13
+ /** Parse "r,g,b[,a]" (0–255 or 0–1) or "#rgb[a]" / "#rrggbb[aa]" into Figma's 0–1 {r,g,b,a}. */
14
+ function parseColor(s: string): { r: number; g: number; b: number; a: number } {
15
+ if (s.startsWith("#")) {
16
+ let h = s.slice(1);
17
+ if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join(""); // #rgb → #rrggbb
18
+ if ((h.length !== 6 && h.length !== 8) || /[^0-9a-fA-F]/.test(h)) {
19
+ console.error(`Invalid hex color: ${s}`); process.exit(1);
20
+ }
21
+ const n = (i: number) => parseInt(h.slice(i, i + 2), 16) / 255;
22
+ return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) : 1 };
23
+ }
24
+ const p = s.split(",").map(Number);
25
+ const bad = (p.length !== 3 && p.length !== 4) || p.some((v) => !Number.isFinite(v) || v < 0);
26
+ const rgb255 = p.slice(0, 3).some((v) => v > 1);
27
+ if (bad || p.slice(0, 3).some((v) => v > (rgb255 ? 255 : 1)) || (p[3] !== undefined && p[3] > 1)) {
28
+ console.error(`Invalid color: ${s} (use r,g,b[,a] as 0–1 or 0–255 RGB, alpha 0–1; or #hex)`);
29
+ process.exit(1);
30
+ }
31
+ const d = rgb255 ? 255 : 1;
32
+ return { r: p[0] / d, g: p[1] / d, b: p[2] / d, a: p[3] ?? 1 };
33
+ }
34
+
35
+ /** Common flags for every canvas command + a thin sender. */
36
+ function canvas(name: string) {
37
+ return program
38
+ .command(name)
39
+ .option("--relay <url>", "relay WebSocket URL", DEFAULT_RELAY)
40
+ .option("--channel <name>", "relay channel", DEFAULT_CH);
41
+ }
42
+ const drive = (o: any, command: string, params: Record<string, unknown> = {}) =>
43
+ runCommand(o.relay, o.channel, command, params);
44
+ /** Number(v) when the option is present (incl. "0"), else undefined. */
45
+ const num = (v: unknown) => (v === undefined ? undefined : Number(v));
11
46
 
12
47
  program
13
48
  .name("figma-api")
@@ -17,9 +52,11 @@ program
17
52
  "Auth: figma-api auth <personal-access-token> (or FIGMA_TOKEN env var)\n" +
18
53
  "Token: create at https://www.figma.com/settings → Personal access tokens\n" +
19
54
  "Most commands accept a file URL or a raw file key. node-id is read from the URL too.\n\n" +
20
- "NOTE: Creating canvas layers (frames/shapes/text) is NOT in the REST API — that\n" +
21
- "lives in the Figma plugin/MCP runtime. Writable here: comments, reactions,\n" +
22
- "variables, dev resources and webhooks (token needs the matching write scopes)."
55
+ "NOTE: Creating canvas layers (frames/shapes/text) is NOT in the REST API — those\n" +
56
+ "go through the plugin bridge (create-frame, create-text, set-fill-color, …). It\n" +
57
+ "speaks the cursor-talk-to-figma WebSocket protocol, so any client in that\n" +
58
+ "ecosystem is interchangeable. REST-writable here: comments, reactions, variables,\n" +
59
+ "dev resources and webhooks (token needs the matching write scopes)."
23
60
  )
24
61
  .version("1.0.0")
25
62
  .addHelpText("after", `
@@ -589,57 +626,196 @@ program
589
626
  .action(async (url: string) => output(await get(`/v1/oembed`, { url })));
590
627
 
591
628
  // ── plugin bridge (canvas writes the REST API can't do) ───────────────────────
629
+ // The relay + plugin speak the cursor-talk-to-figma WebSocket protocol, so these
630
+ // commands are interchangeable with that ecosystem's MCP server / plugin.
592
631
  program
593
632
  .command("bridge")
594
- .description("Start the relay that lets the figma-api CLI drive the Figma plugin (canvas writes)")
595
- .option("--port <n>", "port to listen on", "8917")
633
+ .description("Start the WebSocket relay that lets the CLI drive the Figma plugin (canvas writes)")
634
+ .option("--port <n>", "port to listen on", String(DEFAULT_PORT))
596
635
  .addHelpText("after", `
597
- The Figma REST API cannot create canvas nodes (frames/text/shapes) or edit
598
- variables off-Enterprise. This relay bridges the CLI and the companion Figma
599
- plugin (see plugin/ folder), which can.
636
+ The Figma REST API cannot create canvas nodes (frames/text/shapes). This relay
637
+ bridges the CLI and the companion Figma plugin (see plugin/ folder), which can.
638
+ It speaks the cursor-talk-to-figma WebSocket protocol — compatible with that
639
+ ecosystem's MCP server / plugin.
600
640
 
601
641
  Flow:
602
642
  1. figma-api bridge # start this relay (keep running)
603
643
  2. In Figma desktop: Plugins → Development → Import plugin from manifest →
604
- api-apps/figma-api/plugin/manifest.json, run it, Connect to the relay URL.
605
- 3. figma-api run '...' # CLI → relay → plugin runs it
644
+ plugin/manifest.json, run it, set URL ws://localhost:${DEFAULT_PORT} + channel
645
+ "${DEFAULT_CHANNEL}", click Connect.
646
+ 3. figma-api ping # verify; then create-frame / create-text / …
606
647
 
607
- Cross-machine: expose the relay with 'cloudflared tunnel --url http://localhost:8917'
608
- and paste that https URL into the plugin's Relay field.
648
+ Cross-machine: expose the relay with 'cloudflared tunnel --url http://localhost:${DEFAULT_PORT}'
649
+ and paste the wss:// URL into the plugin.
609
650
 
610
651
  ⚠️ Security: the relay has no auth and 'run' executes arbitrary code in your
611
- Figma document. Only run it on a trusted machine/network; do not expose the
612
- tunnel URL publicly or run code you don't trust.`)
652
+ Figma document. Only run it on a trusted machine/network.`)
613
653
  .action((o: any) => startBridge(Number(o.port)));
614
654
 
615
- program
616
- .command("run <code-or-@file>")
617
- .description("Run arbitrary Figma Plugin API code in the connected plugin (full canvas + variables write)")
618
- .option("--relay <url>", "relay URL", DEFAULT_RELAY)
655
+ canvas("ping")
656
+ .description("Ping the connected plugin to verify the bridge round-trip")
657
+ .addHelpText("after", `\nReturns the plugin's current page name and selection.\nExample:\n figma-api ping`)
658
+ .action((o: any) => drive(o, "ping"));
659
+
660
+ // ── canvas reads ──────────────────────────────────────────────────────────────
661
+ canvas("get-document-info")
662
+ .description("Get the current page and its top-level children (via plugin)")
663
+ .action((o: any) => drive(o, "get_document_info"));
664
+
665
+ canvas("get-selection")
666
+ .description("Get the current selection on the canvas (via plugin)")
667
+ .action((o: any) => drive(o, "get_selection"));
668
+
669
+ canvas("get-node-info <node-id>")
670
+ .description("Get full info for one node (via plugin)")
671
+ .action((id: string, o: any) => drive(o, "get_node_info", { nodeId: id }));
672
+
673
+ canvas("get-nodes-info <ids>")
674
+ .description("Get full info for several nodes (comma-separated ids, via plugin)")
675
+ .action((ids: string, o: any) => drive(o, "get_nodes_info", { nodeIds: ids.split(",") }));
676
+
677
+ canvas("get-styles")
678
+ .description("List local paint/text/effect/grid styles (via plugin)")
679
+ .action((o: any) => drive(o, "get_styles"));
680
+
681
+ canvas("get-components")
682
+ .description("List local components in the document (via plugin)")
683
+ .action((o: any) => drive(o, "get_local_components"));
684
+
685
+ canvas("export-image <node-id>")
686
+ .description("Export a node as a base64 PNG (via plugin)")
687
+ .option("--scale <n>", "scale factor", "1")
688
+ .action((id: string, o: any) => drive(o, "export_node_as_image", { nodeId: id, scale: Number(o.scale) }));
689
+
690
+ // ── canvas creation ───────────────────────────────────────────────────────────
691
+ canvas("create-frame")
692
+ .description("Create a frame on the canvas (via plugin)")
693
+ .option("--x <n>", "x", "0").option("--y <n>", "y", "0")
694
+ .option("--width <n>", "width", "100").option("--height <n>", "height", "100")
695
+ .option("--name <name>", "layer name")
696
+ .option("--parent <id>", "parent node id (defaults to current page)")
697
+ .option("--fill <color>", "fill color: r,g,b[,a] or #hex")
698
+ .option("--stroke <color>", "stroke color: r,g,b[,a] or #hex")
699
+ .option("--stroke-weight <n>", "stroke weight")
700
+ .option("--layout <mode>", "auto layout: NONE | HORIZONTAL | VERTICAL")
701
+ .option("--item-spacing <n>", "item spacing (auto layout)")
702
+ .addHelpText("after", `\nExample:\n figma-api create-frame --width 320 --height 200 --fill "#1F6FEB" --name Card`)
703
+ .action((o: any) => drive(o, "create_frame", {
704
+ x: Number(o.x), y: Number(o.y), width: Number(o.width), height: Number(o.height),
705
+ name: o.name, parentId: o.parent,
706
+ fillColor: o.fill ? parseColor(o.fill) : undefined,
707
+ strokeColor: o.stroke ? parseColor(o.stroke) : undefined,
708
+ strokeWeight: num(o.strokeWeight),
709
+ layoutMode: o.layout, itemSpacing: num(o.itemSpacing),
710
+ }));
711
+
712
+ canvas("create-rectangle")
713
+ .description("Create a rectangle on the canvas (via plugin)")
714
+ .option("--x <n>", "x", "0").option("--y <n>", "y", "0")
715
+ .option("--width <n>", "width", "100").option("--height <n>", "height", "100")
716
+ .option("--name <name>", "layer name").option("--parent <id>", "parent node id")
717
+ .addHelpText("after", `\nExample:\n figma-api create-rectangle --width 80 --height 80 --name Box`)
718
+ .action((o: any) => drive(o, "create_rectangle", {
719
+ x: Number(o.x), y: Number(o.y), width: Number(o.width), height: Number(o.height), name: o.name, parentId: o.parent,
720
+ }));
721
+
722
+ canvas("create-text <text>")
723
+ .description("Create a text node on the canvas (via plugin)")
724
+ .option("--x <n>", "x", "0").option("--y <n>", "y", "0")
725
+ .option("--size <n>", "font size", "14").option("--weight <n>", "font weight (100–900)", "400")
726
+ .option("--color <color>", "font color: r,g,b[,a] or #hex")
727
+ .option("--name <name>", "layer name").option("--parent <id>", "parent node id")
728
+ .addHelpText("after", `\nExample:\n figma-api create-text "Hello" --size 24 --weight 700 --color "#111"`)
729
+ .action((text: string, o: any) => drive(o, "create_text", {
730
+ text, x: Number(o.x), y: Number(o.y), fontSize: Number(o.size), fontWeight: Number(o.weight),
731
+ fontColor: o.color ? parseColor(o.color) : undefined, name: o.name, parentId: o.parent,
732
+ }));
733
+
734
+ canvas("create-instance <component-key>")
735
+ .description("Create an instance of a published component (via plugin)")
736
+ .option("--x <n>", "x", "0").option("--y <n>", "y", "0").option("--parent <id>", "parent node id")
737
+ .action((key: string, o: any) => drive(o, "create_component_instance", { componentKey: key, x: Number(o.x), y: Number(o.y), parentId: o.parent }));
738
+
739
+ // ── canvas edits ──────────────────────────────────────────────────────────────
740
+ canvas("set-fill-color <node-id> <color>")
741
+ .description("Set a node's solid fill (color: r,g,b[,a] or #hex, via plugin)")
742
+ .action((id: string, color: string, o: any) => drive(o, "set_fill_color", { nodeId: id, color: parseColor(color) }));
743
+
744
+ canvas("set-stroke-color <node-id> <color>")
745
+ .description("Set a node's solid stroke (via plugin)")
746
+ .option("--weight <n>", "stroke weight", "1")
747
+ .action((id: string, color: string, o: any) => drive(o, "set_stroke_color", { nodeId: id, color: parseColor(color), weight: Number(o.weight) }));
748
+
749
+ canvas("set-corner-radius <node-id> <radius>")
750
+ .description("Set a node's corner radius (via plugin)")
751
+ .action((id: string, radius: string, o: any) => drive(o, "set_corner_radius", { nodeId: id, radius: Number(radius) }));
752
+
753
+ canvas("set-text <node-id> <text>")
754
+ .description("Replace a text node's content (via plugin)")
755
+ .action((id: string, text: string, o: any) => drive(o, "set_text_content", { nodeId: id, text }));
756
+
757
+ canvas("move-node <node-id> <x> <y>")
758
+ .description("Move a node to (x, y) (via plugin)")
759
+ .action((id: string, x: string, y: string, o: any) => drive(o, "move_node", { nodeId: id, x: Number(x), y: Number(y) }));
760
+
761
+ canvas("resize-node <node-id> <width> <height>")
762
+ .description("Resize a node (via plugin)")
763
+ .action((id: string, w: string, h: string, o: any) => drive(o, "resize_node", { nodeId: id, width: Number(w), height: Number(h) }));
764
+
765
+ canvas("clone-node <node-id>")
766
+ .description("Clone a node (via plugin)")
767
+ .option("--x <n>", "x of the clone").option("--y <n>", "y of the clone").option("--parent <id>", "parent node id")
768
+ .action((id: string, o: any) => drive(o, "clone_node", { nodeId: id, x: num(o.x), y: num(o.y), parentId: o.parent }));
769
+
770
+ canvas("delete-node <node-id>")
771
+ .description("Delete a node (via plugin)")
772
+ .action((id: string, o: any) => drive(o, "delete_node", { nodeId: id }));
773
+
774
+ canvas("delete-nodes <ids>")
775
+ .description("Delete several nodes (comma-separated ids, via plugin)")
776
+ .action((ids: string, o: any) => drive(o, "delete_multiple_nodes", { nodeIds: ids.split(",") }));
777
+
778
+ canvas("set-layout <node-id> <mode>")
779
+ .description("Set auto layout mode: NONE | HORIZONTAL | VERTICAL (via plugin)")
780
+ .action((id: string, mode: string, o: any) => drive(o, "set_layout_mode", { nodeId: id, layoutMode: mode }));
781
+
782
+ canvas("set-padding <node-id>")
783
+ .description("Set auto-layout padding (via plugin)")
784
+ .option("--top <n>").option("--right <n>").option("--bottom <n>").option("--left <n>")
785
+ .action((id: string, o: any) => drive(o, "set_padding", {
786
+ nodeId: id, paddingTop: num(o.top), paddingRight: num(o.right),
787
+ paddingBottom: num(o.bottom), paddingLeft: num(o.left),
788
+ }));
789
+
790
+ canvas("set-item-spacing <node-id> <spacing>")
791
+ .description("Set auto-layout item spacing (via plugin)")
792
+ .action((id: string, s: string, o: any) => drive(o, "set_item_spacing", { nodeId: id, itemSpacing: Number(s) }));
793
+
794
+ canvas("focus <node-id>")
795
+ .description("Select a node and zoom the viewport to it (via plugin)")
796
+ .action((id: string, o: any) => drive(o, "set_focus", { nodeId: id }));
797
+
798
+ canvas("select <ids>")
799
+ .description("Set the selection (comma-separated ids) and zoom to it (via plugin)")
800
+ .action((ids: string, o: any) => drive(o, "set_selections", { nodeIds: ids.split(",") }));
801
+
802
+ // ── escape hatch: arbitrary Plugin API code ──────────────────────────────────
803
+ canvas("run <code-or-@file>")
804
+ .description("Run arbitrary Figma Plugin API code in the connected plugin (escape hatch)")
619
805
  .addHelpText("after", `
620
- Whatever the Figma Plugin API can do, this can do create frames/text/shapes,
621
- edit variables (no Enterprise paywall, unlike REST), read selection, etc.
622
- \`figma\` is in scope; you may use await and \`return\` a value (nodes come back as
623
- {id,type,name}).
624
-
625
- Pass code inline or as @file.js. Requires a running bridge + Connected plugin.
806
+ Whatever the Figma Plugin API can do, this can do. \`figma\` is in scope; you may
807
+ use await and \`return\` a value (nodes come back as {id,type,name}). Prefer the
808
+ named create-*/set-* commands; reach for 'run' only for things they don't cover.
809
+ Pass code inline or as @file.js.
626
810
 
627
811
  ⚠️ Executes arbitrary code in your Figma document — only run code you trust.
628
812
 
629
813
  Examples:
630
- figma-api run 'return figma.currentPage.selection.map(n => ({id:n.id, type:n.type, name:n.name}))'
631
- figma-api run 'const t = figma.createText(); await figma.loadFontAsync({family:"Inter",style:"Regular"}); t.characters="Hi from CLI"; figma.currentPage.appendChild(t); return t'
814
+ figma-api run 'return figma.currentPage.selection.map(n => n.name)'
632
815
  figma-api run @make-card.js`)
633
- .action(async (codeArg: string, o: any) => {
816
+ .action((codeArg: string, o: any) => {
634
817
  const code = codeArg.startsWith("@") ? readFileSync(codeArg.slice(1), "utf-8") : codeArg;
635
- await sendCommand(o.relay, "eval", { code });
818
+ return drive(o, "eval", { code });
636
819
  });
637
820
 
638
- program
639
- .command("ping")
640
- .description("Ping the connected plugin to verify the bridge round-trip")
641
- .option("--relay <url>", "relay URL", DEFAULT_RELAY)
642
- .addHelpText("after", `\nReturns the plugin's current page name and selection.\nExample:\n figma-api ping`)
643
- .action(async (o: any) => sendCommand(o.relay, "ping", {}));
644
-
645
821
  program.parse();