@todoforai/figma-api 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +54 -25
- package/README.md +40 -20
- 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 +167 -101
- package/src/index.ts +224 -40
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,204 @@ 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
|
-
2.
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
643
|
+
2. Connect a plugin in the Figma DESKTOP app (pick one):
|
|
644
|
+
EASY: install "Cursor Talk To Figma MCP" from the Figma Community, run it,
|
|
645
|
+
set port ${DEFAULT_PORT}, Connect. Its random channel is fine — the relay
|
|
646
|
+
bridges channels, so no --channel needed.
|
|
647
|
+
ADVANCED: Plugins → Development → Import plugin from manifest →
|
|
648
|
+
plugin/manifest.json, set URL ws://localhost:${DEFAULT_PORT}, channel
|
|
649
|
+
"${DEFAULT_CHANNEL}", Connect.
|
|
650
|
+
3. figma-api ping # verify; then create-frame / create-text / …
|
|
651
|
+
|
|
652
|
+
Use the DESKTOP app: a browser tab can't reach ws://localhost:${DEFAULT_PORT} (an https
|
|
653
|
+
page blocks insecure ws), so it never connects. If desktop is impossible, expose the
|
|
654
|
+
relay over TLS with 'cloudflared tunnel --url http://localhost:${DEFAULT_PORT}' and paste the
|
|
655
|
+
wss:// URL into the plugin.
|
|
609
656
|
|
|
610
657
|
⚠️ 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.`)
|
|
658
|
+
Figma document. Only run it on a trusted machine/network.`)
|
|
613
659
|
.action((o: any) => startBridge(Number(o.port)));
|
|
614
660
|
|
|
615
|
-
|
|
616
|
-
.
|
|
617
|
-
.
|
|
618
|
-
|
|
661
|
+
canvas("ping")
|
|
662
|
+
.description("Ping the connected plugin to verify the bridge round-trip")
|
|
663
|
+
.addHelpText("after", `\nReturns the current document info. Works with both the bundled plugin and the\ncommunity Cursor-Talk-To-Figma plugin.\nExample:\n figma-api ping`)
|
|
664
|
+
// get_document_info exists in both the bundled and the community plugin (unlike
|
|
665
|
+
// "ping", which is bundled-only), so verification works whichever is connected.
|
|
666
|
+
.action((o: any) => drive(o, "get_document_info"));
|
|
667
|
+
|
|
668
|
+
// ── canvas reads ──────────────────────────────────────────────────────────────
|
|
669
|
+
canvas("get-document-info")
|
|
670
|
+
.description("Get the current page and its top-level children (via plugin)")
|
|
671
|
+
.action((o: any) => drive(o, "get_document_info"));
|
|
672
|
+
|
|
673
|
+
canvas("get-selection")
|
|
674
|
+
.description("Get the current selection on the canvas (via plugin)")
|
|
675
|
+
.action((o: any) => drive(o, "get_selection"));
|
|
676
|
+
|
|
677
|
+
canvas("get-node-info <node-id>")
|
|
678
|
+
.description("Get full info for one node (via plugin)")
|
|
679
|
+
.action((id: string, o: any) => drive(o, "get_node_info", { nodeId: id }));
|
|
680
|
+
|
|
681
|
+
canvas("get-nodes-info <ids>")
|
|
682
|
+
.description("Get full info for several nodes (comma-separated ids, via plugin)")
|
|
683
|
+
.action((ids: string, o: any) => drive(o, "get_nodes_info", { nodeIds: ids.split(",") }));
|
|
684
|
+
|
|
685
|
+
canvas("get-styles")
|
|
686
|
+
.description("List local paint/text/effect/grid styles (via plugin)")
|
|
687
|
+
.action((o: any) => drive(o, "get_styles"));
|
|
688
|
+
|
|
689
|
+
canvas("get-components")
|
|
690
|
+
.description("List local components in the document (via plugin)")
|
|
691
|
+
.action((o: any) => drive(o, "get_local_components"));
|
|
692
|
+
|
|
693
|
+
canvas("export-image <node-id>")
|
|
694
|
+
.description("Export a node as a base64 PNG (via plugin)")
|
|
695
|
+
.option("--scale <n>", "scale factor", "1")
|
|
696
|
+
.action((id: string, o: any) => drive(o, "export_node_as_image", { nodeId: id, scale: Number(o.scale) }));
|
|
697
|
+
|
|
698
|
+
// ── canvas creation ───────────────────────────────────────────────────────────
|
|
699
|
+
canvas("create-frame")
|
|
700
|
+
.description("Create a frame on the canvas (via plugin)")
|
|
701
|
+
.option("--x <n>", "x", "0").option("--y <n>", "y", "0")
|
|
702
|
+
.option("--width <n>", "width", "100").option("--height <n>", "height", "100")
|
|
703
|
+
.option("--name <name>", "layer name")
|
|
704
|
+
.option("--parent <id>", "parent node id (defaults to current page)")
|
|
705
|
+
.option("--fill <color>", "fill color: r,g,b[,a] or #hex")
|
|
706
|
+
.option("--stroke <color>", "stroke color: r,g,b[,a] or #hex")
|
|
707
|
+
.option("--stroke-weight <n>", "stroke weight")
|
|
708
|
+
.option("--layout <mode>", "auto layout: NONE | HORIZONTAL | VERTICAL")
|
|
709
|
+
.option("--item-spacing <n>", "item spacing (auto layout)")
|
|
710
|
+
.addHelpText("after", `\nExample:\n figma-api create-frame --width 320 --height 200 --fill "#1F6FEB" --name Card`)
|
|
711
|
+
.action((o: any) => drive(o, "create_frame", {
|
|
712
|
+
x: Number(o.x), y: Number(o.y), width: Number(o.width), height: Number(o.height),
|
|
713
|
+
name: o.name, parentId: o.parent,
|
|
714
|
+
fillColor: o.fill ? parseColor(o.fill) : undefined,
|
|
715
|
+
strokeColor: o.stroke ? parseColor(o.stroke) : undefined,
|
|
716
|
+
strokeWeight: num(o.strokeWeight),
|
|
717
|
+
layoutMode: o.layout, itemSpacing: num(o.itemSpacing),
|
|
718
|
+
}));
|
|
719
|
+
|
|
720
|
+
canvas("create-rectangle")
|
|
721
|
+
.description("Create a rectangle on the canvas (via plugin)")
|
|
722
|
+
.option("--x <n>", "x", "0").option("--y <n>", "y", "0")
|
|
723
|
+
.option("--width <n>", "width", "100").option("--height <n>", "height", "100")
|
|
724
|
+
.option("--name <name>", "layer name").option("--parent <id>", "parent node id")
|
|
725
|
+
.addHelpText("after", `\nExample:\n figma-api create-rectangle --width 80 --height 80 --name Box`)
|
|
726
|
+
.action((o: any) => drive(o, "create_rectangle", {
|
|
727
|
+
x: Number(o.x), y: Number(o.y), width: Number(o.width), height: Number(o.height), name: o.name, parentId: o.parent,
|
|
728
|
+
}));
|
|
729
|
+
|
|
730
|
+
canvas("create-text <text>")
|
|
731
|
+
.description("Create a text node on the canvas (via plugin)")
|
|
732
|
+
.option("--x <n>", "x", "0").option("--y <n>", "y", "0")
|
|
733
|
+
.option("--size <n>", "font size", "14").option("--weight <n>", "font weight (100–900)", "400")
|
|
734
|
+
.option("--color <color>", "font color: r,g,b[,a] or #hex")
|
|
735
|
+
.option("--name <name>", "layer name").option("--parent <id>", "parent node id")
|
|
736
|
+
.addHelpText("after", `\nExample:\n figma-api create-text "Hello" --size 24 --weight 700 --color "#111"`)
|
|
737
|
+
.action((text: string, o: any) => drive(o, "create_text", {
|
|
738
|
+
text, x: Number(o.x), y: Number(o.y), fontSize: Number(o.size), fontWeight: Number(o.weight),
|
|
739
|
+
fontColor: o.color ? parseColor(o.color) : undefined, name: o.name, parentId: o.parent,
|
|
740
|
+
}));
|
|
741
|
+
|
|
742
|
+
canvas("create-instance <component-key>")
|
|
743
|
+
.description("Create an instance of a published component (via plugin)")
|
|
744
|
+
.option("--x <n>", "x", "0").option("--y <n>", "y", "0").option("--parent <id>", "parent node id")
|
|
745
|
+
.action((key: string, o: any) => drive(o, "create_component_instance", { componentKey: key, x: Number(o.x), y: Number(o.y), parentId: o.parent }));
|
|
746
|
+
|
|
747
|
+
// ── canvas edits ──────────────────────────────────────────────────────────────
|
|
748
|
+
canvas("set-fill-color <node-id> <color>")
|
|
749
|
+
.description("Set a node's solid fill (color: r,g,b[,a] or #hex, via plugin)")
|
|
750
|
+
.action((id: string, color: string, o: any) => drive(o, "set_fill_color", { nodeId: id, color: parseColor(color) }));
|
|
751
|
+
|
|
752
|
+
canvas("set-stroke-color <node-id> <color>")
|
|
753
|
+
.description("Set a node's solid stroke (via plugin)")
|
|
754
|
+
.option("--weight <n>", "stroke weight", "1")
|
|
755
|
+
.action((id: string, color: string, o: any) => drive(o, "set_stroke_color", { nodeId: id, color: parseColor(color), weight: Number(o.weight) }));
|
|
756
|
+
|
|
757
|
+
canvas("set-corner-radius <node-id> <radius>")
|
|
758
|
+
.description("Set a node's corner radius (via plugin)")
|
|
759
|
+
.action((id: string, radius: string, o: any) => drive(o, "set_corner_radius", { nodeId: id, radius: Number(radius) }));
|
|
760
|
+
|
|
761
|
+
canvas("set-text <node-id> <text>")
|
|
762
|
+
.description("Replace a text node's content (via plugin)")
|
|
763
|
+
.action((id: string, text: string, o: any) => drive(o, "set_text_content", { nodeId: id, text }));
|
|
764
|
+
|
|
765
|
+
canvas("move-node <node-id> <x> <y>")
|
|
766
|
+
.description("Move a node to (x, y) (via plugin)")
|
|
767
|
+
.action((id: string, x: string, y: string, o: any) => drive(o, "move_node", { nodeId: id, x: Number(x), y: Number(y) }));
|
|
768
|
+
|
|
769
|
+
canvas("resize-node <node-id> <width> <height>")
|
|
770
|
+
.description("Resize a node (via plugin)")
|
|
771
|
+
.action((id: string, w: string, h: string, o: any) => drive(o, "resize_node", { nodeId: id, width: Number(w), height: Number(h) }));
|
|
772
|
+
|
|
773
|
+
canvas("clone-node <node-id>")
|
|
774
|
+
.description("Clone a node (via plugin)")
|
|
775
|
+
.option("--x <n>", "x of the clone").option("--y <n>", "y of the clone").option("--parent <id>", "parent node id")
|
|
776
|
+
.action((id: string, o: any) => drive(o, "clone_node", { nodeId: id, x: num(o.x), y: num(o.y), parentId: o.parent }));
|
|
777
|
+
|
|
778
|
+
canvas("delete-node <node-id>")
|
|
779
|
+
.description("Delete a node (via plugin)")
|
|
780
|
+
.action((id: string, o: any) => drive(o, "delete_node", { nodeId: id }));
|
|
781
|
+
|
|
782
|
+
canvas("delete-nodes <ids>")
|
|
783
|
+
.description("Delete several nodes (comma-separated ids, via plugin)")
|
|
784
|
+
.action((ids: string, o: any) => drive(o, "delete_multiple_nodes", { nodeIds: ids.split(",") }));
|
|
785
|
+
|
|
786
|
+
canvas("set-layout <node-id> <mode>")
|
|
787
|
+
.description("Set auto layout mode: NONE | HORIZONTAL | VERTICAL (via plugin)")
|
|
788
|
+
.action((id: string, mode: string, o: any) => drive(o, "set_layout_mode", { nodeId: id, layoutMode: mode }));
|
|
789
|
+
|
|
790
|
+
canvas("set-padding <node-id>")
|
|
791
|
+
.description("Set auto-layout padding (via plugin)")
|
|
792
|
+
.option("--top <n>").option("--right <n>").option("--bottom <n>").option("--left <n>")
|
|
793
|
+
.action((id: string, o: any) => drive(o, "set_padding", {
|
|
794
|
+
nodeId: id, paddingTop: num(o.top), paddingRight: num(o.right),
|
|
795
|
+
paddingBottom: num(o.bottom), paddingLeft: num(o.left),
|
|
796
|
+
}));
|
|
797
|
+
|
|
798
|
+
canvas("set-item-spacing <node-id> <spacing>")
|
|
799
|
+
.description("Set auto-layout item spacing (via plugin)")
|
|
800
|
+
.action((id: string, s: string, o: any) => drive(o, "set_item_spacing", { nodeId: id, itemSpacing: Number(s) }));
|
|
801
|
+
|
|
802
|
+
canvas("focus <node-id>")
|
|
803
|
+
.description("Select a node and zoom the viewport to it (via plugin)")
|
|
804
|
+
.action((id: string, o: any) => drive(o, "set_focus", { nodeId: id }));
|
|
805
|
+
|
|
806
|
+
canvas("select <ids>")
|
|
807
|
+
.description("Set the selection (comma-separated ids) and zoom to it (via plugin)")
|
|
808
|
+
.action((ids: string, o: any) => drive(o, "set_selections", { nodeIds: ids.split(",") }));
|
|
809
|
+
|
|
810
|
+
// ── escape hatch: arbitrary Plugin API code ──────────────────────────────────
|
|
811
|
+
canvas("run <code-or-@file>")
|
|
812
|
+
.description("Run arbitrary Figma Plugin API code in the connected plugin (escape hatch)")
|
|
619
813
|
.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.
|
|
814
|
+
Whatever the Figma Plugin API can do, this can do. \`figma\` is in scope; you may
|
|
815
|
+
use await and \`return\` a value (nodes come back as {id,type,name}). Prefer the
|
|
816
|
+
named create-*/set-* commands; reach for 'run' only for things they don't cover.
|
|
817
|
+
Pass code inline or as @file.js.
|
|
626
818
|
|
|
627
819
|
⚠️ Executes arbitrary code in your Figma document — only run code you trust.
|
|
628
820
|
|
|
629
821
|
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'
|
|
822
|
+
figma-api run 'return figma.currentPage.selection.map(n => n.name)'
|
|
632
823
|
figma-api run @make-card.js`)
|
|
633
|
-
.action(
|
|
824
|
+
.action((codeArg: string, o: any) => {
|
|
634
825
|
const code = codeArg.startsWith("@") ? readFileSync(codeArg.slice(1), "utf-8") : codeArg;
|
|
635
|
-
|
|
826
|
+
return drive(o, "eval", { code });
|
|
636
827
|
});
|
|
637
828
|
|
|
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
829
|
program.parse();
|