conductor-figma 1.0.2 → 3.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.
- package/LICENSE +20 -0
- package/README.md +59 -153
- package/bin/conductor.js +1 -75
- package/figma-plugin/code.js +755 -0
- package/figma-plugin/manifest.json +14 -0
- package/figma-plugin/ui.html +77 -0
- package/package.json +25 -16
- package/src/bridge.js +60 -0
- package/src/design/intelligence.js +273 -294
- package/src/server.js +82 -196
- package/src/tools/handlers.js +145 -463
- package/src/tools/registry.js +1144 -336
- package/src/blueprints.js +0 -775
- package/src/design/craftguide.js +0 -181
- package/src/design/exporter.js +0 -72
- package/src/index.js +0 -33
- package/src/orchestrator.js +0 -100
- package/src/relay.js +0 -176
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════
|
|
2
|
+
// CONDUCTOR v3 — Figma Plugin
|
|
3
|
+
// ═══════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
figma.showUI(__html__, { width: 320, height: 200, themeColors: true })
|
|
6
|
+
|
|
7
|
+
let ws = null
|
|
8
|
+
const PORT = 3055
|
|
9
|
+
|
|
10
|
+
function connect() {
|
|
11
|
+
figma.ui.postMessage({ type: 'connect', port: PORT })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
figma.ui.onmessage = async (msg) => {
|
|
15
|
+
if (msg.type === 'ws-message') {
|
|
16
|
+
try {
|
|
17
|
+
const { id, command, data } = msg.data
|
|
18
|
+
const result = await executeCommand(command, data)
|
|
19
|
+
figma.ui.postMessage({ type: 'ws-send', data: JSON.stringify({ id, result }) })
|
|
20
|
+
} catch (e) {
|
|
21
|
+
const { id } = msg.data
|
|
22
|
+
figma.ui.postMessage({ type: 'ws-send', data: JSON.stringify({ id, error: e.message }) })
|
|
23
|
+
}
|
|
24
|
+
} else if (msg.type === 'connected') {
|
|
25
|
+
figma.notify('✓ Conductor connected', { timeout: 2000 })
|
|
26
|
+
} else if (msg.type === 'disconnected') {
|
|
27
|
+
figma.notify('Conductor disconnected', { timeout: 2000 })
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
connect()
|
|
32
|
+
|
|
33
|
+
// ─── Helpers ───
|
|
34
|
+
function hexToRGB(hex) {
|
|
35
|
+
hex = hex.replace('#', '')
|
|
36
|
+
const hasAlpha = hex.length === 8
|
|
37
|
+
return {
|
|
38
|
+
r: parseInt(hex.slice(0, 2), 16) / 255,
|
|
39
|
+
g: parseInt(hex.slice(2, 4), 16) / 255,
|
|
40
|
+
b: parseInt(hex.slice(4, 6), 16) / 255,
|
|
41
|
+
a: hasAlpha ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function solidPaint(hex, opacity) {
|
|
46
|
+
const c = hexToRGB(hex)
|
|
47
|
+
return [{ type: 'SOLID', color: { r: c.r, g: c.g, b: c.b }, opacity: opacity !== undefined ? opacity : c.a }]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getNode(id) {
|
|
51
|
+
if (!id) return null
|
|
52
|
+
const resolved = id.startsWith('$') ? resolveRef(id) : id
|
|
53
|
+
return figma.getNodeById(resolved)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const refMap = {}
|
|
57
|
+
function storeRef(idx, nodeId) { refMap['$' + idx] = nodeId }
|
|
58
|
+
function resolveRef(ref) {
|
|
59
|
+
const match = ref.match(/^\$(\d+)\.id$/)
|
|
60
|
+
return match ? refMap['$' + match[1]] : ref
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadFont(family, style) {
|
|
64
|
+
try { await figma.loadFontAsync({ family: family || 'Inter', style: style || 'Regular' }) }
|
|
65
|
+
catch { await figma.loadFontAsync({ family: 'Inter', style: 'Regular' }) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function serializeNode(node, depth) {
|
|
69
|
+
if (!node || depth < 0) return null
|
|
70
|
+
const base = { id: node.id, name: node.name, type: node.type }
|
|
71
|
+
if (node.width !== undefined) { base.width = Math.round(node.width); base.height = Math.round(node.height) }
|
|
72
|
+
if (node.x !== undefined) { base.x = Math.round(node.x); base.y = Math.round(node.y) }
|
|
73
|
+
if ('children' in node && depth > 0) {
|
|
74
|
+
base.children = node.children.map(c => serializeNode(c, depth - 1)).filter(Boolean)
|
|
75
|
+
}
|
|
76
|
+
if ('fills' in node && Array.isArray(node.fills)) {
|
|
77
|
+
base.fills = node.fills.map(f => {
|
|
78
|
+
if (f.type === 'SOLID') return { type: 'SOLID', color: rgbToHex(f.color), opacity: f.opacity }
|
|
79
|
+
return { type: f.type }
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
if (node.type === 'TEXT') {
|
|
83
|
+
base.characters = node.characters
|
|
84
|
+
base.fontSize = node.fontSize
|
|
85
|
+
}
|
|
86
|
+
return base
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rgbToHex(c) {
|
|
90
|
+
return '#' + [c.r, c.g, c.b].map(v => Math.round(v * 255).toString(16).padStart(2, '0')).join('')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function nodeInfo(node) {
|
|
94
|
+
const info = serializeNode(node, 0)
|
|
95
|
+
if ('layoutMode' in node) {
|
|
96
|
+
info.layoutMode = node.layoutMode
|
|
97
|
+
info.paddingTop = node.paddingTop; info.paddingRight = node.paddingRight
|
|
98
|
+
info.paddingBottom = node.paddingBottom; info.paddingLeft = node.paddingLeft
|
|
99
|
+
info.itemSpacing = node.itemSpacing
|
|
100
|
+
info.primaryAxisAlignItems = node.primaryAxisAlignItems
|
|
101
|
+
info.counterAxisAlignItems = node.counterAxisAlignItems
|
|
102
|
+
}
|
|
103
|
+
if ('cornerRadius' in node) info.cornerRadius = node.cornerRadius
|
|
104
|
+
if ('opacity' in node) info.opacity = node.opacity
|
|
105
|
+
if ('effects' in node) info.effects = node.effects
|
|
106
|
+
if ('strokes' in node) info.strokes = node.strokes
|
|
107
|
+
if ('constraints' in node) info.constraints = node.constraints
|
|
108
|
+
if (node.type === 'TEXT') {
|
|
109
|
+
info.fontName = node.fontName
|
|
110
|
+
info.textAlignHorizontal = node.textAlignHorizontal
|
|
111
|
+
info.lineHeight = node.lineHeight
|
|
112
|
+
info.letterSpacing = node.letterSpacing
|
|
113
|
+
}
|
|
114
|
+
return info
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ═══ Command Router ═══
|
|
118
|
+
async function executeCommand(cmd, data) {
|
|
119
|
+
switch (cmd) {
|
|
120
|
+
// ─── CREATE ───
|
|
121
|
+
case 'create_frame': {
|
|
122
|
+
const frame = figma.createFrame()
|
|
123
|
+
frame.name = data.name || 'Frame'
|
|
124
|
+
if (data.width) frame.resize(data.width, data.height || data.width)
|
|
125
|
+
frame.layoutMode = data.direction || 'VERTICAL'
|
|
126
|
+
frame.primaryAxisSizingMode = data.primaryAxisSizingMode === 'FILL' ? 'FIXED' : (data.primaryAxisSizingMode || 'HUG')
|
|
127
|
+
frame.counterAxisSizingMode = data.counterAxisSizingMode || 'HUG'
|
|
128
|
+
if (data.paddingTop !== undefined) frame.paddingTop = data.paddingTop
|
|
129
|
+
if (data.paddingRight !== undefined) frame.paddingRight = data.paddingRight
|
|
130
|
+
if (data.paddingBottom !== undefined) frame.paddingBottom = data.paddingBottom
|
|
131
|
+
if (data.paddingLeft !== undefined) frame.paddingLeft = data.paddingLeft
|
|
132
|
+
if (data.gap !== undefined) frame.itemSpacing = data.gap
|
|
133
|
+
if (data.fill) frame.fills = solidPaint(data.fill)
|
|
134
|
+
if (data.cornerRadius) frame.cornerRadius = data.cornerRadius
|
|
135
|
+
if (data.primaryAxisAlignItems) frame.primaryAxisAlignItems = data.primaryAxisAlignItems
|
|
136
|
+
if (data.counterAxisAlignItems) frame.counterAxisAlignItems = data.counterAxisAlignItems
|
|
137
|
+
if (data.x !== undefined) { frame.x = data.x; frame.y = data.y || 0 }
|
|
138
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(frame) }
|
|
139
|
+
return { id: frame.id, name: frame.name, type: 'FRAME' }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case 'create_text': {
|
|
143
|
+
const fn = data.fontName || { family: data.fontFamily || 'Inter', style: data.fontWeight || 'Regular' }
|
|
144
|
+
await loadFont(fn.family, fn.style)
|
|
145
|
+
const text = figma.createText()
|
|
146
|
+
text.fontName = fn
|
|
147
|
+
text.characters = (data.text || '').replace(/\\n/g, '\n')
|
|
148
|
+
text.fontSize = data.fontSize || 16
|
|
149
|
+
if (data.color) text.fills = solidPaint(data.color)
|
|
150
|
+
if (data.textAlignHorizontal) text.textAlignHorizontal = data.textAlignHorizontal
|
|
151
|
+
if (data.textAlignVertical) text.textAlignVertical = data.textAlignVertical
|
|
152
|
+
if (data.lineHeight) {
|
|
153
|
+
text.lineHeight = data.lineHeight > 5 ? { value: data.lineHeight, unit: 'PIXELS' } : { value: data.lineHeight * 100, unit: 'PERCENT' }
|
|
154
|
+
}
|
|
155
|
+
if (data.letterSpacing) text.letterSpacing = { value: data.letterSpacing, unit: 'PIXELS' }
|
|
156
|
+
if (data.maxWidth) { text.textAutoResize = 'HEIGHT'; text.resize(data.maxWidth, text.height) }
|
|
157
|
+
if (data.textCase) text.textCase = data.textCase
|
|
158
|
+
if (data.textDecoration) text.textDecoration = data.textDecoration
|
|
159
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(text) }
|
|
160
|
+
return { id: text.id, name: text.name, type: 'TEXT' }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'create_rectangle': {
|
|
164
|
+
const rect = figma.createRectangle()
|
|
165
|
+
rect.name = data.name || 'Rectangle'
|
|
166
|
+
rect.resize(data.width, data.height)
|
|
167
|
+
if (data.fill) rect.fills = solidPaint(data.fill)
|
|
168
|
+
if (data.cornerRadius) rect.cornerRadius = data.cornerRadius
|
|
169
|
+
if (data.opacity !== undefined) rect.opacity = data.opacity
|
|
170
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(rect) }
|
|
171
|
+
return { id: rect.id, name: rect.name, type: 'RECTANGLE' }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case 'create_ellipse': {
|
|
175
|
+
const el = figma.createEllipse()
|
|
176
|
+
el.name = data.name || 'Ellipse'
|
|
177
|
+
el.resize(data.width, data.height)
|
|
178
|
+
if (data.fill) el.fills = solidPaint(data.fill)
|
|
179
|
+
if (data.opacity !== undefined) el.opacity = data.opacity
|
|
180
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(el) }
|
|
181
|
+
return { id: el.id, name: el.name, type: 'ELLIPSE' }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case 'create_line': {
|
|
185
|
+
const line = figma.createLine()
|
|
186
|
+
line.name = data.name || 'Line'
|
|
187
|
+
line.resize(data.length || 100, 0)
|
|
188
|
+
if (data.direction === 'VERTICAL') line.rotation = 90
|
|
189
|
+
line.strokes = solidPaint(data.strokeColor || '#ffffff', 1)
|
|
190
|
+
line.strokeWeight = data.strokeWeight || 1
|
|
191
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(line) }
|
|
192
|
+
return { id: line.id, name: line.name, type: 'LINE' }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
case 'create_svg_node': {
|
|
196
|
+
const node = figma.createNodeFromSvg(data.svg)
|
|
197
|
+
if (data.name) node.name = data.name
|
|
198
|
+
if (data.width && data.height) node.resize(data.width, data.height)
|
|
199
|
+
else if (data.width) { const scale = data.width / node.width; node.resize(data.width, node.height * scale) }
|
|
200
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(node) }
|
|
201
|
+
return { id: node.id, name: node.name, type: node.type }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case 'create_icon': {
|
|
205
|
+
const svg = data._svg
|
|
206
|
+
const node = figma.createNodeFromSvg(svg)
|
|
207
|
+
node.name = data.icon || 'Icon'
|
|
208
|
+
node.resize(data._size, data._size)
|
|
209
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(node) }
|
|
210
|
+
return { id: node.id, name: node.name, type: node.type }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case 'create_component': {
|
|
214
|
+
let comp
|
|
215
|
+
if (data.fromNodeId) {
|
|
216
|
+
const source = getNode(data.fromNodeId)
|
|
217
|
+
if (!source) throw new Error('Node not found: ' + data.fromNodeId)
|
|
218
|
+
comp = figma.createComponentFromNode(source)
|
|
219
|
+
} else {
|
|
220
|
+
comp = figma.createComponent()
|
|
221
|
+
if (data.width && data.height) comp.resize(data.width, data.height)
|
|
222
|
+
}
|
|
223
|
+
comp.name = data.name
|
|
224
|
+
if (data.description) comp.description = data.description
|
|
225
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(comp) }
|
|
226
|
+
return { id: comp.id, name: comp.name, type: 'COMPONENT' }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'create_component_instance': {
|
|
230
|
+
const comp = getNode(data.componentId)
|
|
231
|
+
if (!comp || comp.type !== 'COMPONENT') throw new Error('Not a component: ' + data.componentId)
|
|
232
|
+
const inst = comp.createInstance()
|
|
233
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(inst) }
|
|
234
|
+
return { id: inst.id, name: inst.name, type: 'INSTANCE' }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── MODIFY ───
|
|
238
|
+
case 'modify_node': {
|
|
239
|
+
const node = getNode(data.nodeId)
|
|
240
|
+
if (!node) throw new Error('Node not found: ' + data.nodeId)
|
|
241
|
+
if (data.x !== undefined) node.x = data.x
|
|
242
|
+
if (data.y !== undefined) node.y = data.y
|
|
243
|
+
if (data.width !== undefined && data.height !== undefined) node.resize(data.width, data.height)
|
|
244
|
+
if (data.name) node.name = data.name
|
|
245
|
+
if (data.visible !== undefined) node.visible = data.visible
|
|
246
|
+
if (data.locked !== undefined) node.locked = data.locked
|
|
247
|
+
if (data.opacity !== undefined) node.opacity = data.opacity
|
|
248
|
+
if (data.rotation !== undefined) node.rotation = data.rotation
|
|
249
|
+
if (data.cornerRadius !== undefined && 'cornerRadius' in node) node.cornerRadius = data.cornerRadius
|
|
250
|
+
if (data.fill && 'fills' in node) node.fills = solidPaint(data.fill)
|
|
251
|
+
return { id: node.id, name: node.name, modified: true }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
case 'set_fill': {
|
|
255
|
+
const node = getNode(data.nodeId)
|
|
256
|
+
if (!node || !('fills' in node)) throw new Error('Cannot set fill on: ' + data.nodeId)
|
|
257
|
+
if (data._fill) {
|
|
258
|
+
if (data._fill.type === 'SOLID') node.fills = [data._fill]
|
|
259
|
+
else node.fills = [data._fill]
|
|
260
|
+
} else if (data.color) {
|
|
261
|
+
node.fills = solidPaint(data.color, data.opacity)
|
|
262
|
+
}
|
|
263
|
+
return { id: node.id, fill: 'set' }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case 'set_stroke': {
|
|
267
|
+
const node = getNode(data.nodeId)
|
|
268
|
+
if (!node || !('strokes' in node)) throw new Error('Cannot set stroke on: ' + data.nodeId)
|
|
269
|
+
node.strokes = solidPaint(data.color || '#ffffff', data.opacity)
|
|
270
|
+
node.strokeWeight = data.weight || 1
|
|
271
|
+
if (data.align) node.strokeAlign = data.align
|
|
272
|
+
if (data.dashPattern) node.dashPattern = data.dashPattern
|
|
273
|
+
return { id: node.id, stroke: 'set' }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case 'set_effects': {
|
|
277
|
+
const node = getNode(data.nodeId)
|
|
278
|
+
if (!node || !('effects' in node)) throw new Error('Cannot set effects on: ' + data.nodeId)
|
|
279
|
+
const effects = []
|
|
280
|
+
if (data.shadow) {
|
|
281
|
+
const s = data.shadow
|
|
282
|
+
effects.push({
|
|
283
|
+
type: 'DROP_SHADOW', visible: true,
|
|
284
|
+
color: { ...hexToRGB(s.color || '#00000040'), a: 0.25 },
|
|
285
|
+
offset: { x: s.offsetX || s.offset?.x || 0, y: s.offsetY || s.offset?.y || 4 },
|
|
286
|
+
radius: s.blur || 8, spread: s.spread || 0,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
if (data.blur) effects.push({ type: 'LAYER_BLUR', visible: true, radius: data.blur })
|
|
290
|
+
if (data.backgroundBlur) effects.push({ type: 'BACKGROUND_BLUR', visible: true, radius: data.backgroundBlur })
|
|
291
|
+
node.effects = effects
|
|
292
|
+
return { id: node.id, effects: effects.length }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
case 'set_auto_layout': {
|
|
296
|
+
const node = getNode(data.nodeId)
|
|
297
|
+
if (!node || node.type !== 'FRAME') throw new Error('Not a frame: ' + data.nodeId)
|
|
298
|
+
if (data.direction) node.layoutMode = data.direction
|
|
299
|
+
if (data.paddingTop !== undefined) node.paddingTop = data.paddingTop
|
|
300
|
+
if (data.paddingRight !== undefined) node.paddingRight = data.paddingRight
|
|
301
|
+
if (data.paddingBottom !== undefined) node.paddingBottom = data.paddingBottom
|
|
302
|
+
if (data.paddingLeft !== undefined) node.paddingLeft = data.paddingLeft
|
|
303
|
+
if (data.gap !== undefined) node.itemSpacing = data.gap
|
|
304
|
+
if (data.primaryAxisAlignItems) node.primaryAxisAlignItems = data.primaryAxisAlignItems
|
|
305
|
+
if (data.counterAxisAlignItems) node.counterAxisAlignItems = data.counterAxisAlignItems
|
|
306
|
+
if (data.primaryAxisSizingMode) node.primaryAxisSizingMode = data.primaryAxisSizingMode === 'FILL' ? 'FIXED' : data.primaryAxisSizingMode
|
|
307
|
+
if (data.counterAxisSizingMode) node.counterAxisSizingMode = data.counterAxisSizingMode === 'FILL' ? 'FIXED' : data.counterAxisSizingMode
|
|
308
|
+
return { id: node.id, layout: 'set' }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case 'delete_node': {
|
|
312
|
+
const node = getNode(data.nodeId)
|
|
313
|
+
if (!node) throw new Error('Node not found')
|
|
314
|
+
const name = node.name
|
|
315
|
+
node.remove()
|
|
316
|
+
return { deleted: name }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
case 'move_to_parent': {
|
|
320
|
+
const node = getNode(data.nodeId)
|
|
321
|
+
const parent = getNode(data.parentId)
|
|
322
|
+
if (!node || !parent) throw new Error('Node or parent not found')
|
|
323
|
+
if ('appendChild' in parent) {
|
|
324
|
+
if (data.index !== undefined) parent.insertChild(data.index, node)
|
|
325
|
+
else parent.appendChild(node)
|
|
326
|
+
}
|
|
327
|
+
return { id: node.id, parent: parent.id }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case 'duplicate_node': {
|
|
331
|
+
const node = getNode(data.nodeId)
|
|
332
|
+
if (!node) throw new Error('Node not found')
|
|
333
|
+
const copies = []
|
|
334
|
+
for (let i = 0; i < (data.count || 1); i++) {
|
|
335
|
+
const copy = node.clone()
|
|
336
|
+
if (data.offsetX || data.offsetY) { copy.x = node.x + (data.offsetX || 0) * (i + 1); copy.y = node.y + (data.offsetY || 0) * (i + 1) }
|
|
337
|
+
copies.push({ id: copy.id, name: copy.name })
|
|
338
|
+
}
|
|
339
|
+
return { copies }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
case 'rename_node': {
|
|
343
|
+
const node = getNode(data.nodeId)
|
|
344
|
+
if (!node) throw new Error('Node not found')
|
|
345
|
+
node.name = data.name
|
|
346
|
+
return { id: node.id, name: data.name }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
case 'set_visibility': {
|
|
350
|
+
const node = getNode(data.nodeId)
|
|
351
|
+
if (!node) throw new Error('Node not found')
|
|
352
|
+
node.visible = data.visible
|
|
353
|
+
return { id: node.id, visible: data.visible }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case 'set_constraints': {
|
|
357
|
+
const node = getNode(data.nodeId)
|
|
358
|
+
if (!node) throw new Error('Node not found')
|
|
359
|
+
node.constraints = {
|
|
360
|
+
horizontal: data.horizontal || node.constraints.horizontal,
|
|
361
|
+
vertical: data.vertical || node.constraints.vertical,
|
|
362
|
+
}
|
|
363
|
+
return { id: node.id, constraints: node.constraints }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
case 'resize_node': {
|
|
367
|
+
const node = getNode(data.nodeId)
|
|
368
|
+
if (!node) throw new Error('Node not found')
|
|
369
|
+
if (data.scale) { node.resize(node.width * data.scale, node.height * data.scale) }
|
|
370
|
+
else { node.resize(data.width || node.width, data.height || node.height) }
|
|
371
|
+
return { id: node.id, width: Math.round(node.width), height: Math.round(node.height) }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
case 'set_corner_radius': {
|
|
375
|
+
const node = getNode(data.nodeId)
|
|
376
|
+
if (!node || !('cornerRadius' in node)) throw new Error('Cannot set radius')
|
|
377
|
+
if (data.radius !== undefined) node.cornerRadius = data.radius
|
|
378
|
+
if (data.topLeft !== undefined) { node.topLeftRadius = data.topLeft; node.topRightRadius = data.topRight || 0; node.bottomRightRadius = data.bottomRight || 0; node.bottomLeftRadius = data.bottomLeft || 0 }
|
|
379
|
+
return { id: node.id, cornerRadius: node.cornerRadius }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
case 'set_opacity': {
|
|
383
|
+
const node = getNode(data.nodeId)
|
|
384
|
+
if (!node) throw new Error('Node not found')
|
|
385
|
+
node.opacity = data.opacity
|
|
386
|
+
return { id: node.id, opacity: data.opacity }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
case 'set_rotation': {
|
|
390
|
+
const node = getNode(data.nodeId)
|
|
391
|
+
if (!node) throw new Error('Node not found')
|
|
392
|
+
node.rotation = data.angle
|
|
393
|
+
return { id: node.id, rotation: data.angle }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case 'set_clip_content': {
|
|
397
|
+
const node = getNode(data.nodeId)
|
|
398
|
+
if (!node) throw new Error('Node not found')
|
|
399
|
+
node.clipsContent = data.clip
|
|
400
|
+
return { id: node.id, clipsContent: data.clip }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case 'flatten_node': {
|
|
404
|
+
const node = getNode(data.nodeId)
|
|
405
|
+
if (!node) throw new Error('Node not found')
|
|
406
|
+
const flat = figma.flatten([node])
|
|
407
|
+
return { id: flat.id, name: flat.name }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case 'set_layout_sizing': {
|
|
411
|
+
const node = getNode(data.nodeId)
|
|
412
|
+
if (!node) throw new Error('Node not found')
|
|
413
|
+
if (data.horizontal) node.layoutSizingHorizontal = data.horizontal
|
|
414
|
+
if (data.vertical) node.layoutSizingVertical = data.vertical
|
|
415
|
+
return { id: node.id }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
case 'group_nodes': {
|
|
419
|
+
const nodes = data.nodeIds.map(id => getNode(id)).filter(Boolean)
|
|
420
|
+
if (nodes.length < 2) throw new Error('Need at least 2 nodes to group')
|
|
421
|
+
const group = figma.group(nodes, nodes[0].parent)
|
|
422
|
+
if (data.name) group.name = data.name
|
|
423
|
+
return { id: group.id, name: group.name }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
case 'ungroup_nodes': {
|
|
427
|
+
const node = getNode(data.nodeId)
|
|
428
|
+
if (!node || node.type !== 'GROUP') throw new Error('Not a group')
|
|
429
|
+
const parent = node.parent
|
|
430
|
+
const children = [...node.children]
|
|
431
|
+
for (const child of children) parent.appendChild(child)
|
|
432
|
+
node.remove()
|
|
433
|
+
return { ungrouped: children.length }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── VECTOR ───
|
|
437
|
+
case 'boolean_operation': {
|
|
438
|
+
const nodes = data.nodeIds.map(id => getNode(id)).filter(Boolean)
|
|
439
|
+
if (nodes.length < 2) throw new Error('Need at least 2 nodes')
|
|
440
|
+
const ops = { UNION: 'UNION', SUBTRACT: 'SUBTRACT', INTERSECT: 'INTERSECT', EXCLUDE: 'EXCLUDE' }
|
|
441
|
+
const result = figma.union(nodes, nodes[0].parent) // Note: actual API varies
|
|
442
|
+
return { id: result.id, operation: data.operation }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
case 'create_divider': {
|
|
446
|
+
const rect = figma.createRectangle()
|
|
447
|
+
rect.name = data.name || 'Divider'
|
|
448
|
+
if (data.direction === 'VERTICAL') rect.resize(data.thickness || 1, data.length || 100)
|
|
449
|
+
else rect.resize(data.length || 100, data.thickness || 1)
|
|
450
|
+
rect.fills = solidPaint(data.color || '#1e1e3a')
|
|
451
|
+
if (data.parentId) { const p = getNode(data.parentId); if (p && 'appendChild' in p) p.appendChild(rect) }
|
|
452
|
+
return { id: rect.id, name: rect.name }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── READ ───
|
|
456
|
+
case 'get_selection': {
|
|
457
|
+
return figma.currentPage.selection.map(n => nodeInfo(n))
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
case 'get_page_structure': {
|
|
461
|
+
const page = data.pageId ? figma.getNodeById(data.pageId) : figma.currentPage
|
|
462
|
+
return serializeNode(page, data.depth || 3)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
case 'get_node_info': {
|
|
466
|
+
const node = getNode(data.nodeId)
|
|
467
|
+
if (!node) throw new Error('Node not found')
|
|
468
|
+
return nodeInfo(node)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
case 'get_nodes_info': {
|
|
472
|
+
return data.nodeIds.map(id => { const n = getNode(id); return n ? nodeInfo(n) : null }).filter(Boolean)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
case 'find_nodes': {
|
|
476
|
+
const scope = data.withinId ? getNode(data.withinId) : figma.currentPage
|
|
477
|
+
if (!scope) throw new Error('Scope not found')
|
|
478
|
+
let results = scope.findAll ? scope.findAll(n => {
|
|
479
|
+
if (data.type && n.type !== data.type) return false
|
|
480
|
+
if (data.query && !n.name.toLowerCase().includes(data.query.toLowerCase())) return false
|
|
481
|
+
return true
|
|
482
|
+
}) : []
|
|
483
|
+
return results.slice(0, 50).map(n => ({ id: n.id, name: n.name, type: n.type }))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
case 'get_local_styles': {
|
|
487
|
+
const paint = figma.getLocalPaintStyles().map(s => ({ id: s.id, name: s.name, type: 'PAINT' }))
|
|
488
|
+
const text = figma.getLocalTextStyles().map(s => ({ id: s.id, name: s.name, type: 'TEXT', fontSize: s.fontSize }))
|
|
489
|
+
const effect = figma.getLocalEffectStyles().map(s => ({ id: s.id, name: s.name, type: 'EFFECT' }))
|
|
490
|
+
return { paint, text, effect, total: paint.length + text.length + effect.length }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
case 'list_components': {
|
|
494
|
+
const page = data.pageId ? figma.getNodeById(data.pageId) : figma.currentPage
|
|
495
|
+
const comps = page.findAll(n => n.type === 'COMPONENT')
|
|
496
|
+
return comps.map(c => ({ id: c.id, name: c.name, description: c.description || '' }))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
case 'list_pages': {
|
|
500
|
+
return figma.root.children.map(p => ({ id: p.id, name: p.name, childCount: p.children.length }))
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
case 'get_document_info': {
|
|
504
|
+
return { name: figma.root.name, pages: figma.root.children.map(p => ({ id: p.id, name: p.name })) }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
case 'set_selection': {
|
|
508
|
+
const nodes = data.nodeIds.map(id => getNode(id)).filter(Boolean)
|
|
509
|
+
figma.currentPage.selection = nodes
|
|
510
|
+
if (data.zoomToFit !== false && nodes.length) figma.viewport.scrollAndZoomIntoView(nodes)
|
|
511
|
+
return { selected: nodes.length }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
case 'set_focus': {
|
|
515
|
+
const node = getNode(data.nodeId)
|
|
516
|
+
if (!node) throw new Error('Node not found')
|
|
517
|
+
figma.currentPage.selection = [node]
|
|
518
|
+
figma.viewport.scrollAndZoomIntoView([node])
|
|
519
|
+
return { focused: node.id }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
case 'list_available_fonts': {
|
|
523
|
+
const fonts = await figma.listAvailableFontsAsync()
|
|
524
|
+
const families = [...new Set(fonts.map(f => f.fontName.family))].sort()
|
|
525
|
+
return { count: families.length, families: families.slice(0, 100) }
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
case 'get_selection_colors': {
|
|
529
|
+
const colors = new Set()
|
|
530
|
+
for (const node of figma.currentPage.selection) {
|
|
531
|
+
if ('fills' in node && Array.isArray(node.fills)) {
|
|
532
|
+
node.fills.forEach(f => { if (f.type === 'SOLID') colors.add(rgbToHex(f.color)) })
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return [...colors]
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
case 'measure_distance': {
|
|
539
|
+
const n1 = getNode(data.nodeId1), n2 = getNode(data.nodeId2)
|
|
540
|
+
if (!n1 || !n2) throw new Error('Node not found')
|
|
541
|
+
const dx = Math.abs(n1.absoluteTransform[0][2] - n2.absoluteTransform[0][2])
|
|
542
|
+
const dy = Math.abs(n1.absoluteTransform[1][2] - n2.absoluteTransform[1][2])
|
|
543
|
+
return { horizontal: Math.round(dx), vertical: Math.round(dy), diagonal: Math.round(Math.sqrt(dx*dx + dy*dy)) }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ─── EXPORT ───
|
|
547
|
+
case 'export_as_svg': {
|
|
548
|
+
const node = getNode(data.nodeId)
|
|
549
|
+
if (!node) throw new Error('Node not found')
|
|
550
|
+
const svg = await node.exportAsync({ format: 'SVG_STRING' })
|
|
551
|
+
return { svg: typeof svg === 'string' ? svg : new TextDecoder().decode(svg) }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
case 'export_as_png': {
|
|
555
|
+
const node = getNode(data.nodeId)
|
|
556
|
+
if (!node) throw new Error('Node not found')
|
|
557
|
+
const bytes = await node.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: data.scale || 2 } })
|
|
558
|
+
return { size: bytes.byteLength, format: 'PNG', scale: data.scale || 2 }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
case 'screenshot': {
|
|
562
|
+
if (data.nodeId) {
|
|
563
|
+
const node = getNode(data.nodeId)
|
|
564
|
+
if (!node) throw new Error('Node not found')
|
|
565
|
+
const bytes = await node.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: data.scale || 2 } })
|
|
566
|
+
return { size: bytes.byteLength }
|
|
567
|
+
}
|
|
568
|
+
return { message: 'Viewport screenshot not available in plugin API' }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ─── VARIABLES ───
|
|
572
|
+
case 'create_variable_collection': {
|
|
573
|
+
const collection = figma.variables.createVariableCollection(data.name)
|
|
574
|
+
if (data.modes && data.modes.length > 1) {
|
|
575
|
+
for (let i = 1; i < data.modes.length; i++) collection.addMode(data.modes[i])
|
|
576
|
+
collection.renameMode(collection.modes[0].modeId, data.modes[0])
|
|
577
|
+
}
|
|
578
|
+
return { id: collection.id, name: collection.name, modes: collection.modes }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
case 'create_variable': {
|
|
582
|
+
const coll = figma.variables.getVariableCollectionById(data.collectionId)
|
|
583
|
+
if (!coll) throw new Error('Collection not found')
|
|
584
|
+
const typeMap = { COLOR: 'COLOR', FLOAT: 'FLOAT', STRING: 'STRING', BOOLEAN: 'BOOLEAN' }
|
|
585
|
+
const variable = figma.variables.createVariable(data.name, coll, typeMap[data.type] || 'STRING')
|
|
586
|
+
// Set default value
|
|
587
|
+
if (data.type === 'COLOR') variable.setValueForMode(coll.defaultModeId, hexToRGB(data.value))
|
|
588
|
+
else if (data.type === 'FLOAT') variable.setValueForMode(coll.defaultModeId, parseFloat(data.value))
|
|
589
|
+
else if (data.type === 'BOOLEAN') variable.setValueForMode(coll.defaultModeId, data.value === 'true')
|
|
590
|
+
else variable.setValueForMode(coll.defaultModeId, data.value)
|
|
591
|
+
return { id: variable.id, name: variable.name }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
case 'get_variables': {
|
|
595
|
+
const collections = figma.variables.getLocalVariableCollections()
|
|
596
|
+
return collections.map(c => ({
|
|
597
|
+
id: c.id, name: c.name, modes: c.modes,
|
|
598
|
+
variables: c.variableIds.map(vid => {
|
|
599
|
+
const v = figma.variables.getVariableById(vid)
|
|
600
|
+
return v ? { id: v.id, name: v.name, type: v.resolvedType } : null
|
|
601
|
+
}).filter(Boolean)
|
|
602
|
+
}))
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
case 'bind_variable': {
|
|
606
|
+
const node = getNode(data.nodeId)
|
|
607
|
+
const variable = figma.variables.getVariableById(data.variableId)
|
|
608
|
+
if (!node || !variable) throw new Error('Node or variable not found')
|
|
609
|
+
node.setBoundVariable(data.property, variable)
|
|
610
|
+
return { bound: true, property: data.property, variable: variable.name }
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ─── BATCH ───
|
|
614
|
+
case 'batch_rename': {
|
|
615
|
+
const results = []
|
|
616
|
+
for (let i = 0; i < data.nodeIds.length; i++) {
|
|
617
|
+
const node = getNode(data.nodeIds[i])
|
|
618
|
+
if (!node) continue
|
|
619
|
+
const name = data.pattern.replace('{n}', String((data.startNumber || 1) + i)).replace('{name}', node.name)
|
|
620
|
+
node.name = name
|
|
621
|
+
results.push({ id: node.id, name })
|
|
622
|
+
}
|
|
623
|
+
return { renamed: results.length, results }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
case 'batch_style': {
|
|
627
|
+
let count = 0
|
|
628
|
+
for (const id of data.nodeIds) {
|
|
629
|
+
const node = getNode(id)
|
|
630
|
+
if (!node) continue
|
|
631
|
+
if (data.changes.fill && 'fills' in node) node.fills = solidPaint(data.changes.fill)
|
|
632
|
+
if (data.changes.opacity !== undefined) node.opacity = data.changes.opacity
|
|
633
|
+
if (data.changes.cornerRadius !== undefined && 'cornerRadius' in node) node.cornerRadius = data.changes.cornerRadius
|
|
634
|
+
count++
|
|
635
|
+
}
|
|
636
|
+
return { styled: count }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
case 'batch_replace_text': {
|
|
640
|
+
const scope = data.nodeId ? getNode(data.nodeId) : figma.currentPage
|
|
641
|
+
const textNodes = scope.findAll(n => n.type === 'TEXT')
|
|
642
|
+
let count = 0
|
|
643
|
+
for (const node of textNodes) {
|
|
644
|
+
if (node.characters.includes(data.find) || (!data.matchCase && node.characters.toLowerCase().includes(data.find.toLowerCase()))) {
|
|
645
|
+
await loadFont(node.fontName.family, node.fontName.style)
|
|
646
|
+
const regex = new RegExp(data.find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), data.matchCase ? 'g' : 'gi')
|
|
647
|
+
node.characters = node.characters.replace(regex, data.replace)
|
|
648
|
+
count++
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return { replaced: count }
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
case 'batch_replace_color': {
|
|
655
|
+
const scope = data.nodeId ? getNode(data.nodeId) : figma.currentPage
|
|
656
|
+
const findRgb = hexToRGB(data.find)
|
|
657
|
+
let count = 0
|
|
658
|
+
scope.findAll(n => {
|
|
659
|
+
if ('fills' in n && Array.isArray(n.fills)) {
|
|
660
|
+
const newFills = n.fills.map(f => {
|
|
661
|
+
if (f.type === 'SOLID' && Math.abs(f.color.r - findRgb.r) < 0.02 && Math.abs(f.color.g - findRgb.g) < 0.02 && Math.abs(f.color.b - findRgb.b) < 0.02) {
|
|
662
|
+
count++
|
|
663
|
+
return { ...f, color: hexToRGB(data.replace) }
|
|
664
|
+
}
|
|
665
|
+
return f
|
|
666
|
+
})
|
|
667
|
+
n.fills = newFills
|
|
668
|
+
}
|
|
669
|
+
return false
|
|
670
|
+
})
|
|
671
|
+
return { replaced: count }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
case 'batch_delete': {
|
|
675
|
+
let count = 0
|
|
676
|
+
for (const id of data.nodeIds) {
|
|
677
|
+
const node = getNode(id)
|
|
678
|
+
if (node) { node.remove(); count++ }
|
|
679
|
+
}
|
|
680
|
+
return { deleted: count }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
case 'batch_set_visibility': {
|
|
684
|
+
for (const id of data.nodeIds) {
|
|
685
|
+
const node = getNode(id)
|
|
686
|
+
if (node) node.visible = data.visible
|
|
687
|
+
}
|
|
688
|
+
return { updated: data.nodeIds.length }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
case 'select_all_by_type': {
|
|
692
|
+
const scope = data.withinId ? getNode(data.withinId) : figma.currentPage
|
|
693
|
+
const nodes = scope.findAll(n => n.type === data.type)
|
|
694
|
+
figma.currentPage.selection = nodes
|
|
695
|
+
return { selected: nodes.length }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
case 'clean_hidden_layers': {
|
|
699
|
+
const scope = getNode(data.nodeId)
|
|
700
|
+
if (!scope) throw new Error('Node not found')
|
|
701
|
+
const hidden = scope.findAll(n => !n.visible)
|
|
702
|
+
if (data.dryRun) return { wouldDelete: hidden.length, nodes: hidden.map(n => ({ id: n.id, name: n.name })) }
|
|
703
|
+
hidden.forEach(n => n.remove())
|
|
704
|
+
return { deleted: hidden.length }
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ─── DESIGN SYSTEM (handled partially client-side) ───
|
|
708
|
+
case 'scan_design_system': {
|
|
709
|
+
const page = data.pageId ? figma.getNodeById(data.pageId) : figma.currentPage
|
|
710
|
+
const colors = new Set(), fonts = new Set(), radii = new Set(), spacings = new Set()
|
|
711
|
+
page.findAll(n => {
|
|
712
|
+
if ('fills' in n && Array.isArray(n.fills)) n.fills.forEach(f => { if (f.type === 'SOLID') colors.add(rgbToHex(f.color)) })
|
|
713
|
+
if (n.type === 'TEXT') fonts.add(n.fontName.family + ' ' + n.fontName.style)
|
|
714
|
+
if ('cornerRadius' in n && typeof n.cornerRadius === 'number') radii.add(n.cornerRadius)
|
|
715
|
+
if ('itemSpacing' in n) spacings.add(n.itemSpacing)
|
|
716
|
+
if ('paddingTop' in n) { spacings.add(n.paddingTop); spacings.add(n.paddingRight); spacings.add(n.paddingBottom); spacings.add(n.paddingLeft) }
|
|
717
|
+
return false
|
|
718
|
+
})
|
|
719
|
+
return {
|
|
720
|
+
colors: [...colors].sort(),
|
|
721
|
+
fonts: [...fonts].sort(),
|
|
722
|
+
radii: [...radii].sort((a,b) => a-b),
|
|
723
|
+
spacings: [...spacings].sort((a,b) => a-b),
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
case 'detect_inconsistencies': {
|
|
728
|
+
const scope = data.nodeId ? getNode(data.nodeId) : figma.currentPage
|
|
729
|
+
const issues = []
|
|
730
|
+
scope.findAll(n => {
|
|
731
|
+
// Check spacing off grid
|
|
732
|
+
if ('paddingTop' in n) {
|
|
733
|
+
;['paddingTop','paddingRight','paddingBottom','paddingLeft','itemSpacing'].forEach(prop => {
|
|
734
|
+
if (n[prop] % 4 !== 0 && n[prop] !== 0) issues.push({ node: n.name, id: n.id, issue: `${prop} (${n[prop]}px) not on 4px grid` })
|
|
735
|
+
})
|
|
736
|
+
}
|
|
737
|
+
// Check generic names
|
|
738
|
+
if (/^(Frame|Group|Rectangle|Ellipse|Line)\s*\d*$/.test(n.name)) {
|
|
739
|
+
issues.push({ node: n.name, id: n.id, issue: 'Generic layer name' })
|
|
740
|
+
}
|
|
741
|
+
return false
|
|
742
|
+
})
|
|
743
|
+
return { issues: issues.slice(0, 100), total: issues.length }
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
case 'check_naming': {
|
|
747
|
+
const scope = getNode(data.nodeId) || figma.currentPage
|
|
748
|
+
const bad = scope.findAll(n => /^(Frame|Group|Rectangle|Ellipse|Line|Vector|Text)\s*\d*$/.test(n.name))
|
|
749
|
+
return { genericNames: bad.length, nodes: bad.slice(0, 50).map(n => ({ id: n.id, name: n.name, type: n.type })) }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
default:
|
|
753
|
+
throw new Error(`Unknown command: ${cmd}. Conductor v3 has 150+ tools — check the docs.`)
|
|
754
|
+
}
|
|
755
|
+
}
|