@design-twin/dtwin 0.1.0 → 0.1.1
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/README.md +34 -1
- package/figma-plugin/code.js +86 -4
- package/figma-plugin/ui.html +39 -3
- package/package.json +1 -1
- package/src/commands.ts +14 -1
- package/src/figma-mcp.ts +35 -6
- package/src/figma-pull.ts +5 -0
- package/src/init.ts +32 -7
package/README.md
CHANGED
|
@@ -18,6 +18,14 @@ bun add -g @design-twin/dtwin # puts `dtwin` in ~/.bun/bin (on your PATH via t
|
|
|
18
18
|
inside it — `dtwin init` prints the full path to import) and the design-to-code bundles the MCP server's
|
|
19
19
|
`design_get_component` / `design_drift_lint` load (`layer/`).
|
|
20
20
|
|
|
21
|
+
Or with Homebrew (macOS, Linux), which installs Bun for you and keeps both up to date with `brew upgrade`:
|
|
22
|
+
```
|
|
23
|
+
brew install its-magdy/tap/dtwin
|
|
24
|
+
```
|
|
25
|
+
The formula installs the same npm package into Homebrew's prefix; every release updates it. Paths `dtwin init` writes
|
|
26
|
+
(the `.mcp.json` server entry, the plugin manifest it prints) are spelt through `$(brew --prefix)/opt/dtwin/…`, which
|
|
27
|
+
survives an upgrade — not the versioned `Cellar/dtwin/<version>/` folder the next `brew upgrade` deletes.
|
|
28
|
+
|
|
21
29
|
Or from a clone (to work on the code):
|
|
22
30
|
```
|
|
23
31
|
git clone https://github.com/its-magdy/design-twin.git && cd design-twin
|
|
@@ -811,13 +819,38 @@ execution**, unlike some community servers; a `fill` / `color` must be a hex col
|
|
|
811
819
|
`#rrggbb` or `#rrggbbaa` with the `#`, never a name like `red` — and anything else is refused (by the
|
|
812
820
|
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
|
|
813
821
|
create or overwrite without touching the file — there is no undo from the MCP side, so preview anything
|
|
814
|
-
that overwrites; refused outright when the file is in read-only Dev Mode
|
|
822
|
+
that overwrites; refused outright when the file is in read-only Dev Mode — and **refused unless the designer has
|
|
823
|
+
ticked "Allow edits"** in the plugin window, see below), and two that read the export already on disk and
|
|
815
824
|
need no Figma connection — `design_get_component` (one component's variant node trees) and
|
|
816
825
|
`design_drift_lint` (the component map vs the export; see below). The frame-walking export tools accept opt-in read flags
|
|
817
826
|
`css` / `measurements` / `pluginData` / `motion` / `sharedData`; `figma_export_design_system` doesn't
|
|
818
827
|
take them (it never walks a node — see below) but DOES take `variantVisuals`, since the component
|
|
819
828
|
catalog it builds is exactly what that flag enriches.
|
|
820
829
|
|
|
830
|
+
### "Allow edits" — the plugin changes nothing until the designer opts in
|
|
831
|
+
The plugin window has an **Allow edits** checkbox, **off by default**. While it is off the plugin only reads:
|
|
832
|
+
every `figma_write` batch is refused whole, before any op runs, and the tool returns this error (nothing in the
|
|
833
|
+
file changed):
|
|
834
|
+
|
|
835
|
+
> Edits are off in the Design Twin plugin — tick 'Allow edits' in the plugin window, then retry. Nothing in the file was changed.
|
|
836
|
+
|
|
837
|
+
That is not something an agent can fix. **Ask the user to tick "Allow edits" in the Design Twin plugin window,
|
|
838
|
+
then retry the same call.** The plugin's main thread holds the setting and checks it on every write; nothing on
|
|
839
|
+
the bridge side can turn it on.
|
|
840
|
+
|
|
841
|
+
- **Dry run still works while it is off.** `dryRun: true` previews the batch as before and adds `editsAllowed`
|
|
842
|
+
(read from the plugin's `whoami`): `false` → a real write would be refused right now (the preview also carries
|
|
843
|
+
`wouldRefuse` with the sentence above); `true` → it would be applied; `null` → unknown (no plugin connected,
|
|
844
|
+
several connected with no `client`, or a plugin built before the opt-in). Check it before asking the user to
|
|
845
|
+
review a preview, so the request to tick the box comes in the same breath.
|
|
846
|
+
- **The setting is per user, for every file.** It is saved in Figma's `clientStorage`, which is already scoped
|
|
847
|
+
to one user and this plugin. It is not per file: the plugin cannot reliably name a file (`fileKey` is only for
|
|
848
|
+
private plugins, and file names repeat), and a per-file flag stored in the document would itself be an edit.
|
|
849
|
+
Untick it when you are done.
|
|
850
|
+
- **No write is silent.** Each applied batch (and each refused one) shows a Figma toast and a line in the plugin
|
|
851
|
+
window, e.g. "Design Twin applied 3 edits to this file."
|
|
852
|
+
- `dtwin whoami` prints the state (`edits: off — …` / `edits: ALLOWED — …`) and `editsAllowed` is in its JSON.
|
|
853
|
+
|
|
821
854
|
### `writeToDisk` — the export path that doesn't go through the context window
|
|
822
855
|
Every export tool takes `writeToDisk: true` (plus an optional `outDir`). It writes the export through
|
|
823
856
|
the same `write-out.ts` the CLI uses — same layout, by construction — and returns a **compact index**
|
package/figma-plugin/code.js
CHANGED
|
@@ -792,6 +792,7 @@
|
|
|
792
792
|
function missingWriteFields(op, fields) {
|
|
793
793
|
return WRITE_OPS[op].required.filter((k) => fields[k] === void 0);
|
|
794
794
|
}
|
|
795
|
+
var EDITS_OFF_MESSAGE = "Edits are off in the Design Twin plugin \u2014 tick 'Allow edits' in the plugin window, then retry. Nothing in the file was changed.";
|
|
795
796
|
var COMMAND_TRAITS = {
|
|
796
797
|
ping: { run: "identity", export: false },
|
|
797
798
|
whoami: { run: "identity", export: false },
|
|
@@ -3847,6 +3848,35 @@
|
|
|
3847
3848
|
return { designSystem, layersDoc, assets: r.assets.slice() };
|
|
3848
3849
|
}
|
|
3849
3850
|
|
|
3851
|
+
// src/edits.ts
|
|
3852
|
+
var KEY = "allowEdits";
|
|
3853
|
+
var allowed = false;
|
|
3854
|
+
var loaded = (async () => {
|
|
3855
|
+
try {
|
|
3856
|
+
allowed = await figma.clientStorage.getAsync(KEY) === true;
|
|
3857
|
+
} catch (e) {
|
|
3858
|
+
console.error("[export] could not read the Allow edits setting; edits stay off:", e);
|
|
3859
|
+
allowed = false;
|
|
3860
|
+
}
|
|
3861
|
+
})();
|
|
3862
|
+
async function editsAllowed() {
|
|
3863
|
+
await loaded;
|
|
3864
|
+
return allowed;
|
|
3865
|
+
}
|
|
3866
|
+
function editsAllowedNow() {
|
|
3867
|
+
return allowed;
|
|
3868
|
+
}
|
|
3869
|
+
async function setEditsAllowed(on) {
|
|
3870
|
+
await loaded;
|
|
3871
|
+
allowed = on;
|
|
3872
|
+
try {
|
|
3873
|
+
await figma.clientStorage.setAsync(KEY, on);
|
|
3874
|
+
} catch (e) {
|
|
3875
|
+
console.error("[export] could not save the Allow edits setting:", e);
|
|
3876
|
+
}
|
|
3877
|
+
return allowed;
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3850
3880
|
// src/writes.ts
|
|
3851
3881
|
function solidPaint(hex, what) {
|
|
3852
3882
|
const c = parseHexColor(hex);
|
|
@@ -3868,11 +3898,17 @@
|
|
|
3868
3898
|
if (p.type === "PAGE") await p.loadAsync();
|
|
3869
3899
|
return p;
|
|
3870
3900
|
}
|
|
3901
|
+
var EditsRevoked = class extends Error {
|
|
3902
|
+
};
|
|
3903
|
+
function consent() {
|
|
3904
|
+
if (!editsAllowedNow()) throw new EditsRevoked("'Allow edits' was turned off in the Design Twin plugin partway through this batch \u2014 the ops before this one were applied. Tick 'Allow edits' in the plugin window, then retry the rest.");
|
|
3905
|
+
}
|
|
3871
3906
|
async function applyWrite(op) {
|
|
3872
3907
|
switch (op.op) {
|
|
3873
3908
|
case "createFrame": {
|
|
3874
3909
|
const parent = await resolveParent(op.parentId);
|
|
3875
3910
|
const fill = op.fill ? solidPaint(op.fill, "createFrame fill") : null;
|
|
3911
|
+
consent();
|
|
3876
3912
|
const f = figma.createFrame();
|
|
3877
3913
|
if (op.name) f.name = op.name;
|
|
3878
3914
|
if (op.width && op.height) f.resize(op.width, op.height);
|
|
@@ -3891,8 +3927,13 @@
|
|
|
3891
3927
|
case "createText": {
|
|
3892
3928
|
const parent = await resolveParent(op.parentId);
|
|
3893
3929
|
const fill = op.fill ? solidPaint(op.fill, "createText fill") : null;
|
|
3930
|
+
consent();
|
|
3894
3931
|
const t = figma.createText();
|
|
3895
3932
|
await figma.loadFontAsync(t.fontName);
|
|
3933
|
+
if (!editsAllowedNow()) {
|
|
3934
|
+
t.remove();
|
|
3935
|
+
consent();
|
|
3936
|
+
}
|
|
3896
3937
|
t.characters = op.text || "";
|
|
3897
3938
|
if (op.fontSize) t.fontSize = op.fontSize;
|
|
3898
3939
|
if (fill) t.fills = [fill];
|
|
@@ -3907,7 +3948,9 @@
|
|
|
3907
3948
|
const nd = op.nodeId ? await figma.getNodeByIdAsync(op.nodeId) : null;
|
|
3908
3949
|
if (!nd) throw new Error("setFill: no node with id '" + op.nodeId + "' (invalid or removed)");
|
|
3909
3950
|
if (!("fills" in nd)) throw new Error("setFill: node '" + nd.name + "' (" + nd.type + ") has no fills");
|
|
3910
|
-
|
|
3951
|
+
const paint = solidPaint(op.color, "setFill color");
|
|
3952
|
+
consent();
|
|
3953
|
+
nd.fills = [paint];
|
|
3911
3954
|
return ifDefined("id", op.nodeId);
|
|
3912
3955
|
}
|
|
3913
3956
|
case "setText": {
|
|
@@ -3915,6 +3958,7 @@
|
|
|
3915
3958
|
if (!nd) throw new Error("setText: no node with id '" + op.nodeId + "' (invalid or removed)");
|
|
3916
3959
|
if (nd.type !== "TEXT") throw new Error("setText: node '" + nd.name + "' is a " + nd.type + ", not TEXT");
|
|
3917
3960
|
await loadNodeFonts(nd);
|
|
3961
|
+
consent();
|
|
3918
3962
|
nd.characters = op.text || "";
|
|
3919
3963
|
return ifDefined("id", op.nodeId);
|
|
3920
3964
|
}
|
|
@@ -3923,8 +3967,31 @@
|
|
|
3923
3967
|
}
|
|
3924
3968
|
}
|
|
3925
3969
|
async function applyWrites(ops) {
|
|
3926
|
-
const
|
|
3970
|
+
const r = await gatedWrites(Array.isArray(ops) ? ops : []);
|
|
3971
|
+
announce(r);
|
|
3972
|
+
return r;
|
|
3973
|
+
}
|
|
3974
|
+
function announce(r) {
|
|
3975
|
+
const n = r.applied.length;
|
|
3976
|
+
const edits = n + " edit" + (n === 1 ? "" : "s");
|
|
3977
|
+
let text = null;
|
|
3978
|
+
if (r.refused === "edits-off") text = n ? "Design Twin applied " + edits + ", then stopped \u2014 'Allow edits' was turned off." : "Design Twin refused an edit request \u2014 'Allow edits' is off.";
|
|
3979
|
+
else if (r.ok && n) text = "Design Twin applied " + edits + " to this file.";
|
|
3980
|
+
else if (!r.ok && n) text = "Design Twin applied " + edits + ", then one failed: " + (r.error || "unknown error");
|
|
3981
|
+
if (text === null) return;
|
|
3982
|
+
try {
|
|
3983
|
+
figma.notify(text, r.ok ? void 0 : { error: true });
|
|
3984
|
+
} catch (e) {
|
|
3985
|
+
console.error("[export] figma.notify failed:", e);
|
|
3986
|
+
}
|
|
3987
|
+
const m = { type: "writes", applied: n, ok: r.ok, text };
|
|
3988
|
+
figma.ui.postMessage(m);
|
|
3989
|
+
}
|
|
3990
|
+
async function gatedWrites(list) {
|
|
3927
3991
|
const applied = [];
|
|
3992
|
+
if (list.length && !await editsAllowed()) {
|
|
3993
|
+
return { ok: false, applied, refused: "edits-off", error: EDITS_OFF_MESSAGE };
|
|
3994
|
+
}
|
|
3928
3995
|
if (list.length && figma.editorType === "dev") {
|
|
3929
3996
|
const first2 = list[0];
|
|
3930
3997
|
return {
|
|
@@ -3948,7 +4015,8 @@
|
|
|
3948
4015
|
applied,
|
|
3949
4016
|
failedAt: i,
|
|
3950
4017
|
failedOp: op && op.op,
|
|
3951
|
-
error: errMsg(e)
|
|
4018
|
+
error: errMsg(e),
|
|
4019
|
+
...e instanceof EditsRevoked ? { refused: "edits-off" } : {}
|
|
3952
4020
|
};
|
|
3953
4021
|
}
|
|
3954
4022
|
}
|
|
@@ -4003,7 +4071,9 @@
|
|
|
4003
4071
|
// `dtwin list clients` and `dtwin doctor` can never disagree about which build is running.
|
|
4004
4072
|
pluginVersion: "0.1.0",
|
|
4005
4073
|
fileKey: fileKey === void 0 ? null : fileKey,
|
|
4006
|
-
fileKeyAvailable: typeof fileKey === "string" && fileKey.length > 0
|
|
4074
|
+
fileKeyAvailable: typeof fileKey === "string" && fileKey.length > 0,
|
|
4075
|
+
// So figma_write's dry run can say a real write would be refused before anyone sends one.
|
|
4076
|
+
editsAllowed: await editsAllowed()
|
|
4007
4077
|
};
|
|
4008
4078
|
return r;
|
|
4009
4079
|
};
|
|
@@ -4065,6 +4135,8 @@
|
|
|
4065
4135
|
// cost class as listPages depth 2, not that of an export.
|
|
4066
4136
|
case "listLibraries":
|
|
4067
4137
|
return () => listLibraries();
|
|
4138
|
+
// applyWrites refuses the batch whole while "Allow edits" is off (edits.ts) — the gate lives there, not
|
|
4139
|
+
// here, so no caller of it can skip it.
|
|
4068
4140
|
case "write": {
|
|
4069
4141
|
const ops = req.args.ops;
|
|
4070
4142
|
return () => applyWrites(ops);
|
|
@@ -4083,6 +4155,8 @@
|
|
|
4083
4155
|
"get-identity",
|
|
4084
4156
|
"run-selection",
|
|
4085
4157
|
"run-full",
|
|
4158
|
+
"get-allow-edits",
|
|
4159
|
+
"set-allow-edits",
|
|
4086
4160
|
"cancel",
|
|
4087
4161
|
"bridge"
|
|
4088
4162
|
]);
|
|
@@ -4159,6 +4233,14 @@
|
|
|
4159
4233
|
post2({ type: "identity", identity });
|
|
4160
4234
|
break;
|
|
4161
4235
|
}
|
|
4236
|
+
case "get-allow-edits": {
|
|
4237
|
+
post2({ type: "allow-edits", allowed: await editsAllowed() });
|
|
4238
|
+
break;
|
|
4239
|
+
}
|
|
4240
|
+
case "set-allow-edits": {
|
|
4241
|
+
post2({ type: "allow-edits", allowed: await setEditsAllowed(raw.allowed === true) });
|
|
4242
|
+
break;
|
|
4243
|
+
}
|
|
4162
4244
|
case "run-selection": {
|
|
4163
4245
|
await runSelection();
|
|
4164
4246
|
break;
|
package/figma-plugin/ui.html
CHANGED
|
@@ -60,6 +60,13 @@
|
|
|
60
60
|
have to look ALIVE — a bar frozen at 0% reads as a hang. */
|
|
61
61
|
#progress.indet #bar { width: 30%; animation: indet 1.2s linear infinite; }
|
|
62
62
|
@keyframes indet { from { margin-left: -30%; } to { margin-left: 100%; } }
|
|
63
|
+
/* "Allow edits": always visible (never inside the collapsed connection section), because it is the one
|
|
64
|
+
control an agent will ask the designer to find. */
|
|
65
|
+
#edits-row { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; margin: 6px 0; }
|
|
66
|
+
#edits-row input { flex: 0 0 auto; width: 14px; height: 14px; margin: 0; padding: 0; }
|
|
67
|
+
#edits-state { margin-left: auto; font-size: 11px; padding: 1px 7px; border-radius: 9px;
|
|
68
|
+
background: var(--figma-color-bg-secondary, #f6f6f6); color: var(--figma-color-text-tertiary, #888); }
|
|
69
|
+
#edits-state.on { background: var(--figma-color-bg-warning-tertiary, #fff4e0); color: var(--figma-color-text-warning, #8a5a00); }
|
|
63
70
|
#cancel { border-color: var(--figma-color-border-danger-strong, #c0362c); color: var(--figma-color-text-danger, #c0362c); }
|
|
64
71
|
</style>
|
|
65
72
|
|
|
@@ -85,6 +92,17 @@
|
|
|
85
92
|
|
|
86
93
|
<div class="hint">Save the downloads into your project's <code>design/</code> folder (assets into <code>design/assets/</code>).</div>
|
|
87
94
|
|
|
95
|
+
<div class="sep"></div>
|
|
96
|
+
<div class="label">Edits</div>
|
|
97
|
+
<!-- Off by default. While it is off the plugin only READS this file: every edit request from the bridge is
|
|
98
|
+
refused before anything changes (figma-plugin/src/edits.ts is the authority, not this checkbox). -->
|
|
99
|
+
<label id="edits-row" for="allow-edits">
|
|
100
|
+
<input id="allow-edits" type="checkbox" disabled />
|
|
101
|
+
<span>Allow edits</span>
|
|
102
|
+
<span id="edits-state">off</span>
|
|
103
|
+
</label>
|
|
104
|
+
<div class="hint" id="edits-hint" style="margin-top:2px">Lets Claude Code change this file over the bridge (add frames and text, set fills and text). Off: it can only read. Every change is announced here.</div>
|
|
105
|
+
|
|
88
106
|
<div class="sep"></div>
|
|
89
107
|
<!-- Optional. Closed by default; opened automatically only when it needs the user (a rejected token). -->
|
|
90
108
|
<details id="connect">
|
|
@@ -307,9 +325,9 @@ var DesignTwinRelay = (() => {
|
|
|
307
325
|
var p = $("progress"); if (p) p.hidden = false;
|
|
308
326
|
setProgress(null);
|
|
309
327
|
setBusy(true);
|
|
310
|
-
setStatus(info.source
|
|
311
|
-
|
|
312
|
-
: "
|
|
328
|
+
setStatus(info.source !== "bridge" ? "Exporting…"
|
|
329
|
+
: info.label === "write" ? "Applying edits requested over the bridge…"
|
|
330
|
+
: "Export in progress — requested over the bridge (" + info.label + ")…");
|
|
313
331
|
}
|
|
314
332
|
// Tear the run chrome down. Deliberately does NOT clear `cancelSent`: the main thread sends
|
|
315
333
|
// run-end BEFORE the error that the cancellation produced, and both need to know it was a cancel.
|
|
@@ -452,6 +470,18 @@ var DesignTwinRelay = (() => {
|
|
|
452
470
|
syncBatchButtons();
|
|
453
471
|
return;
|
|
454
472
|
}
|
|
473
|
+
if (m.type === "allow-edits") {
|
|
474
|
+
// The main thread's answer is the truth (edits.ts): the checkbox only ever shows what it holds.
|
|
475
|
+
var ae = $("allow-edits"); if (ae) { ae.checked = !!m.allowed; ae.disabled = false; }
|
|
476
|
+
var es = $("edits-state"); if (es) { es.textContent = m.allowed ? "on" : "off"; es.className = m.allowed ? "on" : ""; }
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (m.type === "writes") {
|
|
480
|
+
// A write batch changed the file (or was refused): never silent. It arrives inside the write's own run
|
|
481
|
+
// (run-begin … run-end, and run-end leaves the status alone), so it replaces that run's line.
|
|
482
|
+
setStatus(m.text, !m.ok);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
455
485
|
if (m.type === "token") {
|
|
456
486
|
bridgeToken = m.token || "";
|
|
457
487
|
var t = $("token"); if (t) t.value = bridgeToken;
|
|
@@ -672,6 +702,12 @@ var DesignTwinRelay = (() => {
|
|
|
672
702
|
return a.text != null ? new Blob([a.text], { type: type }) : new Blob([b64ToBytes(a.base64)], { type: type });
|
|
673
703
|
});
|
|
674
704
|
};
|
|
705
|
+
var ae = $("allow-edits"); if (ae) ae.onchange = function () {
|
|
706
|
+
// Disabled until the main thread confirms, so a double toggle cannot race its own answer.
|
|
707
|
+
ae.disabled = true;
|
|
708
|
+
parent.postMessage({ pluginMessage: { type: "set-allow-edits", allowed: !!ae.checked } }, "*");
|
|
709
|
+
};
|
|
710
|
+
parent.postMessage({ pluginMessage: { type: "get-allow-edits" } }, "*");
|
|
675
711
|
// Ask code.js for the stored token; connect() runs once it replies. Also proves the round-trip.
|
|
676
712
|
parent.postMessage({ pluginMessage: { type: "get-token" } }, "*");
|
|
677
713
|
setStatus("Ready — select a frame, or use the blue button.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@design-twin/dtwin",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Design Twin bridge: local CLI + MCP server between Claude Code and the Design Twin Figma plugin. Reads a design on the free Figma plan, fully offline.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Mohamed Magdy Omar",
|
package/src/commands.ts
CHANGED
|
@@ -170,6 +170,9 @@ export interface WhoamiReply {
|
|
|
170
170
|
/** null when `figma.fileKey` is unavailable (not a private plugin) */
|
|
171
171
|
fileKey: string | null;
|
|
172
172
|
fileKeyAvailable: boolean;
|
|
173
|
+
/** Whether the designer has ticked "Allow edits" in the plugin window (edits.ts). Absent from a plugin
|
|
174
|
+
* built before the opt-in existed — which applies every write it is sent. */
|
|
175
|
+
editsAllowed?: boolean;
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
/** One selected node (figma.currentPage.selection, summarised). */
|
|
@@ -285,13 +288,23 @@ export type ExportReply = FullExportReply | DesignSystemReply | ScreenReply | Sc
|
|
|
285
288
|
export interface AppliedWrite {
|
|
286
289
|
id?: string;
|
|
287
290
|
}
|
|
288
|
-
/**
|
|
291
|
+
/** Why a whole batch was refused before any op ran — the one refusal a caller must hand to the PERSON at
|
|
292
|
+
* the Figma window rather than fix itself. `edits-off`: the designer has not ticked "Allow edits"
|
|
293
|
+
* (figma-plugin/src/edits.ts); nothing in the file changed. */
|
|
294
|
+
export type WriteRefusal = "edits-off";
|
|
295
|
+
/** The sentence a refused `edits-off` batch carries as `error`, and what figma_write shows the agent. */
|
|
296
|
+
export const EDITS_OFF_MESSAGE =
|
|
297
|
+
"Edits are off in the Design Twin plugin — tick 'Allow edits' in the plugin window, then retry. Nothing in the file was changed.";
|
|
298
|
+
/** writes.ts applyWrites' reply: `ok: false` on a partial failure, with what was applied before it. A batch
|
|
299
|
+
* refused whole carries `refused` (and no `failedAt`: no op was tried); one stopped partway because the designer
|
|
300
|
+
* unticked "Allow edits" carries `refused` AND `failedAt`, with the applied prefix. */
|
|
289
301
|
export interface WriteResult {
|
|
290
302
|
ok: boolean;
|
|
291
303
|
applied: AppliedWrite[];
|
|
292
304
|
failedAt?: number;
|
|
293
305
|
failedOp?: string;
|
|
294
306
|
error?: string;
|
|
307
|
+
refused?: WriteRefusal;
|
|
295
308
|
}
|
|
296
309
|
|
|
297
310
|
// ---------------------------------------------------------------- the map
|
package/src/figma-mcp.ts
CHANGED
|
@@ -36,7 +36,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto
|
|
|
36
36
|
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
37
37
|
// The command/reply contract shared with the plugin (commands.ts): every route request below is
|
|
38
38
|
// typed per command from it, so a tool cannot read a reply field the plugin never sends.
|
|
39
|
-
import { WRITE_OP_NAMES, writeOpPreview, type Cmd, type Commands, type ExportReply, type WriteOp } from "./commands.ts";
|
|
39
|
+
import { EDITS_OFF_MESSAGE, WRITE_OP_NAMES, writeOpPreview, type Cmd, type Commands, type ExportReply, type WriteOp } from "./commands.ts";
|
|
40
40
|
// TIMEOUTS is the per-command budget table shared with the dtwin CLI. Every EXPORT tool needs a
|
|
41
41
|
// budget well above server-core's 120s default (tuned for ping/getSelection, not for a walk of every
|
|
42
42
|
// node on a page) — a whole-file walk is the slow case, but a single big frame is the same shape of
|
|
@@ -681,7 +681,10 @@ const writeOpShape = z.object({
|
|
|
681
681
|
}).passthrough();
|
|
682
682
|
|
|
683
683
|
// Each step is commands.ts writeOpPreview — the op's WRITE_OPS row, which the plugin's wire check (writeOpError) reads too.
|
|
684
|
-
|
|
684
|
+
// `editsAllowed` is the plugin's "Allow edits" checkbox as its whoami reports it: false => a real write would be
|
|
685
|
+
// refused (and `wouldRefuse` says what to ask the designer); null => unknown (no plugin reachable, or one built
|
|
686
|
+
// before the opt-in).
|
|
687
|
+
function previewWrites(ops: WriteOp[], editsAllowed: boolean | null) {
|
|
685
688
|
const steps = ops.map(writeOpPreview);
|
|
686
689
|
return {
|
|
687
690
|
dryRun: true,
|
|
@@ -690,10 +693,30 @@ function previewWrites(ops: WriteOp[]) {
|
|
|
690
693
|
overwrites: steps.filter((s) => s.effect === "overwrites").length,
|
|
691
694
|
invalid: steps.filter((s) => "invalid" in s).length,
|
|
692
695
|
steps,
|
|
693
|
-
|
|
696
|
+
editsAllowed,
|
|
697
|
+
...(editsAllowed === false ? { wouldRefuse: EDITS_OFF_MESSAGE } : {}),
|
|
698
|
+
note: "Nothing was changed. Overwrites cannot be undone from here (only Cmd-Z in Figma). " +
|
|
699
|
+
(editsAllowed === false
|
|
700
|
+
? "A real write would be REFUSED right now: 'Allow edits' is off in the Design Twin plugin window — ask the designer to tick it, then re-call without dryRun."
|
|
701
|
+
: editsAllowed === null
|
|
702
|
+
? "Could not read the plugin's 'Allow edits' setting (no single plugin reachable); a real write is refused while it is off. Re-call without dryRun to apply."
|
|
703
|
+
: "Re-call without dryRun to apply."),
|
|
694
704
|
};
|
|
695
705
|
}
|
|
696
706
|
|
|
707
|
+
// The dry run's read of the "Allow edits" checkbox. Never waits for a plugin to connect (the preview must answer
|
|
708
|
+
// with none): nothing connected, several connected with no `client` named, or any failure, is `null` (unknown).
|
|
709
|
+
async function readEditsAllowed(a: { client?: string | undefined } | undefined): Promise<boolean | null> {
|
|
710
|
+
try {
|
|
711
|
+
const n = (await route.listClients()).length;
|
|
712
|
+
if (n === 0 || (!(a && a.client) && n > 1)) return null;
|
|
713
|
+
const who = await ask("whoami", {}, { timeoutMs: TIMEOUTS.command, target: a && a.client });
|
|
714
|
+
return typeof who.editsAllowed === "boolean" ? who.editsAllowed : null;
|
|
715
|
+
} catch {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
697
720
|
server.registerTool(
|
|
698
721
|
"figma_write",
|
|
699
722
|
{
|
|
@@ -705,7 +728,10 @@ server.registerTool(
|
|
|
705
728
|
"Ops are applied in order and are NOT transactional: if one fails, the earlier ones stay applied " +
|
|
706
729
|
"and their node ids are reported alongside the error so you can continue or clean up. " +
|
|
707
730
|
"There is no undo from here (only the designer's own Cmd-Z) — so for anything that OVERWRITES " +
|
|
708
|
-
"(setFill/setText), call once with dryRun:true, show the user the preview, and only then apply."
|
|
731
|
+
"(setFill/setText), call once with dryRun:true, show the user the preview, and only then apply. " +
|
|
732
|
+
"The plugin applies nothing unless the designer has ticked 'Allow edits' in its window (off by default); " +
|
|
733
|
+
"while it is off a write is refused with that instruction, and a dry run reports editsAllowed:false — " +
|
|
734
|
+
"ask the designer to tick it, then retry.",
|
|
709
735
|
inputSchema: {
|
|
710
736
|
...clientShape,
|
|
711
737
|
// Constrain `op` to the four ops the plugin actually implements — reject unknown ops at the
|
|
@@ -715,7 +741,7 @@ server.registerTool(
|
|
|
715
741
|
// 50 ops is several screens' worth of frames/text, and a larger job is simply several calls.
|
|
716
742
|
.max(50, "at most 50 ops per call — split a larger job into several calls (writes cannot be undone from here)")
|
|
717
743
|
.describe("Ordered list of write operations (each must have an `op` field). At most 50 per call."),
|
|
718
|
-
dryRun: z.boolean().optional().describe("true = validate the batch and return a preview of what each op would create/overwrite, WITHOUT touching the Figma file (needs no plugin connection)."),
|
|
744
|
+
dryRun: z.boolean().optional().describe("true = validate the batch and return a preview of what each op would create/overwrite, WITHOUT touching the Figma file (needs no plugin connection; when one is connected it also reports editsAllowed — false means a real write would be refused)."),
|
|
719
745
|
},
|
|
720
746
|
// destructiveHint MUST stay true. Per the MCP schema it asserts, when false, that "the tool
|
|
721
747
|
// performs only additive updates" — but setFill/setText OVERWRITE properties on nodes the user
|
|
@@ -728,10 +754,13 @@ server.registerTool(
|
|
|
728
754
|
guarded(async (a) => {
|
|
729
755
|
// The preview is built HERE, not plugin-side: it must work before a connection exists, and the
|
|
730
756
|
// annotations above are only a hint — not every MCP client turns destructiveHint into a prompt.
|
|
731
|
-
if (a.dryRun) return textResult(previewWrites(a.ops || []));
|
|
757
|
+
if (a.dryRun) return textResult(previewWrites(a.ops || [], await readEditsAllowed(a)));
|
|
732
758
|
// No cancel signal here (unlike the reads): a write cannot be un-applied, and aborting the wait would
|
|
733
759
|
// only lose the `applied` ids below.
|
|
734
760
|
const r = await ask("write", { ops: a.ops || [] }, { target: a && a.client });
|
|
761
|
+
// The designer has not consented: nothing was tried, so there is no op index and nothing to clean up. The
|
|
762
|
+
// fix is the PERSON's (a checkbox in the plugin window), so say exactly that rather than "write failed".
|
|
763
|
+
if (r.refused === "edits-off" && !r.applied.length) return errorResult(r.error || EDITS_OFF_MESSAGE);
|
|
735
764
|
// Partial failure is reported as an error result, but MUST still carry `applied` — those nodes
|
|
736
765
|
// exist in the document and the agent needs their ids to continue or undo.
|
|
737
766
|
if (!r.ok) {
|
package/src/figma-pull.ts
CHANGED
|
@@ -947,6 +947,11 @@ async function whoamiCmd({ route, send, finish }: Session): Promise<void> {
|
|
|
947
947
|
console.error("[dtwin] plugin version: " + (r.pluginVersion || "unknown (older bundle — predates version reporting)"));
|
|
948
948
|
const stale = pluginStalenessNote(r.pluginVersion);
|
|
949
949
|
if (stale) console.error("[dtwin] ! " + stale);
|
|
950
|
+
console.error("[dtwin] edits: " + (r.editsAllowed === true
|
|
951
|
+
? "ALLOWED — the designer ticked 'Allow edits' in the plugin window; figma_write can change this file"
|
|
952
|
+
: r.editsAllowed === false
|
|
953
|
+
? "off — the plugin refuses every write until the designer ticks 'Allow edits' in the plugin window (reads are unaffected)"
|
|
954
|
+
: "unknown (older bundle — predates the 'Allow edits' opt-in, and applies every write)"));
|
|
950
955
|
console.error("[dtwin] fileKey: " + (r.fileKeyAvailable
|
|
951
956
|
? r.fileKey + " (available — usable as a stable routing key)"
|
|
952
957
|
: "UNAVAILABLE (gated to private plugins; routing must use a server-minted id)"));
|
package/src/init.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Rules it keeps:
|
|
10
10
|
// - Never overwrites. Every file it would write is skipped, and said so, if it already exists;
|
|
11
11
|
// .mcp.json is MERGED (an existing "figma" entry is left exactly as it is). The one exception is
|
|
12
|
-
// an entry of OURS that can no longer start (mcpEntryProblems: a bun or figma-mcp.ts that is gone,
|
|
12
|
+
// an entry of OURS that can no longer start (mcpEntryProblems: a bun or figma-mcp.ts that is gone or spelt through a versioned Homebrew Cellar folder,
|
|
13
13
|
// a Node-era `node …/dist/figma-mcp.js`, bun without its two flags, a `bunx @design-twin/dtwin mcp` that fetches the
|
|
14
14
|
// package at start-up): --mcp re-points that entry, under
|
|
15
15
|
// its own key and keeping its other fields, at this install — and says so (--dry-run only says so).
|
|
@@ -106,8 +106,21 @@ const BUN_FLAGS = ["--no-install", "--no-env-file"] as const;
|
|
|
106
106
|
* process.execPath alone is the resolved file, and for a Homebrew (or any versioned) install that is a versioned path
|
|
107
107
|
* (…/Cellar/bun/1.4.2/bin/bun) the next `brew upgrade` deletes, leaving Claude Code a server it cannot start. */
|
|
108
108
|
function stableBunPath(execPath: string = process.execPath, onPath: string | null = Bun.which("bun")): string {
|
|
109
|
-
if (!onPath) return execPath;
|
|
110
|
-
try { return fs.realpathSync(onPath) === fs.realpathSync(execPath) ? onPath : execPath; } catch { return execPath; }
|
|
109
|
+
if (!onPath) return stableKegPath(execPath);
|
|
110
|
+
try { return fs.realpathSync(onPath) === fs.realpathSync(execPath) ? onPath : stableKegPath(execPath); } catch { return stableKegPath(execPath); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The upgrade-proof spelling of a path inside a Homebrew keg. Homebrew installs a formula into a versioned folder,
|
|
114
|
+
* `<prefix>/Cellar/<formula>/<version>/…`, that the next `brew upgrade` deletes, and keeps `<prefix>/opt/<formula>` as a
|
|
115
|
+
* symlink to whichever version is current. Bun resolves symlinks, so a file run through that opt link still sees its
|
|
116
|
+
* Cellar path (import.meta.dirname, process.execPath) — and an absolute path written from it (.mcp.json's server and
|
|
117
|
+
* bun, the plugin manifest Figma remembers) would stop working after one upgrade. Returns the opt spelling when it
|
|
118
|
+
* resolves to the very same file, else `p` unchanged (not in a Cellar, or a layout where opt is elsewhere). */
|
|
119
|
+
function stableKegPath(p: string): string {
|
|
120
|
+
const m = /^(.*)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+(?:[\\/](.*))?$/.exec(p);
|
|
121
|
+
if (!m || m[1] === undefined || m[2] === undefined) return p;
|
|
122
|
+
const opt = path.join(m[1], "opt", m[2], m[3] ?? "");
|
|
123
|
+
try { return fs.realpathSync(opt) === fs.realpathSync(p) ? opt : p; } catch { return p; }
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
/** The bunx cache directory `dir` sits in, or null. `bunx @design-twin/dtwin …` installs the package into a cache tree
|
|
@@ -129,9 +142,16 @@ export interface EntryProbe {
|
|
|
129
142
|
cwd?: string;
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
/** A path that works today but sits in a versioned Homebrew folder the next `brew upgrade` deletes — when the opt
|
|
146
|
+
* spelling of it exists (stableKegPath), so init's repair has somewhere stable to point. Empty otherwise. */
|
|
147
|
+
function kegProblem(what: string, p: string): string[] {
|
|
148
|
+
const stable = stableKegPath(p);
|
|
149
|
+
return stable === p ? [] : [`${what} ${p} is in a versioned Homebrew folder the next \`brew upgrade\` deletes (the stable spelling is ${stable})`];
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
/** What is wrong with an .mcp.json entry of OURS (isOurMcpEntry), one phrase each — empty when it will start: its command
|
|
133
153
|
* exists, it runs on Bun (not Node, not the old bridge/dist build), it names a server script (not a package bunx would
|
|
134
|
-
* fetch), that script exists outside a bunx cache, and bun gets the two flags before that script. The ONE rule for init (repair it) and doctor (report it). */
|
|
154
|
+
* fetch), that script exists outside a bunx cache, neither is spelt through a versioned Homebrew Cellar folder, and bun gets the two flags before that script. The ONE rule for init (repair it) and doctor (report it). */
|
|
135
155
|
function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => Bun.which(c), cwd = process.cwd() }: EntryProbe = {}): string[] {
|
|
136
156
|
if (!isRecord(e) || typeof e.command !== "string" || e.command === "") return ["it has no `command`"];
|
|
137
157
|
const command = e.command;
|
|
@@ -142,6 +162,7 @@ function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => B
|
|
|
142
162
|
if (nodeShaped) out.push(`it runs on Node (\`${command}\`) — Design Twin runs on Bun only`);
|
|
143
163
|
const found = /[\\/]/.test(command) ? exists(path.resolve(cwd, command)) : which(command) !== null;
|
|
144
164
|
if (!found) out.push(`its command \`${command}\` ${/[\\/]/.test(command) ? "does not exist" : "is not on PATH"}`);
|
|
165
|
+
else if (/[\\/]/.test(command)) out.push(...kegProblem("its command", path.resolve(cwd, command)));
|
|
145
166
|
const i = args.findIndex((a) => FIGMA_MCP_SCRIPT.test(a));
|
|
146
167
|
const script = args[i];
|
|
147
168
|
// No server script: the package-runner form (`bunx @design-twin/dtwin mcp`, `bun x @design-twin/dtwin mcp`, an older `npx designtwin
|
|
@@ -156,6 +177,7 @@ function mcpEntryProblems(e: unknown, { exists = fs.existsSync, which = (c) => B
|
|
|
156
177
|
// figma-mcp.js / .mjs (bridge/dist, or the older bridge/figma-mcp.mjs) is the Node-era build: the package ships .ts now
|
|
157
178
|
if (/\.m?js$/.test(script)) out.push(`it points at the old Node build (${script})`);
|
|
158
179
|
else if (!exists(abs)) out.push(`its server script ${script} does not exist`);
|
|
180
|
+
else out.push(...kegProblem("its server script", abs));
|
|
159
181
|
const cache = bunxCacheOf(abs);
|
|
160
182
|
if (cache) out.push(`its server script is in a bunx cache (${cache}) that Bun replaces or deletes on its own`);
|
|
161
183
|
if (!nodeShaped) {
|
|
@@ -367,7 +389,9 @@ function main(argv: string[]): void {
|
|
|
367
389
|
// ran init is one this package is known to work with, spelt the way that survives its upgrades
|
|
368
390
|
// (stableBunPath). The flags are the shebang's (figma-pull.ts): no auto-install from the network, and
|
|
369
391
|
// the project's .env stays out of the server's environment.
|
|
370
|
-
|
|
392
|
+
// Under Homebrew this file runs from a versioned Cellar folder, so the directory is spelt through opt (stableKegPath).
|
|
393
|
+
const here = stableKegPath(import.meta.dirname);
|
|
394
|
+
const mcpEntry: McpEntry = { command: stableBunPath(), args: [...BUN_FLAGS, path.join(here, "figma-mcp.ts")] };
|
|
371
395
|
// Run through `bunx @design-twin/dtwin init`: this very file is in bunx's cache, so no entry may point at it.
|
|
372
396
|
const cache = bunxCacheOf(import.meta.dirname);
|
|
373
397
|
const refuseMcp = cache ? bunxRefusal(cache) : undefined;
|
|
@@ -378,7 +402,8 @@ function main(argv: string[]): void {
|
|
|
378
402
|
|
|
379
403
|
// The Figma plugin's manifest: a repo checkout's (bridge/src/ -> ../../figma-plugin/), else the copy the npm
|
|
380
404
|
// package carries (../figma-plugin/, put there by bridge/pack.ts). Neither: the step names the repo instead.
|
|
381
|
-
|
|
405
|
+
// Spelt from `here` too: Figma remembers the path a plugin was imported from, and a Cellar one is gone after an upgrade.
|
|
406
|
+
const manifest = [path.join(here, "..", "..", "figma-plugin", "manifest.json"), path.join(here, "..", "figma-plugin", "manifest.json")].find((m) => fs.existsSync(m));
|
|
382
407
|
// Step 3 is already done for anyone who reached init THROUGH the installed plugin's help skill —
|
|
383
408
|
// which is the documented path. Claude Code records installed plugins under ~/.claude; when the
|
|
384
409
|
// marker is there, saying "install the plugin" is noise at best and confusing at worst.
|
|
@@ -421,4 +446,4 @@ function main(argv: string[]): void {
|
|
|
421
446
|
);
|
|
422
447
|
}
|
|
423
448
|
|
|
424
|
-
export { detectProfile, plan, apply, main, isOurMcpEntry, mcpEntryProblems, stableBunPath, bunxCacheOf, readMcpJson, usesTailwind, usesVite };
|
|
449
|
+
export { detectProfile, plan, apply, main, isOurMcpEntry, mcpEntryProblems, stableBunPath, stableKegPath, bunxCacheOf, readMcpJson, usesTailwind, usesVite };
|