@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 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), and two that read the export already on disk and
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**
@@ -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
- nd.fills = [solidPaint(op.color, "setFill color")];
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 list = Array.isArray(ops) ? ops : [];
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;
@@ -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 === "bridge"
311
- ? "Export in progress — requested over the bridge (" + info.label + ")…"
312
- : "Exporting…");
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.0",
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
- /** writes.ts applyWrites' reply: `ok: false` on a partial failure, with what was applied before it. */
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
- function previewWrites(ops: WriteOp[]) {
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
- note: "Nothing was changed. Overwrites cannot be undone from here (only Cmd-Z in Figma). Re-call without dryRun to apply.",
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
- const mcpEntry: McpEntry = { command: stableBunPath(), args: [...BUN_FLAGS, path.join(import.meta.dirname, "figma-mcp.ts")] };
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
- const manifest = [path.join(import.meta.dirname, "..", "..", "figma-plugin", "manifest.json"), path.join(import.meta.dirname, "..", "figma-plugin", "manifest.json")].find((m) => fs.existsSync(m));
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 };