@pedroab0/skills 0.0.0-stage → 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.
@@ -0,0 +1,1314 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const https = require('https');
6
+ const http = require('http');
7
+ const os = require('os');
8
+ const { URL } = require('url');
9
+
10
+ // -----------------------------------------------------------------------------
11
+ // Environment and Auth Utilities
12
+ // -----------------------------------------------------------------------------
13
+
14
+ function loadEnv() {
15
+ const home = process.env.HOME || '';
16
+ const candidates = [
17
+ path.join(process.cwd(), '.env'),
18
+ home ? path.join(home, '.env') : null,
19
+ home ? path.join(home, '.zshrc') : null,
20
+ home ? path.join(home, '.bashrc') : null,
21
+ ].filter(Boolean);
22
+
23
+ for (const envPath of candidates) {
24
+ if (fs.existsSync(envPath)) {
25
+ try {
26
+ const envContent = fs.readFileSync(envPath, 'utf8');
27
+ const lines = envContent.split('\n');
28
+ for (const line of lines) {
29
+ const parsed = parseEnvLine(line);
30
+ if (parsed && parsed.key) {
31
+ // For dot-env files, load any variable; for shell configs, only load FIGMA_ variables
32
+ const isShellConfig = envPath.endsWith('.zshrc') || envPath.endsWith('.bashrc');
33
+ if (!process.env[parsed.key] && (!isShellConfig || parsed.key.startsWith('FIGMA_'))) {
34
+ process.env[parsed.key] = parsed.val;
35
+ }
36
+ }
37
+ }
38
+ } catch (e) {
39
+ // Ignore errors loading env candidates
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ function parseEnvLine(line) {
46
+ if (!line || typeof line !== 'string') return null;
47
+ let trimmed = line.trim();
48
+ if (trimmed.startsWith('export ')) {
49
+ trimmed = trimmed.substring(7).trim();
50
+ }
51
+ if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) {
52
+ return null;
53
+ }
54
+
55
+ const eqIdx = trimmed.indexOf('=');
56
+ const key = trimmed.slice(0, eqIdx).trim();
57
+ let rest = trimmed.slice(eqIdx + 1).trim();
58
+
59
+ let val = '';
60
+ if (rest.startsWith('"')) {
61
+ const closeIdx = rest.indexOf('"', 1);
62
+ val = closeIdx !== -1 ? rest.slice(1, closeIdx) : rest.slice(1);
63
+ } else if (rest.startsWith("'")) {
64
+ const closeIdx = rest.indexOf("'", 1);
65
+ val = closeIdx !== -1 ? rest.slice(1, closeIdx) : rest.slice(1);
66
+ } else {
67
+ // Unquoted: strip trailing inline comments
68
+ val = rest.replace(/\s+#.*$/, '').trim();
69
+ }
70
+
71
+ return { key, val };
72
+ }
73
+
74
+ function getFigmaToken() {
75
+ return process.env.FIGMA_ACCESS_TOKEN || process.env.FIGMA_TOKEN || null;
76
+ }
77
+
78
+ function getAuthHeaders(token) {
79
+ if (!token) return {};
80
+ const trimmed = token.trim();
81
+ if (trimmed.startsWith('Bearer ')) {
82
+ return { Authorization: trimmed };
83
+ }
84
+ return { 'X-Figma-Token': trimmed };
85
+ }
86
+
87
+ // -----------------------------------------------------------------------------
88
+ // Network Utilities
89
+ // -----------------------------------------------------------------------------
90
+
91
+ function makeRequest(urlStr, headers = {}, redirectCount = 0) {
92
+ if (redirectCount > 5) {
93
+ return Promise.reject(new Error('Too many HTTP redirects'));
94
+ }
95
+
96
+ const parsedUrl = new URL(urlStr);
97
+ const isHttps = parsedUrl.protocol === 'https:';
98
+ const client = isHttps ? https : http;
99
+
100
+ const options = {
101
+ hostname: parsedUrl.hostname,
102
+ port: parsedUrl.port || (isHttps ? 443 : 80),
103
+ path: parsedUrl.pathname + parsedUrl.search,
104
+ headers: {
105
+ 'User-Agent': 'FigmaExtractorAgentSkill/1.0',
106
+ ...headers,
107
+ },
108
+ };
109
+
110
+ if (process.env.FIGMA_IGNORE_SSL === 'true' && isHttps) {
111
+ options.rejectUnauthorized = false;
112
+ }
113
+
114
+ return new Promise((resolve, reject) => {
115
+ const req = client.get(options, (res) => {
116
+ // Handle HTTP redirects (301, 302, 307, 308)
117
+ if (
118
+ res.statusCode >= 300 &&
119
+ res.statusCode < 400 &&
120
+ res.headers.location
121
+ ) {
122
+ res.resume();
123
+ const redirectUrl = new URL(res.headers.location, urlStr).toString();
124
+ return resolve(makeRequest(redirectUrl, headers, redirectCount + 1));
125
+ }
126
+
127
+ let data = '';
128
+ res.setEncoding('utf8');
129
+ res.on('data', (chunk) => {
130
+ data += chunk;
131
+ });
132
+ res.on('end', () => {
133
+ if (res.statusCode >= 200 && res.statusCode < 300) {
134
+ resolve(data);
135
+ } else {
136
+ const err = new Error(
137
+ `Request failed with status code ${res.statusCode}: ${data}`,
138
+ );
139
+ err.statusCode = res.statusCode;
140
+ err.responseBody = data;
141
+ err.headers = res.headers;
142
+ if (res.headers['retry-after']) {
143
+ const parsed = parseInt(res.headers['retry-after'], 10);
144
+ err.retryAfter = !isNaN(parsed) ? parsed : res.headers['retry-after'];
145
+ }
146
+ if (res.headers['x-figma-rate-limit-type']) {
147
+ err.rateLimitType = res.headers['x-figma-rate-limit-type'];
148
+ }
149
+ reject(err);
150
+ }
151
+ });
152
+ });
153
+
154
+ req.on('error', (err) => {
155
+ reject(err);
156
+ });
157
+
158
+ req.setTimeout(30000, () => {
159
+ req.destroy();
160
+ reject(new Error('Request timed out after 30 seconds'));
161
+ });
162
+ });
163
+ }
164
+
165
+ function downloadBinaryFile(urlStr, targetPath, redirectCount = 0) {
166
+ if (redirectCount > 5) {
167
+ return Promise.reject(new Error('Too many HTTP redirects'));
168
+ }
169
+
170
+ const parsedUrl = new URL(urlStr);
171
+ const isHttps = parsedUrl.protocol === 'https:';
172
+ const client = isHttps ? https : http;
173
+
174
+ const options = {
175
+ hostname: parsedUrl.hostname,
176
+ port: parsedUrl.port || (isHttps ? 443 : 80),
177
+ path: parsedUrl.pathname + parsedUrl.search,
178
+ headers: {
179
+ 'User-Agent': 'FigmaExtractorAgentSkill/1.0',
180
+ },
181
+ };
182
+
183
+ if (process.env.FIGMA_IGNORE_SSL === 'true' && isHttps) {
184
+ options.rejectUnauthorized = false;
185
+ }
186
+
187
+ return new Promise((resolve, reject) => {
188
+ const dir = path.dirname(targetPath);
189
+ if (!fs.existsSync(dir)) {
190
+ fs.mkdirSync(dir, { recursive: true });
191
+ }
192
+
193
+ let fileStream = null;
194
+ const cleanupPartial = () => {
195
+ if (fileStream) {
196
+ try {
197
+ fileStream.destroy();
198
+ } catch (_) {}
199
+ }
200
+ fs.unlink(targetPath, () => {});
201
+ };
202
+
203
+ const req = client.get(options, (res) => {
204
+ if (
205
+ res.statusCode >= 300 &&
206
+ res.statusCode < 400 &&
207
+ res.headers.location
208
+ ) {
209
+ res.resume();
210
+ const redirectUrl = new URL(res.headers.location, urlStr).toString();
211
+ return resolve(downloadBinaryFile(redirectUrl, targetPath, redirectCount + 1));
212
+ }
213
+
214
+ if (res.statusCode < 200 || res.statusCode >= 300) {
215
+ res.resume();
216
+ const err = new Error(`Image download failed with status code ${res.statusCode}`);
217
+ err.statusCode = res.statusCode;
218
+ err.headers = res.headers;
219
+ if (res.headers && res.headers['retry-after']) {
220
+ const parsed = parseInt(res.headers['retry-after'], 10);
221
+ err.retryAfter = !isNaN(parsed) ? parsed : res.headers['retry-after'];
222
+ }
223
+ if (res.headers && res.headers['x-figma-rate-limit-type']) {
224
+ err.rateLimitType = res.headers['x-figma-rate-limit-type'];
225
+ }
226
+ return reject(err);
227
+ }
228
+
229
+ fileStream = fs.createWriteStream(targetPath);
230
+ res.pipe(fileStream);
231
+
232
+ fileStream.on('finish', () => {
233
+ fileStream.close(() => resolve(targetPath));
234
+ });
235
+
236
+ fileStream.on('error', (err) => {
237
+ cleanupPartial();
238
+ reject(err);
239
+ });
240
+ });
241
+
242
+ req.on('error', (err) => {
243
+ cleanupPartial();
244
+ reject(err);
245
+ });
246
+
247
+ req.setTimeout(30000, () => {
248
+ req.destroy();
249
+ cleanupPartial();
250
+ reject(new Error('Image download timed out after 30 seconds'));
251
+ });
252
+ });
253
+ }
254
+
255
+ // -----------------------------------------------------------------------------
256
+ // URL and Node ID Normalization
257
+ // -----------------------------------------------------------------------------
258
+
259
+ function parseFigmaUrl(urlStr) {
260
+ let cleanUrl = (urlStr || '').trim();
261
+ if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
262
+ cleanUrl = 'https://' + cleanUrl;
263
+ }
264
+
265
+ try {
266
+ const url = new URL(cleanUrl);
267
+ const pathParts = url.pathname.split('/').filter(Boolean);
268
+ let fileKey = null;
269
+
270
+ const keyIdx = pathParts.findIndex((part) =>
271
+ ['file', 'design', 'board', 'proto'].includes(part.toLowerCase()),
272
+ );
273
+
274
+ if (keyIdx !== -1 && pathParts[keyIdx + 1]) {
275
+ fileKey = pathParts[keyIdx + 1];
276
+ } else if (pathParts.length > 0 && /^[a-zA-Z0-9_-]{20,}$/.test(pathParts[0])) {
277
+ fileKey = pathParts[0];
278
+ }
279
+
280
+ let rawNodeId = url.searchParams.get('node-id');
281
+ let nodeId = rawNodeId ? normalizeNodeId(rawNodeId) : null;
282
+
283
+ return { fileKey, nodeId };
284
+ } catch (e) {
285
+ return { fileKey: null, nodeId: null };
286
+ }
287
+ }
288
+
289
+ function normalizeNodeId(nodeId) {
290
+ if (!nodeId) return null;
291
+ let decoded = decodeURIComponent(String(nodeId).trim());
292
+ let normalized = decoded.replace(/-/g, ':');
293
+ normalized = normalized.replace(/^:+|:+$/g, '');
294
+ return normalized;
295
+ }
296
+
297
+ // -----------------------------------------------------------------------------
298
+ // Color and Token Utilities
299
+ // -----------------------------------------------------------------------------
300
+
301
+ function formatRgba(color, opacity) {
302
+ if (!color) return undefined;
303
+ const r = Math.min(255, Math.max(0, Math.round((color.r ?? 0) * 255)));
304
+ const g = Math.min(255, Math.max(0, Math.round((color.g ?? 0) * 255)));
305
+ const b = Math.min(255, Math.max(0, Math.round((color.b ?? 0) * 255)));
306
+ const colorAlpha = color.a !== undefined ? color.a : 1;
307
+ const fillOpacity = opacity !== undefined ? opacity : 1;
308
+ const a = Math.min(1, Math.max(0, Number((colorAlpha * fillOpacity).toFixed(2))));
309
+
310
+ const hex =
311
+ '#' +
312
+ [r, g, b]
313
+ .map((x) => x.toString(16).padStart(2, '0'))
314
+ .join('')
315
+ .toUpperCase();
316
+
317
+ return a < 1 ? `rgba(${r}, ${g}, ${b}, ${a})` : hex;
318
+ }
319
+
320
+ function resolveVariableToken(varAliasOrId, varMap) {
321
+ if (!varAliasOrId || !varMap) return undefined;
322
+ const varId = typeof varAliasOrId === 'object' ? varAliasOrId.id : varAliasOrId;
323
+ return varMap[varId]?.name || undefined;
324
+ }
325
+
326
+ function simplifyFills(fills, boundVars, varMap) {
327
+ if (!Array.isArray(fills) || fills.length === 0) return undefined;
328
+ const boundFills = Array.isArray(boundVars?.fills) ? boundVars.fills : [];
329
+ const results = [];
330
+
331
+ for (let i = 0; i < fills.length; i++) {
332
+ const fill = fills[i];
333
+ if (fill.visible === false) continue;
334
+
335
+ const res = { type: fill.type };
336
+ if (fill.type === 'SOLID' && fill.color) {
337
+ res.color = formatRgba(fill.color, fill.opacity);
338
+ }
339
+ if (fill.opacity !== undefined && fill.opacity !== 1) {
340
+ res.opacity = fill.opacity;
341
+ }
342
+
343
+ const token = resolveVariableToken(boundFills[i], varMap);
344
+ if (token) res.token = token;
345
+
346
+ results.push(res);
347
+ }
348
+
349
+ return results.length > 0 ? results : undefined;
350
+ }
351
+
352
+ function simplifyStrokes(strokes, strokeWeight, strokeAlign, boundVars, varMap) {
353
+ if (!Array.isArray(strokes) || strokes.length === 0) return undefined;
354
+ const boundStrokes = Array.isArray(boundVars?.strokes) ? boundVars.strokes : [];
355
+ const colors = [];
356
+
357
+ for (let i = 0; i < strokes.length; i++) {
358
+ const s = strokes[i];
359
+ if (s.visible === false) continue;
360
+ const col = s.color ? formatRgba(s.color, s.opacity) : s.type;
361
+ const token = resolveVariableToken(boundStrokes[i], varMap);
362
+ colors.push(token ? { color: col, token } : col);
363
+ }
364
+
365
+ if (colors.length === 0) return undefined;
366
+
367
+ const res = { weight: strokeWeight, align: strokeAlign, colors };
368
+
369
+ const strokeWeightToken = resolveVariableToken(boundVars?.strokeWeight, varMap);
370
+ if (strokeWeightToken) res.strokeWeightToken = strokeWeightToken;
371
+
372
+ return res;
373
+ }
374
+
375
+ // -----------------------------------------------------------------------------
376
+ // Component Property & Instance Normalization
377
+ // -----------------------------------------------------------------------------
378
+
379
+ function normalizePropKey(rawKey) {
380
+ if (!rawKey) return '';
381
+ // Strip Figma property ID hash: "Variant#1234:56" -> "Variant", "Has Icon#402" -> "Has Icon"
382
+ let clean = rawKey.replace(/#.*$/, '').trim();
383
+ // Convert to camelCase while preserving Unicode letters (\p{L}) and digits (\p{N})
384
+ return clean
385
+ .replace(/[^\p{L}\p{N}]+(\p{L})/gu, (_, chr) => chr.toUpperCase())
386
+ .replace(/[^\p{L}\p{N}]+/gu, '')
387
+ .replace(/^\p{Lu}/u, (chr) => chr.toLowerCase());
388
+ }
389
+
390
+ function normalizeComponentProps(componentProperties) {
391
+ if (!componentProperties || typeof componentProperties !== 'object') return undefined;
392
+ const props = {};
393
+
394
+ for (const [key, propDef] of Object.entries(componentProperties)) {
395
+ const cleanKey = normalizePropKey(key);
396
+ if (!cleanKey) continue;
397
+
398
+ if (propDef && typeof propDef === 'object' && 'value' in propDef) {
399
+ props[cleanKey] = propDef.value;
400
+ } else {
401
+ props[cleanKey] = propDef;
402
+ }
403
+ }
404
+
405
+ return Object.keys(props).length > 0 ? props : undefined;
406
+ }
407
+
408
+ // -----------------------------------------------------------------------------
409
+ // AST Pruning & Token Optimization
410
+ // -----------------------------------------------------------------------------
411
+
412
+ function pruneFigmaNode(node, context = {}, options = {}) {
413
+ if (!node || typeof node !== 'object') return node;
414
+
415
+ const { varMap, components, componentSets } = context;
416
+
417
+ const pruned = {
418
+ name: node.name,
419
+ type: node.type,
420
+ };
421
+
422
+ if (options.includeIds && node.id) {
423
+ pruned.id = node.id;
424
+ }
425
+
426
+ if (node.visible === false) pruned.visible = false;
427
+
428
+ const bound = node.boundVariables || {};
429
+
430
+ // Component Instance & Master Resolution
431
+ if (node.type === 'INSTANCE') {
432
+ if (node.componentId && components && components[node.componentId]) {
433
+ const master = components[node.componentId];
434
+ const set = master.componentSetId && componentSets ? componentSets[master.componentSetId] : null;
435
+ pruned.component = {
436
+ name: set ? set.name : master.name,
437
+ variant: master.name,
438
+ };
439
+ if (options.includeIds) {
440
+ pruned.component.id = node.componentId;
441
+ }
442
+ }
443
+
444
+ const props = normalizeComponentProps(node.componentProperties);
445
+ if (props) pruned.props = props;
446
+
447
+ // Shallow instances: skip internal rendering sub-layers if requested
448
+ if (options.shallowInstances) {
449
+ if (node.absoluteBoundingBox) {
450
+ pruned.bounds = {
451
+ width: Math.round(node.absoluteBoundingBox.width),
452
+ height: Math.round(node.absoluteBoundingBox.height),
453
+ };
454
+ }
455
+ return pruned;
456
+ }
457
+ } else if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
458
+ const props = normalizeComponentProps(node.componentProperties);
459
+ if (props) pruned.props = props;
460
+ }
461
+
462
+ // Layout & Flexbox properties
463
+ if (node.layoutMode && node.layoutMode !== 'NONE') {
464
+ pruned.layoutMode = node.layoutMode;
465
+ if (node.primaryAxisAlignItems) pruned.primaryAxisAlignItems = node.primaryAxisAlignItems;
466
+ if (node.counterAxisAlignItems) pruned.counterAxisAlignItems = node.counterAxisAlignItems;
467
+
468
+ if (node.itemSpacing) {
469
+ pruned.itemSpacing = node.itemSpacing;
470
+ const spacingToken = resolveVariableToken(bound.itemSpacing, varMap);
471
+ if (spacingToken) pruned.itemSpacingToken = spacingToken;
472
+ }
473
+
474
+ if (node.paddingLeft || node.paddingRight || node.paddingTop || node.paddingBottom) {
475
+ pruned.padding = {
476
+ top: node.paddingTop ?? 0,
477
+ right: node.paddingRight ?? 0,
478
+ bottom: node.paddingBottom ?? 0,
479
+ left: node.paddingLeft ?? 0,
480
+ };
481
+ const padToken =
482
+ resolveVariableToken(bound.paddingTop, varMap) ||
483
+ resolveVariableToken(bound.paddingLeft, varMap);
484
+ if (padToken) pruned.paddingToken = padToken;
485
+ }
486
+
487
+ if (node.layoutWrap) pruned.layoutWrap = node.layoutWrap;
488
+ }
489
+
490
+ if (node.layoutGrow) pruned.layoutGrow = node.layoutGrow;
491
+ if (node.layoutAlign) pruned.layoutAlign = node.layoutAlign;
492
+
493
+ // Dimensions & Coordinates
494
+ if (node.absoluteBoundingBox) {
495
+ pruned.bounds = {
496
+ width: Math.round(node.absoluteBoundingBox.width),
497
+ height: Math.round(node.absoluteBoundingBox.height),
498
+ };
499
+ }
500
+
501
+ if (node.minWidth) pruned.minWidth = node.minWidth;
502
+ if (node.maxWidth) pruned.maxWidth = node.maxWidth;
503
+ if (node.minHeight) pruned.minHeight = node.minHeight;
504
+ if (node.maxHeight) pruned.maxHeight = node.maxHeight;
505
+
506
+ // Styling: Fills, Strokes, Borders, Corners
507
+ const fills = simplifyFills(node.fills, bound, varMap);
508
+ if (fills) pruned.fills = fills;
509
+
510
+ const strokes = simplifyStrokes(node.strokes, node.strokeWeight, node.strokeAlign, bound, varMap);
511
+ if (strokes) pruned.strokes = strokes;
512
+
513
+ if (node.cornerRadius) {
514
+ pruned.cornerRadius = node.cornerRadius;
515
+ const radiusToken = resolveVariableToken(bound.cornerRadius, varMap);
516
+ if (radiusToken) pruned.cornerRadiusToken = radiusToken;
517
+ }
518
+ if (node.rectangleCornerRadii) pruned.cornerRadii = node.rectangleCornerRadii;
519
+ if (node.opacity !== undefined && node.opacity !== 1) pruned.opacity = node.opacity;
520
+
521
+ // Effects (Drop Shadows, Layer Blurs)
522
+ if (Array.isArray(node.effects) && node.effects.length > 0) {
523
+ const visibleEffects = node.effects.filter((e) => e.visible !== false);
524
+ if (visibleEffects.length > 0) {
525
+ pruned.effects = visibleEffects.map((e) => ({
526
+ type: e.type,
527
+ radius: e.radius,
528
+ color: e.color ? formatRgba(e.color) : undefined,
529
+ offset: e.offset,
530
+ }));
531
+ }
532
+ }
533
+
534
+ // Typography (for TEXT nodes)
535
+ if (node.type === 'TEXT') {
536
+ pruned.characters = node.characters;
537
+ if (node.style) {
538
+ pruned.typography = {
539
+ fontFamily: node.style.fontFamily,
540
+ fontSize: node.style.fontSize,
541
+ fontWeight: node.style.fontWeight,
542
+ lineHeightPx: node.style.lineHeightPx ? Math.round(node.style.lineHeightPx) : undefined,
543
+ letterSpacing: node.style.letterSpacing,
544
+ textAlignHorizontal: node.style.textAlignHorizontal,
545
+ textAlignVertical: node.style.textAlignVertical,
546
+ };
547
+ }
548
+ }
549
+
550
+ // Recursively process children
551
+ if (Array.isArray(node.children) && node.children.length > 0) {
552
+ pruned.children = node.children
553
+ .filter((c) => c.visible !== false)
554
+ .map((c) => pruneFigmaNode(c, context, options));
555
+ }
556
+
557
+ return pruned;
558
+ }
559
+
560
+ // -----------------------------------------------------------------------------
561
+ // Rate Limit Circuit Breaker & Safety Gate
562
+ // -----------------------------------------------------------------------------
563
+
564
+ const RATE_GATE_WINDOW_MS = 60 * 1000; // 60 seconds sliding window
565
+ const MAX_REQUESTS_PER_WINDOW = 5; // Max 5 calls per minute (well below Figma 10/min threshold)
566
+ const MIN_REQUEST_INTERVAL_MS = 2500; // Minimum 2.5s between consecutive requests
567
+
568
+ function getRateGateFilePath() {
569
+ const home = process.env.HOME || (typeof os.homedir === 'function' ? os.homedir() : '');
570
+ return home ? path.join(home, '.figma_ratelimit.json') : path.join(process.cwd(), '.figma_ratelimit.json');
571
+ }
572
+
573
+ function loadRateGateState() {
574
+ const filePath = getRateGateFilePath();
575
+ if (fs.existsSync(filePath)) {
576
+ try {
577
+ const data = fs.readFileSync(filePath, 'utf8');
578
+ return JSON.parse(data);
579
+ } catch (_) {}
580
+ }
581
+ return { files: {} };
582
+ }
583
+
584
+ function saveRateGateState(state) {
585
+ const filePath = getRateGateFilePath();
586
+ try {
587
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2), 'utf8');
588
+ } catch (_) {}
589
+ }
590
+
591
+ function resetRateGateState(fileKey) {
592
+ const state = loadRateGateState();
593
+ if (!state.files) state.files = {};
594
+ if (fileKey) {
595
+ delete state.files[fileKey];
596
+ } else {
597
+ state.files = {};
598
+ }
599
+ saveRateGateState(state);
600
+ }
601
+
602
+ function recordRateLimitLockout(fileKey, retryAfterSeconds) {
603
+ if (!fileKey || !retryAfterSeconds) return;
604
+ const state = loadRateGateState();
605
+ if (!state.files) state.files = {};
606
+ const fileState = state.files[fileKey] || { timestamps: [], lockedUntil: 0 };
607
+ fileState.lockedUntil = Date.now() + (retryAfterSeconds * 1000);
608
+ fileState.lastRetryAfter = retryAfterSeconds;
609
+ state.files[fileKey] = fileState;
610
+ saveRateGateState(state);
611
+ }
612
+
613
+ async function checkAndApplyRateGate(fileKey, options = {}) {
614
+ if (!fileKey) return;
615
+ const state = loadRateGateState();
616
+ if (!state.files) state.files = {};
617
+ const fileState = state.files[fileKey] || { timestamps: [], lockedUntil: 0 };
618
+ const now = Date.now();
619
+
620
+ // 1. Circuit Breaker: Is the file actively locked out by a previous 429 response?
621
+ if (fileState.lockedUntil && fileState.lockedUntil > now) {
622
+ const remainingSec = Math.ceil((fileState.lockedUntil - now) / 1000);
623
+ const unlockDate = new Date(fileState.lockedUntil).toLocaleString();
624
+ const hours = (remainingSec / 3600).toFixed(1);
625
+ const err = new Error(
626
+ `FIGMA_CIRCUIT_BREAKER_ACTIVE: File "${fileKey}" is actively locked by Figma rate limiting until ${unlockDate} (~${remainingSec}s / ~${hours}h remaining). Outgoing API requests to this file are blocked to prevent extending the lockout duration. To continue immediately, duplicate this file in Figma Drafts (Right-click -> Duplicate) to obtain a fresh file key.`
627
+ );
628
+ err.isCircuitBreaker = true;
629
+ err.statusCode = 429;
630
+ err.retryAfter = remainingSec;
631
+ err.lockedUntil = fileState.lockedUntil;
632
+ throw err;
633
+ }
634
+
635
+ // 2. Sliding Window: Clean timestamps older than 60 seconds
636
+ fileState.timestamps = (fileState.timestamps || []).filter((t) => now - t < RATE_GATE_WINDOW_MS);
637
+
638
+ // 3. Inter-request pacing: Ensure at least MIN_REQUEST_INTERVAL_MS since last call
639
+ if (fileState.timestamps.length > 0 && !options.noWait) {
640
+ const lastTimestamp = fileState.timestamps[fileState.timestamps.length - 1];
641
+ const elapsed = now - lastTimestamp;
642
+ if (elapsed < MIN_REQUEST_INTERVAL_MS) {
643
+ const waitMs = MIN_REQUEST_INTERVAL_MS - elapsed;
644
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
645
+ }
646
+ }
647
+
648
+ // 4. Sliding window burst limit: Cap at MAX_REQUESTS_PER_WINDOW calls/minute
649
+ if (fileState.timestamps.length >= MAX_REQUESTS_PER_WINDOW && !options.noWait) {
650
+ const oldestTimestamp = fileState.timestamps[0];
651
+ const waitMs = RATE_GATE_WINDOW_MS - (Date.now() - oldestTimestamp) + 200;
652
+ if (waitMs > 0) {
653
+ console.error(
654
+ `[RateLimitGuard] Approaching Figma rate limit: pausing ${(waitMs / 1000).toFixed(1)}s to protect file quota...`
655
+ );
656
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
657
+ }
658
+ }
659
+
660
+ // Record this request timestamp
661
+ const currentNow = Date.now();
662
+ fileState.timestamps = (fileState.timestamps || []).filter((t) => currentNow - t < RATE_GATE_WINDOW_MS);
663
+ fileState.timestamps.push(currentNow);
664
+ state.files[fileKey] = fileState;
665
+ saveRateGateState(state);
666
+ }
667
+
668
+ // -----------------------------------------------------------------------------
669
+ // Figma API Execution
670
+ // -----------------------------------------------------------------------------
671
+
672
+ async function executeFigmaRequest(url, headers, fileKey, options = {}) {
673
+ if (!options.bypassRateGate && fileKey) {
674
+ await checkAndApplyRateGate(fileKey, options);
675
+ }
676
+ return makeRequest(url, headers);
677
+ }
678
+
679
+ async function fetchFigmaVariables(fileKey, headers, options = {}) {
680
+ const state = loadRateGateState();
681
+ if (state.files && state.files[fileKey] && state.files[fileKey].variablesSupported === false) {
682
+ // Proactively skip: this file does not support variables or lacks scope
683
+ return null;
684
+ }
685
+
686
+ const url = `https://api.figma.com/v1/files/${fileKey}/variables/local`;
687
+ try {
688
+ const rawData = await executeFigmaRequest(url, headers, fileKey, options);
689
+ const parsed = JSON.parse(rawData);
690
+ if (parsed.status === 200 && parsed.meta) {
691
+ return parsed.meta;
692
+ }
693
+ return null;
694
+ } catch (e) {
695
+ // Only re-throw rate limit (429) so callers can respect rate-limiting policies
696
+ if (e.statusCode === 429) {
697
+ throw e;
698
+ }
699
+ // Proactively record lack of variable scope so subsequent runs save an API call
700
+ if (e.statusCode === 403 || e.statusCode === 404) {
701
+ const currentState = loadRateGateState();
702
+ if (!currentState.files) currentState.files = {};
703
+ const fileState = currentState.files[fileKey] || { timestamps: [], lockedUntil: 0 };
704
+ fileState.variablesSupported = false;
705
+ currentState.files[fileKey] = fileState;
706
+ saveRateGateState(currentState);
707
+ }
708
+ return null;
709
+ }
710
+ }
711
+
712
+ function buildVariableMap(variablesMeta) {
713
+ const varMap = {};
714
+ const dictionary = {
715
+ colors: {},
716
+ spacing: {},
717
+ radii: {},
718
+ typography: {},
719
+ other: {},
720
+ };
721
+
722
+ if (!variablesMeta || !variablesMeta.variables) {
723
+ return { varMap, dictionary };
724
+ }
725
+
726
+ const variables = variablesMeta.variables;
727
+ const sets = variablesMeta.variableSets || {};
728
+
729
+ for (const [id, variable] of Object.entries(variables)) {
730
+ const name = variable.name;
731
+ const type = variable.resolvedType;
732
+ varMap[id] = { name, resolvedType: type };
733
+
734
+ const set = sets[variable.variableSetId];
735
+ const defaultModeId = set?.defaultModeId;
736
+ const modes = variable.valuesByMode || {};
737
+ const val = (defaultModeId && modes[defaultModeId]) ?? Object.values(modes)[0];
738
+
739
+ let formattedVal = val;
740
+ if (type === 'COLOR' && val && typeof val === 'object') {
741
+ formattedVal = formatRgba(val);
742
+ }
743
+
744
+ // Categorize into dictionary buckets.
745
+ // Priority order: colors > spacing > radii > typography > other
746
+ // Variables with overlapping keywords (e.g. "text/fill/primary") land in the first matching bucket.
747
+ const lowerName = name.toLowerCase();
748
+ if (type === 'COLOR' || lowerName.includes('color') || lowerName.includes('brand') || lowerName.includes('bg') || lowerName.includes('fill')) {
749
+ dictionary.colors[name] = formattedVal;
750
+ } else if (lowerName.includes('spac') || lowerName.includes('gap') || lowerName.includes('padding') || lowerName.includes('margin') || lowerName.includes('offset')) {
751
+ dictionary.spacing[name] = formattedVal;
752
+ } else if (lowerName.includes('radi') || lowerName.includes('corner') || lowerName.includes('round')) {
753
+ dictionary.radii[name] = formattedVal;
754
+ } else if (lowerName.includes('font') || lowerName.includes('text') || lowerName.includes('size') || lowerName.includes('typography')) {
755
+ dictionary.typography[name] = formattedVal;
756
+ } else {
757
+ dictionary.other[name] = formattedVal;
758
+ }
759
+ }
760
+
761
+ return { varMap, dictionary };
762
+ }
763
+
764
+ async function fetchFigmaNodes(fileKey, nodeId, headers, options = {}) {
765
+ let url = `https://api.figma.com/v1/files/${fileKey}`;
766
+ if (nodeId) {
767
+ url += `/nodes?ids=${encodeURIComponent(nodeId)}`;
768
+ if (options.depth) url += `&depth=${options.depth}`;
769
+ } else if (options.depth) {
770
+ url += `?depth=${options.depth}`;
771
+ }
772
+
773
+ const rawData = await executeFigmaRequest(url, headers, fileKey, options);
774
+ const parsed = JSON.parse(rawData);
775
+
776
+ // Fetch variables unless explicitly disabled
777
+ let varMap = {};
778
+ let tokensDictionary = null;
779
+ if (!options.noVariables) {
780
+ const varsMeta = await fetchFigmaVariables(fileKey, headers, options);
781
+ const built = buildVariableMap(varsMeta);
782
+ varMap = built.varMap;
783
+ tokensDictionary = built.dictionary;
784
+ }
785
+
786
+ const context = {
787
+ varMap,
788
+ components: parsed.components || {},
789
+ componentSets: parsed.componentSets || {},
790
+ };
791
+
792
+ if (nodeId && parsed.nodes) {
793
+ const targetNode = parsed.nodes[nodeId];
794
+ if (!targetNode || !targetNode.document) {
795
+ throw new Error(`Node ID "${nodeId}" not found in Figma file "${fileKey}".`);
796
+ }
797
+
798
+ // Merge components if present in target node
799
+ if (targetNode.components) {
800
+ context.components = { ...context.components, ...targetNode.components };
801
+ }
802
+ if (targetNode.componentSets) {
803
+ context.componentSets = { ...context.componentSets, ...targetNode.componentSets };
804
+ }
805
+
806
+ if (options.raw) {
807
+ const rawRes = {
808
+ name: parsed.name,
809
+ lastModified: parsed.lastModified,
810
+ thumbnailUrl: parsed.thumbnailUrl,
811
+ node: targetNode.document,
812
+ components: targetNode.components,
813
+ componentSets: targetNode.componentSets,
814
+ schemaVersion: parsed.schemaVersion,
815
+ };
816
+ if (options.includeTokens && tokensDictionary) {
817
+ rawRes.tokens = tokensDictionary;
818
+ }
819
+ return rawRes;
820
+ }
821
+
822
+ const res = {
823
+ name: parsed.name,
824
+ node: pruneFigmaNode(targetNode.document, context, options),
825
+ };
826
+ if (options.includeTokens && tokensDictionary) {
827
+ res.tokens = tokensDictionary;
828
+ }
829
+ return res;
830
+ }
831
+
832
+ if (options.raw) {
833
+ const rawRes = {
834
+ name: parsed.name,
835
+ lastModified: parsed.lastModified,
836
+ document: parsed.document,
837
+ components: parsed.components,
838
+ componentSets: parsed.componentSets,
839
+ };
840
+ if (options.includeTokens && tokensDictionary) {
841
+ rawRes.tokens = tokensDictionary;
842
+ }
843
+ return rawRes;
844
+ }
845
+
846
+ const res = {
847
+ name: parsed.name,
848
+ document: pruneFigmaNode(parsed.document, context, options),
849
+ };
850
+ if (options.includeTokens && tokensDictionary) {
851
+ res.tokens = tokensDictionary;
852
+ }
853
+ return res;
854
+ }
855
+
856
+ async function fetchFigmaImage(fileKey, nodeId, headers, targetPath, options = {}) {
857
+ const scale = options.scale || 2;
858
+ const format = options.format || 'png';
859
+ const url = `https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(nodeId)}&scale=${scale}&format=${format}`;
860
+
861
+ const rawData = await executeFigmaRequest(url, headers, fileKey, options);
862
+ const parsed = JSON.parse(rawData);
863
+
864
+ if (parsed.err) {
865
+ throw new Error(`Figma Image API error: ${parsed.err}`);
866
+ }
867
+
868
+ const imageUrl = parsed.images ? parsed.images[nodeId] : null;
869
+ if (!imageUrl) {
870
+ throw new Error(`No rendered image returned for node ID "${nodeId}".`);
871
+ }
872
+
873
+ const resolvedPath = path.resolve(
874
+ targetPath || `./.specs/figma_${fileKey}_${nodeId.replace(/:/g, '_')}.${format}`,
875
+ );
876
+
877
+ await downloadBinaryFile(imageUrl, resolvedPath);
878
+ return {
879
+ path: resolvedPath,
880
+ format,
881
+ scale,
882
+ remoteUrl: imageUrl,
883
+ };
884
+ }
885
+
886
+ // -----------------------------------------------------------------------------
887
+ // CLI Argument Parsing
888
+ // -----------------------------------------------------------------------------
889
+
890
+ function parseArguments(argv) {
891
+ const args = argv.slice(2);
892
+ const result = {
893
+ fileKey: null,
894
+ nodeId: null,
895
+ specName: null,
896
+ specJsonPath: null,
897
+ imagePath: null,
898
+ downloadImage: false,
899
+ raw: false,
900
+ depth: null,
901
+ scale: 2,
902
+ format: 'png',
903
+ tokensOnly: false,
904
+ shallowInstances: false,
905
+ noVariables: false,
906
+ includeTokens: false,
907
+ includeIds: false,
908
+ pretty: false,
909
+ help: false,
910
+ };
911
+
912
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
913
+ result.help = true;
914
+ return result;
915
+ }
916
+
917
+ const positional = [];
918
+
919
+ for (let i = 0; i < args.length; i++) {
920
+ const arg = args[i];
921
+ if (arg === '--raw') {
922
+ result.raw = true;
923
+ } else if (arg === '--spec') {
924
+ if (i + 1 >= args.length) continue;
925
+ result.specName = args[++i];
926
+ } else if (arg === '--image' || arg === '-i' || arg === '--download-image') {
927
+ result.downloadImage = true;
928
+ } else if (arg === '--image-path' || arg === '-o' || arg === '--output') {
929
+ if (i + 1 >= args.length) continue;
930
+ result.imagePath = args[++i];
931
+ result.downloadImage = true;
932
+ } else if (arg === '--depth') {
933
+ if (i + 1 >= args.length) continue;
934
+ result.depth = parseInt(args[++i], 10);
935
+ } else if (arg === '--scale') {
936
+ if (i + 1 >= args.length) continue;
937
+ result.scale = parseFloat(args[++i]);
938
+ } else if (arg === '--format') {
939
+ if (i + 1 >= args.length) continue;
940
+ result.format = args[++i].toLowerCase();
941
+ } else if (arg === '--tokens' || arg === '--variables') {
942
+ result.tokensOnly = true;
943
+ } else if (arg === '--shallow-instances') {
944
+ result.shallowInstances = true;
945
+ } else if (arg === '--no-variables') {
946
+ result.noVariables = true;
947
+ } else if (arg === '--include-tokens') {
948
+ result.includeTokens = true;
949
+ } else if (arg === '--include-ids') {
950
+ result.includeIds = true;
951
+ } else if (arg === '--pretty') {
952
+ result.pretty = true;
953
+ } else if (arg === '--reset-circuit-breaker' || arg === '--reset-rate-gate') {
954
+ result.resetRateGate = true;
955
+ } else if (arg === '--bypass-circuit-breaker' || arg === '--bypass-rate-gate' || arg === '--no-rate-gate') {
956
+ result.bypassRateGate = true;
957
+ } else if (arg === '--force' || arg === '-f' || arg === '--fresh') {
958
+ result.force = true;
959
+ } else if (!arg.startsWith('-')) {
960
+ positional.push(arg);
961
+ }
962
+ }
963
+
964
+ // Handle atomic --spec <name> paths
965
+ if (result.specName) {
966
+ let rawSpec = result.specName.trim();
967
+ const hasDir = rawSpec.includes('/') || rawSpec.includes('\\');
968
+ const specDir = hasDir ? path.dirname(rawSpec) : './.specs';
969
+ let baseName = path.basename(rawSpec);
970
+ if (baseName.endsWith('.json')) baseName = baseName.slice(0, -5);
971
+ else if (baseName.endsWith('.png')) baseName = baseName.slice(0, -4);
972
+
973
+ result.specJsonPath = path.join(specDir, `${baseName}.json`);
974
+ if (!result.imagePath) {
975
+ result.imagePath = path.join(specDir, `${baseName}.png`);
976
+ }
977
+ }
978
+
979
+ if (positional.length === 0 && !result.help) {
980
+ return result;
981
+ }
982
+
983
+ const first = positional[0];
984
+ const isUrl = first.includes('figma.com') || first.startsWith('http://') || first.startsWith('https://');
985
+
986
+ if (isUrl) {
987
+ const parsed = parseFigmaUrl(first);
988
+ result.fileKey = parsed.fileKey;
989
+ result.nodeId = parsed.nodeId || (positional.length >= 2 ? normalizeNodeId(positional[1]) : null);
990
+ } else {
991
+ // Parameters style: <fileKey> [nodeId]
992
+ result.fileKey = positional[0];
993
+ if (positional.length >= 2) {
994
+ result.nodeId = normalizeNodeId(positional[1]);
995
+ }
996
+ }
997
+
998
+ return result;
999
+ }
1000
+
1001
+ function printUsage() {
1002
+ console.log(`
1003
+ Figma Extractor - Design-System-Aware Agent Skill
1004
+
1005
+ Usage:
1006
+ node fetch_figma.js <FigmaURL> [options]
1007
+ node fetch_figma.js <fileKey> [nodeId] [options]
1008
+
1009
+ Options:
1010
+ --spec <name> Atomically save ./.specs/<name>.json (add -i to also download preview PNG)
1011
+ --image, -i Download rendered preview image from Figma (Tier 1 call)
1012
+ --image-path, -o <path> Target path for saved preview image (implies --image)
1013
+ --tokens, --variables Export only the design token dictionary (colors, spacing, radii)
1014
+ --shallow-instances Collapse internal layers of component instances into props
1015
+ --include-tokens Include complete tokens dictionary in node output
1016
+ --include-ids Keep Figma internal node IDs in pruned AST
1017
+ --pretty Pretty-print JSON with 2-space indentation (default is minified)
1018
+ --raw Return full raw Figma API response (unpruned)
1019
+ --depth <number> Traverse node tree down to specific depth
1020
+ --scale <1|2|3|4> Image render scale (default: 2)
1021
+ --format <png|svg|jpg|pdf> Image format (default: png)
1022
+ --no-variables Skip querying /variables/local
1023
+ --help, -h Show this help message
1024
+
1025
+ Examples:
1026
+ # Atomic SDD extraction (AST only - 1 Tier 1 call)
1027
+ node fetch_figma.js "https://www.figma.com/design/.../App?node-id=4023-474" --spec card
1028
+
1029
+ # Atomic SDD extraction with companion preview image (2 Tier 1 calls)
1030
+ node fetch_figma.js "https://www.figma.com/design/.../App?node-id=4023-474" --spec card -i
1031
+
1032
+ # Download preview image to custom path
1033
+ node fetch_figma.js "https://www.figma.com/design/.../App?node-id=4023-474" -i -o ./.specs/preview.png
1034
+
1035
+ # Export tokens only
1036
+ node fetch_figma.js "https://www.figma.com/design/.../App" --tokens
1037
+ `);
1038
+ }
1039
+
1040
+ // -----------------------------------------------------------------------------
1041
+ // Main CLI Function
1042
+ // -----------------------------------------------------------------------------
1043
+
1044
+ async function main() {
1045
+ loadEnv();
1046
+ const config = parseArguments(process.argv);
1047
+
1048
+ if (config.resetRateGate) {
1049
+ resetRateGateState(config.fileKey);
1050
+ console.log(
1051
+ JSON.stringify(
1052
+ {
1053
+ message: `Rate limit safety gate cleared${config.fileKey ? ` for "${config.fileKey}"` : ''}.`,
1054
+ success: true,
1055
+ },
1056
+ null,
1057
+ config.pretty ? 2 : 0,
1058
+ ),
1059
+ );
1060
+ return;
1061
+ }
1062
+
1063
+ if (config.help || !config.fileKey) {
1064
+ printUsage();
1065
+ if (!config.help) {
1066
+ console.error(
1067
+ JSON.stringify(
1068
+ {
1069
+ error: 'Missing required Figma URL or fileKey.',
1070
+ },
1071
+ null,
1072
+ config.pretty ? 2 : 0,
1073
+ ),
1074
+ );
1075
+ process.exit(1);
1076
+ }
1077
+ process.exit(0);
1078
+ }
1079
+
1080
+ const token = getFigmaToken();
1081
+ if (!token) {
1082
+ console.error(
1083
+ JSON.stringify(
1084
+ {
1085
+ error:
1086
+ 'FIGMA_ACCESS_TOKEN is not set. Please set FIGMA_ACCESS_TOKEN in your environment or in the workspace .env file.',
1087
+ isTokenMissing: true,
1088
+ },
1089
+ null,
1090
+ config.pretty ? 2 : 0,
1091
+ ),
1092
+ );
1093
+ process.exit(1);
1094
+ }
1095
+
1096
+ const headers = getAuthHeaders(token);
1097
+
1098
+ try {
1099
+ // Proactive Spec Reuse: If spec already exists locally and --force is not passed, reuse with 0 API calls
1100
+ if (config.specJsonPath && fs.existsSync(path.resolve(config.specJsonPath)) && !config.force) {
1101
+ try {
1102
+ const rawContent = fs.readFileSync(path.resolve(config.specJsonPath), 'utf8');
1103
+ const specLabel = path.basename(config.specJsonPath, '.json');
1104
+ const sizeKb = (Buffer.byteLength(rawContent, 'utf8') / 1024).toFixed(1);
1105
+ console.log(`\n✔ Reusing local Figma specification for "${specLabel}" (0 API calls):`);
1106
+ console.log(` • AST Spec: ${config.specJsonPath} (${sizeKb} KB)`);
1107
+ if (config.imagePath && fs.existsSync(path.resolve(config.imagePath))) {
1108
+ console.log(` • Preview: ${config.imagePath}`);
1109
+ }
1110
+ console.log(` (Pass --force or -f to re-fetch freshly from Figma API)\n`);
1111
+ return;
1112
+ } catch (_) {}
1113
+ }
1114
+
1115
+ // Mode: Tokens Only
1116
+ if (config.tokensOnly) {
1117
+ const varsMeta = await fetchFigmaVariables(config.fileKey, headers, {
1118
+ bypassRateGate: config.bypassRateGate,
1119
+ });
1120
+ if (!varsMeta) {
1121
+ console.error(
1122
+ JSON.stringify(
1123
+ {
1124
+ error:
1125
+ 'FIGMA_VARIABLES_NOT_ACCESSIBLE: Variables could not be fetched from /variables/local. Ensure your Figma access token has the "file_variables:read" scope and the file belongs to an Organization or Enterprise workspace supporting design tokens.',
1126
+ statusCode: 403,
1127
+ },
1128
+ null,
1129
+ config.pretty ? 2 : 0,
1130
+ ),
1131
+ );
1132
+ process.exit(1);
1133
+ }
1134
+ const { dictionary } = buildVariableMap(varsMeta);
1135
+ console.log(
1136
+ JSON.stringify(
1137
+ {
1138
+ tokens: dictionary,
1139
+ },
1140
+ null,
1141
+ config.pretty ? 2 : 0,
1142
+ ),
1143
+ );
1144
+ return;
1145
+ }
1146
+
1147
+ // 1. Fetch Node JSON
1148
+ const nodeData = await fetchFigmaNodes(config.fileKey, config.nodeId, headers, {
1149
+ raw: config.raw,
1150
+ depth: config.depth,
1151
+ shallowInstances: config.shallowInstances,
1152
+ noVariables: config.noVariables,
1153
+ includeTokens: config.includeTokens,
1154
+ includeIds: config.includeIds,
1155
+ bypassRateGate: config.bypassRateGate,
1156
+ });
1157
+
1158
+ // 2. Fetch Image Preview if requested
1159
+ let imageResult = null;
1160
+ if (config.downloadImage && config.nodeId) {
1161
+ try {
1162
+ if (config.imagePath && fs.existsSync(path.resolve(config.imagePath))) {
1163
+ imageResult = {
1164
+ path: path.resolve(config.imagePath),
1165
+ source: 'local',
1166
+ };
1167
+ } else {
1168
+ imageResult = await fetchFigmaImage(
1169
+ config.fileKey,
1170
+ config.nodeId,
1171
+ headers,
1172
+ config.imagePath,
1173
+ { scale: config.scale, format: config.format, bypassRateGate: config.bypassRateGate },
1174
+ );
1175
+ }
1176
+ } catch (imgErr) {
1177
+ imageResult = {
1178
+ error: `Failed to download image preview: ${imgErr.message}`,
1179
+ };
1180
+ }
1181
+ }
1182
+
1183
+ // 3. Assemble structured result (compact & token-optimized)
1184
+ const output = {
1185
+ ...nodeData,
1186
+ };
1187
+
1188
+ if (imageResult) {
1189
+ output.image = imageResult;
1190
+ }
1191
+
1192
+ if (config.specJsonPath) {
1193
+ const jsonDir = path.dirname(path.resolve(config.specJsonPath));
1194
+ if (!fs.existsSync(jsonDir)) {
1195
+ fs.mkdirSync(jsonDir, { recursive: true });
1196
+ }
1197
+ const jsonContent = JSON.stringify(output, null, config.pretty ? 2 : 0);
1198
+ fs.writeFileSync(path.resolve(config.specJsonPath), jsonContent, 'utf8');
1199
+
1200
+ const sizeKb = (Buffer.byteLength(jsonContent, 'utf8') / 1024).toFixed(1);
1201
+ const specLabel = path.basename(config.specJsonPath, '.json');
1202
+ console.log(`\n✔ Saved Figma specification for "${specLabel}":`);
1203
+ console.log(` • AST Spec: ${config.specJsonPath} (${sizeKb} KB)`);
1204
+ if (imageResult && !imageResult.error && imageResult.path) {
1205
+ console.log(` • Preview: ${config.imagePath}`);
1206
+ } else if (imageResult && imageResult.error) {
1207
+ console.log(` ⚠ Preview: ${imageResult.error}`);
1208
+ } else {
1209
+ console.log(` • Preview: (skipped to conserve Tier 1 quota; pass -i to download PNG)`);
1210
+ }
1211
+ console.log(``);
1212
+ } else {
1213
+ console.log(JSON.stringify(output, null, config.pretty ? 2 : 0));
1214
+ }
1215
+ } catch (error) {
1216
+ if (error.statusCode === 429 && error.retryAfter && !error.isCircuitBreaker) {
1217
+ recordRateLimitLockout(config.fileKey, error.retryAfter);
1218
+ }
1219
+ const errorPayload = formatApiError(error);
1220
+ console.error(JSON.stringify(errorPayload, null, config.pretty ? 2 : 0));
1221
+ process.exit(1);
1222
+ }
1223
+ }
1224
+
1225
+ function formatApiError(error) {
1226
+ let errorMessage = error.message;
1227
+ let isCircuitBreaker = Boolean(error.isCircuitBreaker);
1228
+ let isTokenExpiredOrInvalid = false;
1229
+ let isSslError = false;
1230
+ let isRateLimited = error.statusCode === 429 || isCircuitBreaker;
1231
+ const retryAfter = error.retryAfter !== undefined ? error.retryAfter : null;
1232
+ const rateLimitType = error.rateLimitType || null;
1233
+
1234
+ if (isCircuitBreaker) {
1235
+ errorMessage = error.message;
1236
+ } else if (error.statusCode === 401 || error.statusCode === 403) {
1237
+ isTokenExpiredOrInvalid = true;
1238
+ errorMessage =
1239
+ 'FIGMA_TOKEN_EXPIRED_OR_INVALID: The provided Figma access token is expired, invalid, or unauthorized. Create a new token in Figma Settings -> Personal Access Tokens and export FIGMA_ACCESS_TOKEN or add it to .env.';
1240
+ } else if (error.statusCode === 429) {
1241
+ isRateLimited = true;
1242
+ let retryMsg = ' Please wait 30-60 seconds before making more requests.';
1243
+ if (retryAfter !== null) {
1244
+ if (retryAfter > 3600) {
1245
+ const hours = Math.round(retryAfter / 3600);
1246
+ const days = (retryAfter / 86400).toFixed(1);
1247
+ retryMsg = ` Please retry after ${retryAfter} seconds (~${hours} hours / ${days} days). This file or token may have exceeded the monthly Starter plan quota (up to 20 calls/month). Consider moving the file to a paid team or waiting until the quota resets.`;
1248
+ } else {
1249
+ retryMsg = ` Please retry after ${retryAfter} second${retryAfter === 1 ? '' : 's'}.`;
1250
+ }
1251
+ }
1252
+ const typeMsg = rateLimitType ? ` (Policy: ${rateLimitType})` : '';
1253
+ errorMessage = `FIGMA_RATE_LIMIT_EXCEEDED: Figma API rate limit reached.${typeMsg}${retryMsg}`;
1254
+ } else if (
1255
+ error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
1256
+ error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
1257
+ (error.message &&
1258
+ (error.message.toLowerCase().includes('self-signed') ||
1259
+ error.message.toLowerCase().includes('self signed')))
1260
+ ) {
1261
+ isSslError = true;
1262
+ errorMessage =
1263
+ 'FIGMA_SSL_CERTIFICATE_ERROR: Self-signed certificate error. Set FIGMA_IGNORE_SSL=true in your .env file or environment to bypass.';
1264
+ }
1265
+
1266
+ const payload = {
1267
+ error: errorMessage,
1268
+ statusCode: error.statusCode || null,
1269
+ isCircuitBreaker,
1270
+ isTokenExpiredOrInvalid,
1271
+ isSslError,
1272
+ isRateLimited,
1273
+ };
1274
+
1275
+ if (retryAfter !== null) {
1276
+ payload.retryAfter = retryAfter;
1277
+ }
1278
+ if (rateLimitType) {
1279
+ payload.rateLimitType = rateLimitType;
1280
+ }
1281
+
1282
+ return payload;
1283
+ }
1284
+
1285
+ // Export internal functions for unit testing
1286
+ module.exports = {
1287
+ loadEnv,
1288
+ parseEnvLine,
1289
+ getFigmaToken,
1290
+ getAuthHeaders,
1291
+ parseFigmaUrl,
1292
+ normalizeNodeId,
1293
+ formatRgba,
1294
+ simplifyFills,
1295
+ simplifyStrokes,
1296
+ normalizePropKey,
1297
+ normalizeComponentProps,
1298
+ buildVariableMap,
1299
+ pruneFigmaNode,
1300
+ parseArguments,
1301
+ formatApiError,
1302
+ executeFigmaRequest,
1303
+ fetchFigmaVariables,
1304
+ checkAndApplyRateGate,
1305
+ loadRateGateState,
1306
+ saveRateGateState,
1307
+ resetRateGateState,
1308
+ recordRateLimitLockout,
1309
+ getRateGateFilePath,
1310
+ };
1311
+
1312
+ if (require.main === module) {
1313
+ main();
1314
+ }