@todoforai/figma-api 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TODOforAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # figma-api
2
+
3
+ Figma CLI — a subcommand-per-endpoint wrapper of the official **Figma REST API**,
4
+ plus a **plugin bridge** for the canvas writes the REST API can't do.
5
+
6
+ ```bash
7
+ figma-api auth figd_xxx # personal access token (or FIGMA_TOKEN env)
8
+ figma-api me
9
+ figma-api file https://figma.com/design/AbC123/My-File
10
+ figma-api comment-add AbC123 "looks great" --node 1:2
11
+ ```
12
+
13
+ Inspired by the Figma MCP servers (read design context) but extended to the full
14
+ REST surface **and** to live canvas manipulation through a companion plugin.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm i -g @todoforai/figma-api # or: bun add -g @todoforai/figma-api
20
+ figma-api --help
21
+ ```
22
+
23
+ ## Auth
24
+
25
+ Personal Access Token, stored in `~/.config/figma-api/credentials.json`, or passed
26
+ via `FIGMA_TOKEN` (`FIGMA_API_TOKEN`) env. OAuth bearer tokens also work.
27
+
28
+ ```bash
29
+ # https://www.figma.com/settings → Personal access tokens → Generate
30
+ figma-api auth figd_xxx
31
+ ```
32
+
33
+ Scopes: reads need `file_content:read`; writes need the matching scope
34
+ (`file_comments:write`, `file_dev_resources:write`, `webhooks:write`,
35
+ `file_variables:*` is Enterprise-only).
36
+
37
+ ## REST commands
38
+
39
+ Every file command takes a raw file key **or** a Figma URL (`node-id` is parsed
40
+ from the URL).
41
+
42
+ Read: `me`, `file`, `nodes`, `file-meta`, `versions`, `images`, `image-fills`,
43
+ `comments`, `reactions`, `projects`, `project-files`, `components`/`team-components`/
44
+ `component`, `component-sets`/`team-component-sets`, `styles`/`team-styles`/`style`,
45
+ `variables-local`/`variables-published`, `dev-resources`, `webhooks`/`webhook`/
46
+ `webhook-requests`, `analytics`, `activity-logs`, `payments`, `oembed`.
47
+
48
+ Write: `comment-add`/`comment-delete`, `reaction-add`/`reaction-delete`,
49
+ `variables-modify`, `dev-resource-add`/`dev-resources-bulk-add`/`dev-resources-update`/
50
+ `dev-resource-delete`, `webhook-create`/`webhook-update`/`webhook-delete`.
51
+
52
+ Run `figma-api <command> --help` for parameters, scopes and examples.
53
+
54
+ ## Plugin bridge — canvas writes
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:
59
+
60
+ ```
61
+ figma-api run '...' ──POST──▶ relay (figma-api bridge) ◀──poll── Figma plugin
62
+ ──result─▶ (runs on canvas)
63
+ ```
64
+
65
+ Setup:
66
+
67
+ 1. `figma-api bridge` — start the relay (keep it running).
68
+ 2. Figma **desktop** → Plugins → Development → **Import plugin from manifest** →
69
+ `plugin/manifest.json`. Run it, paste the relay URL, click **Connect**.
70
+ 3. Drive it from the CLI:
71
+
72
+ ```bash
73
+ 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
76
+ ```
77
+
78
+ `run` executes arbitrary Figma Plugin API code (`figma` in scope, `await` and
79
+ `return` supported), so it subsumes any draw/create helper.
80
+
81
+ Cross-machine: `cloudflared tunnel --url http://localhost:8917` and paste that
82
+ https URL into the plugin.
83
+
84
+ > ⚠️ **Security:** the relay has no auth and `run` executes arbitrary code in your
85
+ > Figma document. Only use it on a trusted machine/network; don't expose the tunnel
86
+ > URL publicly or run code you don't trust.
87
+
88
+ > Note: development (unpublished) plugins can only be imported in the **Figma
89
+ > desktop app**, not the browser. Once published, the browser works too.
90
+
91
+ ## Why both REST and a plugin?
92
+
93
+ | | REST API | Plugin bridge |
94
+ |---|----------|---------------|
95
+ | Files / nodes / images (read) | ✅ | ✅ |
96
+ | Comments, dev resources, webhooks | ✅ | — |
97
+ | Create frames / text / shapes | ❌ (no endpoint) | ✅ |
98
+ | Edit variables off-Enterprise | ❌ (403) | ✅ |
99
+ | Works headless / CI | ✅ | needs Figma open |
100
+
101
+ ## Reference
102
+
103
+ - [figma/rest-api-spec](https://github.com/figma/rest-api-spec) — authoritative endpoint list.
104
+ - [GLips/Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP) — read design-context MCP.
105
+ - [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.
108
+
109
+ ## Dev
110
+
111
+ ```bash
112
+ bun install
113
+ bun src/index.ts --help
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@todoforai/figma-api",
3
+ "version": "1.0.0",
4
+ "description": "Figma CLI — full REST API wrapper (files, comments, variables, webhooks…) plus a plugin bridge for canvas writes.",
5
+ "type": "module",
6
+ "bin": {
7
+ "figma-api": "src/index.ts"
8
+ },
9
+ "files": ["src", "plugin", "README.md", "LICENSE"],
10
+ "keywords": ["figma", "figma-api", "cli", "rest", "design", "plugin", "mcp"],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/todoforai/figma-api.git"
14
+ },
15
+ "license": "MIT",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "dependencies": {
20
+ "commander": "^14.0.3"
21
+ }
22
+ }
package/plugin/code.js ADDED
@@ -0,0 +1,48 @@
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`.
5
+
6
+ // Serialize a value for JSON transport: Figma nodes → {id,type,name}, recurse
7
+ // plain objects/arrays, guard against cycles and unstringifiable values.
8
+ function serialize(v, seen) {
9
+ if (v == null || typeof v === "boolean" || typeof v === "number" || typeof v === "string") return v;
10
+ if (typeof v === "bigint") return v.toString();
11
+ if (typeof v === "function" || typeof v === "symbol" || typeof v === "undefined") return undefined;
12
+ if (typeof v.id === "string" && typeof v.type === "string") return { id: v.id, type: v.type, name: v.name };
13
+ seen = seen || new Set();
14
+ if (seen.has(v)) return "[circular]";
15
+ seen.add(v);
16
+ if (Array.isArray(v)) return v.map((x) => serialize(x, seen));
17
+ const o = {};
18
+ for (const k of Object.keys(v)) { try { o[k] = serialize(v[k], seen); } catch (_) {} }
19
+ return o;
20
+ }
21
+
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) };
25
+ }
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.
29
+ 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 };
34
+ }
35
+
36
+ figma.showUI(__html__, { width: 340, height: 320 });
37
+
38
+ 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);
45
+ } else if (msg.type === "close") {
46
+ figma.closePlugin();
47
+ }
48
+ };
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "figma-api bridge",
3
+ "id": "figma-api-bridge-todoforai",
4
+ "api": "1.0.0",
5
+ "main": "code.js",
6
+ "ui": "ui.html",
7
+ "editorType": ["figma"],
8
+ "documentAccess": "dynamic-page",
9
+ "networkAccess": {
10
+ "allowedDomains": ["*"],
11
+ "reasoning": "Long-polls the local figma-api relay to receive drawing commands from the CLI."
12
+ }
13
+ }
package/plugin/ui.html ADDED
@@ -0,0 +1,64 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head><meta charset="utf-8" /></head>
4
+ <body style="font:13px -apple-system,Segoe UI,Roboto,sans-serif;margin:0;padding:14px;color:#1a1a1a">
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>
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" />
10
+
11
+ <div style="display:flex;gap:8px;margin-top:10px">
12
+ <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>
14
+ </div>
15
+
16
+ <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>
18
+
19
+ <script>
20
+ const $ = (id) => document.getElementById(id);
21
+ let running = false;
22
+ const log = (m) => { const l = $("log"); l.textContent += m + "\n"; l.scrollTop = l.scrollHeight; };
23
+
24
+ let resolveResult = null;
25
+ // plugin code → result → resolve the pending exec
26
+ onmessage = (e) => {
27
+ const m = e.data.pluginMessage;
28
+ if (m && m.type === "result" && resolveResult) { const r = resolveResult; resolveResult = null; r(m); }
29
+ };
30
+
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;
42
+ }
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.";
58
+ }
59
+
60
+ $("connect").onclick = () => { if (!running) { running = true; loop(); } };
61
+ $("stop").onclick = () => { running = false; };
62
+ </script>
63
+ </body>
64
+ </html>
package/src/api.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const BASE = "https://api.figma.com";
6
+ const CONFIG_DIR = join(homedir(), ".config", "figma-api");
7
+ const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
8
+
9
+ export interface Credentials {
10
+ token: string;
11
+ }
12
+
13
+ export function saveCredentials(creds: Credentials): void {
14
+ mkdirSync(CONFIG_DIR, { recursive: true });
15
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2) + "\n");
16
+ }
17
+
18
+ export function loadCredentials(): Credentials | null {
19
+ if (!existsSync(CREDENTIALS_FILE)) return null;
20
+ return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8"));
21
+ }
22
+
23
+ export function credentialsExist(): boolean {
24
+ return existsSync(CREDENTIALS_FILE) || !!(process.env.FIGMA_TOKEN || process.env.FIGMA_API_TOKEN);
25
+ }
26
+
27
+ /** Resolve token from env (FIGMA_TOKEN / FIGMA_API_TOKEN) or saved credentials. */
28
+ export function loadToken(): string {
29
+ const env = process.env.FIGMA_TOKEN || process.env.FIGMA_API_TOKEN;
30
+ if (env) return env;
31
+ const creds = loadCredentials();
32
+ if (!creds?.token) {
33
+ console.error("No token found. Run: figma-api auth <personal-access-token>");
34
+ console.error("Or set FIGMA_TOKEN env var. Create a token at https://www.figma.com/settings (Personal access tokens).");
35
+ process.exit(1);
36
+ }
37
+ return creds.token;
38
+ }
39
+
40
+ /** OAuth bearer tokens start with "figo"/"figu"; PATs use the X-Figma-Token header. */
41
+ function authHeaders(): Record<string, string> {
42
+ const token = loadToken();
43
+ if (token.startsWith("Bearer ") || /^fig[ou]/.test(token)) {
44
+ return { Authorization: token.startsWith("Bearer ") ? token : `Bearer ${token}` };
45
+ }
46
+ return { "X-Figma-Token": token };
47
+ }
48
+
49
+ function buildUrl(path: string, params?: Record<string, unknown>): string {
50
+ const url = new URL(`${BASE}${path}`);
51
+ if (params) {
52
+ for (const [k, v] of Object.entries(params)) {
53
+ if (v === undefined || v === null || v === "") continue;
54
+ url.searchParams.set(k, String(v));
55
+ }
56
+ }
57
+ return url.toString();
58
+ }
59
+
60
+ async function request(method: string, path: string, params?: Record<string, unknown>, body?: unknown): Promise<Response> {
61
+ const init: RequestInit = { method, headers: { ...authHeaders() } };
62
+ if (body !== undefined) {
63
+ (init.headers as Record<string, string>)["Content-Type"] = "application/json";
64
+ init.body = JSON.stringify(body);
65
+ }
66
+ return fetch(buildUrl(path, params), init);
67
+ }
68
+
69
+ export const get = (path: string, params?: Record<string, unknown>) => request("GET", path, params);
70
+ export const post = (path: string, body?: unknown, params?: Record<string, unknown>) => request("POST", path, params, body ?? {});
71
+ export const put = (path: string, body?: unknown, params?: Record<string, unknown>) => request("PUT", path, params, body ?? {});
72
+ export const del = (path: string, params?: Record<string, unknown>) => request("DELETE", path, params);
73
+
74
+ /** Print a Response as pretty JSON (or raw text), exit non-zero on HTTP error. */
75
+ export async function output(res: Response): Promise<void> {
76
+ const text = await res.text();
77
+ let data: unknown = text;
78
+ try { data = JSON.parse(text); } catch { /* keep raw */ }
79
+ if (!res.ok) {
80
+ console.error(`HTTP ${res.status} ${res.statusText}`);
81
+ console.error(typeof data === "string" ? data : JSON.stringify(data, null, 2));
82
+ process.exit(1);
83
+ }
84
+ console.log(typeof data === "string" ? data : JSON.stringify(data, null, 2));
85
+ }
86
+
87
+ /**
88
+ * Accept a raw file key or a Figma URL and return { fileKey, nodeId? }.
89
+ * URLs: https://www.figma.com/file|design|board/<KEY>/<name>?node-id=1-2
90
+ * node-id query uses "1-2" form; the API expects "1:2".
91
+ */
92
+ export function parseFigmaTarget(input: string): { fileKey: string; nodeId?: string } {
93
+ const urlMatch = input.match(/figma\.com\/(?:file|design|board|proto)\/([A-Za-z0-9]+)/);
94
+ const fileKey = urlMatch ? urlMatch[1] : input;
95
+ let nodeId: string | undefined;
96
+ const nodeMatch = input.match(/[?&]node-id=([^&]+)/);
97
+ if (nodeMatch) nodeId = decodeURIComponent(nodeMatch[1]).replace(/-/g, ":");
98
+ return { fileKey, nodeId };
99
+ }
100
+
101
+ /** Read JSON from an inline string or a @file.json path. */
102
+ export function readJsonArg(arg: string): unknown {
103
+ const raw = arg.startsWith("@") ? readFileSync(arg.slice(1), "utf-8") : arg;
104
+ return JSON.parse(raw);
105
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,96 @@
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.
5
+ //
6
+ // figma-api draw-* ──POST /cmd──▶ relay ◀──GET /poll── plugin
7
+ // ──POST /result─▶ (draws, returns ids)
8
+
9
+ interface Cmd { id: string; op: string; [k: string]: unknown }
10
+
11
+ const queue: Cmd[] = [];
12
+ const resultWaiters = new Map<string, (v: unknown) => void>();
13
+ const pollWaiters: ((c: Cmd | null) => void)[] = [];
14
+ let lastSeen = 0;
15
+
16
+ function json(body: unknown, status = 200): Response {
17
+ return new Response(JSON.stringify(body), {
18
+ status,
19
+ headers: { "content-type": "application/json", "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
20
+ });
21
+ }
22
+
23
+ export function startBridge(port: number): void {
24
+ Bun.serve({
25
+ port,
26
+ async fetch(req) {
27
+ const url = new URL(req.url);
28
+ if (req.method === "OPTIONS") return json({}, 204);
29
+
30
+ // Health / status
31
+ if (url.pathname === "/health") {
32
+ return json({ ok: true, queued: queue.length, pluginSeenMsAgo: lastSeen ? Date.now() - lastSeen : null });
33
+ }
34
+
35
+ // CLI → enqueue a command and wait for the plugin's result
36
+ if (url.pathname === "/cmd" && req.method === "POST") {
37
+ const body = (await req.json()) as Record<string, unknown>;
38
+ const id = crypto.randomUUID().slice(0, 8);
39
+ const cmd: Cmd = { id, op: String(body.op), ...body };
40
+ const waiter = pollWaiters.shift();
41
+ if (waiter) waiter(cmd);
42
+ else queue.push(cmd);
43
+ const result = await new Promise((resolve) => {
44
+ resultWaiters.set(id, resolve);
45
+ setTimeout(() => { if (resultWaiters.delete(id)) resolve({ timeout: true }); }, 30000);
46
+ });
47
+ return json({ id, result });
48
+ }
49
+
50
+ // Plugin → long-poll for the next command
51
+ if (url.pathname === "/poll") {
52
+ lastSeen = Date.now();
53
+ const next = queue.shift();
54
+ if (next) return json(next);
55
+ const cmd = await new Promise<Cmd | null>((resolve) => {
56
+ pollWaiters.push(resolve);
57
+ setTimeout(() => {
58
+ const i = pollWaiters.indexOf(resolve);
59
+ if (i >= 0) { pollWaiters.splice(i, 1); resolve(null); }
60
+ }, 25000);
61
+ });
62
+ return json(cmd ?? {});
63
+ }
64
+
65
+ // Plugin → post a command result
66
+ if (url.pathname === "/result" && req.method === "POST") {
67
+ const body = (await req.json()) as { id: string; result: unknown };
68
+ const w = resultWaiters.get(body.id);
69
+ if (w) { resultWaiters.delete(body.id); w(body.result); }
70
+ return json({ ok: true });
71
+ }
72
+
73
+ return json({ error: "not found" }, 404);
74
+ },
75
+ });
76
+ console.log(`figma-api bridge listening on http://localhost:${port}`);
77
+ console.log("Endpoints: POST /cmd GET /poll POST /result GET /health");
78
+ console.log("Point the plugin at this URL (or a cloudflared tunnel of it), then run draw-* commands.");
79
+ }
80
+
81
+ /** CLI helper: push a command to a running bridge and return the plugin's result. */
82
+ export async function sendCommand(relay: string, op: string, params: Record<string, unknown>): Promise<void> {
83
+ const res = await fetch(`${relay.replace(/\/$/, "")}/cmd`, {
84
+ method: "POST",
85
+ headers: { "content-type": "application/json" },
86
+ body: JSON.stringify({ op, ...params }),
87
+ }).catch((e) => { console.error(`Bridge not reachable at ${relay}: ${e.message}\nStart it with: figma-api bridge`); process.exit(1); });
88
+ const data = (await (res as Response).json()) as { result?: { timeout?: boolean; error?: string } };
89
+ const result = data.result;
90
+ if (result?.timeout) {
91
+ console.error("Timed out waiting for the plugin. Is it open and Connected to the relay?");
92
+ process.exit(1);
93
+ }
94
+ console.log(JSON.stringify(result ?? data, null, 2));
95
+ if (result?.error) process.exit(1);
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,645 @@
1
+ #!/usr/bin/env bun
2
+ import { program } from "commander";
3
+ import { readFileSync } from "fs";
4
+ import {
5
+ saveCredentials, loadCredentials,
6
+ get, post, put, del, output, parseFigmaTarget, readJsonArg,
7
+ } from "./api";
8
+ import { startBridge, sendCommand } from "./bridge";
9
+
10
+ const DEFAULT_RELAY = process.env.FIGMA_RELAY || "http://localhost:8917";
11
+
12
+ program
13
+ .name("figma-api")
14
+ .description(
15
+ "Figma REST API CLI — raw read+write wrapper for files, nodes, images, comments,\n" +
16
+ "components, styles, variables, dev resources, webhooks, projects and analytics.\n" +
17
+ "Auth: figma-api auth <personal-access-token> (or FIGMA_TOKEN env var)\n" +
18
+ "Token: create at https://www.figma.com/settings → Personal access tokens\n" +
19
+ "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)."
23
+ )
24
+ .version("1.0.0")
25
+ .addHelpText("after", `
26
+ Examples:
27
+ figma-api auth figd_xxx
28
+ figma-api me
29
+ figma-api file https://www.figma.com/design/AbC123/My-File
30
+ figma-api nodes AbC123 --ids 1:2,3:4
31
+ figma-api images AbC123 --ids 1:2 --format svg --scale 2
32
+ figma-api comments AbC123
33
+ figma-api comment-add AbC123 "Looks great!" --x 100 --y 200 --node 1:2
34
+ figma-api variables-local AbC123
35
+ figma-api webhook-create FILE_UPDATE https://my.app/hook --context file --context-id AbC123
36
+
37
+ Run 'figma-api <command> --help' for per-command details, parameters and examples.`);
38
+
39
+ // ── auth ───────────────────────────────────────────────────────────────────
40
+ program
41
+ .command("auth [token]")
42
+ .description("Save a personal access token, or show auth status")
43
+ .addHelpText("after", `
44
+ Get a token:
45
+ 1. Open https://www.figma.com/settings
46
+ 2. Scroll to "Personal access tokens" → Generate new token
47
+ 3. Pick scopes: file_content:read is enough for reads. For writes also add
48
+ file_comments:write, file_variables:write, file_dev_resources:write,
49
+ webhooks:write (some scopes need a paid/Enterprise plan).
50
+
51
+ Examples:
52
+ figma-api auth # show current status + instructions
53
+ figma-api auth figd_abc123 # save token to ~/.config/figma-api/credentials.json
54
+
55
+ You can also skip saving and pass the token via env:
56
+ FIGMA_TOKEN=figd_abc123 figma-api me`)
57
+ .action((token?: string) => {
58
+ if (token) {
59
+ saveCredentials({ token });
60
+ console.log("✅ Token saved to ~/.config/figma-api/credentials.json");
61
+ return;
62
+ }
63
+ console.log("Figma REST API — Personal Access Token auth\n");
64
+ console.log("How to get a token:");
65
+ console.log(" 1. https://www.figma.com/settings → Personal access tokens");
66
+ console.log(" 2. Generate, choose scopes (reads: file_content:read; writes need more)");
67
+ console.log(" 3. Save it: figma-api auth <token>\n");
68
+ const env = process.env.FIGMA_TOKEN || process.env.FIGMA_API_TOKEN;
69
+ const creds = loadCredentials();
70
+ if (env) console.log(`Current status:\n ✅ FIGMA_TOKEN env set (${env.slice(0, 8)}...)`);
71
+ else if (creds?.token) console.log(`Current status:\n ✅ Saved token ${creds.token.slice(0, 8)}...`);
72
+ else console.log("Current status:\n ❌ No token saved and no FIGMA_TOKEN env var");
73
+ });
74
+
75
+ // ── user ─────────────────────────────────────────────────────────────────────
76
+ program
77
+ .command("me")
78
+ .description("Get the authenticated user (whoami)")
79
+ .addHelpText("after", `\nExample:\n figma-api me`)
80
+ .action(async () => output(await get("/v1/me")));
81
+
82
+ // ── files ─────────────────────────────────────────────────────────────────────
83
+ program
84
+ .command("file <key-or-url>")
85
+ .description("Get the full document JSON for a file")
86
+ .option("--ids <ids>", "comma-separated node IDs to limit the returned tree (e.g. 1:2,3:4)")
87
+ .option("--depth <n>", "how deep to traverse the document tree (1 = pages only)")
88
+ .option("--version <id>", "a specific version ID (default: current)")
89
+ .option("--geometry <mode>", "set to 'paths' to include vector geometry")
90
+ .option("--branch-data", "include branch metadata")
91
+ .addHelpText("after", `
92
+ Examples:
93
+ figma-api file https://www.figma.com/design/AbC123/My-File
94
+ figma-api file AbC123 --depth 2
95
+ figma-api file AbC123 --ids 1:2,3:4 --geometry paths
96
+
97
+ Tip: pipe to jq, e.g. figma-api file AbC123 --depth 1 | jq '.document.children[].name'`)
98
+ .action(async (input: string, o: any) => {
99
+ const { fileKey, nodeId } = parseFigmaTarget(input);
100
+ const ids = o.ids ?? nodeId;
101
+ await output(await get(`/v1/files/${fileKey}`, {
102
+ ids, depth: o.depth, version: o.version, geometry: o.geometry,
103
+ branch_data: o.branchData ? true : undefined,
104
+ }));
105
+ });
106
+
107
+ program
108
+ .command("nodes <key-or-url>")
109
+ .description("Get document JSON for specific nodes only (lighter than 'file')")
110
+ .option("--ids <ids>", "comma-separated node IDs (required unless node-id is in the URL)")
111
+ .option("--depth <n>", "traversal depth within each node")
112
+ .option("--version <id>", "a specific version ID")
113
+ .option("--geometry <mode>", "set to 'paths' to include vector geometry")
114
+ .addHelpText("after", `
115
+ Examples:
116
+ figma-api nodes AbC123 --ids 1:2,3:4
117
+ figma-api nodes "https://www.figma.com/design/AbC123/x?node-id=1-2" # ids from URL`)
118
+ .action(async (input: string, o: any) => {
119
+ const { fileKey, nodeId } = parseFigmaTarget(input);
120
+ const ids = o.ids ?? nodeId;
121
+ if (!ids) { console.error("Provide --ids or a URL containing node-id"); process.exit(1); }
122
+ await output(await get(`/v1/files/${fileKey}/nodes`, {
123
+ ids, depth: o.depth, version: o.version, geometry: o.geometry,
124
+ }));
125
+ });
126
+
127
+ program
128
+ .command("file-meta <key-or-url>")
129
+ .description("Get lightweight file metadata (name, last touched, editor type, thumbnail)")
130
+ .addHelpText("after", `\nExample:\n figma-api file-meta AbC123`)
131
+ .action(async (input: string) => {
132
+ const { fileKey } = parseFigmaTarget(input);
133
+ await output(await get(`/v1/files/${fileKey}/meta`));
134
+ });
135
+
136
+ program
137
+ .command("versions <key-or-url>")
138
+ .description("List the version history of a file")
139
+ .addHelpText("after", `\nExample:\n figma-api versions AbC123`)
140
+ .action(async (input: string) => {
141
+ const { fileKey } = parseFigmaTarget(input);
142
+ await output(await get(`/v1/files/${fileKey}/versions`));
143
+ });
144
+
145
+ // ── images ────────────────────────────────────────────────────────────────────
146
+ program
147
+ .command("images <key-or-url>")
148
+ .description("Render nodes to images and return their URLs")
149
+ .option("--ids <ids>", "comma-separated node IDs to render (or from URL node-id)")
150
+ .option("--format <fmt>", "jpg | png | svg | pdf", "png")
151
+ .option("--scale <n>", "scale factor 0.01–4 (raster only)")
152
+ .option("--version <id>", "a specific version ID")
153
+ .option("--svg-include-id", "include id attributes in SVG output")
154
+ .option("--svg-include-node-id", "include node id attributes in SVG output")
155
+ .option("--no-svg-outline-text", "keep SVG text as <text> instead of vector outlines")
156
+ .option("--no-svg-simplify-stroke", "do not simplify inside/outside strokes in SVG")
157
+ .option("--no-contents-only", "include content that overlaps the rendered node")
158
+ .option("--use-absolute-bounds", "render full node dimensions ignoring clipping")
159
+ .addHelpText("after", `
160
+ Returns a JSON map of node-id → rendered image URL (valid for a short time).
161
+ Examples:
162
+ figma-api images AbC123 --ids 1:2 --format svg
163
+ figma-api images AbC123 --ids 1:2,3:4 --format png --scale 2`)
164
+ .action(async (input: string, o: any) => {
165
+ const { fileKey, nodeId } = parseFigmaTarget(input);
166
+ const ids = o.ids ?? nodeId;
167
+ if (!ids) { console.error("Provide --ids or a URL containing node-id"); process.exit(1); }
168
+ await output(await get(`/v1/images/${fileKey}`, {
169
+ ids, format: o.format, scale: o.scale, version: o.version,
170
+ svg_include_id: o.svgIncludeId ? true : undefined,
171
+ svg_include_node_id: o.svgIncludeNodeId ? true : undefined,
172
+ svg_outline_text: o.svgOutlineText === false ? false : undefined,
173
+ svg_simplify_stroke: o.svgSimplifyStroke === false ? false : undefined,
174
+ contents_only: o.contentsOnly === false ? false : undefined,
175
+ use_absolute_bounds: o.useAbsoluteBounds ? true : undefined,
176
+ }));
177
+ });
178
+
179
+ program
180
+ .command("image-fills <key-or-url>")
181
+ .description("Get download URLs for all images placed as fills in a file")
182
+ .addHelpText("after", `\nExample:\n figma-api image-fills AbC123`)
183
+ .action(async (input: string) => {
184
+ const { fileKey } = parseFigmaTarget(input);
185
+ await output(await get(`/v1/files/${fileKey}/images`));
186
+ });
187
+
188
+ // ── comments (read + write) ────────────────────────────────────────────────────
189
+ program
190
+ .command("comments <key-or-url>")
191
+ .description("List comments in a file")
192
+ .option("--as-md", "return comment bodies as Markdown")
193
+ .addHelpText("after", `\nExample:\n figma-api comments AbC123`)
194
+ .action(async (input: string, o: any) => {
195
+ const { fileKey } = parseFigmaTarget(input);
196
+ await output(await get(`/v1/files/${fileKey}/comments`, { as_md: o.asMd ? true : undefined }));
197
+ });
198
+
199
+ program
200
+ .command("comment-add <key-or-url> <message>")
201
+ .description("Add a comment to a file (write)")
202
+ .option("--x <px>", "pin x coordinate on the canvas")
203
+ .option("--y <px>", "pin y coordinate on the canvas")
204
+ .option("--node <id>", "anchor the pin to a node id (with optional --x/--y offset)")
205
+ .option("--reply-to <comment-id>", "reply to an existing comment instead of a new pin")
206
+ .addHelpText("after", `
207
+ Pin placement: use --x/--y for an absolute canvas pin, optionally --node to anchor
208
+ to a node, or --reply-to to thread under an existing comment.
209
+ Examples:
210
+ figma-api comment-add AbC123 "Tighten this spacing" --x 120 --y 240
211
+ figma-api comment-add AbC123 "On this frame" --node 1:2
212
+ figma-api comment-add AbC123 "Agreed" --reply-to 99887766`)
213
+ .action(async (input: string, message: string, o: any) => {
214
+ const { fileKey } = parseFigmaTarget(input);
215
+ const body: Record<string, unknown> = { message };
216
+ if (o.replyTo) body.comment_id = o.replyTo;
217
+ else if (o.node) {
218
+ body.client_meta = { node_id: o.node, node_offset: { x: Number(o.x ?? 0), y: Number(o.y ?? 0) } };
219
+ } else if (o.x !== undefined || o.y !== undefined) {
220
+ if (o.x === undefined || o.y === undefined) {
221
+ console.error("A canvas pin needs both --x and --y (or use --node).");
222
+ process.exit(1);
223
+ }
224
+ body.client_meta = { x: Number(o.x), y: Number(o.y) };
225
+ }
226
+ await output(await post(`/v1/files/${fileKey}/comments`, body));
227
+ });
228
+
229
+ program
230
+ .command("comment-delete <key-or-url> <comment-id>")
231
+ .description("Delete a comment (write)")
232
+ .addHelpText("after", `\nExample:\n figma-api comment-delete AbC123 99887766`)
233
+ .action(async (input: string, commentId: string) => {
234
+ const { fileKey } = parseFigmaTarget(input);
235
+ await output(await del(`/v1/files/${fileKey}/comments/${commentId}`));
236
+ });
237
+
238
+ program
239
+ .command("reactions <key-or-url> <comment-id>")
240
+ .description("List reactions on a comment")
241
+ .option("--cursor <c>", "pagination cursor")
242
+ .addHelpText("after", `\nExample:\n figma-api reactions AbC123 99887766`)
243
+ .action(async (input: string, commentId: string, o: any) => {
244
+ const { fileKey } = parseFigmaTarget(input);
245
+ await output(await get(`/v1/files/${fileKey}/comments/${commentId}/reactions`, { cursor: o.cursor }));
246
+ });
247
+
248
+ program
249
+ .command("reaction-add <key-or-url> <comment-id> <emoji>")
250
+ .description("Add an emoji reaction to a comment (write)")
251
+ .addHelpText("after", `
252
+ Emoji must be a supported shortcode, e.g. :eyes:, :heart_eyes:, :+1:.
253
+ Example:
254
+ figma-api reaction-add AbC123 99887766 :eyes:`)
255
+ .action(async (input: string, commentId: string, emoji: string) => {
256
+ const { fileKey } = parseFigmaTarget(input);
257
+ await output(await post(`/v1/files/${fileKey}/comments/${commentId}/reactions`, { emoji }));
258
+ });
259
+
260
+ program
261
+ .command("reaction-delete <key-or-url> <comment-id> <emoji>")
262
+ .description("Remove an emoji reaction from a comment (write)")
263
+ .addHelpText("after", `\nExample:\n figma-api reaction-delete AbC123 99887766 :eyes:`)
264
+ .action(async (input: string, commentId: string, emoji: string) => {
265
+ const { fileKey } = parseFigmaTarget(input);
266
+ await output(await del(`/v1/files/${fileKey}/comments/${commentId}/reactions`, { emoji }));
267
+ });
268
+
269
+ // ── projects ────────────────────────────────────────────────────────────────
270
+ program
271
+ .command("projects <team-id>")
272
+ .description("List projects in a team")
273
+ .addHelpText("after", `
274
+ team-id is in the team URL: figma.com/files/team/<TEAM_ID>/...
275
+ Example:
276
+ figma-api projects 1101234567890`)
277
+ .action(async (teamId: string) => output(await get(`/v1/teams/${teamId}/projects`)));
278
+
279
+ program
280
+ .command("project-files <project-id>")
281
+ .description("List files in a project")
282
+ .option("--branch-data", "include branch metadata for each file")
283
+ .addHelpText("after", `\nExample:\n figma-api project-files 55512345`)
284
+ .action(async (projectId: string, o: any) =>
285
+ output(await get(`/v1/projects/${projectId}/files`, { branch_data: o.branchData ? true : undefined })));
286
+
287
+ // ── components & styles ───────────────────────────────────────────────────────
288
+ program
289
+ .command("components <key-or-url>")
290
+ .description("List published components in a file")
291
+ .addHelpText("after", `\nExample:\n figma-api components AbC123`)
292
+ .action(async (input: string) => {
293
+ const { fileKey } = parseFigmaTarget(input);
294
+ await output(await get(`/v1/files/${fileKey}/components`));
295
+ });
296
+
297
+ program
298
+ .command("team-components <team-id>")
299
+ .description("List published components in a team library (paginated)")
300
+ .option("--page-size <n>", "results per page")
301
+ .option("--after <cursor>", "pagination cursor")
302
+ .addHelpText("after", `\nExample:\n figma-api team-components 1101234567890 --page-size 50`)
303
+ .action(async (teamId: string, o: any) =>
304
+ output(await get(`/v1/teams/${teamId}/components`, { page_size: o.pageSize, after: o.after })));
305
+
306
+ program
307
+ .command("component <key>")
308
+ .description("Get a single published component by its key")
309
+ .addHelpText("after", `\nExample:\n figma-api component 8f2c...`)
310
+ .action(async (key: string) => output(await get(`/v1/components/${key}`)));
311
+
312
+ program
313
+ .command("component-sets <key-or-url>")
314
+ .description("List published component sets (variants) in a file")
315
+ .addHelpText("after", `\nExample:\n figma-api component-sets AbC123`)
316
+ .action(async (input: string) => {
317
+ const { fileKey } = parseFigmaTarget(input);
318
+ await output(await get(`/v1/files/${fileKey}/component_sets`));
319
+ });
320
+
321
+ program
322
+ .command("team-component-sets <team-id>")
323
+ .description("List published component sets in a team library (paginated)")
324
+ .option("--page-size <n>", "results per page")
325
+ .option("--after <cursor>", "pagination cursor")
326
+ .addHelpText("after", `\nExample:\n figma-api team-component-sets 1101234567890`)
327
+ .action(async (teamId: string, o: any) =>
328
+ output(await get(`/v1/teams/${teamId}/component_sets`, { page_size: o.pageSize, after: o.after })));
329
+
330
+ program
331
+ .command("styles <key-or-url>")
332
+ .description("List published styles in a file")
333
+ .addHelpText("after", `\nExample:\n figma-api styles AbC123`)
334
+ .action(async (input: string) => {
335
+ const { fileKey } = parseFigmaTarget(input);
336
+ await output(await get(`/v1/files/${fileKey}/styles`));
337
+ });
338
+
339
+ program
340
+ .command("team-styles <team-id>")
341
+ .description("List published styles in a team library (paginated)")
342
+ .option("--page-size <n>", "results per page")
343
+ .option("--after <cursor>", "pagination cursor")
344
+ .addHelpText("after", `\nExample:\n figma-api team-styles 1101234567890`)
345
+ .action(async (teamId: string, o: any) =>
346
+ output(await get(`/v1/teams/${teamId}/styles`, { page_size: o.pageSize, after: o.after })));
347
+
348
+ program
349
+ .command("style <key>")
350
+ .description("Get a single published style by its key")
351
+ .addHelpText("after", `\nExample:\n figma-api style 1a2b...`)
352
+ .action(async (key: string) => output(await get(`/v1/styles/${key}`)));
353
+
354
+ // ── variables (read + write, Enterprise) ──────────────────────────────────────
355
+ program
356
+ .command("variables-local <key-or-url>")
357
+ .description("Get local variables and collections in a file (Enterprise)")
358
+ .addHelpText("after", `
359
+ Requires file_variables:read scope and an Enterprise plan.
360
+ Example:
361
+ figma-api variables-local AbC123`)
362
+ .action(async (input: string) => {
363
+ const { fileKey } = parseFigmaTarget(input);
364
+ await output(await get(`/v1/files/${fileKey}/variables/local`));
365
+ });
366
+
367
+ program
368
+ .command("variables-published <key-or-url>")
369
+ .description("Get published variables from a library file (Enterprise)")
370
+ .addHelpText("after", `\nExample:\n figma-api variables-published AbC123`)
371
+ .action(async (input: string) => {
372
+ const { fileKey } = parseFigmaTarget(input);
373
+ await output(await get(`/v1/files/${fileKey}/variables/published`));
374
+ });
375
+
376
+ program
377
+ .command("variables-modify <key-or-url> <json>")
378
+ .description("Create / update / delete variables and collections in bulk (write, Enterprise)")
379
+ .addHelpText("after", `
380
+ Requires file_variables:write scope + Enterprise. <json> is a payload object (or
381
+ @file.json) with any of: variableCollections, variableModes, variables,
382
+ variableModeValues. Each entry has an "action" of CREATE | UPDATE | DELETE.
383
+
384
+ Example (inline):
385
+ figma-api variables-modify AbC123 '{"variableCollections":[{"action":"CREATE","name":"Tokens"}]}'
386
+ Example (from file):
387
+ figma-api variables-modify AbC123 @payload.json
388
+
389
+ Docs: https://www.figma.com/developers/api#post-variables-endpoint`)
390
+ .action(async (input: string, json: string) => {
391
+ const { fileKey } = parseFigmaTarget(input);
392
+ await output(await post(`/v1/files/${fileKey}/variables`, readJsonArg(json)));
393
+ });
394
+
395
+ // ── dev resources (read + write) ──────────────────────────────────────────────
396
+ program
397
+ .command("dev-resources <key-or-url>")
398
+ .description("List dev resources (links) attached to nodes in a file")
399
+ .option("--node-ids <ids>", "comma-separated node IDs to filter by")
400
+ .addHelpText("after", `\nExample:\n figma-api dev-resources AbC123 --node-ids 1:2,3:4`)
401
+ .action(async (input: string, o: any) => {
402
+ const { fileKey } = parseFigmaTarget(input);
403
+ await output(await get(`/v1/files/${fileKey}/dev_resources`, { node_ids: o.nodeIds }));
404
+ });
405
+
406
+ program
407
+ .command("dev-resource-add <key-or-url> <name> <url>")
408
+ .description("Attach a dev resource link to a node (write)")
409
+ .requiredOption("--node <id>", "node id to attach the link to")
410
+ .addHelpText("after", `
411
+ Example:
412
+ figma-api dev-resource-add AbC123 "PR #42" https://github.com/org/repo/pull/42 --node 1:2
413
+
414
+ Bulk: use 'dev-resources-bulk-add' with a JSON array for multiple at once.`)
415
+ .action(async (input: string, name: string, url: string, o: any) => {
416
+ const { fileKey } = parseFigmaTarget(input);
417
+ const body = { dev_resources: [{ name, url, file_key: fileKey, node_id: o.node }] };
418
+ await output(await post(`/v1/dev_resources`, body));
419
+ });
420
+
421
+ program
422
+ .command("dev-resources-bulk-add <json>")
423
+ .description("Create multiple dev resources from a JSON array (write)")
424
+ .addHelpText("after", `
425
+ <json> (or @file.json) is an array of { name, url, file_key, node_id }.
426
+ Example:
427
+ figma-api dev-resources-bulk-add '[{"name":"Docs","url":"https://x","file_key":"AbC123","node_id":"1:2"}]'`)
428
+ .action(async (json: string) => {
429
+ const arr = readJsonArg(json);
430
+ await output(await post(`/v1/dev_resources`, { dev_resources: arr }));
431
+ });
432
+
433
+ program
434
+ .command("dev-resources-update <json>")
435
+ .description("Update existing dev resources by id from a JSON array (write)")
436
+ .addHelpText("after", `
437
+ <json> (or @file.json) is an array of { id, name?, url? }.
438
+ Example:
439
+ figma-api dev-resources-update '[{"id":"abc","name":"Renamed"}]'`)
440
+ .action(async (json: string) => {
441
+ const arr = readJsonArg(json);
442
+ await output(await put(`/v1/dev_resources`, { dev_resources: arr }));
443
+ });
444
+
445
+ program
446
+ .command("dev-resource-delete <key-or-url> <dev-resource-id>")
447
+ .description("Delete a dev resource from a file (write)")
448
+ .addHelpText("after", `\nExample:\n figma-api dev-resource-delete AbC123 abc-id`)
449
+ .action(async (input: string, id: string) => {
450
+ const { fileKey } = parseFigmaTarget(input);
451
+ await output(await del(`/v1/files/${fileKey}/dev_resources/${id}`));
452
+ });
453
+
454
+ // ── webhooks v2 (read + write) ─────────────────────────────────────────────────
455
+ program
456
+ .command("webhooks")
457
+ .description("List webhooks (by context or for the whole plan)")
458
+ .option("--context <type>", "team | project | file")
459
+ .option("--context-id <id>", "id matching the context type")
460
+ .option("--plan-api-id <id>", "list all webhooks for a plan")
461
+ .option("--cursor <c>", "pagination cursor")
462
+ .addHelpText("after", `
463
+ Examples:
464
+ figma-api webhooks --context file --context-id AbC123
465
+ figma-api webhooks --context team --context-id 1101234567890`)
466
+ .action(async (o: any) =>
467
+ output(await get(`/v2/webhooks`, { context: o.context, context_id: o.contextId, plan_api_id: o.planApiId, cursor: o.cursor })));
468
+
469
+ program
470
+ .command("webhook <webhook-id>")
471
+ .description("Get a single webhook by id")
472
+ .addHelpText("after", `\nExample:\n figma-api webhook 1234567`)
473
+ .action(async (id: string) => output(await get(`/v2/webhooks/${id}`)));
474
+
475
+ program
476
+ .command("webhook-create <event> <endpoint>")
477
+ .description("Create a webhook (write)")
478
+ .requiredOption("--context <type>", "team | project | file")
479
+ .requiredOption("--context-id <id>", "id matching the context type")
480
+ .requiredOption("--passcode <code>", "passcode echoed back in each request for verification")
481
+ .option("--status <status>", "ACTIVE | PAUSED", "ACTIVE")
482
+ .option("--description <text>", "human description of the webhook")
483
+ .addHelpText("after", `
484
+ event: PING | FILE_UPDATE | FILE_VERSION_UPDATE | FILE_DELETE | LIBRARY_PUBLISH
485
+ | FILE_COMMENT | DEV_MODE_STATUS_UPDATE
486
+ Requires webhooks:write scope. Figma sends a PING to the endpoint on creation.
487
+ Example:
488
+ figma-api webhook-create FILE_UPDATE https://my.app/hook --context file --context-id AbC123 --passcode s3cret`)
489
+ .action(async (event: string, endpoint: string, o: any) => {
490
+ const body: Record<string, unknown> = {
491
+ event_type: event, endpoint, context: o.context, context_id: o.contextId,
492
+ passcode: o.passcode, status: o.status,
493
+ };
494
+ if (o.description) body.description = o.description;
495
+ await output(await post(`/v2/webhooks`, body));
496
+ });
497
+
498
+ program
499
+ .command("webhook-update <webhook-id>")
500
+ .description("Update a webhook's event, endpoint, status, passcode or description (write)")
501
+ .option("--event <event>", "new event type")
502
+ .option("--endpoint <url>", "new endpoint URL")
503
+ .option("--passcode <code>", "new passcode")
504
+ .option("--status <status>", "ACTIVE | PAUSED")
505
+ .option("--description <text>", "new description")
506
+ .addHelpText("after", `\nExample:\n figma-api webhook-update 1234567 --status PAUSED`)
507
+ .action(async (id: string, o: any) => {
508
+ const body: Record<string, unknown> = {};
509
+ if (o.event) body.event_type = o.event;
510
+ if (o.endpoint) body.endpoint = o.endpoint;
511
+ if (o.passcode) body.passcode = o.passcode;
512
+ if (o.status) body.status = o.status;
513
+ if (o.description) body.description = o.description;
514
+ await output(await put(`/v2/webhooks/${id}`, body));
515
+ });
516
+
517
+ program
518
+ .command("webhook-delete <webhook-id>")
519
+ .description("Delete a webhook (write)")
520
+ .addHelpText("after", `\nExample:\n figma-api webhook-delete 1234567`)
521
+ .action(async (id: string) => output(await del(`/v2/webhooks/${id}`)));
522
+
523
+ program
524
+ .command("webhook-requests <webhook-id>")
525
+ .description("Get recent delivery attempts (requests + responses) for a webhook")
526
+ .addHelpText("after", `\nExample:\n figma-api webhook-requests 1234567`)
527
+ .action(async (id: string) => output(await get(`/v2/webhooks/${id}/requests`)));
528
+
529
+ // ── library analytics (Enterprise) ─────────────────────────────────────────────
530
+ program
531
+ .command("analytics <key-or-url> <asset> <kind>")
532
+ .description("Library analytics: asset=component|style|variable, kind=actions|usages (Enterprise)")
533
+ .requiredOption("--group-by <field>", "actions: component|style|variable | team ; usages: component|style|variable | file")
534
+ .option("--start-date <YYYY-MM-DD>", "range start (actions only)")
535
+ .option("--end-date <YYYY-MM-DD>", "range end (actions only)")
536
+ .option("--order <dir>", "asc | desc")
537
+ .option("--cursor <c>", "pagination cursor")
538
+ .addHelpText("after", `
539
+ Requires library_analytics:read + Enterprise.
540
+ Examples:
541
+ figma-api analytics AbC123 component usages --group-by file
542
+ figma-api analytics AbC123 style actions --group-by team --start-date 2024-01-01 --end-date 2024-02-01`)
543
+ .action(async (input: string, asset: string, kind: string, o: any) => {
544
+ const { fileKey } = parseFigmaTarget(input);
545
+ if (!["component", "style", "variable"].includes(asset)) { console.error("asset must be component|style|variable"); process.exit(1); }
546
+ if (!["actions", "usages"].includes(kind)) { console.error("kind must be actions|usages"); process.exit(1); }
547
+ await output(await get(`/v1/analytics/libraries/${fileKey}/${asset}/${kind}`, {
548
+ group_by: o.groupBy, start_date: o.startDate, end_date: o.endDate, order: o.order, cursor: o.cursor,
549
+ }));
550
+ });
551
+
552
+ // ── activity / payments / oembed ────────────────────────────────────────────────
553
+ program
554
+ .command("activity-logs")
555
+ .description("Get activity logs for an organization (Enterprise, org token)")
556
+ .option("--events <list>", "comma-separated event types to filter")
557
+ .option("--start-time <unix>", "range start (epoch seconds)")
558
+ .option("--end-time <unix>", "range end (epoch seconds)")
559
+ .option("--limit <n>", "max events")
560
+ .option("--order <dir>", "asc | desc")
561
+ .addHelpText("after", `\nExample:\n figma-api activity-logs --limit 100 --order desc`)
562
+ .action(async (o: any) => output(await get(`/v1/activity_logs`, {
563
+ events: o.events, start_time: o.startTime, end_time: o.endTime, limit: o.limit, order: o.order,
564
+ })));
565
+
566
+ program
567
+ .command("payments")
568
+ .description("Get payment information for a plugin/widget/community-file user")
569
+ .option("--token <plugin_payment_token>", "plugin payment token (from plugin runtime; alternative to --user-id)")
570
+ .option("--user-id <id>", "the Figma user id (use with one of --plugin-id/--widget-id/--community-file-id)")
571
+ .option("--plugin-id <id>", "plugin resource id")
572
+ .option("--widget-id <id>", "widget resource id")
573
+ .option("--community-file-id <id>", "community file resource id")
574
+ .addHelpText("after", `
575
+ Provide either --token, or --user-id together with one of --plugin-id / --widget-id
576
+ / --community-file-id.
577
+ Examples:
578
+ figma-api payments --token <plugin_payment_token>
579
+ figma-api payments --user-id 12345 --plugin-id 67890`)
580
+ .action(async (o: any) => output(await get(`/v1/payments`, {
581
+ plugin_payment_token: o.token, user_id: o.userId,
582
+ plugin_id: o.pluginId, widget_id: o.widgetId, community_file_id: o.communityFileId,
583
+ })));
584
+
585
+ program
586
+ .command("oembed <url>")
587
+ .description("Get oEmbed metadata for a public Figma file/prototype URL")
588
+ .addHelpText("after", `\nExample:\n figma-api oembed https://www.figma.com/design/AbC123/My-File`)
589
+ .action(async (url: string) => output(await get(`/v1/oembed`, { url })));
590
+
591
+ // ── plugin bridge (canvas writes the REST API can't do) ───────────────────────
592
+ program
593
+ .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")
596
+ .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.
600
+
601
+ Flow:
602
+ 1. figma-api bridge # start this relay (keep running)
603
+ 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
606
+
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.
609
+
610
+ ⚠️ 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.`)
613
+ .action((o: any) => startBridge(Number(o.port)));
614
+
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)
619
+ .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.
626
+
627
+ ⚠️ Executes arbitrary code in your Figma document — only run code you trust.
628
+
629
+ 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'
632
+ figma-api run @make-card.js`)
633
+ .action(async (codeArg: string, o: any) => {
634
+ const code = codeArg.startsWith("@") ? readFileSync(codeArg.slice(1), "utf-8") : codeArg;
635
+ await sendCommand(o.relay, "eval", { code });
636
+ });
637
+
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
+ program.parse();