@spectratools/figma-cli 0.2.0 → 0.3.1

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.
Files changed (3) hide show
  1. package/dist/cli.js +1151 -4
  2. package/dist/index.js +1010 -26
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -2,32 +2,13 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { readFileSync, realpathSync } from "fs";
5
- import { dirname, resolve } from "path";
5
+ import { dirname, resolve as resolve2 } from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { initTelemetry, shutdownTelemetry } from "@spectratools/cli-shared/telemetry";
8
- import { Cli } from "incur";
9
- var __dirname = dirname(fileURLToPath(import.meta.url));
10
- var pkg = JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"));
11
- var cli = Cli.create("figma", {
12
- version: pkg.version,
13
- description: "Query Figma REST API data from the command line."
14
- });
15
- var isMain = (() => {
16
- const entrypoint = process.argv[1];
17
- if (!entrypoint) {
18
- return false;
19
- }
20
- try {
21
- return realpathSync(entrypoint) === realpathSync(fileURLToPath(import.meta.url));
22
- } catch {
23
- return false;
24
- }
25
- })();
26
- if (isMain) {
27
- initTelemetry("figma");
28
- process.on("beforeExit", () => shutdownTelemetry());
29
- cli.serve();
30
- }
8
+ import { Cli as Cli7 } from "incur";
9
+
10
+ // src/commands/comments.ts
11
+ import { Cli, z as z4 } from "incur";
31
12
 
32
13
  // src/api.ts
33
14
  import {
@@ -61,12 +42,18 @@ var styleSchema = z.object({
61
42
  style_type: z.string(),
62
43
  description: z.string().optional()
63
44
  });
45
+ var clientMetaSchema = z.object({
46
+ node_id: z.string().optional(),
47
+ node_offset: z.object({ x: z.number(), y: z.number() }).optional()
48
+ }).optional();
64
49
  var commentSchema = z.object({
65
50
  id: z.string(),
66
51
  message: z.string(),
67
52
  created_at: z.string(),
53
+ resolved_at: z.string().nullable().optional(),
68
54
  user: userSchema,
69
- order_id: z.union([z.string(), z.number()]).optional()
55
+ order_id: z.union([z.string(), z.number()]).optional(),
56
+ client_meta: clientMetaSchema
70
57
  });
71
58
  var fileMetaSchema = z.object({
72
59
  name: z.string(),
@@ -189,13 +176,1010 @@ function createFigmaClient(apiKey) {
189
176
  };
190
177
  }
191
178
 
179
+ // src/commands/_common.ts
180
+ import { z as z3 } from "incur";
181
+
192
182
  // src/auth.ts
193
183
  import { z as z2 } from "incur";
194
184
  var figmaEnv = z2.object({
195
185
  FIGMA_API_KEY: z2.string().describe("Figma personal access token")
196
186
  });
187
+
188
+ // src/commands/_common.ts
189
+ var fileKeyArg = z3.string().describe("Figma file key (from the file URL)");
190
+ var formatOption = z3.enum(["json", "table"]).default("json").describe("Output format: json or table");
191
+ function outputFormatter(data, format) {
192
+ if (format === "json") {
193
+ return JSON.stringify(data, null, 2);
194
+ }
195
+ const rows = Array.isArray(data) ? data : [data];
196
+ if (rows.length === 0) return "(no data)";
197
+ const keys = Object.keys(rows[0]);
198
+ const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => String(r[k] ?? "").length)));
199
+ const header = keys.map((k, i) => k.padEnd(widths[i])).join(" ");
200
+ const separator = widths.map((w) => "-".repeat(w)).join(" ");
201
+ const body = rows.map((r) => keys.map((k, i) => String(r[k] ?? "").padEnd(widths[i])).join(" ")).join("\n");
202
+ return `${header}
203
+ ${separator}
204
+ ${body}`;
205
+ }
206
+
207
+ // src/commands/comments.ts
208
+ var commentsCli = Cli.create("comments", {
209
+ description: "List and post comments on Figma files."
210
+ });
211
+ commentsCli.command("list", {
212
+ description: "List all comments on a Figma file.",
213
+ args: z4.object({ fileKey: fileKeyArg }),
214
+ options: z4.object({ format: formatOption }),
215
+ env: figmaEnv,
216
+ output: z4.unknown(),
217
+ examples: [
218
+ {
219
+ args: { fileKey: "abc123" },
220
+ options: { format: "json" },
221
+ description: "List comments as JSON"
222
+ },
223
+ {
224
+ args: { fileKey: "abc123" },
225
+ options: { format: "table" },
226
+ description: "List comments as table"
227
+ }
228
+ ],
229
+ async run(c) {
230
+ const fileKey = c.args.fileKey;
231
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
232
+ const response = await client.getComments(fileKey);
233
+ const comments = response.comments.map((comment) => ({
234
+ id: comment.id,
235
+ author: comment.user.handle,
236
+ message: comment.message,
237
+ createdAt: comment.created_at,
238
+ resolved: comment.resolved_at != null,
239
+ nodeId: comment.client_meta?.node_id ?? null
240
+ }));
241
+ if (comments.length === 0) {
242
+ return c.ok(
243
+ c.options.format === "json" ? { comments: [], message: "No comments found." } : "No comments found."
244
+ );
245
+ }
246
+ if (c.options.format === "json") {
247
+ return c.ok({ comments, total: comments.length });
248
+ }
249
+ return c.ok(
250
+ outputFormatter(
251
+ comments.map((co) => ({
252
+ id: co.id,
253
+ author: co.author,
254
+ message: co.message.length > 50 ? `${co.message.slice(0, 47)}...` : co.message,
255
+ createdAt: co.createdAt,
256
+ resolved: co.resolved ? "yes" : "no",
257
+ nodeId: co.nodeId ?? "-"
258
+ })),
259
+ "table"
260
+ )
261
+ );
262
+ }
263
+ });
264
+ commentsCli.command("post", {
265
+ description: "Post a comment on a Figma file.",
266
+ args: z4.object({ fileKey: fileKeyArg }),
267
+ options: z4.object({
268
+ message: z4.string().describe("Comment text (required)"),
269
+ "node-id": z4.string().optional().describe("Pin comment to a specific node ID")
270
+ }),
271
+ env: figmaEnv,
272
+ output: z4.unknown(),
273
+ examples: [
274
+ {
275
+ args: { fileKey: "abc123" },
276
+ options: { message: "Looks good!" },
277
+ description: "Post a general comment"
278
+ },
279
+ {
280
+ args: { fileKey: "abc123" },
281
+ options: { message: "Check this spacing", "node-id": "1:42" },
282
+ description: "Post a comment pinned to a node"
283
+ }
284
+ ],
285
+ async run(c) {
286
+ const fileKey = c.args.fileKey;
287
+ const { message, "node-id": nodeId } = c.options;
288
+ if (!message || message.trim().length === 0) {
289
+ return c.error({
290
+ code: "VALIDATION_ERROR",
291
+ message: "--message is required and cannot be empty"
292
+ });
293
+ }
294
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
295
+ const comment = await client.postComment(fileKey, message, nodeId);
296
+ return c.ok({
297
+ id: comment.id,
298
+ message: comment.message,
299
+ author: comment.user.handle,
300
+ createdAt: comment.created_at,
301
+ permalink: `https://www.figma.com/file/${fileKey}?comment=${comment.id}`
302
+ });
303
+ }
304
+ });
305
+
306
+ // src/commands/components.ts
307
+ import { Cli as Cli2, z as z5 } from "incur";
308
+ var componentsCli = Cli2.create("components", {
309
+ description: "List and inspect published Figma components."
310
+ });
311
+ var componentOutputSchema = z5.object({
312
+ key: z5.string(),
313
+ name: z5.string(),
314
+ description: z5.string()
315
+ });
316
+ componentsCli.command("list", {
317
+ description: "List published components in a Figma file.",
318
+ args: z5.object({
319
+ fileKey: z5.string().describe("Figma file key")
320
+ }),
321
+ options: z5.object({}),
322
+ env: figmaEnv,
323
+ output: z5.object({
324
+ components: z5.array(componentOutputSchema),
325
+ total: z5.number()
326
+ }),
327
+ examples: [
328
+ {
329
+ args: { fileKey: "abc123FileKey" },
330
+ options: {},
331
+ description: "List all published components"
332
+ }
333
+ ],
334
+ async run(c) {
335
+ const { fileKey } = c.args;
336
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
337
+ const response = await client.getFileComponents(fileKey);
338
+ const components = response.meta.components;
339
+ return c.ok(
340
+ {
341
+ components: components.map((comp) => ({
342
+ key: comp.key,
343
+ name: comp.name,
344
+ description: comp.description
345
+ })),
346
+ total: components.length
347
+ },
348
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
349
+ cta: {
350
+ commands: components.length > 0 ? [
351
+ {
352
+ command: `components get ${fileKey} ${components[0]?.key}`,
353
+ description: `Get details for "${components[0]?.name}"`
354
+ }
355
+ ] : [
356
+ {
357
+ command: `tokens export ${fileKey}`,
358
+ description: "Export design tokens from this file"
359
+ }
360
+ ]
361
+ }
362
+ }
363
+ );
364
+ }
365
+ });
366
+ componentsCli.command("get", {
367
+ description: "Get details for a specific published component.",
368
+ args: z5.object({
369
+ fileKey: z5.string().describe("Figma file key"),
370
+ componentKey: z5.string().describe("Component key")
371
+ }),
372
+ options: z5.object({}),
373
+ env: figmaEnv,
374
+ output: z5.object({
375
+ key: z5.string(),
376
+ name: z5.string(),
377
+ description: z5.string(),
378
+ found: z5.boolean()
379
+ }),
380
+ examples: [
381
+ {
382
+ args: { fileKey: "abc123FileKey", componentKey: "comp:456" },
383
+ options: {},
384
+ description: "Get details for a specific component"
385
+ }
386
+ ],
387
+ async run(c) {
388
+ const { fileKey, componentKey } = c.args;
389
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
390
+ const response = await client.getFileComponents(fileKey);
391
+ const match = response.meta.components.find((comp) => comp.key === componentKey);
392
+ if (!match) {
393
+ return c.ok(
394
+ {
395
+ key: componentKey,
396
+ name: "",
397
+ description: "",
398
+ found: false
399
+ },
400
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
401
+ cta: {
402
+ commands: [
403
+ {
404
+ command: `components list ${fileKey}`,
405
+ description: "List all available components"
406
+ }
407
+ ]
408
+ }
409
+ }
410
+ );
411
+ }
412
+ return c.ok(
413
+ {
414
+ key: match.key,
415
+ name: match.name,
416
+ description: match.description,
417
+ found: true
418
+ },
419
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
420
+ cta: {
421
+ commands: [
422
+ {
423
+ command: `components list ${fileKey}`,
424
+ description: "List all components"
425
+ },
426
+ {
427
+ command: `tokens export ${fileKey}`,
428
+ description: "Export design tokens"
429
+ }
430
+ ]
431
+ }
432
+ }
433
+ );
434
+ }
435
+ });
436
+
437
+ // src/commands/files.ts
438
+ import { Cli as Cli3, z as z6 } from "incur";
439
+ var pageSchema = z6.object({
440
+ id: z6.string(),
441
+ name: z6.string()
442
+ });
443
+ var fileGetOutputSchema = z6.object({
444
+ name: z6.string(),
445
+ lastModified: z6.string(),
446
+ version: z6.string(),
447
+ thumbnailUrl: z6.string().optional(),
448
+ pages: z6.array(pageSchema)
449
+ });
450
+ var projectFileOutputSchema = z6.object({
451
+ key: z6.string(),
452
+ name: z6.string(),
453
+ lastModified: z6.string(),
454
+ thumbnailUrl: z6.string().optional()
455
+ });
456
+ var filesListOutputSchema = z6.object({
457
+ projectName: z6.string(),
458
+ files: z6.array(projectFileOutputSchema),
459
+ count: z6.number()
460
+ });
461
+ var filesCli = Cli3.create("files", {
462
+ description: "Query Figma file metadata."
463
+ });
464
+ filesCli.command("get", {
465
+ description: "Get metadata for a Figma file.",
466
+ args: z6.object({
467
+ fileKey: fileKeyArg
468
+ }),
469
+ options: z6.object({
470
+ format: formatOption
471
+ }),
472
+ env: figmaEnv,
473
+ output: fileGetOutputSchema,
474
+ examples: [
475
+ {
476
+ args: { fileKey: "abc123XYZ" },
477
+ description: "Get file metadata for a Figma file"
478
+ }
479
+ ],
480
+ async run(c) {
481
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
482
+ const file = await client.getFile(c.args.fileKey, { depth: 1 });
483
+ const doc = file.document;
484
+ const pages = doc?.children?.map((child) => ({ id: child.id, name: child.name })) ?? [];
485
+ const result = {
486
+ name: file.name,
487
+ lastModified: file.lastModified,
488
+ version: file.version,
489
+ thumbnailUrl: void 0,
490
+ pages
491
+ };
492
+ if (c.options.format === "table") {
493
+ process.stdout.write(
494
+ `${outputFormatter(result, "table")}
495
+ `
496
+ );
497
+ }
498
+ return c.ok(
499
+ result,
500
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
501
+ cta: {
502
+ commands: [
503
+ {
504
+ command: "nodes get",
505
+ args: { fileKey: c.args.fileKey, nodeId: pages[0]?.id ?? "<node-id>" },
506
+ description: "Inspect a node in this file"
507
+ }
508
+ ]
509
+ }
510
+ }
511
+ );
512
+ }
513
+ });
514
+ filesCli.command("list", {
515
+ description: "List files in a Figma project.",
516
+ options: z6.object({
517
+ "project-id": z6.string().describe("Figma project ID"),
518
+ format: formatOption
519
+ }),
520
+ env: figmaEnv,
521
+ output: filesListOutputSchema,
522
+ examples: [
523
+ {
524
+ options: { "project-id": "12345" },
525
+ description: "List files in a Figma project"
526
+ }
527
+ ],
528
+ async run(c) {
529
+ const projectId = c.options["project-id"];
530
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
531
+ const project = await client.getProjectFiles(projectId);
532
+ const files = project.files.map((f) => ({
533
+ key: f.key,
534
+ name: f.name,
535
+ lastModified: f.last_modified,
536
+ thumbnailUrl: f.thumbnail_url
537
+ }));
538
+ const result = {
539
+ projectName: project.name,
540
+ files,
541
+ count: files.length
542
+ };
543
+ if (c.options.format === "table") {
544
+ process.stdout.write(`${outputFormatter(files, "table")}
545
+ `);
546
+ }
547
+ return c.ok(
548
+ result,
549
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
550
+ cta: {
551
+ commands: files[0] ? [
552
+ {
553
+ command: "files get",
554
+ args: { fileKey: files[0].key },
555
+ description: `Get metadata for "${files[0].name}"`
556
+ }
557
+ ] : []
558
+ }
559
+ }
560
+ );
561
+ }
562
+ });
563
+
564
+ // src/commands/frames.ts
565
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
566
+ import { resolve } from "path";
567
+ import { Cli as Cli4, z as z7 } from "incur";
568
+ var framesCli = Cli4.create("frames", {
569
+ description: "Export frame metadata and render frame images from Figma files."
570
+ });
571
+ function extractFrames(document, pageFilter) {
572
+ const doc = document;
573
+ const frames = [];
574
+ for (const page of doc.children ?? []) {
575
+ if (page.type !== "CANVAS") continue;
576
+ if (pageFilter && page.name !== pageFilter) continue;
577
+ for (const child of page.children ?? []) {
578
+ if (child.type !== "FRAME") continue;
579
+ frames.push({
580
+ name: child.name,
581
+ nodeId: child.id,
582
+ page: page.name,
583
+ width: child.absoluteBoundingBox?.width ?? 0,
584
+ height: child.absoluteBoundingBox?.height ?? 0
585
+ });
586
+ }
587
+ }
588
+ return frames;
589
+ }
590
+ async function downloadFile(url) {
591
+ const res = await fetch(url);
592
+ if (!res.ok) {
593
+ throw new Error(`Download failed (${res.status}): ${url}`);
594
+ }
595
+ return Buffer.from(await res.arrayBuffer());
596
+ }
597
+ framesCli.command("export", {
598
+ description: "List all top-level frames in a Figma file.",
599
+ args: z7.object({ fileKey: fileKeyArg }),
600
+ options: z7.object({
601
+ page: z7.string().optional().describe("Filter to frames on a specific page"),
602
+ format: formatOption
603
+ }),
604
+ env: figmaEnv,
605
+ output: z7.unknown(),
606
+ examples: [
607
+ {
608
+ args: { fileKey: "abc123" },
609
+ options: { format: "json" },
610
+ description: "List frames in a file"
611
+ },
612
+ {
613
+ args: { fileKey: "abc123" },
614
+ options: { page: "Home", format: "table" },
615
+ description: "List frames on the Home page"
616
+ }
617
+ ],
618
+ async run(c) {
619
+ const fileKey = c.args.fileKey;
620
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
621
+ const file = await client.getFile(fileKey);
622
+ const frames = extractFrames(file.document, c.options.page);
623
+ if (frames.length === 0) {
624
+ const msg = c.options.page ? `No frames found on page "${c.options.page}".` : "No top-level frames found in this file.";
625
+ return c.ok(c.options.format === "json" ? { frames: [], message: msg } : msg);
626
+ }
627
+ if (c.options.format === "json") {
628
+ return c.ok({ frames, total: frames.length });
629
+ }
630
+ return c.ok(outputFormatter(frames, "table"));
631
+ }
632
+ });
633
+ framesCli.command("render", {
634
+ description: "Download rendered images of specific frames from a Figma file.",
635
+ args: z7.object({ fileKey: fileKeyArg }),
636
+ options: z7.object({
637
+ ids: z7.string().describe("Comma-separated node IDs to render"),
638
+ "image-format": z7.enum(["png", "svg"]).default("png").describe("Image format: png or svg (default: png)"),
639
+ scale: z7.coerce.number().min(1).max(4).default(2).describe("Image scale 1-4 (default: 2)"),
640
+ output: z7.string().default(".").describe("Output directory (default: current directory)")
641
+ }),
642
+ env: figmaEnv,
643
+ output: z7.unknown(),
644
+ examples: [
645
+ {
646
+ args: { fileKey: "abc123" },
647
+ options: {
648
+ ids: "1:2,3:4",
649
+ "image-format": "png",
650
+ scale: 2,
651
+ output: "."
652
+ },
653
+ description: "Render frames as 2x PNG"
654
+ }
655
+ ],
656
+ async run(c) {
657
+ const fileKey = c.args.fileKey;
658
+ const { ids, "image-format": imageFormat, scale, output: outputDir } = c.options;
659
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
660
+ const nodeIds = ids.split(",").map((id) => id.trim());
661
+ if (nodeIds.length === 0 || nodeIds.length === 1 && nodeIds[0] === "") {
662
+ return c.error({
663
+ code: "VALIDATION_ERROR",
664
+ message: "--ids is required and cannot be empty"
665
+ });
666
+ }
667
+ const imagesResponse = await client.getImages(fileKey, nodeIds, {
668
+ format: imageFormat,
669
+ scale
670
+ });
671
+ if (imagesResponse.err) {
672
+ return c.error({
673
+ code: "FIGMA_API_ERROR",
674
+ message: imagesResponse.err
675
+ });
676
+ }
677
+ const resolvedDir = resolve(outputDir);
678
+ if (!existsSync(resolvedDir)) {
679
+ mkdirSync(resolvedDir, { recursive: true });
680
+ }
681
+ const results = [];
682
+ for (const nodeId of nodeIds) {
683
+ const imageUrl = imagesResponse.images[nodeId];
684
+ if (!imageUrl) {
685
+ results.push({ nodeId, file: "(no image)", size: 0 });
686
+ continue;
687
+ }
688
+ const safeNodeId = nodeId.replace(/:/g, "-");
689
+ const filename = `${safeNodeId}.${imageFormat}`;
690
+ const filepath = resolve(resolvedDir, filename);
691
+ const buffer = await downloadFile(imageUrl);
692
+ writeFileSync(filepath, buffer);
693
+ results.push({ nodeId, file: filepath, size: buffer.length });
694
+ }
695
+ return c.ok({ rendered: results, total: results.length });
696
+ }
697
+ });
698
+
699
+ // src/commands/nodes.ts
700
+ import { Cli as Cli5, z as z8 } from "incur";
701
+ var boundingBoxSchema = z8.object({
702
+ x: z8.number(),
703
+ y: z8.number(),
704
+ width: z8.number(),
705
+ height: z8.number()
706
+ }).optional();
707
+ var childSummarySchema = z8.object({
708
+ id: z8.string(),
709
+ name: z8.string(),
710
+ type: z8.string()
711
+ });
712
+ var nodeGetOutputSchema = z8.object({
713
+ id: z8.string(),
714
+ name: z8.string(),
715
+ type: z8.string(),
716
+ boundingBox: boundingBoxSchema,
717
+ children: z8.array(childSummarySchema),
718
+ childCount: z8.number()
719
+ });
720
+ function summarizeChildren(children, depth) {
721
+ if (!children || depth <= 0) return [];
722
+ const result = [];
723
+ for (const child of children) {
724
+ result.push({
725
+ id: child.id ?? "",
726
+ name: child.name ?? "",
727
+ type: child.type ?? "UNKNOWN"
728
+ });
729
+ if (depth > 1 && child.children) {
730
+ result.push(...summarizeChildren(child.children, depth - 1));
731
+ }
732
+ }
733
+ return result;
734
+ }
735
+ var nodesCli = Cli5.create("nodes", {
736
+ description: "Inspect Figma file nodes."
737
+ });
738
+ nodesCli.command("get", {
739
+ description: "Get details for a specific node in a Figma file.",
740
+ args: z8.object({
741
+ fileKey: fileKeyArg,
742
+ nodeId: z8.string().describe('Node ID (e.g. "1:2")')
743
+ }),
744
+ options: z8.object({
745
+ depth: z8.coerce.number().default(1).describe("How deep into the node tree to display children (default: 1)"),
746
+ format: formatOption
747
+ }),
748
+ env: figmaEnv,
749
+ output: nodeGetOutputSchema,
750
+ examples: [
751
+ {
752
+ args: { fileKey: "abc123XYZ", nodeId: "1:2" },
753
+ options: { depth: 2 },
754
+ description: "Inspect a node with 2 levels of children"
755
+ }
756
+ ],
757
+ async run(c) {
758
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
759
+ const response = await client.getFileNodes(c.args.fileKey, [c.args.nodeId]);
760
+ const nodeData = response.nodes[c.args.nodeId];
761
+ if (!nodeData?.document) {
762
+ return c.error({
763
+ code: "NODE_NOT_FOUND",
764
+ message: `Node "${c.args.nodeId}" not found in file "${c.args.fileKey}".`
765
+ });
766
+ }
767
+ const doc = nodeData.document;
768
+ const bbox = doc.absoluteBoundingBox ?? doc.absoluteRenderBounds;
769
+ const children = summarizeChildren(doc.children, c.options.depth);
770
+ const result = {
771
+ id: doc.id ?? c.args.nodeId,
772
+ name: doc.name ?? "",
773
+ type: doc.type ?? "UNKNOWN",
774
+ boundingBox: bbox ? { x: bbox.x, y: bbox.y, width: bbox.width, height: bbox.height } : void 0,
775
+ children,
776
+ childCount: doc.children?.length ?? 0
777
+ };
778
+ if (c.options.format === "table") {
779
+ process.stdout.write(
780
+ `${outputFormatter(result, "table")}
781
+ `
782
+ );
783
+ }
784
+ return c.ok(
785
+ result,
786
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
787
+ cta: {
788
+ commands: children[0] ? [
789
+ {
790
+ command: "nodes get",
791
+ args: { fileKey: c.args.fileKey, nodeId: children[0].id },
792
+ description: `Inspect child node "${children[0].name}"`
793
+ }
794
+ ] : []
795
+ }
796
+ }
797
+ );
798
+ }
799
+ });
800
+
801
+ // src/commands/tokens.ts
802
+ import { writeFileSync as writeFileSync2 } from "fs";
803
+ import { Cli as Cli6, z as z9 } from "incur";
804
+
805
+ // src/tokens/dtcg.ts
806
+ function slugify(name) {
807
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
808
+ }
809
+ function colorToDtcgHex(rgba) {
810
+ const r = rgba.r.toString(16).padStart(2, "0");
811
+ const g = rgba.g.toString(16).padStart(2, "0");
812
+ const b = rgba.b.toString(16).padStart(2, "0");
813
+ if (rgba.a < 1) {
814
+ const a = Math.round(rgba.a * 255).toString(16).padStart(2, "0");
815
+ return `#${r}${g}${b}${a}`;
816
+ }
817
+ return `#${r}${g}${b}`;
818
+ }
819
+ function colorToDtcg(token) {
820
+ return {
821
+ $type: "color",
822
+ $value: colorToDtcgHex(token.rgba)
823
+ };
824
+ }
825
+ function typographyToDtcg(token) {
826
+ const group = {
827
+ fontFamily: {
828
+ $type: "fontFamily",
829
+ $value: token.fontFamily
830
+ },
831
+ fontSize: {
832
+ $type: "dimension",
833
+ $value: `${token.fontSize}px`
834
+ },
835
+ fontWeight: {
836
+ $type: "fontWeight",
837
+ $value: token.fontWeight
838
+ },
839
+ letterSpacing: {
840
+ $type: "dimension",
841
+ $value: `${token.letterSpacing}px`
842
+ }
843
+ };
844
+ if (token.lineHeight !== null) {
845
+ group.lineHeight = {
846
+ $type: "dimension",
847
+ $value: `${token.lineHeight}px`
848
+ };
849
+ }
850
+ return group;
851
+ }
852
+ function shadowToDtcg(token) {
853
+ return {
854
+ $type: "shadow",
855
+ $value: {
856
+ color: colorToDtcgHex(token.color),
857
+ offsetX: `${token.offsetX}px`,
858
+ offsetY: `${token.offsetY}px`,
859
+ blur: `${token.radius}px`,
860
+ spread: `${token.spread}px`
861
+ }
862
+ };
863
+ }
864
+ function blurToDtcg(token) {
865
+ return {
866
+ $type: "dimension",
867
+ $value: `${token.radius}px`
868
+ };
869
+ }
870
+ function effectToDtcg(token) {
871
+ if ("color" in token) {
872
+ return shadowToDtcg(token);
873
+ }
874
+ return blurToDtcg(token);
875
+ }
876
+ function toDtcg(tokens) {
877
+ const output = {};
878
+ if (tokens.colors.length > 0) {
879
+ const group = {};
880
+ for (const c of tokens.colors) {
881
+ group[slugify(c.name)] = colorToDtcg(c);
882
+ }
883
+ output.color = group;
884
+ }
885
+ if (tokens.typography.length > 0) {
886
+ const group = {};
887
+ for (const t of tokens.typography) {
888
+ group[slugify(t.name)] = typographyToDtcg(t);
889
+ }
890
+ output.typography = group;
891
+ }
892
+ if (tokens.effects.length > 0) {
893
+ const group = {};
894
+ for (const e of tokens.effects) {
895
+ group[slugify(e.name)] = effectToDtcg(e);
896
+ }
897
+ output.effect = group;
898
+ }
899
+ return output;
900
+ }
901
+
902
+ // src/tokens/extractor.ts
903
+ function clamp(v, min, max) {
904
+ return Math.max(min, Math.min(max, v));
905
+ }
906
+ function toHex(r, g, b, a) {
907
+ const r8 = Math.round(clamp(r, 0, 1) * 255);
908
+ const g8 = Math.round(clamp(g, 0, 1) * 255);
909
+ const b8 = Math.round(clamp(b, 0, 1) * 255);
910
+ const hex = `#${r8.toString(16).padStart(2, "0")}${g8.toString(16).padStart(2, "0")}${b8.toString(16).padStart(2, "0")}`;
911
+ if (a < 1) {
912
+ const a8 = Math.round(clamp(a, 0, 1) * 255);
913
+ return `${hex}${a8.toString(16).padStart(2, "0")}`;
914
+ }
915
+ return hex;
916
+ }
917
+ function sanitizeName(name) {
918
+ return name.trim() || "unnamed";
919
+ }
920
+ function extractColorFromPaints(name, fills) {
921
+ const solid = fills.find((f) => f.type === "SOLID" && f.visible !== false && f.color);
922
+ if (!solid?.color) return null;
923
+ const { r, g, b, a: colorA } = solid.color;
924
+ const a = solid.opacity !== void 0 ? solid.opacity * colorA : colorA;
925
+ return {
926
+ name: sanitizeName(name),
927
+ hex: toHex(r, g, b, a),
928
+ rgba: {
929
+ r: Math.round(clamp(r, 0, 1) * 255),
930
+ g: Math.round(clamp(g, 0, 1) * 255),
931
+ b: Math.round(clamp(b, 0, 1) * 255),
932
+ a: Number.parseFloat(clamp(a, 0, 1).toFixed(2))
933
+ }
934
+ };
935
+ }
936
+ function extractTypography(name, style) {
937
+ return {
938
+ name: sanitizeName(name),
939
+ fontFamily: style.fontFamily,
940
+ fontSize: style.fontSize,
941
+ fontWeight: style.fontWeight,
942
+ lineHeight: style.lineHeightPx ?? null,
943
+ letterSpacing: style.letterSpacing ?? 0
944
+ };
945
+ }
946
+ function extractEffects(name, effects) {
947
+ const tokens = [];
948
+ for (const effect of effects) {
949
+ if (effect.visible === false) continue;
950
+ if (effect.type === "DROP_SHADOW" || effect.type === "INNER_SHADOW") {
951
+ tokens.push({
952
+ name: sanitizeName(name),
953
+ type: effect.type === "DROP_SHADOW" ? "drop-shadow" : "inner-shadow",
954
+ color: effect.color ? {
955
+ r: Math.round(clamp(effect.color.r, 0, 1) * 255),
956
+ g: Math.round(clamp(effect.color.g, 0, 1) * 255),
957
+ b: Math.round(clamp(effect.color.b, 0, 1) * 255),
958
+ a: Number.parseFloat(clamp(effect.color.a, 0, 1).toFixed(2))
959
+ } : { r: 0, g: 0, b: 0, a: 1 },
960
+ offsetX: effect.offset?.x ?? 0,
961
+ offsetY: effect.offset?.y ?? 0,
962
+ radius: effect.radius ?? 0,
963
+ spread: effect.spread ?? 0
964
+ });
965
+ } else if (effect.type === "LAYER_BLUR" || effect.type === "BACKGROUND_BLUR") {
966
+ tokens.push({
967
+ name: sanitizeName(name),
968
+ type: effect.type === "LAYER_BLUR" ? "layer-blur" : "background-blur",
969
+ radius: effect.radius ?? 0
970
+ });
971
+ }
972
+ }
973
+ return tokens;
974
+ }
975
+ function extractTokens(document, styles, filter) {
976
+ const colors = [];
977
+ const typography = [];
978
+ const effects = [];
979
+ if (!styles || Object.keys(styles).length === 0) {
980
+ return { colors, typography, effects };
981
+ }
982
+ const styleByNodeId = /* @__PURE__ */ new Map();
983
+ for (const [nodeId, meta] of Object.entries(styles)) {
984
+ styleByNodeId.set(nodeId, meta);
985
+ }
986
+ const nodeMap = /* @__PURE__ */ new Map();
987
+ function walk(node) {
988
+ if (!node || typeof node !== "object") return;
989
+ const n = node;
990
+ if (n.id) {
991
+ nodeMap.set(n.id, n);
992
+ }
993
+ const nodeStyles = n.styles;
994
+ if (nodeStyles) {
995
+ for (const [_styleType, styleId] of Object.entries(nodeStyles)) {
996
+ if (styleByNodeId.has(styleId)) {
997
+ nodeMap.set(styleId, n);
998
+ }
999
+ }
1000
+ }
1001
+ if (Array.isArray(n.children)) {
1002
+ for (const child of n.children) walk(child);
1003
+ }
1004
+ }
1005
+ walk(document);
1006
+ for (const [nodeId, meta] of styleByNodeId) {
1007
+ const node = nodeMap.get(nodeId);
1008
+ const styleName = meta.name;
1009
+ if (meta.style_type === "FILL" && (!filter || filter === "colors")) {
1010
+ if (node?.fills && Array.isArray(node.fills)) {
1011
+ const color = extractColorFromPaints(styleName, node.fills);
1012
+ if (color) colors.push(color);
1013
+ }
1014
+ } else if (meta.style_type === "TEXT" && (!filter || filter === "typography")) {
1015
+ if (node?.style) {
1016
+ typography.push(extractTypography(styleName, node.style));
1017
+ }
1018
+ } else if (meta.style_type === "EFFECT" && (!filter || filter === "effects")) {
1019
+ if (node?.effects && Array.isArray(node.effects)) {
1020
+ const extracted = extractEffects(styleName, node.effects);
1021
+ effects.push(...extracted);
1022
+ }
1023
+ }
1024
+ }
1025
+ return { colors, typography, effects };
1026
+ }
1027
+ function toFlatTokens(tokens) {
1028
+ const flat = {};
1029
+ for (const c of tokens.colors) {
1030
+ const key = `color.${slugify2(c.name)}`;
1031
+ flat[key] = c.hex;
1032
+ }
1033
+ for (const t of tokens.typography) {
1034
+ const base = `typography.${slugify2(t.name)}`;
1035
+ flat[`${base}.fontFamily`] = t.fontFamily;
1036
+ flat[`${base}.fontSize`] = t.fontSize;
1037
+ flat[`${base}.fontWeight`] = t.fontWeight;
1038
+ if (t.lineHeight !== null) flat[`${base}.lineHeight`] = t.lineHeight;
1039
+ flat[`${base}.letterSpacing`] = t.letterSpacing;
1040
+ }
1041
+ for (const e of tokens.effects) {
1042
+ const base = `effect.${slugify2(e.name)}`;
1043
+ flat[`${base}.type`] = e.type;
1044
+ if ("color" in e) {
1045
+ const s = e;
1046
+ flat[`${base}.offsetX`] = s.offsetX;
1047
+ flat[`${base}.offsetY`] = s.offsetY;
1048
+ flat[`${base}.radius`] = s.radius;
1049
+ flat[`${base}.spread`] = s.spread;
1050
+ flat[`${base}.color`] = `rgba(${s.color.r}, ${s.color.g}, ${s.color.b}, ${s.color.a})`;
1051
+ } else {
1052
+ flat[`${base}.radius`] = e.radius;
1053
+ }
1054
+ }
1055
+ return flat;
1056
+ }
1057
+ function slugify2(name) {
1058
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1059
+ }
1060
+
1061
+ // src/commands/tokens.ts
1062
+ var tokensCli = Cli6.create("tokens", {
1063
+ description: "Extract design tokens from Figma files."
1064
+ });
1065
+ var formatOption2 = z9.enum(["dtcg", "flat", "json"]).default("dtcg").describe("Output format: dtcg (W3C DTCG), flat (key-value), json (raw intermediate)");
1066
+ var filterOption = z9.enum(["colors", "typography", "effects"]).optional().describe("Extract only specific token types");
1067
+ var outputOption = z9.string().optional().describe("Write output to file instead of stdout");
1068
+ tokensCli.command("export", {
1069
+ description: "Extract design tokens from a Figma file.",
1070
+ args: z9.object({
1071
+ fileKey: z9.string().describe("Figma file key")
1072
+ }),
1073
+ options: z9.object({
1074
+ format: formatOption2,
1075
+ filter: filterOption,
1076
+ output: outputOption
1077
+ }),
1078
+ env: figmaEnv,
1079
+ output: z9.unknown(),
1080
+ examples: [
1081
+ {
1082
+ args: { fileKey: "abc123FileKey" },
1083
+ options: { format: "dtcg" },
1084
+ description: "Export tokens in DTCG format"
1085
+ },
1086
+ {
1087
+ args: { fileKey: "abc123FileKey" },
1088
+ options: { format: "flat", filter: "colors" },
1089
+ description: "Export only color tokens in flat format"
1090
+ }
1091
+ ],
1092
+ async run(c) {
1093
+ const { fileKey } = c.args;
1094
+ const client = createFigmaClient(c.env.FIGMA_API_KEY);
1095
+ const file = await client.getFile(fileKey);
1096
+ const tokens = extractTokens(
1097
+ file.document,
1098
+ file.styles,
1099
+ c.options.filter
1100
+ );
1101
+ let result;
1102
+ if (c.options.format === "dtcg") {
1103
+ result = toDtcg(tokens);
1104
+ } else if (c.options.format === "flat") {
1105
+ result = toFlatTokens(tokens);
1106
+ } else {
1107
+ result = tokens;
1108
+ }
1109
+ if (c.options.output) {
1110
+ writeFileSync2(c.options.output, JSON.stringify(result, null, 2), "utf8");
1111
+ return c.ok(
1112
+ { written: c.options.output, format: c.options.format },
1113
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1114
+ cta: {
1115
+ commands: [
1116
+ {
1117
+ command: `tokens export ${fileKey} --format flat`,
1118
+ description: "Export tokens in flat format"
1119
+ }
1120
+ ]
1121
+ }
1122
+ }
1123
+ );
1124
+ }
1125
+ return c.ok(
1126
+ result,
1127
+ c.format === "json" || c.format === "jsonl" ? void 0 : {
1128
+ cta: {
1129
+ commands: [
1130
+ {
1131
+ command: `tokens export ${fileKey} --format flat`,
1132
+ description: "Export in flat key-value format"
1133
+ },
1134
+ {
1135
+ command: `tokens export ${fileKey} --filter colors`,
1136
+ description: "Export only color tokens"
1137
+ },
1138
+ {
1139
+ command: `components list ${fileKey}`,
1140
+ description: "List published components"
1141
+ }
1142
+ ]
1143
+ }
1144
+ }
1145
+ );
1146
+ }
1147
+ });
1148
+
1149
+ // src/cli.ts
1150
+ var __dirname = dirname(fileURLToPath(import.meta.url));
1151
+ var pkg = JSON.parse(readFileSync(resolve2(__dirname, "../package.json"), "utf8"));
1152
+ var cli = Cli7.create("figma", {
1153
+ version: pkg.version,
1154
+ description: "Query Figma REST API data from the command line."
1155
+ });
1156
+ cli.command(tokensCli);
1157
+ cli.command(componentsCli);
1158
+ cli.command(filesCli);
1159
+ cli.command(nodesCli);
1160
+ cli.command(framesCli);
1161
+ cli.command(commentsCli);
1162
+ var isMain = (() => {
1163
+ const entrypoint = process.argv[1];
1164
+ if (!entrypoint) {
1165
+ return false;
1166
+ }
1167
+ try {
1168
+ return realpathSync(entrypoint) === realpathSync(fileURLToPath(import.meta.url));
1169
+ } catch {
1170
+ return false;
1171
+ }
1172
+ })();
1173
+ if (isMain) {
1174
+ initTelemetry("figma");
1175
+ process.on("beforeExit", () => shutdownTelemetry());
1176
+ cli.serve();
1177
+ }
197
1178
  export {
198
1179
  cli,
199
1180
  createFigmaClient,
200
- figmaEnv
1181
+ extractTokens,
1182
+ figmaEnv,
1183
+ toDtcg,
1184
+ toFlatTokens
201
1185
  };