@design-twin/dtwin 0.0.0-stage → 0.1.0-alpha.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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +949 -2
  3. package/package.json +49 -4
  4. package/src/abort-reason.ts +12 -0
  5. package/src/asset-compare.ts +175 -0
  6. package/src/asset-owners.ts +280 -0
  7. package/src/atomic-write.ts +79 -0
  8. package/src/bridge-holder.ts +294 -0
  9. package/src/cli-commands.ts +427 -0
  10. package/src/client-presence.ts +146 -0
  11. package/src/clock.ts +45 -0
  12. package/src/commands.ts +561 -0
  13. package/src/daemon-auth.ts +174 -0
  14. package/src/daemon.ts +788 -0
  15. package/src/delivery.ts +85 -0
  16. package/src/design-system-layout.ts +232 -0
  17. package/src/doc-types.ts +1054 -0
  18. package/src/doctor.ts +602 -0
  19. package/src/errmsg.ts +14 -0
  20. package/src/export-reader.ts +409 -0
  21. package/src/export-verdict.ts +258 -0
  22. package/src/figma-mcp.ts +940 -0
  23. package/src/figma-pull.ts +1265 -0
  24. package/src/file-lock.ts +268 -0
  25. package/src/file-memo.ts +62 -0
  26. package/src/hash.ts +6 -0
  27. package/src/hex-color.ts +39 -0
  28. package/src/human-ms.ts +7 -0
  29. package/src/init.ts +424 -0
  30. package/src/instance-overrides.ts +7 -0
  31. package/src/json-file.ts +79 -0
  32. package/src/json-size.ts +108 -0
  33. package/src/json-util.ts +51 -0
  34. package/src/library-layout.ts +193 -0
  35. package/src/node-id.ts +69 -0
  36. package/src/pages-layout.ts +483 -0
  37. package/src/pending-requests.ts +187 -0
  38. package/src/plugin-frames.ts +32 -0
  39. package/src/png.ts +196 -0
  40. package/src/ports.ts +25 -0
  41. package/src/project-layout.ts +189 -0
  42. package/src/quick-keys.ts +68 -0
  43. package/src/read-json.ts +67 -0
  44. package/src/read-opts.ts +125 -0
  45. package/src/read-plan.ts +105 -0
  46. package/src/read-run.ts +119 -0
  47. package/src/route.ts +243 -0
  48. package/src/seed-components.ts +229 -0
  49. package/src/served-session.ts +347 -0
  50. package/src/server-core.ts +1093 -0
  51. package/src/settings.ts +156 -0
  52. package/src/source-stamp.ts +16 -0
  53. package/src/staleness.ts +63 -0
  54. package/src/stdio.ts +156 -0
  55. package/src/svg-normalize.ts +205 -0
  56. package/src/timeouts.ts +76 -0
  57. package/src/timings.ts +118 -0
  58. package/src/token-store.ts +261 -0
  59. package/src/variables-merge.ts +337 -0
  60. package/src/verbs.ts +140 -0
  61. package/src/write-out.ts +1255 -0
package/README.md CHANGED
@@ -1,3 +1,950 @@
1
- # Temporary Holding Version
1
+ # Design Twin — bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **figma-pull (read CLI) + figma-mcp (write MCP).**
4
+
5
+ Local, loopback-only bridge between Claude Code and the **Design Twin** Figma plugin.
6
+ The plugin's hidden UI iframe is a WebSocket **client**; these processes are the **server**
7
+ (`127.0.0.1:8787`). Nothing touches the internet. See `../ARCHITECTURE.md` for the full design.
8
+
9
+ ## Install (once)
10
+ The `designtwin` package is not on npm; install it from a clone:
11
+ ```
12
+ git clone https://github.com/its-magdy/design-twin.git && cd design-twin
13
+ bun install # @modelcontextprotocol/sdk (+ zod), for every workspace
14
+ cd bridge && bun link # puts `dtwin` in ~/.bun/bin (on your PATH); nothing to build
15
+ ```
16
+ It needs [Bun](https://bun.sh/install) ≥ 1.4.2 (`engines.bun`); Node is not supported.
17
+ Or skip the link and run `bun --no-install --no-env-file /path/to/design-twin/bridge/src/figma-pull.ts` wherever
18
+ this README says `dtwin` (those two flags are what the bin's shebang adds: without them, Bun loads the `.env` of the
19
+ project you run it in and may fetch a missing package from the registry).
20
+ The `dtwin` bin's shebang needs an `env` that understands `-S` (GNU coreutils ≥ 8.30, macOS, FreeBSD). Where it
21
+ does not (BusyBox — Alpine and other minimal images), `dtwin` fails before Bun starts; run it as
22
+ `bun --no-install --no-env-file "$(command -v dtwin)" …` (or the path above) instead.
23
+
24
+ The plugin manifest allows the bridge in **both** dev and published builds
25
+ (`allowedDomains: ws://localhost:{8787,8788,8789}`). Those three ports are the only ones the plugin can dial — `FIGMA_BRIDGE_PORT` is
26
+ validated against them and exits rather than binding a port nothing could ever reach.
27
+
28
+ > **Re-import the plugin after its manifest changes — required for library reads.** The manifest declares
29
+ > `"permissions": ["teamlibrary"]`, without which Figma denies the team-library APIs. A plugin
30
+ > imported from an older manifest keeps running happily and simply returns **no libraries** — a silent
31
+ > empty result, not an error. If `--list-libraries` / `figma_list_libraries` shows nothing, re-import
32
+ > `figma-plugin/manifest.json` (Plugins → Development → Import plugin from manifest…) first.
33
+
34
+ **Languages:** the whole bridge is **TypeScript** (ESM) under `src/` — `figma-pull.ts` (read CLI),
35
+ `server-core.ts` (WS core), `figma-mcp.ts` (MCP server), and the modules they share. From a checkout,
36
+ Bun runs it directly with **zero build** (`bun bridge/src/figma-pull.ts …`, `bun bridge/src/figma-mcp.ts`), and the
37
+ package ships the same TypeScript: its `dtwin` bin — what `bun link` puts on your PATH — is `src/figma-pull.ts`,
38
+ whose shebang is `#!/usr/bin/env -S bun --no-install --no-env-file` (so a `.env` or a missing package in the
39
+ project you run it from can neither change its environment nor make Bun fetch a module from the registry;
40
+ `env -S` needs GNU coreutils ≥ 8.30 or a BSD/macOS `env` — on BusyBox, call `bun --no-install --no-env-file` on
41
+ the file yourself, see Install above).
42
+ The WebSocket server is Bun's own (`Bun.serve`), so the only runtime dependencies are the MCP SDK and zod.
43
+ ```
44
+ bun run typecheck # tsc --noEmit (the only check there is no emit step)
45
+ ```
46
+
47
+ ### Bridge token (required)
48
+ The bridge authenticates every connection with a shared token — loopback binding alone is not
49
+ access control (a sandboxed browser iframe on any site the user visits sends `Origin: null` and
50
+ could otherwise drive the plugin), so the token is the real gate.
51
+
52
+ **You do not have to set anything up.** The first time a bridge starts it generates a token, saves it,
53
+ prints it once, and reuses it forever after. Paste it into the plugin's **Bridge token** field (the
54
+ plugin saves it per-user in `clientStorage`) and neither side asks again.
55
+
56
+ ```
57
+ dtwin --token-status # where it lives, which source wins, its fingerprint — never the token
58
+ dtwin --show-token # print the token itself (stdout only, so `dtwin --show-token | pbcopy` works)
59
+ dtwin --rotate-token # replace it — you must then re-paste it into the plugin
60
+ dtwin --forget-token # delete it; the next bridge start mints a new one
61
+ dtwin --token-file <p> # read the token from <p> for this run instead of the stored one
62
+ ```
63
+
64
+ **Where it is stored**
65
+
66
+ | OS | Path |
67
+ |---|---|
68
+ | macOS / Linux | `$XDG_CONFIG_HOME/design-twin/bridge-token`, else `~/.config/design-twin/bridge-token` |
69
+ | Windows | `%APPDATA%\design-twin\bridge-token` |
70
+
71
+ Written `0600` (owner only) in a `0700` directory, atomically. `DESIGNTWIN_CONFIG_DIR` overrides the
72
+ directory outright — for a container, a CI runner, or a test.
73
+
74
+ **Precedence** — first match wins:
75
+
76
+ 1. `--token-file <path>`
77
+ 2. `FIGMA_BRIDGE_TOKEN` (the CI / ephemeral escape hatch)
78
+ 3. the saved file
79
+ 4. otherwise: generate one, save it, print it once
80
+
81
+ There is deliberately **no `--token <value>` flag**. Process arguments are world-readable through
82
+ `ps` and `/proc/<pid>/cmdline`, so a value flag would hand the secret to every other user on the
83
+ machine for as long as the command runs. A *path* is not a secret; the file it points at is.
84
+
85
+ `FIGMA_BRIDGE_TOKEN` works and wins over the file. It is not the recommended setup, for two reasons: exported environment variables
86
+ propagate to every child process and leak into logs, and it has to be re-exported in every shell.
87
+ When both are present `--token-status` says so explicitly — a saved token silently shadowed by an
88
+ env var is otherwise a genuinely confusing state to debug.
89
+
90
+ **What this protects against, and what it doesn't.** The `0600` file stops *other users* on the
91
+ machine. It does not stop another process running as *you* — only an OS keychain would, and this
92
+ bridge deliberately doesn't use one: `keytar` is archived, its live replacements are native binaries
93
+ with per-platform install friction, and that is disproportionate for a loopback token that grants no
94
+ authority beyond driving a Figma plugin you already have open. If that trade doesn't suit your
95
+ environment, keep the token in your own secret manager and pass `--token-file`.
96
+
97
+ ## Set a project up: `dtwin init`
98
+
99
+ Run in the root of the project you are **building** (not this repo):
100
+
101
+ ```
102
+ dtwin init # design/ + design/export/, README, target.json, bridge token, next steps
103
+ dtwin init --mcp # …and register this MCP server in ./.mcp.json (merged; a broken entry of ours is repaired)
104
+ dtwin init --dry-run # print what it would do; write nothing, mint no token
105
+ ```
106
+
107
+ It lays out the project as two halves, and the split is the point:
108
+
109
+ ```
110
+ design/
111
+ export/ <- dtwin writes ONLY here. rm -rf and re-pull loses nothing.
112
+ pages/<Page>/<Screen>__<node-id>.json one screen, any pull shape — carries `sourceFile` (the
113
+ connected Figma file this pull actually talked to; absent
114
+ on a screen pulled before this field existed, never
115
+ guessed from another file's export)
116
+ pages/index.json every screen pulled, each row also carrying `sourceFile`
117
+ design-system/ tokens, styles, component catalogs
118
+ SCHEMA.md scripting quick keys (generated beside the data; same text
119
+ as the top of ir-fields.md; rewritten only when its text
120
+ changes, a SCHEMA.md of your own is never touched)
121
+ variables.json the union of every screen's tokens (merged, never replaced)
122
+ assets/ named after the Figma layer, deduped by content
123
+ README.md what dtwin owns vs what you own
124
+ target.json the stack (profile: null when nothing was detected — build-screen fills it in)
125
+ codeconnect.local.json Figma component -> your code component. HAND-OWNED.
126
+ plan/ audit/ verify/ decisions and evidence from the skills
127
+ ```
128
+
129
+ The two halves live apart so that "delete design/export/ and re-pull" — the obvious way to recover from
130
+ a bad export — never touches a hand-mapped component map. Projects created before the split keep
131
+ their export directly in `design/`; every command still finds it and `dtwin doctor` says which layout
132
+ it found.
133
+
134
+ It needs no bridge, plugin or free port. It never overwrites: an existing `target.json`, an existing
135
+ `designtwin` entry in `.mcp.json` (or this server already registered under any other name), or an
136
+ unparseable `.mcp.json` are all left alone and reported. The one exception is an entry of **ours** that
137
+ can no longer start — its `bun` or `figma-mcp.ts` is gone (a deleted clone, a `brew upgrade` that removed
138
+ the versioned bun it named), or it is a Node-era entry (`node …/bridge/dist/figma-mcp.js`), or bun runs it
139
+ without `--no-install --no-env-file`, or it runs the package by name (`bunx @design-twin/dtwin mcp`, which fetches it
140
+ from the registry or Bun's cache at every start): `--mcp` re-points that entry, under its own key and keeping its other
141
+ fields, at this install, and prints `REPAIRED …` with what was wrong (`--dry-run` only prints it). The entry
142
+ names the bun on your PATH when that is the bun running init (`~/.bun/bin/bun`, Homebrew's
143
+ `/opt/homebrew/bin/bun` — paths that survive an upgrade), else the running bun's own path. Run from a
144
+ `bunx` cache (`bunx @design-twin/dtwin init`), `--mcp` refuses to register anything: Bun replaces or deletes that
145
+ cache on its own, so install the package for good first. The server is registered as `designtwin`,
146
+ never `figma` — that is the name Figma's own MCP server usually has, and Claude Code loads one server
147
+ per name, so the two sit side by side. `--mcp` is
148
+ opt-in because a registered MCP server holds port 8787 for as long as Claude Code runs (`dtwin`
149
+ commands still work — they route through it, see "One bridge, shared" below). The two
150
+ steps it cannot do — importing the plugin into Figma and pasting the token — it prints.
151
+
152
+ ## Commands at a glance
153
+
154
+ ```
155
+ dtwin list # what is in the file: pages + top-level frames, with ids
156
+ dtwin pull --page Screens # export ONE page into ./design/export
157
+ dtwin screenshot 12:34 # a reference PNG of one node
158
+ dtwin doctor # something not working? start here
159
+ dtwin seed # components.json from the Code Connect files in your code (offline)
160
+ ```
161
+
162
+ Every command is shorthand for a flag, and **the flags keep working unchanged** — the rest of this
163
+ README documents the flags, and either spelling is fine everywhere:
164
+
165
+ | Command | Same as |
166
+ |---|---|
167
+ | `dtwin pull [outDir] [flags]` | `dtwin [outDir] [flags]` |
168
+ | `dtwin list` · `list pages` · `list libraries` · `list clients` | `--list` · `--list-pages` · `--list-libraries` · `--list-clients` |
169
+ | `dtwin list children <id\|url>` | `--children <id\|url>` |
170
+ | `dtwin screenshot <id\|url> [--scale N]` | `--screenshot <id\|url>` |
171
+ | `dtwin whoami` | `--whoami` |
172
+ | `dtwin serve` · `stop` · `status` | `--serve` · `--stop` · `--daemon-status` |
173
+ | `dtwin token` · `token show` · `token rotate` · `token forget` | `--token-status` · `--show-token` · `--rotate-token` · `--forget-token` |
174
+ | `dtwin help` | `--help` |
175
+ | `dtwin doctor` · `dtwin init` · `dtwin mcp` · `dtwin seed` | their own entry points (no flag form) |
176
+
177
+ Every verb has its own page: `dtwin <verb> --help` (e.g. `dtwin whoami --help`, `dtwin serve --help`) prints
178
+ that verb's flags and exits without starting a bridge. `outDir` is **positional** (`dtwin pull my-dir`):
179
+ `--out` / `-o` / `--output` / `--out-dir` / `--dir` are refused with a hint saying so. The port is chosen
180
+ only by the `FIGMA_BRIDGE_PORT` environment variable (8787, 8788 or 8789; there is no `--port` flag, and
181
+ `DTWIN_PORT` is not read — `dtwin` and `doctor` warn when it is set).
182
+
183
+ A command is only recognised as the **first** argument, so `dtwin my-dir` still means "pull into
184
+ `./my-dir`". For an output directory spelled like a command, write `dtwin pull list` or `dtwin ./list`.
185
+ With no outDir the export lands in `design/export` — the only place doctor, cross-check, audit and
186
+ build-screen read. Never pull into bare `design`: that writes a second, parallel export tree beside
187
+ `design/export/` (dtwin warns when it sees one).
188
+
189
+ ## Seed the component map from Code Connect: `dtwin seed`
190
+
191
+ If the project you are building already has Code Connect files, they are the Figma-component →
192
+ code-component map you would otherwise write by hand. `dtwin seed` reads them — template files
193
+ (`// url=` + `// component=` + `// source=` headers), `figma.connect(...)` calls, and `@FigmaConnect`
194
+ (SwiftUI/Compose) — and fills `components.json`:
195
+
196
+ ```
197
+ dtwin seed # scan . and merge into design/components.json
198
+ dtwin seed src design # [codeRoot] [outDir]
199
+ dtwin seed --dry-run # scan and report what it would add; write nothing
200
+ ```
201
+
202
+ It only reads source files: no bridge, plugin or token, and never the network. The merge is
203
+ fill-missing-only — an existing entry's hand-authored fields are never overwritten — and each new
204
+ entry leaves `import` and `props` for you to fill in. A mapping that carries only a node id (no
205
+ component name) is named through `<outDir>/design-system.json`, so export the design system first;
206
+ without it such mappings are skipped and the run says so. In a clone of this repo the same script
207
+ also runs directly: `bun bridge/src/seed-components.ts [codeRoot] [outDir] [--dry-run]`.
208
+
209
+ ## Something not working: `dtwin doctor`
210
+
211
+ Every failure looks the same from outside — a pull that waits, then times out. `doctor` tells the
212
+ causes apart, marks each check ✓ / ! / ✗, and gives the one next step:
213
+
214
+ ```
215
+ dtwin doctor # exit code 1 only if a check is ✗ (a note, !, is not a failure)
216
+ dtwin doctor --wait 30 # wait longer for the plugin to connect (default 10s; it retries every second)
217
+ dtwin doctor --json # { ok, checks: [{ id, title, status, detail, next, skipped }] }
218
+ ```
219
+
220
+ `ok` is false when a check is ✗ **or** `skipped` (a check that could not run — the plugin probe while something
221
+ else holds the port, or before a token exists — is a ! marked `skipped: true`, since nothing it would have verified was).
222
+
223
+ Once a plugin connects, the probe can run up to ~6.5 s past `--wait`: the bridge's settle (so a second
224
+ open file is seen), then each file's `hello` (so it is named).
225
+
226
+ 1. **Bun** version against this package's `engines.bun` (≥ 1.4.2).
227
+ 2. **Bridge token** — saved or not, where, loose file permissions, and whether `FIGMA_BRIDGE_TOKEN` is
228
+ shadowing the saved one. Fingerprint only, never the token.
229
+ 3. **Daemon** — running or not, and which files are connected to it (on Windows: a note that it is not
230
+ supported there, and why).
231
+ 4. **Port** (`FIGMA_BRIDGE_PORT`, one of 8787/8788/8789) — free, held by the daemon, held by another
232
+ bridge (almost always the MCP server), or held by something unrelated.
233
+ 5. **Figma plugin** — can it actually connect? If a plugin is running with a *different* token, doctor
234
+ says so with both fingerprints, instead of the "nothing connected" that case otherwise looks like.
235
+ 6. **Project** (the current directory) — `design/`, `design/target.json` and the profile build-screen will use
236
+ (bundled or custom, plus the project's own `design/profile.md` when it exists), which export LAYOUT it
237
+ found, the age of the last export (its newest stamp; stale past 24h), **which Figma files that export mixes**, the component map
238
+ (`design/codeconnect.local.json`, or the older repo-root location), and the `.mcp.json`
239
+ registration (a legacy `figma` key still works; new setups use `designtwin`). A registered entry that
240
+ cannot start — its command or `figma-mcp.ts` missing, Node-shaped (`node`, a `bridge/dist` script), bun
241
+ without `--no-install --no-env-file`, a script in a `bunx` cache, or the package run by name
242
+ (`bunx @design-twin/dtwin mcp`) — is a ✗ naming `dtwin init --mcp`,
243
+ which repairs it. `DT_TEST_EPHEMERAL_PORT` (a test-only seam) set in the environment is a note too.
244
+
245
+ The "which Figma files" line exists because one stamp describes exactly one file, and on a project
246
+ whose screens came from a different Figma file than its design system it would name the wrong one.
247
+ doctor lists every source with its own age — the screens of each Figma file, the design system — and
248
+ when they are more than one Figma file it points at `/designtwin:audit-design`, whose cross-file check
249
+ proves whether the screens and the design system really are the same system. `figma_status`'s
250
+ `snapshot` is the same verdict (bridge/src/export-verdict.ts), so the two never disagree.
251
+
252
+ It changes nothing: no token is created, no file written. Check 5 opens a bridge for a few seconds,
253
+ so it only runs when the port is free **and** a token already exists; when a daemon is up doctor asks
254
+ the daemon instead, and when something else holds the port it identifies the holder with a plain HTTP
255
+ request (a bridge answers `426`) rather than a WebSocket — a WebSocket client would be registered as a
256
+ plugin and disturb whoever owns that bridge.
257
+
258
+ ## Read: figma-pull (CLI) — recommended for bulk extraction
259
+
260
+ Every direct run ends with `done in 12.4s — waited 9.8s for the plugin to connect · export 2.4s · write 0.2s`
261
+ (or `via the running daemon · export (incl. any wait for the plugin) 2.6s` when a daemon answered: the
262
+ daemon waits for a not-yet-connected plugin inside the request, so that wait is in the export figure). A long "waited" figure is the plugin
263
+ re-dialling a fresh one-shot bridge (it retries every second, one port at a time, slower in a background window), not the export
264
+ itself; the line says so past 5 s. `dtwin serve` in another terminal keeps one bridge open and the plugin
265
+ connected, so every later command skips that wait.
266
+
267
+ `dtwin help` (or `--help` / `-h`) prints the quick start, the command table and every flag below, and
268
+ exits — no bridge is started, no token minted.
269
+ An unknown or mistyped flag is an error with a suggestion (`--lst` → "did you mean --list?"), never
270
+ silently ignored — a typo must never fall through to a full pull.
271
+
272
+ A full pull writes `design/export/design-system.json` as a slim MANIFEST (`exportedAt`/`file`/`colorProfile`,
273
+ `files` pointers, `counts`) over the catalog split under `design/export/design-system/`, one file per Figma
274
+ concept: `tokens.json` (variable collections → modes → variables), one file per style type —
275
+ `styles.paint.json`/`styles.text.json`/`styles.effect.json`/`styles.grid.json`/`styles.animation.json` — (the
276
+ separate Paint/Text/Effect/Grid style system, plus custom animation styles), `components.local.json` (components that are real nodes in this
277
+ file — a `COMPONENT_SET`'s heavy per-variant node trees, or a standalone `COMPONENT`'s own node tree,
278
+ are NOT inlined here; they live in a sibling `design-system/components/<name>__<id>.json`, pointed at
279
+ by that entry's `variantsFile`/`nodeFile`, opt-in via `--variant-visuals`; see `design-to-code/get-component.ts`
280
+ below), `components.library.json` (`remote: true` —
281
+ consumed from a published library, recovered from instances, props possibly inferred) and
282
+ `hygiene.json` (the lint report). Every part repeats the
283
+ `exportedAt` stamp, so `design-to-code/` reads freshness off whichever part it is handed. The split files sit
284
+ in a subdirectory because `design/tokens.json` and `design/components.json` are your
285
+ hand-authored, non-regenerable config maps.
286
+
287
+ ```
288
+ # from the repo root, with the Figma file open + plugin running:
289
+ dtwin pull # design-system.json + design-system/ + CURRENT-page frames + assets/
290
+ dtwin pull --all-pages # like above, but frame trees from EVERY page
291
+ dtwin pull --selection # just the current selection
292
+ dtwin pull --design-system # ONLY design-system.json + design-system/ — no page
293
+ # walk, no assets/ (the cheap "tokens only" pull)
294
+ dtwin pull --as-library "Acme UI" # the COMPLETE catalog of a LIBRARY file — run this
295
+ # with the LIBRARY open, not the file that uses it
296
+ dtwin pull --node <id> # ONE node, fully exported (properties + assets) —
297
+ # see "--node <id>" below
298
+ ```
299
+ **Recommended workflow — discover, then scope.** Never open with a whole-file pull. Work down from
300
+ cheap questions to expensive ones, which is also what Figma's own agent guidance recommends
301
+ (discover first, then scope):
302
+ ```
303
+ dtwin whoami # 0. (optional) which file am I actually connected to?
304
+ dtwin list pages # 1. page NAMES only (near-free — loads no page)
305
+ dtwin list # 2. WHERE is what — pages + top-level layers (ids)
306
+ dtwin list children <id> # 3. peek inside one frame / SECTION
307
+ dtwin pull --node <id> # 4. pull just that ONE node, real export + its own assets
308
+ dtwin pull --page <id> # 4b. (only for a whole page)
309
+ dtwin screenshot <id> # 5. (optional) visually check ONE component after generating code for it
310
+ ```
311
+ `dtwin list libraries` is a side step, not part of that order (the slowest read): run it to learn which
312
+ library owns a token, or before an `--as-library` pull.
313
+ (The flag spellings — `--whoami`, `--list-libraries`, `--list-pages`, `--list`, `--children`,
314
+ `--screenshot` — are the same commands; the sections below are titled by flag.)
315
+
316
+ ### `--node <id>` — one node, fully exported
317
+
318
+ ```
319
+ dtwin pull --node 123:456 # -> design/export/pages/<Page>/<Name>__123_456.json
320
+ dtwin pull --node "https://figma.com/design/KEY/App?node-id=123-456" # a pasted link works too
321
+ ```
322
+
323
+ A REAL export scoped to exactly one node — the CLI twin of the MCP `figma_export_url` tool ("paste a
324
+ link and ask about it," see below). It runs the full `serialize()` walk on that node's subtree and
325
+ exports the assets found inside it, exactly as a `--page` pull would for that subtree, just rooted
326
+ lower. Accepts a bare id, dash form, percent-encoded id, a nested-instance path, or a whole figma.com
327
+ URL — the same lenient parsing `--children`/`--screenshot` use.
328
+
329
+ Don't confuse it with its two neighbors:
330
+ - `--children <id>` peeks at one node's **direct children only** — no recursion, no assets, for
331
+ deciding whether to pull further. Each row has `childCount`; rows sharing name + size also get a
332
+ `title` (the first visible text), and the listing warns once per such group ("N children share name
333
+ … and size … — told apart by `title`/`childCount`"). When the titles match too, those rows also get
334
+ `distinctTexts` (up to 3 texts that row shows and its twins do not); the listing shares one budget of
335
+ node reads, and a group with a row it runs out on gets none, with one warning naming them. When a row has
336
+ no `distinctTexts`, screenshot each candidate.
337
+ - `--screenshot <id>` renders a **PNG only** — skips `serialize()` and the asset walk entirely, for
338
+ visual validation after you've already generated code.
339
+ - `--node <id>` is the one that actually **exports** — real node tree + real assets, just scoped
340
+ smaller than a whole page.
341
+
342
+ It is a SCOPE flag like `--page`/`--selection`/`--all-pages`, so it's mutually exclusive with them,
343
+ and unlike `--screenshot` it composes with read options (`--css`, `--measurements`, etc.) since it
344
+ does a real walk.
345
+
346
+ It files into the **same `pages/` tree** a `--page` pull writes, so nothing downstream has to know
347
+ which pull produced a file:
348
+
349
+ ```
350
+ design/export/pages/<Page>/<Name>__<node-id>.json the screen tree
351
+ design/export/pages/<Page>/<Name>__<node-id>.vars.json exactly the variables THIS screen binds
352
+ design/export/pages/<Page>/<Name>__<node-id>.assets.json its assets, with content hashes,
353
+ plus duplicates / monochrome / heavy; rows carry
354
+ name / owner / context / usedBy (search those,
355
+ not file names)
356
+ design/export/pages/<Page>/index.json merged, one row per screen pulled
357
+ design/export/pages/index.json merged, one row per page
358
+ design/export/variables.json the UNION of every screen's slice
359
+ design/export/assets/ shared, deduped by content
360
+ ```
361
+
362
+ A pull prints these asset lines: `warn N node(s) fell back to raw geometry …` (any fallback, naming the
363
+ nodes), `warn … too heavy to inline` (or "a raster image embedded in an SVG shell" when it is one), and
364
+ `info N hidden graphic(s) not exported (assetSkipped:"hidden"); M reuse a visible twin's file` and
365
+ `info N node(s) hidden (themselves or under a hidden ancestor) kept in the tree (conditional UI)` (only
366
+ the ones hidden themselves carry `hidden:true`). A root that is hidden itself or under a
367
+ hidden ancestor is exported with a warning (its graphics are not rendered, its reference may be blank).
368
+ A file a hidden node reuses from another screen is listed in this screen's `.assets.json` with
369
+ `reusedFrom`.
370
+ `duplicates` is found by a tolerant compare (def ids and numbers within ±0.01 ignored — except an
371
+ embedded image's scale/offset/tile, the numbers of a transform on `<use>`/`<image>` and of a `<pattern>`'s
372
+ x/y/width/height, compared relatively, within 1 %), so the same artwork re-exported under another name
373
+ is one file.
374
+
375
+ Three consequences worth knowing:
376
+
377
+ - **The node id is in the filename**, because a frame NAME does not identify a frame: two frames both
378
+ called `Popup` would otherwise overwrite each other, and a frame whose name ends in a space files as
379
+ `Name___<id>.json`, which looks like a typo. Read `pages/index.json` — it carries each screen's real
380
+ name, page, node id, size and the paths to all three files — rather than reconstructing a filename.
381
+ - **`variables.json` MERGES.** Each pull's slice is unioned in, keyed on each variable's Figma key, so
382
+ pulling screen B never deletes screen A's tokens — replacing the file would leave A's theme
383
+ unresolvable, silently, while the build still rendered from the raw hex beside every binding. The raw
384
+ per-pull slice is kept verbatim as the screen's `.vars.json`. When two screens resolve one variable
385
+ differently the newest wins and the disagreement is recorded under `_conflicts` and in `hygiene`.
386
+ It holds only the variables bound by the pulled nodes — to find the variable behind a library colour,
387
+ look it up on the library catalog: `bun --no-install --no-env-file <scripts>/tokens.js <library>/tokens.json --lookup <hex>` (`ffbc1c`, or a quoted `'#ffbc1c'`:
388
+ an unquoted `#` starts a shell comment).
389
+ - **Assets are named after their Figma layer and deduped by content.** `icons/linear/arrow-down` lands
390
+ as `arrow-down.svg`; the same artwork reached through several instance paths is one file carrying
391
+ every node id that resolved to it; two different icons whose leaf names collide are separated by a
392
+ short content hash, never by overwriting.
393
+
394
+ MCP twin: `figma_export_url` (also accepts a bare node id, not just a URL).
395
+
396
+ ### `--screenshot <id>` — an on-demand PNG of one node
397
+
398
+ ```
399
+ dtwin --screenshot 123:456 # writes design/export/assets/123_456_ref.png — the same
400
+ # path a later --node pull of that frame writes its own
401
+ # reference to, so there is never a second copy
402
+ dtwin --screenshot 123:456 --scale 0.25 # override the default (auto, capped at 2048px on the longest side);
403
+ # writes assets/123_456_shot@0.25x.png, never the reference's name
404
+ ```
405
+
406
+ Every export already carries **one** whole-frame reference PNG per exported root (`reference` in the
407
+ tree), for the codegen agent to self-check the overall page against. That's a weak validation target
408
+ for a **dense** screen, though — eyeballing one small component inside a huge flat image doesn't scale.
409
+ `--screenshot` is the single-node counterpart: pull it for *one* component/instance you just generated
410
+ code for, after `--list`/`--children` gave you its id, and compare it directly.
411
+
412
+ It is cheap next to a real export — it skips `serialize()` and the recursive asset walk entirely, so
413
+ `exportAsync` on the node itself is the only Plugin-API cost — but unlike `--list`/`--children` it
414
+ **writes a file** (it's a tiny export, not a structural index), so it can't be combined with a scope
415
+ flag, another index command, or a read option (`--css` etc. need a node walk this skips).
416
+
417
+ This mirrors Figma's own Dev Mode MCP server: `get_screenshot` is scoped to a single node/selection,
418
+ called **on demand** after `get_metadata`, specifically for post-generation visual validation — not a
419
+ bulk pre-render pass over every node, which would pay the same per-node cost `--no-assets` exists to
420
+ avoid, for a much bigger payload (PNG > structural JSON). If you want a component's reference image
421
+ without picking through instances one at a time, dedupe by the component's own id (or its
422
+ `mainComponent`) rather than pulling every instance of it.
423
+
424
+ It prints `{id, name, type, w, h, scale, png:{w,h}, reference}` on stdout: `w`/`h` are the node's size, `scale`
425
+ the factor the plugin rendered at, `png` the written file's pixel size (keys that do not apply are omitted).
426
+
427
+ MCP twin: `figma_screenshot`.
428
+
429
+ ### `--list-clients` / `--client` — working with two Figma files at once
430
+ ```
431
+ dtwin --list-clients
432
+ ```
433
+ The bridge accepts **one connection per open Figma file**, so a design file and the library it draws
434
+ on can both be connected. Each plugin announces itself on connect (and on every reconnect), so the
435
+ listing shows what you can address:
436
+
437
+ ```
438
+ 2 Figma files connected:
439
+
440
+ c1 "App — Base"
441
+ page "Home" · fileKey KEYBASE · up 12m
442
+ c2 "Acme UI Library"
443
+ page "Tokens" · no fileKey (private-plugin API not in effect) · up 3m
444
+
445
+ Address one with --client <connId | fileKey | part of the file name>, e.g. --client "App".
446
+ ```
447
+
448
+ **`c1`/`c2` are display labels, not stable ids.** They are assigned in *reconnect order* for the
449
+ current bridge's lifetime — whichever plugin's WebSocket lands first becomes `c1`. Two runs of the
450
+ same `--client c1` can select **different Figma files** if the bridge restarted (no `dtwin serve`
451
+ daemon) and the reconnect race went the other way between runs. Prefer the **file name**
452
+ (a substring match, e.g. `--client "App"`) or the **fileKey** when one is available — both are stable
453
+ across restarts; `c1`/`c2` are only for telling two *currently open* files apart in one sitting, exactly
454
+ like `--list-clients`' own listing does. Under a running `dtwin serve` daemon, connIds are assigned
455
+ once per plugin connection and stay stable until that plugin's window disconnects/reconnects.
456
+
457
+ A named target (file name or fileKey) is **waited for**: each open plugin window redials on its own
458
+ clock (every second, one of the three allowed ports at a time), so right after a bridge starts only one of two open files may be connected yet. Once a
459
+ first plugin is connected, `dtwin` gives the named file up to 15 s more (or `--timeout`, whichever is
460
+ shorter) to land before resolving the name; a name that never matches then fails with the list of
461
+ what *is* connected. A bare `c<N>` never waits for its file. The MCP `client` argument gets the same
462
+ wait on every tool: holding the bridge, up to 15 s for a first plugin to connect and then up to 15 s
463
+ more for the name; routed through a daemon, the same two windows, spent by the daemon's bridge.
464
+
465
+ **A fresh bridge settles before it guesses.** For 3.5 s after a bridge's first plugin connects (one
466
+ walk of the three ports at a redial a second, plus a moment for the next window's hello), a command
467
+ with **no** `--client`, or one whose `--client` is a file name — whole or partial: names repeat (two `Untitled`) and a name matches as
468
+ a substring (`App` also matches `App (Copy)`) — is held: a second open file may still be redialling,
469
+ and without it the command would silently go to whichever file landed first instead of being refused
470
+ as ambiguous. The hold outlasts a shorter `--timeout`, which only bounds how long to wait for a first
471
+ plugin. A connId or fileKey is routed at once. A bridge that has had its plugins connected for 3.5 s
472
+ pays nothing — a `dtwin serve` daemon, a running MCP session — until one of them leaves while others
473
+ stay: that window is probably redialling, so the 3.5 s start again. Wherever a command waits like this
474
+ — the one-shot CLI's own bridge, a daemon request that carries a connect window, any bridge still
475
+ settling — a file name, no `--client` and `--list-clients` are also held, within the connect window (or
476
+ the settle, when that ends later), while another connected window has not yet said which file it is
477
+ (usually a few milliseconds; 3 s per window that never says), so a
478
+ refusal or a listing names that file instead of showing it as unidentified; a window that never says is
479
+ listed unidentified. Whatever the settle and those holds add up to, the wait ends at most 3 s past the
480
+ longer of the settle and its window (the window capped at 15 s), counted from when a plugin went live —
481
+ a window that keeps connecting and dropping without saying which file it is cannot hold a command.
482
+ `--list-clients` on its own bridge waits the same 3.5 s, so it lists every window that redialled in
483
+ that time; a settled MCP session or `dtwin serve` daemon reads its list directly, with no wait. A window
484
+ that redials more slowly (Figma throttles a background window's timers) can still land after that;
485
+ with two files open, address the one you mean.
486
+
487
+ `--client` is an **address**, not a scope — it composes with every other flag:
488
+ ```
489
+ dtwin design/base --client "Marketing" --page 12:34
490
+ dtwin design/lib --client "Acme UI" --as-library "Acme UI"
491
+ ```
492
+ Give each file its own output directory, as above, and their exports never collide.
493
+
494
+ **With one file connected, omit it** — nothing changes from the single-file workflow. **With several,
495
+ omitting it is an error** that lists your choices:
496
+
497
+ ```
498
+ 2 Figma files are connected to the bridge — say which one to use.
499
+ c1 "App — Base" fileKey KEYBASE
500
+ c2 "Acme UI Library"
501
+ Pass the connId, the fileKey, or part of the file name (CLI: --client <id|name>; MCP: client: "<id|name>").
502
+ ```
503
+
504
+ That refusal is deliberate. An export pulled from the wrong file is indistinguishable from a correct
505
+ one — it writes the same shape of JSON to the same directory and reports success — so the bridge
506
+ refuses rather than picking, the same call `adb` makes with "more than one device".
507
+
508
+ MCP twins: `figma_list_clients`, plus a `client` argument on every tool. `figma_status` lists the
509
+ roster instead of failing when several are connected.
510
+
511
+ ### `--whoami` — who is connected (and can two files connect at once?)
512
+ ```
513
+ dtwin --whoami
514
+ ```
515
+ Prints both halves of the connection (`connection` is the **addressed** file's socket — the one `--client`
516
+ picked — not whichever connected first): what the **plugin** says it is (`instanceId` minted per plugin
517
+ run, `file`, `page`, and whether `figma.fileKey` is available) and what the **socket** did
518
+ (`connId`, uptime, `connectionsThisRun`, `takeovers`).
519
+
520
+ Use `--list-clients` to see *which* files are connected; use `--whoami` to interrogate **one** of them
521
+ (`--client` selects it). What the fields tell you:
522
+
523
+ | What you see | What it means |
524
+ | --- | --- |
525
+ | `instanceId` **changed** between two calls addressing the *same* file | Figma tore down and re-ran the plugin runtime. The socket survived and the `connId` is unchanged — only the runtime restarted (see the note below) |
526
+ | `fileKeyAvailable: false` | `figma.fileKey` is gated to private plugins, so this file is addressed by `connId` or name instead. Normal for a locally-imported plugin |
527
+ | `takeovers` > 0 | Never happens — connections coexist, so the field is always `0`. It stays so a reader of this field sees `0` rather than the key vanishing |
528
+
529
+ > **Runtime teardown.** At least one comparable project ([`southleft/figma-console-mcp#67`](https://github.com/southleft/figma-console-mcp/issues/67))
530
+ > reports Figma destroying the plugin's JS execution context every 1–3 minutes regardless of focus.
531
+ > This repo's own live runs contradict that (a 25-page `--all-pages` export ran >15 min to
532
+ > completion — hence `TIMEOUTS.exportAll`), so it may be setup-specific. If you plan to leave a
533
+ > connection **idle** while working in another file, check it with `--whoami` first: a changed
534
+ > `instanceId` is the symptom. Related and better attested: an *occluded* Figma window is fine, a
535
+ > **minimized** one gets its renderer suspended.
536
+
537
+ > **Background tab.** Measured live (one page through the daemon): with the exporting
538
+ > file's tab in front, 31–38 progress ticks and ~2,500 nodes per 10 s; with another file's tab in
539
+ > front, 3–5 ticks and ~15 nodes per 10 s — about 100× slower; then 27 ticks in the first 10 s after
540
+ > switching back and the normal 31–38 from the second window on. The export completes either way,
541
+ > but a `--timeout` sized for the foreground fires. Figma Desktop is an Electron app; Electron's
542
+ > `backgroundThrottling` (default `true`) is "Whether to throttle animations and timers when the
543
+ > page becomes background", and the plugin's walk advances on Figma API awaits, so it is the tab
544
+ > that is throttled. Keep the exporting file's tab in front.
545
+
546
+ MCP twin: `figma_whoami`.
547
+
548
+ ### `--list-libraries` — which library owns a token
549
+ ```
550
+ dtwin --list-libraries
551
+ ```
552
+ Prints an aligned table of the libraries this file uses — the local file's own published assets plus
553
+ every **enabled** team library — with each one's variable collections (and variable counts) and how
554
+ many of its components this file uses. It's the library-scoped sibling of `--list`: no tree export, no node
555
+ properties, no assets, same `--timeout`, prints to stdout and writes nothing — but the slowest read there
556
+ is, a few seconds up to ~15 s (about 2 s on a file with no libraries enabled), because it walks every
557
+ instance in the file and makes one Figma call per enabled library variable collection.
558
+
559
+ **Waiting for the plugin.** Every command that needs Figma first waits for the plugin to connect:
560
+ `--timeout <seconds>` bounds that wait as well as the command itself. Without the flag it is 10
561
+ minutes at a terminal and 90 seconds when stderr is not a TTY (an agent's shell, CI) — then
562
+ `no Figma plugin connected within Ns`, exit 1, with the next step. A first word that is not a verb
563
+ but is an obvious guess at one (`dtwin export`, `dtwin ls`, `dtwin check`) is refused with the real
564
+ verb instead of being taken as an output folder; `dtwin pull export` still exports into `./export`.
565
+ `--timeout` takes at most 2147483 seconds (about 24 days, the longest delay one JavaScript timer holds);
566
+ `Infinity`, `0`, a negative or a larger number is refused with a usage error rather than run as an
567
+ export that times out at once.
568
+
569
+ Three honest limits, because they change what the numbers mean:
570
+ - **Component counts are usage-derived.** Figma exposes **no API to enumerate a library's contents**,
571
+ so the count is "components of that library used in *this* file", not the library's size.
572
+ - **Libraries can only be enabled from the Figma UI** (Assets panel → Libraries) — no API can enable
573
+ one. An export is therefore silently scoped to whatever was enabled at export time; if a component
574
+ looks unresolved, an unenabled library is the first thing to check.
575
+ - **An empty result is normal**, not a failure: free plans don't expose library variable collections,
576
+ and a file with no library enabled genuinely has none. The output says so rather than printing an
577
+ empty table. It is also what a **stale plugin** looks like — see the re-import callout above.
578
+
579
+ ### `--as-library <name>` — the COMPLETE catalog of one library
580
+
581
+ ```
582
+ # open the LIBRARY file in Figma (not the design file that consumes it), then:
583
+ dtwin pull --as-library "Acme UI" # -> design/export/libraries/<slug>-<fileKey8>/
584
+ ```
585
+
586
+ `--list-libraries` tells you a library exists and how many variables its collections hold.
587
+ `--as-library` gets you the **values**: every variable with its full per-mode values, every paint /
588
+ text / effect / grid style, and every component with its **real** property definitions. Output lands
589
+ in `design/export/libraries/<slug>-<fileKey8>/` and **never touches `design-system/`** — the two catalogs
590
+ describe different Figma files and answer different questions.
591
+
592
+ **Why it must run inside the library file.** From a consuming file the Plugin API simply does not have
593
+ this data: `getVariablesInLibraryCollectionAsync` returns `name`/`key`/`resolvedType` with **no
594
+ values**, and there is no API to enumerate a library's components at all. Inside the library every one
595
+ of those objects is *local*, so the ordinary local reads return the whole thing — no extra permissions,
596
+ no paid plan, no import step.
597
+
598
+ > **Deliberately not used: `importVariableByKeyAsync`.** It *would* resolve library values from a
599
+ > consuming file, and it is the obvious-looking answer. It is also a **write**: the `import*ByKeyAsync`
600
+ > family materialises into the current document (the plugin typings say exactly that of its sibling
601
+ > `importShaderAsync`), which is why those calls fail in read-only/Dev Mode. Using it here would make a
602
+ > read-plane command subscribe your file to hundreds of variables. This path reads and writes nothing.
603
+
604
+ **Four honest limits**, because they change what the output means:
605
+ 1. **`publish` is per-object, and only meaningful here.** Each variable/style/component carries
606
+ `publish: current | changed | unpublished` (`current` = published and in sync, `changed` =
607
+ published but edited since, `unpublished` = never published). Figma answers `UNPUBLISHED` for
608
+ *everything* when asked from a consuming file or a branch, so the normal `--design-system` pull
609
+ deliberately omits the field rather than emit a confident wrong answer.
610
+ 2. **What the last publish contained cannot be listed.** `publish` describes each *local* object's
611
+ relationship to the library. A component deleted here but still live in the published library is
612
+ invisible to any API — so this is the library file's **current state**, not a diff of what
613
+ consumers see.
614
+ 3. **Nothing is filtered for you.** Unpublished drafts are exported and *labelled*, never dropped:
615
+ `hiddenFromPublishing` is forward-looking publish config, not publish state, so filtering on it
616
+ would be wrong in both directions.
617
+ 4. **One library per run.** A library is a file, so exporting several means running this once per
618
+ library file. Rows accumulate in `design/export/libraries/index.json` — a second library is added, never
619
+ substituted. A variable that aliases into a *different* library stays an unresolved reference until
620
+ that library is exported too.
621
+
622
+ **Re-running is safe.** Every file is rewritten in place with a fresh stamp, and the directory is keyed
623
+ on `fileKey` (which survives a rename) so a renamed library keeps its directory instead of forking.
624
+ Files a previous export produced and this one did not are **reported as `STALE:`, never deleted** — a
625
+ read command does not remove files it did not create.
626
+
627
+ **How the two catalogs fit together.** They stay separate and join on `key` (durable cross-file
628
+ identity, present on variables, collections, styles and components — names collide across libraries):
629
+
630
+ | | `design/export/design-system/` | `design/export/libraries/<lib>/` |
631
+ |---|---|---|
632
+ | Which file | the design file you build screens from | one library file |
633
+ | Variables | the subset your screens reference | **all**, full per-mode values |
634
+ | Components | usage-derived; props *inferred* from instances present | **all**, real property definitions |
635
+ | Styles | local only | **all**, with `key` |
636
+ | Use it for | **codegen** — emit only what is used | **lookup/enrichment** — resolve what exists |
637
+
638
+ Emit CSS from the design file's catalog, not the library's, or you ship every unused primitive in the
639
+ library. The library catalog is what you consult to answer "what else does this token family offer?"
640
+ and "what props does this component *really* take?".
641
+
642
+ Because the layout matches `design-system/`, existing tooling runs on it unchanged:
643
+ ```
644
+ bun design-to-code/tokens.ts design/export/libraries/acme-kit-ab12cd34/tokens.json ./out # DTCG + CSS, no special-casing
645
+ ```
646
+ The generated `tokens.css` / `theme.css` start with a provenance line (`designtwin-source: <dir>/<input> · <n>
647
+ variables · sha256 <12>`); `tokens.js <input> --check <file>` exits 0 when the file is current, 1 when the
648
+ input has changed since (stale), 2 when the file has no such line (regenerate once) or names a different source file (pass that one; a design-system `tokens.json` and a library's are told apart by their parent directory) or the command line is wrong (usage errors are 2 there, never 1). `--lookup <hex>`
649
+ (repeatable; `ffbc1c` or a quoted `'#ffbc1c'`) lists the COLOR variables with that exact value, per mode, following aliases.
650
+
651
+ Scope note: the **design system** (variables, styles, component catalog) always spans the whole
652
+ file. **Frame trees** default to the current page; `--all-pages` walks every page (each screen is
653
+ tagged with its `page` name). All-pages can be large — the CLI is the right tool for it because it
654
+ streams to disk, not into the context window. `--design-system` narrows the OTHER way: it skips the
655
+ frame walk (and therefore the assets a walk would export) and writes only `design-system.json` +
656
+ `design-system/` — tokens, styles, local + library components, hygiene. One honest limit: on a file
657
+ that CONSUMES a library, `--design-system` returns that file's OWN tokens, styles and components plus only
658
+ the library (remote) variables something in the file references (a page pull adds the ones its nodes
659
+ bind) — never the library's catalog. For that, open the LIBRARY file and run `--as-library "<name>"`
660
+ (CLI only); the tell is `remote: true` on the variables and `hygiene.json`'s count line. It shares
661
+ `--selection`/`--all-pages`/`--page`/`--as-library`'s scope slot (pass only one) and, like `--list`/`--list-libraries`,
662
+ refuses the read-option flags (`--css`/`--measurements`/…) since there is no node walk for them to apply to.
663
+ It waits for the plugin to connect, pulls, writes files to `design/`, and exits. The agent then
664
+ Reads those files — big payloads live on disk, not in the context window.
665
+
666
+ **`visuals`.** Every catalogued component/variant carries its own paint under `visuals` on each
667
+ `components.local.json` entry: `{ fills, strokes, effects, radius, opacity, blendMode }` — the same
668
+ fields `ComponentNode`/`ComponentSetNode` carry as any other node. This is always on, not a flag:
669
+ unlike `--css`/`--measurements`/asset export, these six are plain synchronous property reads (Figma's
670
+ Plugin API only marks the genuinely expensive calls `Async` — `getCSSAsync`, `exportAsync` — and
671
+ fills/strokes/effects/cornerRadius/opacity/blendMode aren't among them), so there's no walk-avoiding
672
+ reason to gate them the way `--design-system` gates a page walk. **Two honest limits:**
673
+ 1. It's the component's own base look — a specific instance's overrides elsewhere in the file are
674
+ not reflected, and a variant SET's `visuals` is the SET wrapper node's own paint (Figma's purple
675
+ dashed selection chrome), not a per-variant one. Pass `--variant-visuals` to also walk each set's
676
+ variants and attach their real `layout`/`fills`/`radius`/`tokens`/`css` under `entry.variants[]` —
677
+ opt-in because it costs one extra node walk per variant. A standalone `COMPONENT` (not inside a
678
+ set) gets the same walk, attached as `entry.node` instead — there's no set to enumerate variants
679
+ of. `entry.props` (the prop *schema*) is unaffected either way; a variant's
680
+ `componentPropertyDefinitions` throws, so per-variant `values` there come from `variantProperties`
681
+ or, failing that, the variant name Figma guarantees is `"Prop=Val, ..."`. The node tree itself never
682
+ lands in `components.local.json` — it is written to a sibling
683
+ `design-system/components/<name>__<id>.json`, pointed at by that entry's `variantsFile` (sets) or
684
+ `nodeFile` (standalone components) — absent when nothing was exported for that entry. `entry.variants[]`
685
+ in the slim catalog keeps only `id`/`name`/`key`/`values`. Fetch one component's real node tree(s) with
686
+ `bun design-to-code/get-component.ts design/export/design-system/components.local.json <key|id|name>`.
687
+ 2. It only covers components DEFINED in this file. Components consumed from a published library
688
+ (the `remote:true` entries, recovered via instance-walk — see the honest-limits note above) are
689
+ not covered here; pull `--as-library` on the *source* library file for those. There is no
690
+ client-side fix for this — Figma exposes no API to enumerate a library's contents from a
691
+ consuming file (see `--list-libraries` above), so this is a hard ceiling, not a gap.
692
+
693
+ **Duplicate component names.** The catalog build already flags a component name reused elsewhere in
694
+ the file (e.g. two unrelated "Action Buttons" component sets) as `duplicate component name: '<name>'`
695
+ in `hygiene`. `--design-system`/full pulls echo `hygiene` to stderr as soon as the write completes
696
+ (prefixed `hygiene (N):`), so this shows up in the run itself instead of requiring a manual
697
+ `hygiene.json` diff.
698
+
699
+ Allowlist it in Claude Code so it runs without a prompt (`.claude/settings.json`):
700
+ ```
701
+ { "permissions": { "allow": ["Bash(dtwin:*)"] } }
702
+ ```
703
+
704
+ ### Keep the connection open: `--serve` (daemon)
705
+ Each one-shot run above waits for the plugin to reconnect. For several pulls in a row, hold the bridge
706
+ open and let later invocations reuse it:
707
+ ```
708
+ dtwin --serve # holds the bridge open until stopped (Ctrl-C works too)
709
+ dtwin --daemon-status # is one up, and is the plugin connected?
710
+ dtwin --stop # or: dtwin stop
711
+ ```
712
+ Every ordinary command detects a running daemon and **routes through it automatically** — same
713
+ invocations, no reconnect, no second bridge to collide on 8787.
714
+
715
+ Why a daemon rather than "a flag that just leaves the connection open": a long-running CLI has no way
716
+ to receive further commands, so it would hold the port while being unreachable — blocking the MCP
717
+ server and every other CLI call. Instead one process (`--serve`) owns the bridge and each later
718
+ invocation is a thin client over a unix socket, `designtwin-<port>.sock`, in a directory only you can
719
+ access: `$XDG_RUNTIME_DIR` when it is set (and private), else `$TMPDIR/designtwin-<uid>/`, created
720
+ `0700`. The socket takes no token, so dtwin refuses that directory if it is a symlink, owned by
721
+ another user, or open to group/other — remove it, or point `XDG_RUNTIME_DIR` or `TMPDIR` at a
722
+ directory only you can access. Until then ordinary commands and the MCP server run without a daemon
723
+ (the refusal is printed once on stderr), `--serve` / `--stop` refuse, and `dtwin doctor` fails its
724
+ daemon check with the same message. The location follows `XDG_RUNTIME_DIR` / `TMPDIR`, so two processes
725
+ started with different values (a terminal, and an MCP server launched by an app) see different daemons.
726
+ A daemon started by an earlier dtwin version listens at `$TMPDIR/designtwin-<port>.sock`: `--stop`
727
+ stops it too, and a port-in-use error names that socket.
728
+
729
+ > **On Windows** the daemon listens on a named pipe, `\\.\pipe\designtwin-<port>-<hash>`, named from the
730
+ > bridge token. A pipe cannot be made private to your account the way the socket's directory is (its
731
+ > default access lets any local user open it, and a client cannot check who created it), so before any
732
+ > request both ends prove they hold the bridge token — a mutual HMAC challenge over fresh nonces from each
733
+ > side (`bridge/src/daemon-auth.ts`). A process that cannot prove it is dropped without a word, and a
734
+ > squatter holding the pipe name is refused by every command (nothing is sent to it): `--serve` says so,
735
+ > and commands fall back to opening their own bridge. What it cannot stop: a squatter can still deny you
736
+ > the daemon (move it with `dtwin --rotate-token`), and the pipe is only as private as the token file —
737
+ > `%APPDATA%\design-twin\bridge-token` is per-user; `dtwin doctor` warns when the token comes from a file
738
+ > outside your profile. With no token saved yet there is nothing to name the pipe with: start a bridge once
739
+ > first. `--rotate-token` / `--forget-token` stop a running daemon first (on every OS), since nothing
740
+ > could find it under the new token while it still held the port.
741
+
742
+ Behaviour worth knowing:
743
+ - Requests are **forwarded as they arrive**; the **plugin** decides what may run alongside what, in each
744
+ file, for every caller (a daemon client, the MCP server holding the bridge, the plugin window's own
745
+ buttons). Each command declares how it runs (`bridge/src/commands.ts` `runKind`): exports, screenshots
746
+ and writes run one at a time, in arrival order; the selection and the listings (`list pages` / `list` /
747
+ `list children`, `list libraries`) wait for the run ahead of them, because they read layers and an
748
+ export's catalogue walk briefly hides hidden layers inside instances (`skipInvisibleInstanceChildren`);
749
+ the identity reads (`ping`, `whoami`) answer at once, even during an export. Two files are two plugins,
750
+ so a pull of one file never waits on another file's export.
751
+ - Every request id is unique per bridge (`r<nonce>-<seq>`) and a reply only settles the request on the
752
+ connection it was sent to, so a late reply from an abandoned earlier request cannot answer the
753
+ next one. When the plugin is connected but sends nothing the stall message says so ("the plugin is
754
+ connected but sent nothing for '<cmd>' … it is busy"): an earlier export is still running in that file,
755
+ or a long cold first read of a large page. Retry, or use a warm plugin (`dtwin serve`).
756
+ The plugin sends a `start` frame when a bridge run begins and a `queued` frame when it arrives behind
757
+ another run, so "connected but silent" is distinguishable from "waiting its turn".
758
+ - An **abandoned request is cancelled in the plugin**: when a command times out, when the one-shot
759
+ stall check gives up, when the bridge shuts down, or when the client that asked (a Ctrl-C'd CLI, an
760
+ ended MCP session) disconnects from the daemon before its reply, the bridge tells the plugin to stop
761
+ that work rather than leaving it to finish for nobody (the plugin checks for the cancel at every node, so it
762
+ stops within a node, not at the end of the page; one long single `exportAsync` still runs to its end) —
763
+ and one the plugin still has queued is dropped without ever running. The next run in that file starts
764
+ when the abandoned one has actually stopped, not when the cancel was sent.
765
+ - `--serve` / `--stop` / `--daemon-status` each own the whole invocation; combining one with a pull or
766
+ a read option is refused rather than silently dropping the export you typed.
767
+ - A stale socket left by a crashed daemon reads as "no daemon" and is replaced, rather than making
768
+ every later run fail against a file nobody is listening on.
769
+ - A second `--serve` is **refused**, not allowed to steal the live socket.
770
+ - **Idle shutdown after 120 min** (`FIGMA_DAEMON_IDLE_MIN`, `0` disables). A daemon outlives the
771
+ terminal that started it, so this reaps abandoned ones instead of leaving port 8787 held by a
772
+ process you've forgotten. It is deliberately generous, the clock resets on every request, and a
773
+ request in flight holds it off entirely — a 15-minute `--all-pages` export is never cut short by
774
+ its own daemon. `--daemon-status` shows how much of the window is used.
775
+ - **An MCP server that owns the bridge exits when its client goes away** (stdin closes, or stdout breaks),
776
+ at once, unless another process has used its shared socket; then it keeps serving and idles out on the
777
+ same `FIGMA_DAEMON_IDLE_MIN` window (default 120 min, `0` = never), since other sessions route through
778
+ it. This deliberately departs from the MCP stdio binding's "exit promptly when stdin closes". SIGINT,
779
+ SIGTERM and SIGHUP cancel in-flight reads, remove the socket and exit 0, even while serving others.
780
+
781
+ ## Write / interactive: figma-mcp (MCP over stdio)
782
+ **Not registered in this repo** — there is no `.mcp.json` here on purpose, so the MCP never starts
783
+ while you are working *on* the bridge. Register it in the project you are *building*: a `.mcp.json`
784
+ there that runs `bridge/src/figma-mcp.ts` on Bun — `"command"`: the absolute path of `bun`, `"args"`:
785
+ `["--no-install", "--no-env-file", "<abs path>/bridge/src/figma-mcp.ts"]` — under the key `designtwin` (`dtwin init --mcp`
786
+ writes exactly that, with the `bun` and the clone that ran it; an absolute `bun` keeps working when Claude Code is
787
+ started without `~/.bun/bin` on its PATH). Claude Code then launches it over stdio, and its tools surface as
788
+ `mcp__designtwin__<tool>` (e.g. `mcp__designtwin__figma_status`).
789
+ No token needs to go in that `.mcp.json`: the MCP server reads the same per-user stored token the CLI
790
+ does, so the one you already pasted into the plugin keeps working (set `FIGMA_BRIDGE_TOKEN` in the
791
+ registration only if you deliberately want the MCP on a *different* token from the CLI).
792
+ The server also hosts the bridge WebSocket the plugin connects to. Built on `@modelcontextprotocol/sdk`
793
+ with the `McpServer` + `registerTool` + zod pattern (tool schemas are zod, validated per call).
794
+
795
+ Tools (15): `figma_status`, `figma_list_clients`, `figma_whoami`, `figma_get_selection`, `figma_list_libraries`,
796
+ `figma_list_pages`, `figma_list_children`, `figma_export_full`, `figma_export_design_system`, `figma_export_selection`, `figma_export_url`,
797
+ `figma_screenshot` (an on-demand PNG of ONE node — the single-node visual-validation counterpart to the
798
+ export tools' whole-frame reference PNG; see `--screenshot` above),
799
+ `figma_write` (batch of safe ops — createFrame / createText / setFill / setText; **no arbitrary code
800
+ execution**, unlike some community servers; a `fill` / `color` must be a hex colour — `#rgb`, `#rgba`,
801
+ `#rrggbb` or `#rrggbbaa` with the `#`, never a name like `red` — and anything else is refused (by the
802
+ schema here, and by the plugin for a write from another client) instead of painted as a wrong colour; `dryRun: true` returns a preview of what each op would
803
+ create or overwrite without touching the file — there is no undo from the MCP side, so preview anything
804
+ that overwrites; refused outright when the file is in read-only Dev Mode), and two that read the export already on disk and
805
+ need no Figma connection — `design_get_component` (one component's variant node trees) and
806
+ `design_drift_lint` (the component map vs the export; see below). The frame-walking export tools accept opt-in read flags
807
+ `css` / `measurements` / `pluginData` / `motion` / `sharedData`; `figma_export_design_system` doesn't
808
+ take them (it never walks a node — see below) but DOES take `variantVisuals`, since the component
809
+ catalog it builds is exactly what that flag enriches.
810
+
811
+ ### `writeToDisk` — the export path that doesn't go through the context window
812
+ Every export tool takes `writeToDisk: true` (plus an optional `outDir`). It writes the export through
813
+ the same `write-out.ts` the CLI uses — same layout, by construction — and returns a **compact index**
814
+ (counts + file paths) instead of the node payloads. The agent then Reads/Greps those files at whatever
815
+ granularity it needs.
816
+
817
+ Use it for anything past a quick look. Two things make it not optional:
818
+ - **Asset bytes are never returned inline** (they'd dump megabytes of base64 into context), so
819
+ `writeToDisk` is the *only* way to get `assets/` out of the MCP path.
820
+ - **Inline MCP results are capped** (25k tokens by default, `MAX_MCP_OUTPUT_TOKENS`, and never above
821
+ 48,000 characters — under Claude Code's own 50,000-character persist threshold). The server
822
+ measures the result before returning it: one that would not fit is **written to disk on its own**
823
+ and the index's `note` says so (a real design-system export is ~590 KB ≈ 147k tokens). Passing
824
+ `writeToDisk: false` opts out of that — an oversized result is then an error naming the size,
825
+ never a payload cut off mid-JSON.
826
+
827
+ Every export result (written or inline) carries `sourceFile` (the Figma file it came from) and
828
+ `durationMs {total, request, write}`. When an implicit spill replaces a file whose content differs (the stamps
829
+ `exportedAt`/`generatedAt` aside), the previous one is kept one level as `<file>.prev` and the note names it. A screen
830
+ spill (`figma_export_url`/`figma_export_selection`) keeps ONLY `<screen>.json.prev` (`wrote.prev`) — its `.vars.json`,
831
+ `.assets.json`, `pages/index.json` and the merged `variables.json` are replaced with no copy, so a restored `.prev` is
832
+ paired with the NEW siblings. A page/full/design-system spill keeps one per changed JSON file under `pages/` and
833
+ `design-system/` plus `design-system.json`, listed in `wrote.prevKept`, with the count. `figma_status` reports
834
+ `snapshot` — the export on disk, the verdict `dtwin doctor`'s export line words: `exportedAt`/`ageMs`/`sourceFile`/`file`
835
+ of its NEWEST stamp (whichever source was pulled last — not the design system's by default), `warning` when no source
836
+ carries a usable stamp, `stale` (older than `staleAfterMs`, 24h), `sources[]` (`{kind: "screens"|"design-system"|
837
+ "page-walk"|"screen-doc", sourceFile, count?, exportedAt, ageMs, problem?, file?}` — the screens of each Figma file, `sourceFile:
838
+ null` for rows an older bridge did not stamp) and `problems[]` (files there but unusable); `{file, error, problems}` when
839
+ the only export file there is unusable; null when nothing is exported — plus
840
+ `lastScreenExport` (the newest screen on disk: name, id, file, exportedAt, ageMs) and `lastWrite` (this server process's
841
+ last export: tool, time, whether implicit, file, `prev`, `prevKept` count). `figma_whoami`'s `connection` is the addressed
842
+ client's. `page`, `screens` and `catalogs` arguments accept an array, a JSON-encoded array string or a bare string.
843
+ `design_drift_lint` runs the CLI's own lint run: `screens: string[]` (the CLI's `--screen`) adds `screenCoverage`
844
+ with its warnings, `catalogs: string[]` (the CLI's `--catalog`) adds catalogs the map may point into, both under the
845
+ working directory. A file the CLI would refuse (exit 2: a missing or non-catalog file, a broken `catalogs` entry, a
846
+ non-screen) is a tool error with the CLI's message; an unreadable discovered catalog is a `catalog-unreadable` warning. An interrupted tool call (Claude Code's cancel,
847
+ or the session ending) cancels the in-flight read in the plugin; `figma_write` is never cancelled, since a
848
+ write cannot be un-applied.
849
+
850
+ `outDir` resolves against **the directory the MCP server was started in** — i.e. the project you're
851
+ building, not this repo. Default `FIGMA_EXPORT_DIR` or `design/export/`, the same var `figma_status` reads. The `design_*` tools'
852
+ `exportDir` defaults the same way (`FIGMA_EXPORT_DIR`, else the project's export — `design/export/`, or `design/` on
853
+ the older flat layout), and `design_drift_lint`'s `map` to `design/codeconnect.local.json` (or a root
854
+ `codeconnect.local.json` an older project still has).
855
+ With `writeToDisk: true` or an explicit `outDir` it is checked before the export (inside the project
856
+ lexically or by real path), so a refused path never costs a read; a server started at the filesystem root accepts none.
857
+
858
+ > **One bridge, shared.** Port 8787 has one owner at a time. The MCP server and `dtwin serve` both
859
+ > publish the bridge they own on a local socket, and everything that starts later — a `dtwin` command,
860
+ > a second Claude Code session's MCP server — routes through it instead of binding the port again.
861
+ > When the owner exits, the next MCP call opens the bridge itself (the plugin reconnects on its own).
862
+ > The one holder that does NOT share is a plain one-shot `dtwin pull` with no daemon: while it runs,
863
+ > another `dtwin` command that needs the port exits with `EADDRINUSE`, while an MCP server stays up and
864
+ > only that tool call errors — retry it once the pull is done. `writeToDisk` is still the simplest way
865
+ > to get files from inside an MCP session. On Windows the shared socket is a named pipe guarded by a
866
+ > token handshake (see the `--serve` section); sharing otherwise works the same.
867
+
868
+ **Look before you pull.** `figma_list_pages` (and `figma_list_children` to drill into one frame) return
869
+ a cheap structural index — ids, names, types, sizes; no recursion, no assets, no node properties. Every
870
+ other read here deep-serializes, so without them the only way to learn what a file holds was to export
871
+ all of it: measured 12+ minutes and tens of MB straight into the context window on a real design
872
+ system. Use the index to pick a target, then `figma_export_full({ page: ["<id>"] })` — or
873
+ `skipAssets: true`, which is close to free here since asset bytes are stripped from MCP results anyway.
874
+
875
+ `figma_list_libraries` (optional `client`, like every Figma tool) is a side step, not part of that order: it answers **which design
876
+ libraries** the file draws on — which library owns a token, or what to name in an `--as-library` pull. Its result is deliberately
877
+ compact — libraries, their variable collections, and usage-derived component counts — because a
878
+ discovery call that costs context defeats its own purpose. The same three limits apply as for the CLI
879
+ flag above (usage-derived counts, UI-only library enablement, empty-is-normal), and the tool reports
880
+ them in-result rather than leaving an empty list ambiguous. It is a discovery read (no export) but the
881
+ slowest one — a few seconds up to ~15 s (about 2 s on a file with no libraries enabled): it walks every
882
+ instance and makes one Figma call per enabled library collection. Recommended order for one
883
+ screen: `figma_status` → `figma_list_pages {depth:1}` → `figma_list_children` → `figma_export_url
884
+ {url:"<node id>", writeToDisk:true}`; `figma_export_full({ page: [...] })` only for a whole page, and
885
+ `figma_list_libraries` when you need to know which library owns a token.
886
+
887
+ **Only want the design system?** `figma_export_design_system` skips the page/frame walk (and the
888
+ assets that walk would produce) and returns just variables/styles/components/hygiene — the MCP twin of
889
+ the CLI's `--design-system` flag. Same `writeToDisk`/`outDir` contract as the other export tools; same
890
+ one honest gap, too: on a file that CONSUMES a library this returns that file's own tokens plus only the
891
+ library variables something in it references — never the library's catalog (that is `--as-library`, CLI
892
+ only, run with the library open).
893
+
894
+ ### "Paste a link and ask about it" (`figma_export_url`)
895
+ CLI twin: `dtwin pull --node <id>` (see above) — same underlying `exportNode` op, so a pull from
896
+ either surface lands in the identical shape.
897
+
898
+ Keep Claude Code and the MCP running for the whole session; the plugin stays connected to your
899
+ open file. Then just paste a Figma link and ask — Claude pulls the node the link points to and
900
+ answers from live data, as many times as you like (free, no per-request cap):
901
+ ```
902
+ you: here's the header — https://figma.com/design/KEY/App?node-id=123-456 — what spacing does it use?
903
+ → Claude calls figma_export_url(url); the plugin selects node 123:456 and exports it live.
904
+ ```
905
+ It reads the **live open file** — it can't fetch a link cold. The file the link points to must be
906
+ open in the Figma desktop app with the plugin running. A URL carries `node-id` in dash form
907
+ (`123-456`); the plugin normalises it to Plugin-API colon form (`123:456`) and switches pages /
908
+ scrolls the node into view so you can see what's being read.
909
+
910
+ Pre-approve tools in `.claude/settings.json`:
911
+ ```
912
+ { "permissions": { "allow": ["mcp__designtwin__*"] } }
913
+ ```
914
+
915
+ ## Notes / limits
916
+ - The Figma file must be **open** with the plugin running for either path (never headless).
917
+ - One process owns port 8787 at a time. `figma-mcp` and `dtwin serve` share the bridge they own
918
+ (later `dtwin` commands and MCP servers route through it); only a one-shot pull does not. A `dtwin`
919
+ command that then finds the port taken exits immediately with a clear `EADDRINUSE` message; an MCP
920
+ server stays up and that one tool call errors — retry it after the pull.
921
+ - Windows: the daemon is a named pipe named from the bridge token, and every connection proves the token
922
+ first (`daemon-auth.ts`). A daemon started under an EARLIER token cannot be found or stopped by name —
923
+ `--rotate-token` stops it before rotating, so that only happens to one started with a different
924
+ `FIGMA_BRIDGE_TOKEN`.
925
+ - One WebSocket frame may be at most `FIGMA_BRIDGE_MAX_PAYLOAD_MB` (default 128; clamped just under
926
+ 4096). A value that is not a number above 0 falls back to 128 with a warning — there is no "unlimited".
927
+ An export over it fails with a message quoting the cap in force.
928
+ - Image assets travel as base64 (not JSON int-arrays) — ~3.5–4x smaller on the wire.
929
+ - Profiling: `DTWIN_TIMINGS=1` prints one `[dtwin] timings {…}` JSON line on stderr at the end of a
930
+ `dtwin` command; `DTWIN_TIMINGS=<file>` appends it to that file instead (JSON Lines). It holds the
931
+ connect / export / write split, when each plugin phase (`queued`, `page`, `designSystem`, `assets`, …)
932
+ was first and last heard, the queue wait, the reply's asset count and decoded bytes, and peak RSS —
933
+ counts and times only, never a file, page or layer name, a key or document content. Off by default.
934
+ - v1 sends each response as a single WebSocket frame. If you hit very large pages, add chunking
935
+ (see ARCHITECTURE.md "chunk large payloads").
936
+ - Hidden-graphic reuse (`assetFrom`) compares the node's own rotation/flip and box only, not an
937
+ ancestor's, and not which slot of its parent the icon sits in (two icons of one component in one
938
+ parent variant count as one, even if that variant colours them differently); across screens it depends on pull order (a screen pulled before its twin's screen keeps
939
+ `assetSkipped:"hidden"`, and re-pulling it later changes its JSON with nothing changed in Figma), and
940
+ each `.assets.json` row offers one `reuseKey`. With an old plugin still loaded, the geometry warning
941
+ may name hidden nodes — re-run the plugin.
942
+ - A cross-screen twin comes from any screen JSON on disk, however old: a screen pulled before its icon
943
+ component was redrawn hands its old file to a hidden node of a screen pulled today (the IR carries no
944
+ component version to check). Re-pull every screen in a sync.
945
+ - `list children` titles a colliding row from its "Page Title" slot; when that search runs past its node
946
+ cap (a very large frame), the row's `title` falls back to the first text — which can differ from the
947
+ title the pages index gives the same screen after a pull. `distinctTexts` reads the same way, with a
948
+ budget of 20000 node reads for the whole listing (a group with a row it cuts off gets none — see the warning).
949
+ - Write ops are a deliberately small, explicit set — extend `applyWrite()` in
950
+ `../figma-plugin/src/writes.ts` (rebuild `code.js`) and add a matching `registerTool` here as needed.