@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.
- package/LICENSE +21 -0
- package/README.md +949 -2
- package/package.json +49 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +940 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
package/README.md
CHANGED
|
@@ -1,3 +1,950 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Design Twin — bridge
|
|
2
2
|
|
|
3
|
-
|
|
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.
|