figmatk 0.0.6

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/mcp-server.mjs ADDED
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * FigmaTK MCP Server — exposes deck manipulation as tools for Claude Cowork / Claude Code.
4
+ */
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { z } from 'zod';
8
+ import { FigDeck } from './lib/fig-deck.mjs';
9
+ import { nid, ov, nestedOv, removeNode, parseId, positionChar } from './lib/node-helpers.mjs';
10
+ import { imageOv, hexToHash, hashToHex } from './lib/image-helpers.mjs';
11
+ import { deepClone } from './lib/deep-clone.mjs';
12
+
13
+ const server = new McpServer({
14
+ name: 'figmatk',
15
+ version: '0.0.3',
16
+ });
17
+
18
+ // ── inspect ─────────────────────────────────────────────────────────────
19
+ server.tool(
20
+ 'figmatk_inspect',
21
+ 'Show the node hierarchy tree of a Figma .deck or .fig file',
22
+ { path: z.string().describe('Path to .deck or .fig file') },
23
+ async ({ path }) => {
24
+ const deck = await FigDeck.fromDeckFile(path);
25
+ const lines = [];
26
+ const doc = deck.message.nodeChanges.find(n => n.type === 'DOCUMENT');
27
+ if (!doc) return { content: [{ type: 'text', text: 'No DOCUMENT node found' }] };
28
+
29
+ function walk(nodeId, indent) {
30
+ const node = deck.getNode(nodeId);
31
+ if (!node || node.phase === 'REMOVED') return;
32
+ const id = nid(node);
33
+ const name = node.name || '';
34
+ const type = node.type || '?';
35
+ lines.push(`${' '.repeat(indent)}${type} ${id} "${name}"`);
36
+ const children = deck.childrenMap.get(nodeId) || [];
37
+ for (const child of children) walk(nid(child), indent + 2);
38
+ }
39
+ walk(nid(doc), 0);
40
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
41
+ }
42
+ );
43
+
44
+ // ── list-text ───────────────────────────────────────────────────────────
45
+ server.tool(
46
+ 'figmatk_list_text',
47
+ 'List all text and image content per slide in a .deck file',
48
+ { path: z.string().describe('Path to .deck or .fig file') },
49
+ async ({ path }) => {
50
+ const deck = await FigDeck.fromDeckFile(path);
51
+ const lines = [];
52
+ const slides = deck.getSlides();
53
+ for (const slide of slides) {
54
+ if (slide.phase === 'REMOVED') continue;
55
+ const id = nid(slide);
56
+ lines.push(`\n── Slide ${id} "${slide.name || ''}" ──`);
57
+ const inst = deck.getSlideInstance(id);
58
+ if (!inst?.symbolData?.symbolOverrides) continue;
59
+ for (const ov of inst.symbolData.symbolOverrides) {
60
+ const key = ov.guidPath?.guids?.[0];
61
+ const keyStr = key ? `${key.sessionID}:${key.localID}` : '?';
62
+ if (ov.textData?.characters) {
63
+ lines.push(` [text] ${keyStr}: ${ov.textData.characters.substring(0, 120)}`);
64
+ }
65
+ if (ov.fillPaints?.length) {
66
+ for (const p of ov.fillPaints) {
67
+ if (p.image?.hash) {
68
+ lines.push(` [image] ${keyStr}: ${hashToHex(p.image.hash)}`);
69
+ }
70
+ }
71
+ }
72
+ }
73
+ }
74
+ return { content: [{ type: 'text', text: lines.join('\n') || 'No slides found' }] };
75
+ }
76
+ );
77
+
78
+ // ── list-overrides ──────────────────────────────────────────────────────
79
+ server.tool(
80
+ 'figmatk_list_overrides',
81
+ 'List editable override keys for each symbol in the deck',
82
+ { path: z.string().describe('Path to .deck or .fig file') },
83
+ async ({ path }) => {
84
+ const deck = await FigDeck.fromDeckFile(path);
85
+ const lines = [];
86
+ const symbols = deck.getSymbols();
87
+ for (const sym of symbols) {
88
+ const id = nid(sym);
89
+ lines.push(`\nSymbol ${id} "${sym.name || ''}"`);
90
+ const children = deck.childrenMap.get(id) || [];
91
+ function walkChildren(nodeId, depth) {
92
+ const node = deck.getNode(nodeId);
93
+ if (!node || node.phase === 'REMOVED') return;
94
+ const cid = nid(node);
95
+ const type = node.type || '?';
96
+ const name = node.name || '';
97
+ if (type === 'TEXT' || (node.fillPaints?.some(p => p.type === 'IMAGE'))) {
98
+ lines.push(` ${' '.repeat(depth)}${type} ${cid} "${name}"`);
99
+ }
100
+ const kids = deck.childrenMap.get(cid) || [];
101
+ for (const kid of kids) walkChildren(nid(kid), depth + 1);
102
+ }
103
+ for (const child of children) walkChildren(nid(child), 0);
104
+ }
105
+ return { content: [{ type: 'text', text: lines.join('\n') || 'No symbols found' }] };
106
+ }
107
+ );
108
+
109
+ // ── update-text ─────────────────────────────────────────────────────────
110
+ server.tool(
111
+ 'figmatk_update_text',
112
+ 'Apply text overrides to a slide instance. Pass key=value pairs.',
113
+ {
114
+ path: z.string().describe('Path to .deck file'),
115
+ output: z.string().describe('Output .deck path'),
116
+ instanceId: z.string().describe('Instance node ID (e.g. "1:1631")'),
117
+ overrides: z.record(z.string()).describe('Object of overrideKey: text pairs, e.g. {"75:127": "Hello"}'),
118
+ },
119
+ async ({ path, output, instanceId, overrides }) => {
120
+ const deck = await FigDeck.fromDeckFile(path);
121
+ const inst = deck.getNode(instanceId);
122
+ if (!inst) return { content: [{ type: 'text', text: `Instance ${instanceId} not found` }] };
123
+ if (!inst.symbolData) inst.symbolData = { symbolOverrides: [] };
124
+ if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
125
+
126
+ for (const [key, text] of Object.entries(overrides)) {
127
+ const [s, l] = key.split(':').map(Number);
128
+ inst.symbolData.symbolOverrides.push(ov({ sessionID: s, localID: l }, text));
129
+ }
130
+
131
+ const bytes = await deck.saveDeck(output);
132
+ return { content: [{ type: 'text', text: `Saved ${output} (${bytes} bytes), ${Object.keys(overrides).length} text overrides applied` }] };
133
+ }
134
+ );
135
+
136
+ // ── insert-image ────────────────────────────────────────────────────────
137
+ server.tool(
138
+ 'figmatk_insert_image',
139
+ 'Apply an image fill override to a slide instance',
140
+ {
141
+ path: z.string().describe('Path to .deck file'),
142
+ output: z.string().describe('Output .deck path'),
143
+ instanceId: z.string().describe('Instance node ID'),
144
+ targetKey: z.string().describe('Override key for the image rectangle (e.g. "75:126")'),
145
+ imageHash: z.string().describe('40-char hex SHA-1 hash of the full image'),
146
+ thumbHash: z.string().describe('40-char hex SHA-1 hash of the thumbnail'),
147
+ width: z.number().describe('Image width in pixels'),
148
+ height: z.number().describe('Image height in pixels'),
149
+ imagesDir: z.string().optional().describe('Path to images directory to include in deck'),
150
+ },
151
+ async ({ path, output, instanceId, targetKey, imageHash, thumbHash, width, height, imagesDir }) => {
152
+ const deck = await FigDeck.fromDeckFile(path);
153
+ const inst = deck.getNode(instanceId);
154
+ if (!inst) return { content: [{ type: 'text', text: `Instance ${instanceId} not found` }] };
155
+ if (!inst.symbolData) inst.symbolData = { symbolOverrides: [] };
156
+ if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
157
+
158
+ const [s, l] = targetKey.split(':').map(Number);
159
+ inst.symbolData.symbolOverrides.push(
160
+ imageOv({ sessionID: s, localID: l }, imageHash, thumbHash, width, height)
161
+ );
162
+
163
+ const opts = imagesDir ? { imagesDir } : {};
164
+ const bytes = await deck.saveDeck(output, opts);
165
+ return { content: [{ type: 'text', text: `Saved ${output} (${bytes} bytes), image override applied` }] };
166
+ }
167
+ );
168
+
169
+ // ── clone-slide ─────────────────────────────────────────────────────────
170
+ server.tool(
171
+ 'figmatk_clone_slide',
172
+ 'Duplicate a slide from the deck',
173
+ {
174
+ path: z.string().describe('Path to .deck file'),
175
+ output: z.string().describe('Output .deck path'),
176
+ slideId: z.string().describe('Source slide node ID to clone'),
177
+ },
178
+ async ({ path, output, slideId }) => {
179
+ const deck = await FigDeck.fromDeckFile(path);
180
+ const slide = deck.getNode(slideId);
181
+ if (!slide) return { content: [{ type: 'text', text: `Slide ${slideId} not found` }] };
182
+
183
+ let nextId = deck.maxLocalID() + 1;
184
+ const newSlide = deepClone(slide);
185
+ const newSlideId = nextId++;
186
+ newSlide.guid = { sessionID: 1, localID: newSlideId };
187
+ newSlide.phase = 'CREATED';
188
+ delete newSlide.prototypeInteractions;
189
+ delete newSlide.slideThumbnailHash;
190
+ delete newSlide.editInfo;
191
+
192
+ const inst = deck.getSlideInstance(slideId);
193
+ if (inst) {
194
+ const newInst = deepClone(inst);
195
+ newInst.guid = { sessionID: 1, localID: nextId++ };
196
+ newInst.phase = 'CREATED';
197
+ newInst.parentIndex = { guid: { sessionID: 1, localID: newSlideId }, position: '!' };
198
+ delete newInst.derivedSymbolData;
199
+ delete newInst.derivedSymbolDataLayoutVersion;
200
+ delete newInst.editInfo;
201
+ deck.message.nodeChanges.push(newInst);
202
+ }
203
+
204
+ deck.message.nodeChanges.push(newSlide);
205
+ deck.rebuildMaps();
206
+
207
+ const bytes = await deck.saveDeck(output);
208
+ return { content: [{ type: 'text', text: `Cloned slide ${slideId} → 1:${newSlideId}. Saved ${output} (${bytes} bytes)` }] };
209
+ }
210
+ );
211
+
212
+ // ── remove-slide ────────────────────────────────────────────────────────
213
+ server.tool(
214
+ 'figmatk_remove_slide',
215
+ 'Mark a slide as REMOVED',
216
+ {
217
+ path: z.string().describe('Path to .deck file'),
218
+ output: z.string().describe('Output .deck path'),
219
+ slideId: z.string().describe('Slide node ID to remove'),
220
+ },
221
+ async ({ path, output, slideId }) => {
222
+ const deck = await FigDeck.fromDeckFile(path);
223
+ const slide = deck.getNode(slideId);
224
+ if (!slide) return { content: [{ type: 'text', text: `Slide ${slideId} not found` }] };
225
+ removeNode(slide);
226
+ const inst = deck.getSlideInstance(slideId);
227
+ if (inst) removeNode(inst);
228
+
229
+ const bytes = await deck.saveDeck(output);
230
+ return { content: [{ type: 'text', text: `Removed slide ${slideId}. Saved ${output} (${bytes} bytes)` }] };
231
+ }
232
+ );
233
+
234
+ // ── roundtrip ───────────────────────────────────────────────────────────
235
+ server.tool(
236
+ 'figmatk_roundtrip',
237
+ 'Decode and re-encode a .deck file to validate the pipeline',
238
+ {
239
+ path: z.string().describe('Path to input .deck file'),
240
+ output: z.string().describe('Path to output .deck file'),
241
+ },
242
+ async ({ path, output }) => {
243
+ const deck = await FigDeck.fromDeckFile(path);
244
+ const bytes = await deck.saveDeck(output);
245
+ return { content: [{ type: 'text', text: `Roundtrip complete: ${output} (${bytes} bytes)` }] };
246
+ }
247
+ );
248
+
249
+ // ── Start server ────────────────────────────────────────────────────────
250
+ const transport = new StdioServerTransport();
251
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "figmatk",
3
+ "version": "0.0.6",
4
+ "description": "Figma Toolkit — Swiss-army knife CLI for Figma .deck and .fig files",
5
+ "type": "module",
6
+ "bin": {
7
+ "figmatk": "cli.mjs",
8
+ "figmatk-mcp": "mcp-server.mjs"
9
+ },
10
+ "exports": {
11
+ ".": "./lib/api.mjs",
12
+ "./deck": "./lib/fig-deck.mjs",
13
+ "./node-helpers": "./lib/node-helpers.mjs",
14
+ "./image-helpers": "./lib/image-helpers.mjs",
15
+ "./deep-clone": "./lib/deep-clone.mjs"
16
+ },
17
+ "files": [
18
+ "cli.mjs",
19
+ "mcp-server.mjs",
20
+ "lib/",
21
+ "commands/",
22
+ "skills/",
23
+ ".claude-plugin/",
24
+ ".mcp.json",
25
+ "LICENSE",
26
+ "README.md"
27
+ ],
28
+ "scripts": {
29
+ "start": "node cli.mjs"
30
+ },
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/rcoenen/figmatk.git"
37
+ },
38
+ "homepage": "https://github.com/rcoenen/figmatk",
39
+ "bugs": {
40
+ "url": "https://github.com/rcoenen/figmatk/issues"
41
+ },
42
+ "author": "rcoenen",
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.27.1",
45
+ "archiver": "^7.0.1",
46
+ "fzstd": "^0.1.1",
47
+ "kiwi-schema": "^0.5.0",
48
+ "pako": "^2.1.0",
49
+ "sharp": "^0.34.5",
50
+ "zstd-codec": "^0.1.5"
51
+ },
52
+ "keywords": [
53
+ "figma",
54
+ "deck",
55
+ "fig",
56
+ "slides",
57
+ "presentation",
58
+ "kiwi",
59
+ "binary",
60
+ "cli"
61
+ ],
62
+ "license": "MIT"
63
+ }
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: figmatk
3
+ description: >
4
+ Inspect and modify Figma Slides .deck files. Use when the user asks to
5
+ "edit a deck", "modify a presentation", "inspect slides", "update slide text",
6
+ "insert an image into a slide", "clone a slide", "remove a slide",
7
+ "list slide content", or works with .deck or .fig files.
8
+ metadata:
9
+ version: "0.0.3"
10
+ ---
11
+
12
+ Use the FigmaTK MCP tools to manipulate Figma .deck files. The tools are prefixed with `figmatk_`.
13
+
14
+ ## Available tools
15
+
16
+ - `figmatk_inspect` — Show the node hierarchy tree
17
+ - `figmatk_list_text` — List all text and image content per slide
18
+ - `figmatk_list_overrides` — List editable override keys per symbol
19
+ - `figmatk_update_text` — Apply text overrides to a slide instance
20
+ - `figmatk_insert_image` — Apply image fill override with hash + thumbnail
21
+ - `figmatk_clone_slide` — Duplicate a slide
22
+ - `figmatk_remove_slide` — Mark a slide as REMOVED
23
+ - `figmatk_roundtrip` — Decode and re-encode for validation
24
+
25
+ ## Workflow
26
+
27
+ 1. Start with `figmatk_inspect` to understand the deck structure
28
+ 2. Use `figmatk_list_text` to see current content
29
+ 3. Use `figmatk_list_overrides` to find editable keys
30
+ 4. Apply changes with `figmatk_update_text` or `figmatk_insert_image`
31
+ 5. Always write to a new output path — never overwrite the source
32
+
33
+ ## Critical rules
34
+
35
+ - Text overrides use `overrideKey: text` pairs where keys are `"sessionID:localID"` format
36
+ - Blank text fields must be a single space `" "`, never empty string (crashes Figma)
37
+ - Image overrides require both a full image hash and a thumbnail hash (40-char hex SHA-1)
38
+ - Removed nodes use `phase: 'REMOVED'` — never delete from nodeChanges
39
+ - Always roundtrip-test after modifications to validate the pipeline