/** * figma_copy — flat list of written content (TEXT nodes), optionally scoped * to a subtree. "Copy" as in UX copy / written content — not clipboard copy. * Ideal for copy audits, i18n extraction, proofreading. Strips all layout * and visual data. * * Typical payload: one short line per TEXT node. Smallest tool by far. */ import { Type } from "@sinclair/typebox"; import { defineTool } from "@mariozechner/pi-coding-agent"; import { getFile, getNodes, normalizeNodeId, parseFileKey, parseNodeIdFromUrl, } from "../client.js"; import { extractText, type TextEntry } from "../transforms.js"; interface CopyDetails { fileKey: string; nodeId: string | null; version: string | null; totalEntries: number; truncated: boolean; error: "not_found" | null; } export const copyTool = defineTool({ name: "figma_copy", label: "Figma Copy", description: "Extract the written copy (UX text) from a Figma file or frame as a flat list of {id, path, text}. Cheapest tool — use for copy audits, i18n, content reviews, proofreading. Not related to clipboard copy.", promptSnippet: "figma_copy(file, nodeId?) — flat list of UX text content.", promptGuidelines: [ "Scope with a nodeId when you only care about a single flow or frame — otherwise the whole file is scanned.", "'Copy' here means UX writing / content, not clipboard copy.", ], parameters: Type.Object({ file: Type.String({ description: "File key or URL." }), nodeId: Type.Optional( Type.String({ description: "Optional subtree scope. Accepts 1:234, 1-234, or a full URL with ?node-id=.", }), ), max: Type.Optional( Type.Integer({ minimum: 1, maximum: 5000, description: "Cap on entries returned. Default 1000.", }), ), }), async execute(_id, params, signal) { const fileKey = parseFileKey(params.file); const cap = params.max ?? 1000; let entries: TextEntry[]; let version: string; let fileName: string; let scopedNodeId: string | null = null; if (params.nodeId) { const id = parseNodeIdFromUrl(params.nodeId) ?? normalizeNodeId(params.nodeId); scopedNodeId = id; // depth=undefined → full subtree (still cheap compared to whole file) const resp = await getNodes(fileKey, [id], undefined, signal); const doc = resp.nodes[id]?.document; if (!doc) { const errDetails: CopyDetails = { fileKey, nodeId: id, version: null, totalEntries: 0, truncated: false, error: "not_found", }; return { content: [ { type: "text" as const, text: JSON.stringify({ error: `node ${id} not found` }), }, ], details: errDetails, isError: true, }; } entries = extractText(doc); version = resp.version; fileName = resp.name; } else { const file = await getFile(fileKey, undefined, signal); entries = extractText(file.document); version = file.version; fileName = file.name; } const truncated = entries.length > cap; const shown = entries.slice(0, cap); return { content: [ { type: "text" as const, text: JSON.stringify( { file: { name: fileName, key: fileKey, version }, scopedNodeId, totalEntries: entries.length, shown: shown.length, truncated, entries: shown, }, null, 2, ), }, ], details: ({ fileKey, nodeId: scopedNodeId, version, totalEntries: entries.length, truncated, error: null, }) satisfies CopyDetails as CopyDetails, }; }, });