Unify tool definitions: define once, adapt for AI/CLI/MCP

Move tool logic to @open-pencil/core/tools/schema.ts as framework-agnostic
ToolDef objects. AI adapter generates valibot schemas + Vercel AI tool()
wrappers automatically.

26 tools (was 10): create_shape, render (JSX), set_fill, set_stroke,
set_effects, set_layout, set_constraints, update_node, delete, clone,
rename, reparent, group/ungroup, find_nodes, get_node, get_page_tree,
get_selection, select, list_pages, switch_page, list_variables,
list_collections, create_component, create_instance, eval.

src/ai/tools.ts: 269→28 lines (adapter only).

Tests for all 3 interfaces:
- 26 core tool tests (FigmaAPI directly)
- 11 AI adapter tests (valibot + Vercel AI SDK tool())
- 12 CLI integration tests (eval command on .fig fixture)
323 total, all passing.
This commit is contained in:
Danila Poyarkov 2026-03-01 14:59:19 +03:00 committed by Anton A S
parent 484c3b6a9a
commit 77509d8979
10 changed files with 1581 additions and 267 deletions

View file

@ -39,6 +39,17 @@ The root app (`src/`) is the Tauri/Vite desktop editor. Its `src/engine/` files
- Don't hand-roll `console.log` formatting — use the helpers from `packages/cli/src/format.ts` which re-exports agentfmt with project-specific adapters (`nodeToData`, `nodeDetails`, `nodeToTreeNode`, `nodeToListItem`)
- Every command supports `--json` for machine-readable output
## Tools (AI / MCP / CLI)
- Tool operations are defined once in `packages/core/src/tools/schema.ts` as framework-agnostic `ToolDef` objects
- Each tool has: name, description, typed params, and an `execute(figma: FigmaAPI, args)` function
- `defineTool()` gives type-safe params in the execute body; the array `ALL_TOOLS` erases the generics for adapters
- AI adapter (`packages/core/src/tools/ai-adapter.ts`): `toolsToAI()` converts ToolDefs → valibot schemas + Vercel AI `tool()` wrappers
- `src/ai/tools.ts` is just a thin wire: creates FigmaAPI from editor store, calls `toolsToAI()`
- CLI commands (`packages/cli/src/commands/`) are **not** generated from ToolDefs — they have custom agentfmt formatting, tree walking, pagination. The `eval` command is the CLI's access to all ToolDef operations via FigmaAPI.
- To add a new tool: add a `defineTool()` in `schema.ts`, add to `ALL_TOOLS` array — it's instantly available in AI chat, and via `eval` in CLI
- `FigmaAPI` (`packages/core/src/figma-api.ts`) is the execution target for all tools — Figma Plugin API compatible, uses Symbols for hidden internals
## Code conventions
- `@/` import alias for app cross-directory imports, relative imports within core

View file

@ -50,6 +50,8 @@ export {
} from './scene-graph'
export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api'
export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
export type { ToolDef, ParamDef, ParamType } from './tools'
export { SkiaRenderer, type RenderOverlays } from './renderer'
export { computeLayout, computeAllLayouts } from './layout'
export { getCanvasKit, type CanvasKitOptions } from './canvaskit'

View file

@ -0,0 +1,77 @@
/**
* Adapter: tool definitions Vercel AI SDK `tool()` objects.
*
* Converts ParamDef types to valibot schemas and wraps execute
* functions with FigmaAPI instantiation.
*/
import type { ToolDef, ParamDef, ParamType } from './schema'
import type { FigmaAPI } from '../figma-api'
export interface AIAdapterOptions {
getFigma: () => FigmaAPI
onBeforeExecute?: () => void
onAfterExecute?: () => void
}
export function toolsToAI(
tools: ToolDef[],
options: AIAdapterOptions,
deps: {
v: typeof import('valibot')
valibotSchema: (schema: any) => any
tool: (opts: any) => any
}
): Record<string, any> {
const { v, valibotSchema, tool } = deps
const result: Record<string, any> = {}
for (const def of tools) {
const shape: Record<string, unknown> = {}
for (const [key, param] of Object.entries(def.params)) {
shape[key] = paramToValibot(v, param)
}
result[def.name] = tool({
description: def.description,
inputSchema: valibotSchema(v.object(shape as any)),
execute: async (args: Record<string, unknown>) => {
options.onBeforeExecute?.()
try {
return await def.execute(options.getFigma(), args as any)
} finally {
options.onAfterExecute?.()
}
}
})
}
return result
}
function paramToValibot(v: typeof import('valibot'), param: ParamDef): unknown {
const typeMap: Record<ParamType, () => unknown> = {
string: () => (param.enum ? v.picklist(param.enum as [string, ...string[]]) : v.string()),
number: () => {
const pipes: unknown[] = [v.number()]
if (param.min !== undefined) pipes.push(v.minValue(param.min))
if (param.max !== undefined) pipes.push(v.maxValue(param.max))
return pipes.length > 1 ? v.pipe(...(pipes as [any, any, ...any[]])) : v.number()
},
boolean: () => v.boolean(),
color: () => v.pipe(v.string(), v.description('Color value (hex like #ff0000 or #ff000080)')),
'string[]': () => v.pipe(v.array(v.string()), v.minLength(1))
}
let schema = typeMap[param.type]()
if (param.description && param.type !== 'color') {
schema = v.pipe(schema as any, v.description(param.description))
}
if (!param.required) {
schema = v.optional(schema as any, param.default as any)
}
return schema
}

View file

@ -0,0 +1,3 @@
export { ALL_TOOLS, defineTool } from './schema'
export type { ToolDef, ParamDef, ParamType } from './schema'
export { toolsToAI } from './ai-adapter'

View file

@ -0,0 +1,666 @@
/**
* Tool definition schema.
*
* Each tool is defined once with typed params and an execute function
* that operates on FigmaAPI. Adapters for AI chat (valibot), CLI (citty),
* and MCP (JSON Schema) are generated from these definitions.
*/
import { parseColor } from '../color'
import type { FigmaAPI, FigmaNodeProxy } from '../figma-api'
export type ParamType = 'string' | 'number' | 'boolean' | 'color' | 'string[]'
export interface ParamDef {
type: ParamType
description: string
required?: boolean
default?: unknown
enum?: string[]
min?: number
max?: number
}
export interface ToolDef {
name: string
description: string
params: Record<string, ParamDef>
execute: (figma: FigmaAPI, args: Record<string, any>) => unknown
}
type ResolvedType<T extends ParamType> = T extends 'string'
? string
: T extends 'number'
? number
: T extends 'boolean'
? boolean
: T extends 'color'
? string
: T extends 'string[]'
? string[]
: never
type ResolvedParams<P extends Record<string, ParamDef>> = {
[K in keyof P as P[K]['required'] extends true ? K : never]: ResolvedType<P[K]['type']>
} & {
[K in keyof P as P[K]['required'] extends true ? never : K]?: ResolvedType<P[K]['type']>
}
export function defineTool<P extends Record<string, ParamDef>>(def: {
name: string
description: string
params: P
execute: (figma: FigmaAPI, args: ResolvedParams<P>) => unknown
}): ToolDef {
return def as unknown as ToolDef
}
function nodeToResult(node: FigmaNodeProxy): Record<string, unknown> {
return node.toJSON()
}
function nodeSummary(node: FigmaNodeProxy): { id: string; name: string; type: string } {
return { id: node.id, name: node.name, type: node.type }
}
// ─── Read tools ───────────────────────────────────────────────
export const getSelection = defineTool({
name: 'get_selection',
description: 'Get details about currently selected nodes.',
params: {},
execute: (figma) => {
const sel = figma.currentPage.selection
return { selection: sel.map(nodeToResult) }
}
})
export const getPageTree = defineTool({
name: 'get_page_tree',
description:
'Get the node tree of the current page. Returns all nodes with hierarchy, types, positions, and sizes.',
params: {},
execute: (figma) => {
const page = figma.currentPage
return {
page: page.name,
children: page.children.map(nodeToResult)
}
}
})
export const getNode = defineTool({
name: 'get_node',
description: 'Get detailed properties of a node by ID.',
params: {
id: { type: 'string', description: 'Node ID', required: true }
},
execute: (figma, { id }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
return nodeToResult(node)
}
})
export const findNodes = defineTool({
name: 'find_nodes',
description: 'Find nodes by name pattern and/or type.',
params: {
name: { type: 'string', description: 'Name substring to match (case-insensitive)' },
type: {
type: 'string',
description: 'Node type filter',
enum: [
'FRAME',
'RECTANGLE',
'ELLIPSE',
'TEXT',
'LINE',
'STAR',
'POLYGON',
'SECTION',
'GROUP',
'COMPONENT',
'INSTANCE',
'VECTOR'
]
}
},
execute: (figma, args) => {
const page = figma.currentPage
const matches = page.findAll((node) => {
if (args.type && node.type !== args.type) return false
if (args.name && !node.name.toLowerCase().includes(args.name.toLowerCase())) return false
return true
})
return { count: matches.length, nodes: matches.map(nodeSummary) }
}
})
// ─── Create tools ─────────────────────────────────────────────
export const createShape = defineTool({
name: 'create_shape',
description:
'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.',
params: {
type: {
type: 'string',
description: 'Node type',
required: true,
enum: ['FRAME', 'RECTANGLE', 'ELLIPSE', 'TEXT', 'LINE', 'STAR', 'POLYGON', 'SECTION']
},
x: { type: 'number', description: 'X position', required: true },
y: { type: 'number', description: 'Y position', required: true },
width: { type: 'number', description: 'Width in pixels', required: true, min: 1 },
height: { type: 'number', description: 'Height in pixels', required: true, min: 1 },
name: { type: 'string', description: 'Node name shown in layers panel' },
parent_id: { type: 'string', description: 'Parent node ID to nest inside' }
},
execute: (figma, args) => {
const parentId = args.parent_id
const parent = parentId ? figma.getNodeById(parentId) : null
const createMap: Record<string, () => FigmaNodeProxy> = {
FRAME: () => figma.createFrame(),
RECTANGLE: () => figma.createRectangle(),
ELLIPSE: () => figma.createEllipse(),
TEXT: () => figma.createText(),
LINE: () => figma.createLine(),
STAR: () => figma.createStar(),
POLYGON: () => figma.createPolygon(),
SECTION: () => figma.createSection()
}
const node = createMap[args.type]!()
node.x = args.x
node.y = args.y
node.resize(args.width, args.height)
if (args.name) node.name = args.name
if (parent) parent.appendChild(node)
return nodeSummary(node)
}
})
export const render = defineTool({
name: 'render',
description:
'Render JSX to design nodes. Primary creation tool — creates entire component trees in one call. Example: <Frame name="Card" w={320} h="hug" flex="col" gap={16} p={24} bg="#FFF" rounded={16}><Text size={18} weight="bold">Title</Text></Frame>',
params: {
jsx: { type: 'string', description: 'JSX string to render', required: true },
x: { type: 'number', description: 'X position of the root node' },
y: { type: 'number', description: 'Y position of the root node' },
parent_id: { type: 'string', description: 'Parent node ID to render into' }
},
execute: async (figma, args) => {
const { renderJsx } = await import('../render/render-jsx')
const result = await renderJsx(figma.graph, args.jsx, {
parentId: args.parent_id ?? figma.currentPageId,
x: args.x,
y: args.y
})
return { id: result.id, name: result.name, type: result.type, children: result.childIds }
}
})
// ─── Modify tools ─────────────────────────────────────────────
export const setFill = defineTool({
name: 'set_fill',
description: 'Set the fill color of a node. Accepts hex (#ff0000) or named color.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
color: { type: 'color', description: 'Color value (hex like #ff0000)', required: true }
},
execute: (figma, { id, color }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
const c = parseColor(color)
node.fills = [{ type: 'SOLID', color: c, opacity: 1, visible: true }]
return { id, color: c }
}
})
export const setStroke = defineTool({
name: 'set_stroke',
description: 'Set the stroke (border) of a node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
color: { type: 'color', description: 'Stroke color (hex)', required: true },
weight: { type: 'number', description: 'Stroke weight', default: 1, min: 0.1 },
align: {
type: 'string',
description: 'Stroke alignment',
default: 'INSIDE',
enum: ['INSIDE', 'CENTER', 'OUTSIDE']
}
},
execute: (figma, { id, color, weight, align }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
const c = parseColor(color)
node.strokes = [
{ color: c, weight: weight ?? 1, opacity: 1, visible: true, align: (align ?? 'INSIDE') as 'INSIDE' | 'CENTER' | 'OUTSIDE' }
]
return { id, color: c, weight: weight ?? 1 }
}
})
export const setEffects = defineTool({
name: 'set_effects',
description:
'Set effects on a node (drop shadow, inner shadow, blur). Pass an array or a single effect.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
type: {
type: 'string',
description: 'Effect type',
required: true,
enum: ['DROP_SHADOW', 'INNER_SHADOW', 'FOREGROUND_BLUR', 'BACKGROUND_BLUR']
},
color: { type: 'color', description: 'Shadow color (hex). Ignored for blur.' },
offset_x: { type: 'number', description: 'Shadow X offset', default: 0 },
offset_y: { type: 'number', description: 'Shadow Y offset', default: 4 },
radius: { type: 'number', description: 'Blur radius', default: 4, min: 0 },
spread: { type: 'number', description: 'Shadow spread', default: 0 }
},
execute: (figma, args) => {
const node = figma.getNodeById(args.id)
if (!node) return { error: `Node "${args.id}" not found` }
const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR'
const effect: Record<string, unknown> = {
type: args.type,
visible: true,
radius: args.radius ?? 4
}
if (!isBlur) {
effect.color = args.color ? parseColor(args.color) : { r: 0, g: 0, b: 0, a: 0.25 }
effect.offset = { x: args.offset_x ?? 0, y: args.offset_y ?? 4 }
effect.spread = args.spread ?? 0
}
node.effects = [...node.effects, effect as any]
return { id: args.id, effects: node.effects.length }
}
})
export const updateNode = defineTool({
name: 'update_node',
description:
'Update properties of an existing node: position, size, opacity, corner radius, visibility, text, font.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
x: { type: 'number', description: 'X position' },
y: { type: 'number', description: 'Y position' },
width: { type: 'number', description: 'Width', min: 1 },
height: { type: 'number', description: 'Height', min: 1 },
opacity: { type: 'number', description: 'Opacity (0-1)', min: 0, max: 1 },
corner_radius: { type: 'number', description: 'Corner radius', min: 0 },
visible: { type: 'boolean', description: 'Visibility' },
text: { type: 'string', description: 'Text content (TEXT nodes)' },
font_size: { type: 'number', description: 'Font size', min: 1 },
font_weight: { type: 'number', description: 'Font weight (100-900)' },
name: { type: 'string', description: 'Layer name' }
},
execute: (figma, args) => {
const node = figma.getNodeById(args.id)
if (!node) return { error: `Node "${args.id}" not found` }
const updated: string[] = []
if (args.x !== undefined) { node.x = args.x; updated.push('x') }
if (args.y !== undefined) { node.y = args.y; updated.push('y') }
if (args.width !== undefined || args.height !== undefined) {
node.resize(args.width ?? node.width, args.height ?? node.height)
updated.push('size')
}
if (args.opacity !== undefined) { node.opacity = args.opacity; updated.push('opacity') }
if (args.corner_radius !== undefined) {
node.cornerRadius = args.corner_radius
updated.push('cornerRadius')
}
if (args.visible !== undefined) { node.visible = args.visible; updated.push('visible') }
if (args.name !== undefined) { node.name = args.name; updated.push('name') }
if (args.text !== undefined) {
figma.graph.updateNode(node.id, { text: args.text })
updated.push('text')
}
if (args.font_size !== undefined) {
figma.graph.updateNode(node.id, { fontSize: args.font_size })
updated.push('fontSize')
}
if (args.font_weight !== undefined) {
figma.graph.updateNode(node.id, { fontWeight: args.font_weight })
updated.push('fontWeight')
}
return { id: args.id, updated }
}
})
export const setLayout = defineTool({
name: 'set_layout',
description: 'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, padding.',
params: {
id: { type: 'string', description: 'Frame node ID', required: true },
direction: {
type: 'string',
description: 'Layout direction',
required: true,
enum: ['HORIZONTAL', 'VERTICAL']
},
spacing: { type: 'number', description: 'Gap between items', default: 0, min: 0 },
padding: { type: 'number', description: 'Equal padding on all sides', min: 0 },
padding_horizontal: { type: 'number', description: 'Horizontal padding', min: 0 },
padding_vertical: { type: 'number', description: 'Vertical padding', min: 0 },
align: {
type: 'string',
description: 'Primary axis alignment',
default: 'MIN',
enum: ['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN']
},
counter_align: {
type: 'string',
description: 'Cross axis alignment',
default: 'MIN',
enum: ['MIN', 'CENTER', 'MAX', 'STRETCH']
}
},
execute: (figma, args) => {
const node = figma.getNodeById(args.id)
if (!node) return { error: `Node "${args.id}" not found` }
node.layoutMode = args.direction as 'HORIZONTAL' | 'VERTICAL'
node.itemSpacing = args.spacing ?? 0
node.primaryAxisAlignItems = (args.align ?? 'MIN') as any
node.counterAxisAlignItems = (args.counter_align ?? 'MIN') as any
const ph = args.padding_horizontal ?? args.padding ?? 0
const pv = args.padding_vertical ?? args.padding ?? 0
node.paddingLeft = ph
node.paddingRight = ph
node.paddingTop = pv
node.paddingBottom = pv
return { id: args.id, direction: args.direction, spacing: args.spacing ?? 0 }
}
})
export const setConstraints = defineTool({
name: 'set_constraints',
description: 'Set resize constraints for a node within its parent.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
horizontal: {
type: 'string',
description: 'Horizontal constraint',
enum: ['MIN', 'CENTER', 'MAX', 'STRETCH', 'SCALE']
},
vertical: {
type: 'string',
description: 'Vertical constraint',
enum: ['MIN', 'CENTER', 'MAX', 'STRETCH', 'SCALE']
}
},
execute: (figma, args) => {
const node = figma.getNodeById(args.id)
if (!node) return { error: `Node "${args.id}" not found` }
if (args.horizontal || args.vertical) {
node.constraints = {
horizontal: args.horizontal ?? node.constraints.horizontal,
vertical: args.vertical ?? node.constraints.vertical
}
}
return { id: args.id, constraints: node.constraints }
}
})
// ─── Structure tools ──────────────────────────────────────────
export const deleteNode = defineTool({
name: 'delete_node',
description: 'Delete a node by ID.',
params: {
id: { type: 'string', description: 'Node ID to delete', required: true }
},
execute: (figma, { id }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
node.remove()
return { deleted: id }
}
})
export const cloneNode = defineTool({
name: 'clone_node',
description: 'Clone (duplicate) a node.',
params: {
id: { type: 'string', description: 'Node ID to clone', required: true }
},
execute: (figma, { id }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
const clone = node.clone()
return nodeSummary(clone)
}
})
export const renameNode = defineTool({
name: 'rename_node',
description: 'Rename a node in the layers panel.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
name: { type: 'string', description: 'New name', required: true }
},
execute: (figma, { id, name }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
node.name = name
return { id, name }
}
})
export const reparentNode = defineTool({
name: 'reparent_node',
description: 'Move a node into a different parent.',
params: {
id: { type: 'string', description: 'Node ID to move', required: true },
parent_id: { type: 'string', description: 'New parent node ID', required: true }
},
execute: (figma, { id, parent_id }) => {
const node = figma.getNodeById(id)
const parent = figma.getNodeById(parent_id)
if (!node) return { error: `Node "${id}" not found` }
if (!parent) return { error: `Parent "${parent_id}" not found` }
parent.appendChild(node)
return { id, parent_id }
}
})
export const selectNodes = defineTool({
name: 'select_nodes',
description: 'Select one or more nodes by ID.',
params: {
ids: { type: 'string[]', description: 'Node IDs to select', required: true }
},
execute: (figma, { ids }) => {
figma.currentPage.selection = ids
.map((id) => figma.getNodeById(id))
.filter((n): n is FigmaNodeProxy => n !== null)
return { selected: ids }
}
})
export const groupNodes = defineTool({
name: 'group_nodes',
description: 'Group selected nodes.',
params: {
ids: { type: 'string[]', description: 'Node IDs to group', required: true }
},
execute: (figma, { ids }) => {
const nodes = ids
.map((id) => figma.getNodeById(id))
.filter((n): n is FigmaNodeProxy => n !== null)
if (nodes.length < 2) return { error: 'Need at least 2 nodes to group' }
const parent = nodes[0]!.parent ?? figma.currentPage
const group = figma.group(nodes, parent)
return nodeSummary(group)
}
})
export const ungroupNode = defineTool({
name: 'ungroup_node',
description: 'Ungroup a group node.',
params: {
id: { type: 'string', description: 'Group node ID', required: true }
},
execute: (figma, { id }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
figma.ungroup(node)
return { ungrouped: id }
}
})
// ─── Component tools ──────────────────────────────────────────
export const createComponent = defineTool({
name: 'create_component',
description: 'Convert a frame/group into a component.',
params: {
id: { type: 'string', description: 'Node ID to convert', required: true }
},
execute: (figma, { id }) => {
const node = figma.getNodeById(id)
if (!node) return { error: `Node "${id}" not found` }
const comp = figma.createComponentFromNode(node)
return nodeSummary(comp)
}
})
export const createInstance = defineTool({
name: 'create_instance',
description: 'Create an instance of a component.',
params: {
component_id: { type: 'string', description: 'Component node ID', required: true },
x: { type: 'number', description: 'X position' },
y: { type: 'number', description: 'Y position' }
},
execute: (figma, args) => {
const comp = figma.getNodeById(args.component_id)
if (!comp) return { error: `Component "${args.component_id}" not found` }
const instance = comp.createInstance()
if (args.x !== undefined) instance.x = args.x
if (args.y !== undefined) instance.y = args.y
return nodeSummary(instance)
}
})
// ─── Page tools ───────────────────────────────────────────────
export const listPages = defineTool({
name: 'list_pages',
description: 'List all pages in the document.',
params: {},
execute: (figma) => {
const pages = figma.root.children
return {
current: figma.currentPage.name,
pages: pages.map((p) => ({ id: p.id, name: p.name }))
}
}
})
export const switchPage = defineTool({
name: 'switch_page',
description: 'Switch to a different page by name or ID.',
params: {
page: { type: 'string', description: 'Page name or ID', required: true }
},
execute: (figma, { page }) => {
const target =
figma.root.children.find((p) => p.name === page) ??
figma.getNodeById(page)
if (!target) return { error: `Page "${page}" not found` }
figma.currentPage = target
return { page: target.name, id: target.id }
}
})
// ─── Variable tools ───────────────────────────────────────────
export const listVariables = defineTool({
name: 'list_variables',
description: 'List all design variables (colors, numbers, strings, booleans).',
params: {
type: {
type: 'string',
description: 'Filter by variable type',
enum: ['COLOR', 'FLOAT', 'STRING', 'BOOLEAN']
}
},
execute: (figma, args) => {
const vars = figma.getLocalVariables(args.type)
return { count: vars.length, variables: vars }
}
})
export const listCollections = defineTool({
name: 'list_collections',
description: 'List all variable collections.',
params: {},
execute: (figma) => {
const cols = figma.getLocalVariableCollections()
return { count: cols.length, collections: cols }
}
})
// ─── Eval escape hatch ────────────────────────────────────────
export const evalCode = defineTool({
name: 'eval',
description:
'Execute JavaScript with full Figma Plugin API access. Use for operations not covered by other tools. The `figma` global is available.',
params: {
code: { type: 'string', description: 'JavaScript code to execute', required: true }
},
execute: async (figma, { code }) => {
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
const wrapped = code.trim().startsWith('return') ? code : `return (async () => { ${code} })()`
const fn = new AsyncFunction('figma', wrapped)
const result = await fn(figma)
if (result && typeof result === 'object' && 'toJSON' in result) return result.toJSON()
return result ?? null
}
})
// ─── Registry ─────────────────────────────────────────────────
export const ALL_TOOLS: ToolDef[] = [
getSelection,
getPageTree,
getNode,
findNodes,
createShape,
render,
setFill,
setStroke,
setEffects,
updateNode,
setLayout,
setConstraints,
deleteNode,
cloneNode,
renameNode,
reparentNode,
selectNodes,
groupNodes,
ungroupNode,
createComponent,
createInstance,
listPages,
switchPage,
listVariables,
listCollections,
evalCode
]

View file

@ -1,269 +1,28 @@
import { valibotSchema } from '@ai-sdk/valibot'
import { parseColor, FigmaAPI } from '@open-pencil/core'
import { ALL_TOOLS, FigmaAPI, toolsToAI } from '@open-pencil/core'
import { tool } from 'ai'
import * as v from 'valibot'
import type { EditorStore } from '@/stores/editor'
import type { Color } from '@open-pencil/core'
const nodeTypeSchema = v.picklist([
'FRAME',
'RECTANGLE',
'ELLIPSE',
'TEXT',
'LINE',
'STAR',
'POLYGON',
'SECTION'
])
const rgbaSchema = v.object({
r: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
g: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
b: v.pipe(v.number(), v.minValue(0), v.maxValue(1)),
a: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1)), 1)
})
const hexSchema = v.pipe(
v.string(),
v.regex(/^#?[0-9a-fA-F]{6,8}$/),
v.description('Hex color like #ff0000 or #ff000080')
)
const colorInputSchema = v.union([rgbaSchema, hexSchema])
type ColorInput = v.InferOutput<typeof colorInputSchema>
function resolveColor(input: ColorInput): Color {
if (typeof input === 'string') return parseColor(input)
return { r: input.r, g: input.g, b: input.b, a: input.a ?? 1 }
}
export function createAITools(store: EditorStore) {
return {
create_shape: tool({
description:
'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.',
inputSchema: valibotSchema(
v.object({
type: nodeTypeSchema,
x: v.pipe(v.number(), v.description('X position in canvas coordinates')),
y: v.pipe(v.number(), v.description('Y position in canvas coordinates')),
width: v.pipe(v.number(), v.minValue(1), v.description('Width in pixels')),
height: v.pipe(v.number(), v.minValue(1), v.description('Height in pixels')),
name: v.optional(v.pipe(v.string(), v.description('Node name shown in layers panel'))),
parent_id: v.optional(v.pipe(v.string(), v.description('Parent node ID to nest inside')))
})
),
execute: async ({ type, x, y, width, height, name, parent_id }) => {
const id = store.createShape(type, x, y, width, height, parent_id)
if (name) store.renameNode(id, name)
store.select([id])
return { id, type, x, y, width, height, name: name ?? type.toLowerCase() }
}
}),
set_fill: tool({
description: 'Set the fill color of a node. Accepts hex (#ff0000) or RGBA object.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Node ID')),
color: colorInputSchema
})
),
execute: async ({ id, color }) => {
const c = resolveColor(color)
store.updateNodeWithUndo(
id,
{ fills: [{ type: 'SOLID', color: c, opacity: 1, visible: true }] },
'Set fill'
)
return { id, color: c }
}
}),
set_stroke: tool({
description: 'Set the stroke (border) of a node.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Node ID')),
color: colorInputSchema,
weight: v.optional(v.pipe(v.number(), v.minValue(0.1)), 1),
align: v.optional(v.picklist(['INSIDE', 'CENTER', 'OUTSIDE']), 'INSIDE')
})
),
execute: async ({ id, color, weight, align }) => {
const c = resolveColor(color)
store.updateNodeWithUndo(
id,
{ strokes: [{ color: c, weight, opacity: 1, visible: true, align }] },
'Set stroke'
)
return { id, color: c, weight }
}
}),
update_node: tool({
description:
'Update properties of an existing node: position, size, opacity, corner radius, visibility, text content, font.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Node ID')),
x: v.optional(v.number()),
y: v.optional(v.number()),
width: v.optional(v.pipe(v.number(), v.minValue(1))),
height: v.optional(v.pipe(v.number(), v.minValue(1))),
opacity: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1))),
corner_radius: v.optional(v.pipe(v.number(), v.minValue(0))),
visible: v.optional(v.boolean()),
text: v.optional(v.pipe(v.string(), v.description('Text content (for TEXT nodes)'))),
font_size: v.optional(v.pipe(v.number(), v.minValue(1))),
font_weight: v.optional(v.number()),
name: v.optional(v.string())
})
),
execute: async ({ id, corner_radius, font_size, font_weight, name, ...rest }) => {
const changes: Record<string, unknown> = { ...rest }
if (corner_radius !== undefined) changes.cornerRadius = corner_radius
if (font_size !== undefined) changes.fontSize = font_size
if (font_weight !== undefined) changes.fontWeight = font_weight
store.updateNodeWithUndo(id, changes, 'Update node')
if (name !== undefined) store.renameNode(id, name)
return { id, updated: Object.keys(changes) }
}
}),
set_layout: tool({
description:
'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, and padding.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Frame node ID')),
direction: v.picklist(['HORIZONTAL', 'VERTICAL']),
spacing: v.optional(
v.pipe(v.number(), v.minValue(0), v.description('Gap between items')),
0
),
padding: v.optional(
v.pipe(v.number(), v.minValue(0), v.description('Equal padding on all sides'))
),
padding_horizontal: v.optional(v.pipe(v.number(), v.minValue(0))),
padding_vertical: v.optional(v.pipe(v.number(), v.minValue(0))),
align: v.optional(
v.pipe(
v.picklist(['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN']),
v.description('Primary axis alignment')
),
'MIN'
),
counter_align: v.optional(
v.pipe(
v.picklist(['MIN', 'CENTER', 'MAX', 'STRETCH']),
v.description('Cross axis alignment')
),
'MIN'
)
})
),
execute: async ({
id,
direction,
spacing,
padding,
padding_horizontal,
padding_vertical,
align,
counter_align
}) => {
store.setLayoutMode(id, direction)
const ph = padding_horizontal ?? padding ?? 0
const pv = padding_vertical ?? padding ?? 0
store.updateNodeWithUndo(
id,
{
itemSpacing: spacing,
primaryAxisAlign: align,
counterAxisAlign: counter_align,
paddingLeft: ph,
paddingRight: ph,
paddingTop: pv,
paddingBottom: pv
},
'Set layout'
)
return { id, direction, spacing }
}
}),
delete_node: tool({
description: 'Delete a node by ID.',
inputSchema: valibotSchema(
v.object({
id: v.pipe(v.string(), v.description('Node ID to delete'))
})
),
execute: async ({ id }) => {
store.select([id])
store.deleteSelected()
return { deleted: id }
}
}),
select_nodes: tool({
description: 'Select one or more nodes by ID.',
inputSchema: valibotSchema(
v.object({
ids: v.pipe(v.array(v.string()), v.minLength(1), v.description('Node IDs to select'))
})
),
execute: async ({ ids }) => {
store.select(ids)
return { selected: ids }
}
}),
get_page_tree: tool({
description:
'Get the node tree of the current page. Returns all nodes with their hierarchy, types, positions, and sizes.',
inputSchema: valibotSchema(v.object({})),
execute: async () => {
return toolsToAI(
ALL_TOOLS,
{
getFigma: () => {
const api = new FigmaAPI(store.graph)
const page = api.getNodeById(store.state.currentPageId)
if (!page) return { error: 'No current page' }
return {
page: page.name,
children: page.children.map((c) => c.toJSON())
}
api.currentPage = api.wrapNode(store.state.currentPageId)
api.currentPage.selection = [...store.state.selectedIds]
.map((id) => api.getNodeById(id))
.filter((n): n is NonNullable<typeof n> => n !== null)
return api
},
onAfterExecute: () => {
store.requestRender()
}
}),
get_selection: tool({
description: 'Get details about currently selected nodes.',
inputSchema: valibotSchema(v.object({})),
execute: async () => {
const nodes = store.selectedNodes.value
if (nodes.length === 0) return { selection: [] }
const api = new FigmaAPI(store.graph)
return {
selection: nodes.map((n) => api.wrapNode(n.id).toJSON())
}
}
}),
rename_node: tool({
description: 'Rename a node in the layers panel.',
inputSchema: valibotSchema(
v.object({
id: v.string(),
name: v.string()
})
),
execute: async ({ id, name }) => {
store.renameNode(id, name)
return { id, name }
}
})
}
},
{ v, valibotSchema, tool }
)
}
export type AITools = ReturnType<typeof createAITools>

View file

@ -20,9 +20,7 @@ const jsxCode = computed(() => {
const highlightedLines = computed(() => {
if (!jsxCode.value) return []
const grammar = Prism.languages.jsx ?? Prism.languages.javascript
return jsxCode.value
.split('\n')
.map((line) => Prism.highlight(line, grammar, 'jsx'))
return jsxCode.value.split('\n').map((line) => Prism.highlight(line, grammar, 'jsx'))
})
let copyTimeout: ReturnType<typeof setTimeout> | undefined
@ -60,16 +58,15 @@ watch(jsxCode, () => {
<ScrollAreaRoot class="min-h-0 flex-1">
<ScrollAreaViewport class="size-full">
<div class="p-3">
<div
v-for="(html, i) in highlightedLines"
:key="i"
class="flex text-xs leading-5"
>
<div v-for="(html, i) in highlightedLines" :key="i" class="flex text-xs leading-5">
<span
class="mr-3 shrink-0 select-none text-right text-muted/40"
style="min-width: 1.5em"
>{{ i + 1 }}</span>
<pre class="m-0 min-w-0 flex-1 whitespace-pre-wrap break-words"><code v-html="html" /></pre>
>{{ i + 1 }}</span
>
<pre
class="m-0 min-w-0 flex-1 whitespace-pre-wrap break-words"
><code v-html="html" /></pre>
</div>
</div>
</ScrollAreaViewport>

View file

@ -0,0 +1,190 @@
import { describe, expect, test } from 'bun:test'
import { valibotSchema } from '@ai-sdk/valibot'
import { ALL_TOOLS, FigmaAPI, SceneGraph, toolsToAI } from '@open-pencil/core'
import { tool } from 'ai'
import * as v from 'valibot'
function setup() {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
const tools = toolsToAI(
ALL_TOOLS,
{
getFigma: () => figma,
onAfterExecute: () => {}
},
{ v, valibotSchema, tool }
)
return { graph, figma, tools }
}
describe('AI adapter', () => {
test('generates tool for every definition', () => {
const { tools } = setup()
for (const def of ALL_TOOLS) {
expect(tools[def.name]).toBeDefined()
}
expect(Object.keys(tools).length).toBe(ALL_TOOLS.length)
})
test('each tool has description and execute', () => {
const { tools } = setup()
for (const [name, t] of Object.entries(tools)) {
const aiTool = t as { description: string; execute: Function }
expect(aiTool.description).toBeTruthy()
expect(typeof aiTool.execute).toBe('function')
}
})
test('create_shape tool works through adapter', async () => {
const { tools, figma } = setup()
const createShape = tools.create_shape as { execute: Function }
const result = (await createShape.execute({
type: 'RECTANGLE',
x: 10,
y: 20,
width: 100,
height: 50,
name: 'Test Rect'
})) as any
expect(result.id).toBeTruthy()
expect(result.type).toBe('RECTANGLE')
expect(result.name).toBe('Test Rect')
const node = figma.getNodeById(result.id)!
expect(node.x).toBe(10)
expect(node.y).toBe(20)
expect(node.width).toBe(100)
})
test('set_fill tool works through adapter', async () => {
const { tools, figma } = setup()
const rect = figma.createRectangle()
rect.resize(100, 100)
const setFill = tools.set_fill as { execute: Function }
await setFill.execute({ id: rect.id, color: '#00ff00' })
const fills = figma.getNodeById(rect.id)!.fills
expect(fills.length).toBe(1)
expect(fills[0].color.g).toBeCloseTo(1)
})
test('get_page_tree tool returns structure', async () => {
const { tools, figma } = setup()
const frame = figma.createFrame()
frame.name = 'TestFrame'
frame.resize(200, 200)
const rect = figma.createRectangle()
rect.resize(50, 50)
frame.appendChild(rect)
const getTree = tools.get_page_tree as { execute: Function }
const result = (await getTree.execute({})) as any
expect(result.page).toBeTruthy()
expect(result.children.length).toBeGreaterThan(0)
})
test('onBeforeExecute and onAfterExecute are called', async () => {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
const calls: string[] = []
const tools = toolsToAI(
ALL_TOOLS,
{
getFigma: () => figma,
onBeforeExecute: () => calls.push('before'),
onAfterExecute: () => calls.push('after')
},
{ v, valibotSchema, tool }
)
const listPages = tools.list_pages as { execute: Function }
await listPages.execute({})
expect(calls).toEqual(['before', 'after'])
})
test('onAfterExecute called even on error', async () => {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
let afterCalled = false
const tools = toolsToAI(
ALL_TOOLS,
{
getFigma: () => figma,
onAfterExecute: () => {
afterCalled = true
}
},
{ v, valibotSchema, tool }
)
const evalTool = tools.eval as { execute: Function }
try {
await evalTool.execute({ code: 'throw new Error("test")' })
} catch {
// expected
}
expect(afterCalled).toBe(true)
})
test('find_nodes works through adapter', async () => {
const { tools, figma } = setup()
figma.createRectangle().name = 'Button'
figma.createText().name = 'Label'
figma.createRectangle().name = 'Button Secondary'
const findNodes = tools.find_nodes as { execute: Function }
const result = (await findNodes.execute({ name: 'button' })) as any
expect(result.count).toBe(2)
})
test('set_layout works through adapter', async () => {
const { tools, figma } = setup()
const frame = figma.createFrame()
frame.resize(300, 200)
const setLayout = tools.set_layout as { execute: Function }
await setLayout.execute({
id: frame.id,
direction: 'HORIZONTAL',
spacing: 8,
padding: 16
})
const node = figma.getNodeById(frame.id)!
expect(node.layoutMode).toBe('HORIZONTAL')
expect(node.itemSpacing).toBe(8)
expect(node.paddingLeft).toBe(16)
})
test('render JSX works through adapter', async () => {
const { tools } = setup()
const render = tools.render as { execute: Function }
const result = (await render.execute({
jsx: '<Frame name="Card" w={200} h={100}><Text>Hello</Text></Frame>'
})) as any
expect(result.name).toBe('Card')
expect(result.type).toBe('FRAME')
})
test('delete + get returns error for removed node', async () => {
const { tools, figma } = setup()
const rect = figma.createRectangle()
const id = rect.id
const deleteTool = tools.delete_node as { execute: Function }
await deleteTool.execute({ id })
const getNode = tools.get_node as { execute: Function }
const result = (await getNode.execute({ id })) as any
expect(result.error).toContain('not found')
})
})

View file

@ -0,0 +1,219 @@
import { describe, expect, test } from 'bun:test'
import { join } from 'path'
const CLI = join(import.meta.dir, '../../packages/cli/src/index.ts')
const FIXTURE = join(import.meta.dir, '../fixtures/material3.fig')
async function evalCode(
code: string
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc = Bun.spawn(['bun', CLI, 'eval', FIXTURE, '--code', code, '--json'], {
stdout: 'pipe',
stderr: 'pipe'
})
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text()
])
const exitCode = await proc.exited
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
}
function parseJSON(stdout: string): unknown {
return JSON.parse(stdout)
}
describe('CLI tool operations via eval', () => {
test('create and read back a node', async () => {
const { stdout, exitCode } = await evalCode(`
const r = figma.createRectangle()
r.name = 'TestRect'
r.x = 100
r.y = 200
r.resize(300, 150)
return r.toJSON()
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.name).toBe('TestRect')
expect(result.x).toBe(100)
expect(result.y).toBe(200)
expect(result.width).toBe(300)
expect(result.height).toBe(150)
})
test('set fill on a node', async () => {
const { stdout, exitCode } = await evalCode(`
const r = figma.createRectangle()
r.resize(50, 50)
r.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
return { fills: r.fills }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.fills.length).toBe(1)
expect(result.fills[0].color.r).toBe(1)
})
test('set layout on a frame', async () => {
const { stdout, exitCode } = await evalCode(`
const f = figma.createFrame()
f.resize(300, 200)
f.layoutMode = 'VERTICAL'
f.itemSpacing = 16
f.paddingLeft = 20
f.paddingRight = 20
f.paddingTop = 20
f.paddingBottom = 20
return {
layoutMode: f.layoutMode,
itemSpacing: f.itemSpacing,
paddingLeft: f.paddingLeft
}
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.layoutMode).toBe('VERTICAL')
expect(result.itemSpacing).toBe(16)
expect(result.paddingLeft).toBe(20)
})
test('create component from node', async () => {
const { stdout, exitCode } = await evalCode(`
const f = figma.createFrame()
f.name = 'Button'
f.resize(200, 48)
const comp = figma.createComponentFromNode(f)
return { name: comp.name, type: comp.type }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.name).toBe('Button')
expect(result.type).toBe('COMPONENT')
})
test('group and ungroup nodes', async () => {
const { stdout, exitCode } = await evalCode(`
const r1 = figma.createRectangle()
r1.resize(50, 50)
const r2 = figma.createRectangle()
r2.resize(50, 50)
const group = figma.group([r1, r2], figma.currentPage)
const groupType = group.type
const childCount = group.children.length
figma.ungroup(group)
const ungrouped = figma.getNodeById(group.id)
return { groupType, childCount, ungroupedExists: ungrouped !== null }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.groupType).toBe('GROUP')
expect(result.childCount).toBe(2)
expect(result.ungroupedExists).toBe(false)
})
test('find nodes by type on fixture', async () => {
const { stdout, exitCode } = await evalCode(`
const texts = figma.currentPage.findAllWithCriteria({ types: ['TEXT'] })
return { count: texts.length, hasTexts: texts.length > 0 }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.hasTexts).toBe(true)
expect(result.count).toBeGreaterThan(0)
})
test('clone a node', async () => {
const { stdout, exitCode } = await evalCode(`
const r = figma.createRectangle()
r.name = 'Original'
r.resize(100, 100)
const clone = r.clone()
return {
same: r.id === clone.id,
cloneName: clone.name,
cloneWidth: clone.width
}
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.same).toBe(false)
expect(result.cloneName).toBe('Original')
expect(result.cloneWidth).toBe(100)
})
test('reparent node into frame', async () => {
const { stdout, exitCode } = await evalCode(`
const frame = figma.createFrame()
frame.resize(300, 300)
const rect = figma.createRectangle()
rect.resize(50, 50)
frame.appendChild(rect)
return {
parentId: rect.parent?.id,
isChild: frame.children.some(c => c.id === rect.id)
}
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.isChild).toBe(true)
})
test('set constraints', async () => {
const { stdout, exitCode } = await evalCode(`
const r = figma.createRectangle()
r.resize(100, 100)
r.constraints = { horizontal: 'CENTER', vertical: 'STRETCH' }
return r.constraints
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.horizontal).toBe('CENTER')
expect(result.vertical).toBe('STRETCH')
})
test('set effects', async () => {
const { stdout, exitCode } = await evalCode(`
const f = figma.createFrame()
f.resize(100, 100)
f.effects = [{
type: 'DROP_SHADOW',
color: { r: 0, g: 0, b: 0, a: 0.25 },
offset: { x: 0, y: 4 },
radius: 8,
spread: 0,
visible: true
}]
return { count: f.effects.length, type: f.effects[0].type }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.count).toBe(1)
expect(result.type).toBe('DROP_SHADOW')
})
test('list variables from fixture', async () => {
const { stdout, exitCode } = await evalCode(`
const vars = figma.getLocalVariables()
const cols = figma.getLocalVariableCollections()
return { variables: vars.length, collections: cols.length }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(typeof result.variables).toBe('number')
expect(typeof result.collections).toBe('number')
})
test('switch page', async () => {
const { stdout, exitCode } = await evalCode(`
const pages = figma.root.children
const first = pages[0]
figma.currentPage = first
return { page: figma.currentPage.name, pageCount: pages.length }
`)
expect(exitCode).toBe(0)
const result = parseJSON(stdout) as any
expect(result.page).toBeTruthy()
expect(result.pageCount).toBeGreaterThanOrEqual(1)
})
})

390
tests/engine/tools.test.ts Normal file
View file

@ -0,0 +1,390 @@
import { describe, expect, test } from 'bun:test'
import { ALL_TOOLS, FigmaAPI, SceneGraph } from '@open-pencil/core'
function setup() {
const graph = new SceneGraph()
const figma = new FigmaAPI(graph)
return { graph, figma }
}
describe('tool definitions', () => {
test('all tools have unique names', () => {
const names = ALL_TOOLS.map((t) => t.name)
expect(new Set(names).size).toBe(names.length)
})
test('all tools have description and params', () => {
for (const t of ALL_TOOLS) {
expect(t.name).toBeTruthy()
expect(t.description).toBeTruthy()
expect(typeof t.params).toBe('object')
expect(typeof t.execute).toBe('function')
}
})
test('required params are marked', () => {
for (const t of ALL_TOOLS) {
for (const [key, param] of Object.entries(t.params)) {
expect(typeof param.type).toBe('string')
expect(typeof param.description).toBe('string')
}
}
})
})
describe('create_shape', () => {
test('creates a frame', () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'create_shape')!
const result = tool.execute(figma, {
type: 'FRAME',
x: 100,
y: 200,
width: 300,
height: 400,
name: 'Test Frame'
}) as any
expect(result.name).toBe('Test Frame')
expect(result.type).toBe('FRAME')
const node = figma.getNodeById(result.id)!
expect(node.x).toBe(100)
expect(node.y).toBe(200)
expect(node.width).toBe(300)
expect(node.height).toBe(400)
})
test('creates nested inside parent', () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'create_shape')!
const parent = tool.execute(figma, {
type: 'FRAME',
x: 0,
y: 0,
width: 500,
height: 500,
name: 'Parent'
}) as any
const child = tool.execute(figma, {
type: 'RECTANGLE',
x: 10,
y: 10,
width: 50,
height: 50,
parent_id: parent.id
}) as any
const parentNode = figma.getNodeById(parent.id)!
expect(parentNode.children.some((c) => c.id === child.id)).toBe(true)
})
})
describe('set_fill', () => {
test('sets solid fill', () => {
const { figma } = setup()
const frame = figma.createFrame()
frame.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'set_fill')!
tool.execute(figma, { id: frame.id, color: '#ff0000' })
const fills = figma.getNodeById(frame.id)!.fills
expect(fills.length).toBe(1)
expect(fills[0].color.r).toBeCloseTo(1)
expect(fills[0].color.g).toBeCloseTo(0)
expect(fills[0].color.b).toBeCloseTo(0)
})
test('returns error for missing node', () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'set_fill')!
const result = tool.execute(figma, { id: 'nonexistent', color: '#ff0000' }) as any
expect(result.error).toContain('not found')
})
})
describe('set_stroke', () => {
test('sets stroke', () => {
const { figma } = setup()
const rect = figma.createRectangle()
rect.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'set_stroke')!
tool.execute(figma, { id: rect.id, color: '#0000ff', weight: 2 })
const strokes = figma.getNodeById(rect.id)!.strokes
expect(strokes.length).toBe(1)
expect(strokes[0].color.b).toBeCloseTo(1)
expect(strokes[0].weight).toBe(2)
})
})
describe('set_effects', () => {
test('adds drop shadow', () => {
const { figma } = setup()
const frame = figma.createFrame()
frame.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'set_effects')!
tool.execute(figma, {
id: frame.id,
type: 'DROP_SHADOW',
color: '#000000',
offset_x: 0,
offset_y: 4,
radius: 8,
spread: 0
})
const effects = figma.getNodeById(frame.id)!.effects
expect(effects.length).toBe(1)
expect(effects[0].type).toBe('DROP_SHADOW')
})
test('adds blur without color', () => {
const { figma } = setup()
const frame = figma.createFrame()
frame.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'set_effects')!
tool.execute(figma, { id: frame.id, type: 'BACKGROUND_BLUR', radius: 10 })
const effects = figma.getNodeById(frame.id)!.effects
expect(effects.length).toBe(1)
expect(effects[0].type).toBe('BACKGROUND_BLUR')
})
})
describe('update_node', () => {
test('updates position and size', () => {
const { figma } = setup()
const rect = figma.createRectangle()
rect.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'update_node')!
const result = tool.execute(figma, {
id: rect.id,
x: 50,
y: 75,
width: 200,
height: 150,
opacity: 0.5
}) as any
expect(result.updated).toContain('x')
expect(result.updated).toContain('size')
expect(result.updated).toContain('opacity')
const node = figma.getNodeById(rect.id)!
expect(node.x).toBe(50)
expect(node.y).toBe(75)
expect(node.width).toBe(200)
expect(node.height).toBe(150)
expect(node.opacity).toBe(0.5)
})
test('updates corner radius', () => {
const { figma } = setup()
const rect = figma.createRectangle()
rect.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'update_node')!
tool.execute(figma, { id: rect.id, corner_radius: 12 })
expect(figma.getNodeById(rect.id)!.cornerRadius).toBe(12)
})
})
describe('set_layout', () => {
test('sets auto-layout', () => {
const { figma } = setup()
const frame = figma.createFrame()
frame.resize(300, 200)
const tool = ALL_TOOLS.find((t) => t.name === 'set_layout')!
tool.execute(figma, {
id: frame.id,
direction: 'VERTICAL',
spacing: 16,
padding: 20
})
const node = figma.getNodeById(frame.id)!
expect(node.layoutMode).toBe('VERTICAL')
expect(node.itemSpacing).toBe(16)
expect(node.paddingLeft).toBe(20)
expect(node.paddingTop).toBe(20)
})
})
describe('delete_node', () => {
test('removes a node', () => {
const { figma } = setup()
const rect = figma.createRectangle()
const tool = ALL_TOOLS.find((t) => t.name === 'delete_node')!
tool.execute(figma, { id: rect.id })
expect(figma.getNodeById(rect.id)).toBeNull()
})
})
describe('clone_node', () => {
test('duplicates a node', () => {
const { figma } = setup()
const rect = figma.createRectangle()
rect.name = 'Original'
rect.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'clone_node')!
const result = tool.execute(figma, { id: rect.id }) as any
expect(result.id).not.toBe(rect.id)
expect(result.name).toBe('Original')
})
})
describe('rename_node', () => {
test('renames a node', () => {
const { figma } = setup()
const rect = figma.createRectangle()
const tool = ALL_TOOLS.find((t) => t.name === 'rename_node')!
tool.execute(figma, { id: rect.id, name: 'My Rectangle' })
expect(figma.getNodeById(rect.id)!.name).toBe('My Rectangle')
})
})
describe('reparent_node', () => {
test('moves node into frame', () => {
const { figma } = setup()
const frame = figma.createFrame()
frame.resize(300, 300)
const rect = figma.createRectangle()
rect.resize(50, 50)
const tool = ALL_TOOLS.find((t) => t.name === 'reparent_node')!
tool.execute(figma, { id: rect.id, parent_id: frame.id })
expect(figma.getNodeById(frame.id)!.children.some((c) => c.id === rect.id)).toBe(true)
})
})
describe('group_nodes', () => {
test('groups two nodes', () => {
const { figma } = setup()
const r1 = figma.createRectangle()
r1.resize(50, 50)
const r2 = figma.createRectangle()
r2.resize(50, 50)
const tool = ALL_TOOLS.find((t) => t.name === 'group_nodes')!
const result = tool.execute(figma, { ids: [r1.id, r2.id] }) as any
expect(result.type).toBe('GROUP')
const group = figma.getNodeById(result.id)!
expect(group.children.length).toBe(2)
})
})
describe('find_nodes', () => {
test('finds by name', () => {
const { figma } = setup()
const rect = figma.createRectangle()
rect.name = 'Button Primary'
const text = figma.createText()
text.name = 'Label'
const tool = ALL_TOOLS.find((t) => t.name === 'find_nodes')!
const result = tool.execute(figma, { name: 'button' }) as any
expect(result.count).toBe(1)
expect(result.nodes[0].name).toBe('Button Primary')
})
test('finds by type', () => {
const { figma } = setup()
figma.createRectangle()
figma.createRectangle()
figma.createText()
const tool = ALL_TOOLS.find((t) => t.name === 'find_nodes')!
const result = tool.execute(figma, { type: 'RECTANGLE' }) as any
expect(result.count).toBe(2)
})
})
describe('get_node', () => {
test('returns node details', () => {
const { figma } = setup()
const rect = figma.createRectangle()
rect.name = 'Test Rect'
rect.resize(100, 50)
const tool = ALL_TOOLS.find((t) => t.name === 'get_node')!
const result = tool.execute(figma, { id: rect.id }) as any
expect(result.name).toBe('Test Rect')
expect(result.width).toBe(100)
expect(result.height).toBe(50)
})
})
describe('page tools', () => {
test('list_pages returns pages', () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'list_pages')!
const result = tool.execute(figma, {}) as any
expect(result.pages.length).toBeGreaterThanOrEqual(1)
})
test('switch_page changes page', () => {
const { figma } = setup()
const page2 = figma.createPage()
page2.name = 'Page 2'
const tool = ALL_TOOLS.find((t) => t.name === 'switch_page')!
tool.execute(figma, { page: 'Page 2' })
expect(figma.currentPage.name).toBe('Page 2')
})
})
describe('eval', () => {
test('executes code with figma api', async () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'eval')!
const result = await tool.execute(figma, {
code: 'const r = figma.createRectangle(); r.name = "FromEval"; return r.name;'
})
expect(result).toBe('FromEval')
})
})
describe('set_constraints', () => {
test('sets constraints', () => {
const { figma } = setup()
const rect = figma.createRectangle()
rect.resize(100, 100)
const tool = ALL_TOOLS.find((t) => t.name === 'set_constraints')!
tool.execute(figma, { id: rect.id, horizontal: 'CENTER', vertical: 'STRETCH' })
const node = figma.getNodeById(rect.id)!
expect(node.constraints.horizontal).toBe('CENTER')
expect(node.constraints.vertical).toBe('STRETCH')
})
})
describe('render', () => {
test('renders JSX string', async () => {
const { figma } = setup()
const tool = ALL_TOOLS.find((t) => t.name === 'render')!
const result = (await tool.execute(figma, {
jsx: '<Frame name="Card" w={200} h={100} bg="#FFF"><Text>Hello</Text></Frame>'
})) as any
expect(result.name).toBe('Card')
expect(result.type).toBe('FRAME')
expect(result.children.length).toBeGreaterThan(0)
})
})