@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 +36 -19
- package/README.md +29 -17
- package/package.json +4 -2
- package/plugin/code.js +276 -23
- package/plugin/manifest.json +1 -1
- package/plugin/ui.html +49 -37
- package/src/bridge.ts +144 -94
- package/src/index.ts +214 -38
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)
|
|
10
|
-
|
|
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
|
|
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
|
|
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
|
|
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 #
|
|
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,
|
|
50
|
-
|
|
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
|
|
54
|
-
figma-api
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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:
|
|
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
|
|
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
|
|
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)
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
62
|
-
|
|
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,
|
|
70
|
-
|
|
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
|
|
75
|
-
figma-api
|
|
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
|
-
|
|
79
|
-
`
|
|
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:
|
|
82
|
-
|
|
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
|
-
-
|
|
107
|
-
|
|
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.
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
// plain objects/arrays
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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 === "
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|
package/plugin/manifest.json
CHANGED
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"documentAccess": "dynamic-page",
|
|
9
9
|
"networkAccess": {
|
|
10
10
|
"allowedDomains": ["*"],
|
|
11
|
-
"reasoning": "
|
|
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">
|
|
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="
|
|
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">
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
const
|
|
33
|
-
$("
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 (!
|
|
61
|
-
$("stop").onclick = () => {
|
|
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
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
-
|
|
35
|
+
const channels = new Map<string, Set<ServerWebSocket<WsData>>>();
|
|
36
|
+
|
|
37
|
+
Bun.serve<WsData, undefined>({
|
|
36
38
|
port,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
console.log(
|
|
89
|
-
console.log("
|
|
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
|
|
93
|
-
const setup = () => `
|
|
94
|
-
Canvas writes (create frames/text/shapes
|
|
95
|
-
the
|
|
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.
|
|
98
|
-
|
|
99
|
-
|
|
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,
|
|
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
|
|
107
|
-
and paste
|
|
108
|
-
|
|
109
|
-
/** CLI
|
|
110
|
-
export
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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,
|
|
9
|
-
|
|
10
|
-
const DEFAULT_RELAY = process.env.FIGMA_RELAY ||
|
|
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 —
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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
|
|
595
|
-
.option("--port <n>", "port to listen on",
|
|
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)
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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
|
|
608
|
-
and paste
|
|
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
|
|
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
|
-
|
|
616
|
-
.
|
|
617
|
-
.
|
|
618
|
-
.
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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 =>
|
|
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(
|
|
816
|
+
.action((codeArg: string, o: any) => {
|
|
634
817
|
const code = codeArg.startsWith("@") ? readFileSync(codeArg.slice(1), "utf-8") : codeArg;
|
|
635
|
-
|
|
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();
|