@sangheepark/figma-ds-mcp 0.2.3 → 0.2.5

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.
@@ -21,6 +21,7 @@ const FIGMA_STYLE_KEYS = new Set([
21
21
  'opacity', 'font-family', 'font-size', 'font-weight', 'color',
22
22
  'text-align', 'line-height', 'letter-spacing', 'text-decoration',
23
23
  'text-style', 'effect-style',
24
+ 'backdrop-filter',
24
25
  ]);
25
26
  // emoji 감지 정규식 (surrogate pairs + common symbols)
26
27
  const EMOJI_PATTERN = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}\u{E0020}-\u{E007F}▶♡►◄●○■□★☆♪♫✓✗←→↑↓]/u;
@@ -111,18 +112,52 @@ function enrichSpec(traversal, mapping) {
111
112
  if (component) {
112
113
  node['library-key'] = component['library-key'];
113
114
  // Step 2: _ds_props → variant-props + overrides 분리
115
+ // value 역검색 + default fallback 로직 포함
114
116
  if (node._ds_props) {
115
117
  const variantProps = {};
116
118
  const overrides = {};
117
119
  for (const [key, value] of Object.entries(node._ds_props)) {
118
120
  if (component.variants && key in component.variants) {
119
- // variant axis에 해당하는 prop
121
+ // 1) exact key match → variant-props
120
122
  if (typeof value === 'string' && component.defaults?.[key] !== value) {
121
123
  variantProps[key] = value;
122
124
  }
123
125
  }
126
+ else if (component.variants && typeof value === 'string') {
127
+ // 2) exact match 실패 → value 역검색: 이 값을 가진 variant axis 찾기
128
+ const matchingAxes = [];
129
+ for (const [axis, options] of Object.entries(component.variants)) {
130
+ if (axis in variantProps)
131
+ continue; // 이미 매핑된 axis 스킵
132
+ if (options.includes(value) && component.defaults?.[axis] !== value) {
133
+ matchingAxes.push(axis);
134
+ }
135
+ }
136
+ if (matchingAxes.length === 1) {
137
+ // 정확히 1개 axis에서만 발견 → 매핑
138
+ variantProps[matchingAxes[0]] = value;
139
+ }
140
+ // 0개 또는 2개 이상 → 매핑 포기 (default variant 사용)
141
+ }
142
+ else if (component.properties && typeof value === 'string') {
143
+ // 3) variant가 아닌 prop → property name 역검색
144
+ if (key in component.properties) {
145
+ // exact property name match
146
+ overrides[key] = value;
147
+ }
148
+ else {
149
+ // exact match 실패 → TEXT property 자동 매핑
150
+ const textProps = Object.entries(component.properties)
151
+ .filter(([, type]) => type === 'TEXT')
152
+ .map(([name]) => name);
153
+ if (textProps.length === 1) {
154
+ overrides[textProps[0]] = value;
155
+ }
156
+ // 0개 또는 2개 이상 → 매핑 포기
157
+ }
158
+ }
124
159
  else {
125
- // variant가 아닌 prop → overrides (text, boolean, swap)
160
+ // boolean 기타 → overrides 직접
126
161
  overrides[key] = value;
127
162
  }
128
163
  }
@@ -169,8 +204,8 @@ function enrichSpec(traversal, mapping) {
169
204
  if (normalized)
170
205
  fontWeight = normalized;
171
206
  }
172
- if (fontSize && fontWeight) {
173
- const key = `${fontSize}/${fontWeight}`;
207
+ if (fontSize) {
208
+ const key = `${fontSize}/${fontWeight || '400'}`;
174
209
  const textStyle = mapping.textStyles[key];
175
210
  if (textStyle) {
176
211
  node.style['text-style'] = textStyle;
@@ -200,6 +235,12 @@ function enrichSpec(traversal, mapping) {
200
235
  }
201
236
  }
202
237
  // Step 6: CSS → Figma 정규화
238
+ // 6-pre: overflow:hidden → layout.clip-content 변환 (6-A 필터링 전에 처리)
239
+ if (node.style && node.style['overflow'] === 'hidden') {
240
+ node.layout = node.layout || {};
241
+ node.layout['clip-content'] = true;
242
+ delete node.style['overflow'];
243
+ }
203
244
  // 6-A: CSS-only 속성 필터링 (Figma가 지원하지 않는 CSS 속성 제거)
204
245
  if (node.style) {
205
246
  for (const key of Object.keys(node.style)) {
@@ -215,6 +256,19 @@ function enrichSpec(traversal, mapping) {
215
256
  node.layout.direction = 'column';
216
257
  }
217
258
  }
259
+ // 6-D: absolute + fill 정규화
260
+ // Figma에서 absolute 요소는 parent auto-layout에 참여하지 않으므로
261
+ // fill은 layoutSizingHorizontal/Vertical = 'FILL'로 처리 → constraints STRETCH 동작
262
+ // 별도 변환 불필요 — NodeBuilder가 absolute child에 fill을 적용하면 STRETCH로 동작
263
+ // 다만 x/y가 없으면 0으로 기본값 설정하여 parent 좌상단 정렬
264
+ if (node.layout?.positioning === 'absolute') {
265
+ if ((node.layout.width === 'fill' || node.layout.width === '100%') && node.layout.x === undefined) {
266
+ node.layout.x = '0';
267
+ }
268
+ if ((node.layout.height === 'fill' || node.layout.height === '100%') && node.layout.y === undefined) {
269
+ node.layout.y = '0';
270
+ }
271
+ }
218
272
  // children 재귀
219
273
  if (node.children) {
220
274
  node.children.forEach((child, i) => {
@@ -434,26 +488,42 @@ function splitWords(name) {
434
488
  .split('_')
435
489
  .filter(w => w.length > 0);
436
490
  }
437
- // 3-stage component matching
491
+ // Thresholds for word-split matching
492
+ const AUTO_MATCH_THRESHOLD = 0.80;
493
+ const REVIEW_THRESHOLD = 0.40;
494
+ function buildHint(entry) {
495
+ const parts = [];
496
+ if (entry.variants) {
497
+ const axes = Object.keys(entry.variants).join(', ');
498
+ parts.push(`variants: ${axes}`);
499
+ }
500
+ if (entry.properties) {
501
+ const props = Object.entries(entry.properties)
502
+ .map(([k, v]) => `${k}(${v.type})`)
503
+ .join(', ');
504
+ parts.push(`props: ${props}`);
505
+ }
506
+ return parts.join(' | ') || 'no variants/props';
507
+ }
508
+ // 3-stage component matching with review tier
438
509
  function matchComponent(dsName, allComponents) {
439
510
  // Stage 1: exact match (case-sensitive)
440
511
  const exact = allComponents.get(dsName);
441
512
  if (exact)
442
- return { ...exact, matchType: 'exact' };
513
+ return { match: { ...exact, matchType: 'exact' } };
443
514
  // Stage 2: case-insensitive match
444
515
  const dsLower = dsName.toLowerCase();
445
516
  for (const [name, data] of allComponents) {
446
517
  if (name.toLowerCase() === dsLower) {
447
- return { ...data, matchType: 'case-insensitive' };
518
+ return { match: { ...data, matchType: 'case-insensitive' } };
448
519
  }
449
520
  }
450
521
  // Stage 3: word-split intersection match
451
522
  const inputWords = new Set(splitWords(dsName));
452
523
  if (inputWords.size === 0)
453
- return null;
454
- let bestScore = 0;
455
- let bestMatch = null;
456
- let bestName = '';
524
+ return { match: null };
525
+ // Collect all scores
526
+ const scored = [];
457
527
  for (const [name, data] of allComponents) {
458
528
  const dataWords = new Set(splitWords(name));
459
529
  let intersection = 0;
@@ -461,19 +531,44 @@ function matchComponent(dsName, allComponents) {
461
531
  if (dataWords.has(w))
462
532
  intersection++;
463
533
  }
464
- const score = intersection / inputWords.size;
465
- if (score > bestScore || (score === bestScore && bestName.length > name.length)) {
466
- bestScore = score;
467
- bestMatch = data;
468
- bestName = name;
534
+ // Dice coefficient: balanced scoring that penalizes asymmetric matches
535
+ // e.g., "DropdownSingleSelect" (3 words) "Select" (1 word): 2*1/(3+1) = 0.50
536
+ // vs "dropdown_unique" (2 words): 2*1/(3+2) = 0.40
537
+ const total = inputWords.size + dataWords.size;
538
+ const score = total > 0 ? (2 * intersection) / total : 0;
539
+ if (score >= REVIEW_THRESHOLD) {
540
+ scored.push({ name, data, score });
469
541
  }
470
542
  }
471
- if (bestScore >= 0.8 && bestMatch) {
472
- return { ...bestMatch, matchType: `word-split(${bestScore.toFixed(2)})` };
543
+ scored.sort((a, b) => b.score - a.score);
544
+ // Auto-match: top score >= 0.80
545
+ if (scored.length > 0 && scored[0].score >= AUTO_MATCH_THRESHOLD) {
546
+ return {
547
+ match: { ...scored[0].data, matchType: `word-split(${scored[0].score.toFixed(2)})` },
548
+ };
549
+ }
550
+ // Review tier: 0.40 <= score < 0.80
551
+ if (scored.length > 0) {
552
+ const reviewCandidates = scored.slice(0, 3).map(s => ({
553
+ name: s.name,
554
+ score: parseFloat(s.score.toFixed(2)),
555
+ library: s.data.library,
556
+ hint: buildHint(s.data.entry),
557
+ }));
558
+ return { match: null, reviewCandidates };
473
559
  }
474
- return null;
560
+ // No match at all
561
+ return { match: null };
475
562
  }
476
563
  function generateMapping(traversals, dataDir) {
564
+ // 0. Load _config.json for primary library
565
+ let primaryLibrary = null;
566
+ try {
567
+ const config = JSON.parse(readFileSync(join(dataDir, '_config.json'), 'utf-8'));
568
+ if (config.primary)
569
+ primaryLibrary = config.primary;
570
+ }
571
+ catch { /* _config.json not found — no primary, all libraries equal */ }
477
572
  // 1. Load all data files
478
573
  const files = readdirSync(dataDir);
479
574
  const allComponents = new Map();
@@ -529,8 +624,8 @@ function generateMapping(traversals, dataDir) {
529
624
  if (normalized)
530
625
  fontWeight = normalized;
531
626
  }
532
- if (fontSize && fontWeight) {
533
- fontSpecs.add(`${fontSize}/${fontWeight}`);
627
+ if (fontSize) {
628
+ fontSpecs.add(`${fontSize}/${fontWeight || '400'}`);
534
629
  }
535
630
  }
536
631
  // Collect top-level named frames as potential elements
@@ -542,49 +637,70 @@ function generateMapping(traversals, dataDir) {
542
637
  traversals.forEach(extractFromNode);
543
638
  // 3. Match components
544
639
  const components = {};
640
+ const reviewNeeded = [];
545
641
  for (const dsName of dsNames) {
546
- const match = matchComponent(dsName, allComponents);
547
- if (match) {
642
+ const result = matchComponent(dsName, allComponents);
643
+ if (result.match) {
548
644
  const comp = {
549
- library: match.library,
550
- 'library-key': match.entry.key,
645
+ library: result.match.library,
646
+ 'library-key': result.match.entry.key,
551
647
  };
552
- if (match.entry.variants) {
648
+ if (result.match.entry.variants) {
553
649
  const variants = {};
554
650
  const defaults = {};
555
- for (const [axis, info] of Object.entries(match.entry.variants)) {
651
+ for (const [axis, info] of Object.entries(result.match.entry.variants)) {
556
652
  variants[axis] = info.options;
557
653
  defaults[axis] = info.default;
558
654
  }
559
655
  comp.variants = variants;
560
656
  comp.defaults = defaults;
561
657
  }
562
- if (match.entry.properties) {
658
+ if (result.match.entry.properties) {
563
659
  const properties = {};
564
- for (const [propName, propInfo] of Object.entries(match.entry.properties)) {
660
+ for (const [propName, propInfo] of Object.entries(result.match.entry.properties)) {
565
661
  properties[propName] = propInfo.type;
566
662
  }
567
663
  comp.properties = properties;
568
664
  }
569
665
  components[dsName] = comp;
570
666
  }
667
+ else if (result.reviewCandidates && result.reviewCandidates.length > 0) {
668
+ reviewNeeded.push({ dsName, candidates: result.reviewCandidates });
669
+ }
571
670
  }
572
671
  // 4. Match tokens (HEX → variable name)
573
672
  const tokens = {};
574
- // Build reverse map: normalized HEX → variable name (semantic preferred)
673
+ // Build reverse map: normalized HEX → variable name
674
+ // Priority: primary library > semantic name > last seen
575
675
  const hexToVar = new Map();
576
676
  for (const v of allVariables) {
577
677
  const hex = normalizeHex(v.value);
578
678
  const existing = hexToVar.get(hex);
579
- // Prefer semantic (non-primitive): variables without numbers at the end
580
- if (!existing || (!isPrimitive(v.name) && isPrimitive(existing))) {
581
- hexToVar.set(hex, `{${v.name}}`);
679
+ if (!existing) {
680
+ hexToVar.set(hex, { name: v.name, library: v.library });
681
+ }
682
+ else {
683
+ const existingIsPrimary = existing.library === primaryLibrary;
684
+ const newIsPrimary = v.library === primaryLibrary;
685
+ if (newIsPrimary && !existingIsPrimary) {
686
+ // Primary library always wins
687
+ hexToVar.set(hex, { name: v.name, library: v.library });
688
+ }
689
+ else if (existingIsPrimary && !newIsPrimary) {
690
+ // Existing is primary, keep it
691
+ }
692
+ else {
693
+ // Same priority (both primary or both non-primary): prefer semantic name
694
+ if (!isPrimitive(v.name) && isPrimitive(existing.name)) {
695
+ hexToVar.set(hex, { name: v.name, library: v.library });
696
+ }
697
+ }
582
698
  }
583
699
  }
584
700
  for (const hex of hexColors) {
585
- const varName = hexToVar.get(hex);
586
- if (varName)
587
- tokens[hex] = varName;
701
+ const match = hexToVar.get(hex);
702
+ if (match)
703
+ tokens[hex] = `{${match.name}}`;
588
704
  }
589
705
  // 5. Match text styles (fontSize/fontWeight → style name)
590
706
  const textStyles = {};
@@ -619,7 +735,7 @@ function generateMapping(traversals, dataDir) {
619
735
  };
620
736
  }
621
737
  }
622
- return { elements, components, tokens, textStyles };
738
+ return { elements, components, tokens, textStyles, reviewNeeded };
623
739
  }
624
740
  function normalizeHex(hex) {
625
741
  let h = hex.toUpperCase();
@@ -735,6 +851,20 @@ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
735
851
  mkdirSync(dirname(outputPath), { recursive: true });
736
852
  writeFileSync(outputPath, JSON.stringify(result, null, 2));
737
853
  // Summary for context (not the full mapping)
854
+ const matchedDs = new Set(Object.keys(result.components || {}));
855
+ const reviewDs = new Set((result.reviewNeeded || []).map(r => r.dsName));
856
+ const allDs = new Set();
857
+ function collectDs(node) {
858
+ if (node._ds)
859
+ allDs.add(node._ds);
860
+ node.children?.forEach(collectDs);
861
+ }
862
+ traversals.forEach(collectDs);
863
+ const unmatchedDs = [];
864
+ for (const ds of allDs) {
865
+ if (!matchedDs.has(ds) && !reviewDs.has(ds))
866
+ unmatchedDs.push(ds);
867
+ }
738
868
  const summary = {
739
869
  success: true,
740
870
  savedTo: outputPath,
@@ -744,25 +874,177 @@ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
744
874
  textStyles: Object.keys(result.textStyles || {}).length,
745
875
  elements: Object.keys(result.elements || {}).length,
746
876
  },
747
- unmatchedDs: [],
877
+ reviewNeeded: result.reviewNeeded || [],
878
+ unmatchedDs,
748
879
  };
749
- // Report unmatched _ds names
750
- const matchedDs = new Set(Object.keys(result.components || {}));
880
+ return {
881
+ content: [{
882
+ type: 'text',
883
+ text: JSON.stringify(summary, null, 2),
884
+ }],
885
+ };
886
+ }
887
+ catch (err) {
888
+ return {
889
+ content: [{
890
+ type: 'text',
891
+ text: JSON.stringify({
892
+ success: false,
893
+ error: err instanceof Error ? err.message : String(err),
894
+ }, null, 2),
895
+ }],
896
+ };
897
+ }
898
+ });
899
+ // run_pipeline — unified pipeline tool
900
+ server.tool('run_pipeline', `Run the full spec pipeline: generate_mapping → validate → enrich → gate → tree in one call.
901
+ Supports 2-pass pattern: Pass 1 returns reviewNeeded (ambiguous matches). Agent decides, then Pass 2 with resolutions auto-applies decisions.
902
+ If no reviewNeeded, completes in 1 pass.`, {
903
+ traversals: z.array(z.record(z.unknown())).describe('Array of traversal JSON root nodes'),
904
+ dataDir: z.string().describe('Path to blueprint data directory (e.g., .claude/blueprint/data/)'),
905
+ outputPath: z.string().describe('File path to save spec.json (mapping.json saved alongside)'),
906
+ resolutions: z.record(z.string()).optional().describe('Agent-decided mappings: { dsName: resolvedName }. Pass 2 only.'),
907
+ }, async ({ traversals, dataDir, outputPath, resolutions }) => {
908
+ try {
909
+ let tNodes = traversals;
910
+ // Step 0: Apply resolutions — replace _ds in traversal nodes
911
+ if (resolutions && Object.keys(resolutions).length > 0) {
912
+ // Deep clone to avoid mutating input
913
+ tNodes = JSON.parse(JSON.stringify(tNodes));
914
+ const res = resolutions;
915
+ function applyResolutions(node) {
916
+ if (node._ds && res[node._ds]) {
917
+ node._ds = res[node._ds];
918
+ }
919
+ node.children?.forEach(applyResolutions);
920
+ }
921
+ tNodes.forEach(applyResolutions);
922
+ }
923
+ // Step 1: Generate mapping
924
+ const mappingResult = generateMapping(tNodes, dataDir);
925
+ const mappingPath = outputPath.replace(/\.json$/, '') + '_mapping.json';
926
+ mkdirSync(dirname(mappingPath), { recursive: true });
927
+ writeFileSync(mappingPath, JSON.stringify(mappingResult, null, 2));
928
+ // If reviewNeeded exists and no resolutions provided → return early for agent decision
929
+ const review = mappingResult.reviewNeeded || [];
930
+ if (review.length > 0 && (!resolutions || Object.keys(resolutions).length === 0)) {
931
+ // Also report truly unmatched
932
+ const matchedDs = new Set(Object.keys(mappingResult.components || {}));
933
+ const reviewDs = new Set(review.map(r => r.dsName));
934
+ const allDs = new Set();
935
+ function collectAllDs(node) {
936
+ if (node._ds)
937
+ allDs.add(node._ds);
938
+ node.children?.forEach(collectAllDs);
939
+ }
940
+ tNodes.forEach(collectAllDs);
941
+ const unresolved = [];
942
+ for (const ds of allDs) {
943
+ if (!matchedDs.has(ds) && !reviewDs.has(ds))
944
+ unresolved.push(ds);
945
+ }
946
+ return {
947
+ content: [{
948
+ type: 'text',
949
+ text: JSON.stringify({
950
+ success: false,
951
+ phase: 'review',
952
+ message: `${review.length} component(s) need agent review. Provide resolutions and call again.`,
953
+ reviewNeeded: review,
954
+ unresolved,
955
+ mappingSavedTo: mappingPath,
956
+ stats: {
957
+ components: Object.keys(mappingResult.components || {}).length,
958
+ tokens: Object.keys(mappingResult.tokens || {}).length,
959
+ textStyles: Object.keys(mappingResult.textStyles || {}).length,
960
+ },
961
+ }, null, 2),
962
+ }],
963
+ };
964
+ }
965
+ // Step 2: Validate traversal(s)
966
+ const validationErrors = [];
967
+ const validationWarnings = [];
968
+ for (const t of tNodes) {
969
+ const vResult = validateTraversal(t);
970
+ validationErrors.push(...vResult.errors);
971
+ validationWarnings.push(...vResult.warnings);
972
+ }
973
+ // Step 3: Enrich spec for each traversal
974
+ const specs = [];
975
+ const enrichErrors = [];
976
+ for (const t of tNodes) {
977
+ const eResult = enrichSpec(t, mappingResult);
978
+ if (eResult.errors.length > 0) {
979
+ enrichErrors.push(...eResult.errors);
980
+ }
981
+ specs.push(eResult.spec);
982
+ }
983
+ if (enrichErrors.length > 0) {
984
+ return {
985
+ content: [{
986
+ type: 'text',
987
+ text: JSON.stringify({
988
+ success: false,
989
+ phase: 'enrich',
990
+ errors: enrichErrors,
991
+ validationWarnings,
992
+ }, null, 2),
993
+ }],
994
+ };
995
+ }
996
+ // Step 4: Gate check each spec
997
+ const gateResults = [];
998
+ for (const spec of specs) {
999
+ gateResults.push(gateCheck(spec));
1000
+ }
1001
+ const allGatesPass = gateResults.every(g => g.pass);
1002
+ // Step 5: Save specs and generate trees
1003
+ mkdirSync(dirname(outputPath), { recursive: true });
1004
+ const trees = [];
1005
+ const allStats = [];
1006
+ if (specs.length === 1) {
1007
+ writeFileSync(outputPath, JSON.stringify(specs[0], null, 2));
1008
+ trees.push(specToTree(specs[0]));
1009
+ allStats.push(countSpecNodes(specs[0]));
1010
+ }
1011
+ else {
1012
+ // Multiple specs → save each with index
1013
+ for (let i = 0; i < specs.length; i++) {
1014
+ const specPath = outputPath.replace(/\.json$/, `_${i}.json`);
1015
+ writeFileSync(specPath, JSON.stringify(specs[i], null, 2));
1016
+ trees.push(specToTree(specs[i]));
1017
+ allStats.push(countSpecNodes(specs[i]));
1018
+ }
1019
+ }
1020
+ // Collect unresolved
1021
+ const matchedDs = new Set(Object.keys(mappingResult.components || {}));
751
1022
  const allDs = new Set();
752
- function collectDs(node) {
1023
+ function collectFinalDs(node) {
753
1024
  if (node._ds)
754
1025
  allDs.add(node._ds);
755
- node.children?.forEach(collectDs);
1026
+ node.children?.forEach(collectFinalDs);
756
1027
  }
757
- traversals.forEach(collectDs);
1028
+ tNodes.forEach(collectFinalDs);
1029
+ const unresolved = [];
758
1030
  for (const ds of allDs) {
759
1031
  if (!matchedDs.has(ds))
760
- summary.unmatchedDs.push(ds);
1032
+ unresolved.push(ds);
761
1033
  }
762
1034
  return {
763
1035
  content: [{
764
1036
  type: 'text',
765
- text: JSON.stringify(summary, null, 2),
1037
+ text: JSON.stringify({
1038
+ success: allGatesPass,
1039
+ savedTo: outputPath,
1040
+ mappingSavedTo: mappingPath,
1041
+ tree: trees.length === 1 ? trees[0] : trees,
1042
+ stats: allStats.length === 1 ? allStats[0] : allStats,
1043
+ gateResult: gateResults.length === 1 ? gateResults[0] : gateResults,
1044
+ validationWarnings: validationWarnings.length > 0 ? validationWarnings : undefined,
1045
+ unresolved: unresolved.length > 0 ? unresolved : undefined,
1046
+ reviewNeeded: review.length > 0 ? review : undefined,
1047
+ }, null, 2),
766
1048
  }],
767
1049
  };
768
1050
  }
@@ -772,6 +1054,7 @@ Saves result to outputPath. Replaces ds-data-lookup sub-agent.`, {
772
1054
  type: 'text',
773
1055
  text: JSON.stringify({
774
1056
  success: false,
1057
+ phase: 'error',
775
1058
  error: err instanceof Error ? err.message : String(err),
776
1059
  }, null, 2),
777
1060
  }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sangheepark/figma-ds-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "MCP server for Code to Figma Bridge — bridges Claude Code to Figma plugin via WebSocket",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",