@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/src/index.ts CHANGED
@@ -5,9 +5,44 @@ import {
5
5
  saveCredentials, loadCredentials,
6
6
  get, post, put, del, output, parseFigmaTarget, readJsonArg,
7
7
  } from "./api";
8
- import { startBridge, sendCommand } from "./bridge";
9
-
10
- const DEFAULT_RELAY = process.env.FIGMA_RELAY || "http://localhost:8917";
8
+ import { startBridge, runCommand, DEFAULT_PORT, DEFAULT_CHANNEL } from "./bridge";
9
+
10
+ const DEFAULT_RELAY = process.env.FIGMA_RELAY || `ws://localhost:${DEFAULT_PORT}`;
11
+ const DEFAULT_CH = process.env.FIGMA_CHANNEL || DEFAULT_CHANNEL;
12
+
13
+ /** Parse "r,g,b[,a]" (0–255 or 0–1) or "#rgb[a]" / "#rrggbb[aa]" into Figma's 0–1 {r,g,b,a}. */
14
+ function parseColor(s: string): { r: number; g: number; b: number; a: number } {
15
+ if (s.startsWith("#")) {
16
+ let h = s.slice(1);
17
+ if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join(""); // #rgb → #rrggbb
18
+ if ((h.length !== 6 && h.length !== 8) || /[^0-9a-fA-F]/.test(h)) {
19
+ console.error(`Invalid hex color: ${s}`); process.exit(1);
20
+ }
21
+ const n = (i: number) => parseInt(h.slice(i, i + 2), 16) / 255;
22
+ return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) : 1 };
23
+ }
24
+ const p = s.split(",").map(Number);
25
+ const bad = (p.length !== 3 && p.length !== 4) || p.some((v) => !Number.isFinite(v) || v < 0);
26
+ const rgb255 = p.slice(0, 3).some((v) => v > 1);
27
+ if (bad || p.slice(0, 3).some((v) => v > (rgb255 ? 255 : 1)) || (p[3] !== undefined && p[3] > 1)) {
28
+ console.error(`Invalid color: ${s} (use r,g,b[,a] as 0–1 or 0–255 RGB, alpha 0–1; or #hex)`);
29
+ process.exit(1);
30
+ }
31
+ const d = rgb255 ? 255 : 1;
32
+ return { r: p[0] / d, g: p[1] / d, b: p[2] / d, a: p[3] ?? 1 };
33
+ }
34
+
35
+ /** Common flags for every canvas command + a thin sender. */
36
+ function canvas(name: string) {
37
+ return program
38
+ .command(name)
39
+ .option("--relay <url>", "relay WebSocket URL", DEFAULT_RELAY)
40
+ .option("--channel <name>", "relay channel", DEFAULT_CH);
41
+ }
42
+ const drive = (o: any, command: string, params: Record<string, unknown> = {}) =>
43
+ runCommand(o.relay, o.channel, command, params);
44
+ /** Number(v) when the option is present (incl. "0"), else undefined. */
45
+ const num = (v: unknown) => (v === undefined ? undefined : Number(v));
11
46
 
12
47
  program
13
48
  .name("figma-api")
@@ -17,9 +52,11 @@ program
17
52
  "Auth: figma-api auth <personal-access-token> (or FIGMA_TOKEN env var)\n" +
18
53
  "Token: create at https://www.figma.com/settings → Personal access tokens\n" +
19
54
  "Most commands accept a file URL or a raw file key. node-id is read from the URL too.\n\n" +
20
- "NOTE: Creating canvas layers (frames/shapes/text) is NOT in the REST API — that\n" +
21
- "lives in the Figma plugin/MCP runtime. Writable here: comments, reactions,\n" +
22
- "variables, dev resources and webhooks (token needs the matching write scopes)."
55
+ "NOTE: Creating canvas layers (frames/shapes/text) is NOT in the REST API — those\n" +
56
+ "go through the plugin bridge (create-frame, create-text, set-fill-color, …). It\n" +
57
+ "speaks the cursor-talk-to-figma WebSocket protocol, so any client in that\n" +
58
+ "ecosystem is interchangeable. REST-writable here: comments, reactions, variables,\n" +
59
+ "dev resources and webhooks (token needs the matching write scopes)."
23
60
  )
24
61
  .version("1.0.0")
25
62
  .addHelpText("after", `
@@ -589,57 +626,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 figma-api CLI drive the Figma plugin (canvas writes)")
595
- .option("--port <n>", "port to listen on", "8917")
633
+ .description("Start the WebSocket relay that lets the CLI drive the Figma plugin (canvas writes)")
634
+ .option("--port <n>", "port to listen on", String(DEFAULT_PORT))
596
635
  .addHelpText("after", `
597
- The Figma REST API cannot create canvas nodes (frames/text/shapes) or edit
598
- variables off-Enterprise. This relay bridges the CLI and the companion Figma
599
- plugin (see plugin/ folder), which can.
636
+ The Figma REST API cannot create canvas nodes (frames/text/shapes). This relay
637
+ bridges the CLI and the companion Figma plugin (see plugin/ folder), which can.
638
+ It speaks the cursor-talk-to-figma WebSocket protocol — compatible with that
639
+ ecosystem's MCP server / plugin.
600
640
 
601
641
  Flow:
602
642
  1. figma-api bridge # start this relay (keep running)
603
- 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.
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; do not expose the
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
- 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)
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 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.
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 => ({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'
822
+ figma-api run 'return figma.currentPage.selection.map(n => n.name)'
632
823
  figma-api run @make-card.js`)
633
- .action(async (codeArg: string, o: any) => {
824
+ .action((codeArg: string, o: any) => {
634
825
  const code = codeArg.startsWith("@") ? readFileSync(codeArg.slice(1), "utf-8") : codeArg;
635
- await sendCommand(o.relay, "eval", { code });
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();