@design-twin/dtwin 0.1.0-alpha.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.
@@ -0,0 +1,397 @@
1
+ // GENERATED from design-to-code/ by claude-plugin/build-scripts.js — edit the source, then rebuild.
2
+
3
+
4
+ // design-to-code/cli-args.ts
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ // bridge/src/errmsg.ts
9
+ var errMsg = (e) => typeof e === "string" ? e : String(e && e.message || e);
10
+
11
+ // bridge/src/stdio.ts
12
+ import { format, inspect } from "node:util";
13
+ function installQueuedConsole(o = {}) {
14
+ trackWrites(process.stdout);
15
+ trackWrites(process.stderr);
16
+ const out = o.logTo === "stderr" ? process.stderr : process.stdout;
17
+ const err = process.stderr;
18
+ let indent = "";
19
+ const write = (s, text) => {
20
+ s.write((indent ? text.replace(/^/gm, indent) : text) + "\n");
21
+ };
22
+ const line = (s) => (...a) => {
23
+ write(s, format(...a));
24
+ };
25
+ console.log = console.info = console.debug = line(out);
26
+ console.warn = console.error = line(err);
27
+ console.dir = (item, options) => {
28
+ write(out, inspect(item, { customInspect: false, ...options }));
29
+ };
30
+ console.dirxml = line(out);
31
+ console.table = (data) => {
32
+ write(out, inspect(data));
33
+ };
34
+ console.write = (...data) => {
35
+ let n = 0;
36
+ for (const d of data) {
37
+ const b = typeof d === "string" ? Buffer.from(d) : d instanceof ArrayBuffer ? Buffer.from(d) : Buffer.from(d.buffer, d.byteOffset, d.byteLength);
38
+ if (b.length) {
39
+ out.write(b);
40
+ n += b.length;
41
+ }
42
+ }
43
+ return n;
44
+ };
45
+ console.clear = () => {
46
+ if (out.isTTY) out.write("\x1B[1;1H\x1B[0J");
47
+ };
48
+ console.trace = (...a) => {
49
+ const e = new Error(format(...a));
50
+ e.name = "Trace";
51
+ Error.captureStackTrace(e, console.trace);
52
+ write(err, e.stack ?? `Trace: ${e.message}`);
53
+ };
54
+ console.assert = (cond, ...a) => {
55
+ if (!cond) write(err, a.length ? "Assertion failed: " + format(...a) : "Assertion failed");
56
+ };
57
+ console.group = console.groupCollapsed = (...a) => {
58
+ if (a.length) write(out, format(...a));
59
+ indent += " ";
60
+ };
61
+ console.groupEnd = () => {
62
+ indent = indent.slice(0, -2);
63
+ };
64
+ const counts = /* @__PURE__ */ new Map();
65
+ console.count = (label = "default") => {
66
+ const n = (counts.get(label) ?? 0) + 1;
67
+ counts.set(label, n);
68
+ write(out, `${label}: ${n}`);
69
+ };
70
+ console.countReset = (label = "default") => {
71
+ counts.delete(label);
72
+ };
73
+ const timers = /* @__PURE__ */ new Map();
74
+ const elapsed = (label) => {
75
+ const t = timers.get(label);
76
+ return t === void 0 ? null : `${label}: ${(performance.now() - t).toFixed(3)}ms`;
77
+ };
78
+ console.time = (label = "default") => {
79
+ timers.set(label, performance.now());
80
+ };
81
+ console.timeLog = (label = "default", ...a) => {
82
+ const e = elapsed(label);
83
+ if (e === null) write(err, `Warning: No such label '${label}' for console.timeLog()`);
84
+ else write(out, [e, ...a.map((x) => format(x))].join(" "));
85
+ };
86
+ console.timeEnd = (label = "default") => {
87
+ const e = elapsed(label);
88
+ timers.delete(label);
89
+ write(e === null ? err : out, e ?? `Warning: No such label '${label}' for console.timeEnd()`);
90
+ };
91
+ }
92
+ var inFlight = /* @__PURE__ */ new Map();
93
+ function trackWrites(s) {
94
+ if (inFlight.has(s)) return;
95
+ const st = { n: 0, idle: [] };
96
+ inFlight.set(s, st);
97
+ const write = s.write.bind(s);
98
+ const settle = () => {
99
+ if (--st.n === 0) for (const r of st.idle.splice(0)) r();
100
+ };
101
+ s.write = (chunk, encoding, cb) => {
102
+ const done = typeof encoding === "function" ? encoding : cb;
103
+ const enc = typeof encoding === "function" ? void 0 : encoding;
104
+ if (chunk.length === 0) return write(chunk, enc, done);
105
+ st.n++;
106
+ let called = false;
107
+ return write(chunk, enc, (err) => {
108
+ if (!called) {
109
+ called = true;
110
+ settle();
111
+ }
112
+ done?.(err);
113
+ });
114
+ };
115
+ }
116
+ var CliExit = class extends Error {
117
+ code;
118
+ constructor(code) {
119
+ super(`exit ${code}`);
120
+ this.name = "CliExit";
121
+ this.code = code;
122
+ }
123
+ };
124
+ var cliDepth = 0;
125
+ function exitCli(code) {
126
+ if (cliDepth > 0) throw new CliExit(code);
127
+ process.exit(code);
128
+ }
129
+ function runCli(main2, o, argv = process.argv.slice(2)) {
130
+ installQueuedConsole();
131
+ const done = o.done ?? ((code) => {
132
+ process.exitCode = code;
133
+ });
134
+ const fail = (e) => {
135
+ if (e instanceof CliExit) return done(e.code);
136
+ console.error(o.errorLine ? o.errorLine(e) : `${o.name}: ${e instanceof Error && e.stack || String(e)}`);
137
+ done(o.errorCode ?? 1);
138
+ };
139
+ cliDepth++;
140
+ let settled = false;
141
+ const leave = () => {
142
+ if (!settled) {
143
+ settled = true;
144
+ cliDepth--;
145
+ }
146
+ };
147
+ try {
148
+ const r = main2(argv);
149
+ if (typeof r === "number") {
150
+ leave();
151
+ done(r);
152
+ } else r.then((c) => {
153
+ leave();
154
+ done(c);
155
+ }, (e) => {
156
+ leave();
157
+ fail(e);
158
+ });
159
+ } catch (e) {
160
+ leave();
161
+ fail(e);
162
+ }
163
+ }
164
+
165
+ // design-to-code/proc.ts
166
+ var pause = new Int32Array(new SharedArrayBuffer(4));
167
+
168
+ // design-to-code/cli-args.ts
169
+ var SELF = fileURLToPath(import.meta.url);
170
+ var shellQuote = (p) => /["$`\\!]/.test(p) ? `'${p.replaceAll("'", `'\\''`)}'` : `"${p}"`;
171
+ var SCRIPT_RUNNER = "bun --no-install --no-env-file";
172
+ var scriptCmd = (name) => `${SCRIPT_RUNNER} ${shellQuote(path.join(path.dirname(SELF), name + path.extname(SELF)))}`;
173
+
174
+ // bridge/src/read-json.ts
175
+ import fs from "node:fs";
176
+ var anyJson = (_x) => true;
177
+ function readFailure(e) {
178
+ const code = e && typeof e === "object" && "code" in e ? e.code : void 0;
179
+ if (code === "ENOENT") return { error: "does not exist", missing: true };
180
+ return {
181
+ error: code === "EISDIR" ? "is a directory, not a file" : code === "EACCES" ? "is not readable (permission denied)" : `could not be read (${String(code || e)})`
182
+ };
183
+ }
184
+ function readJson(file, guard) {
185
+ let buf;
186
+ try {
187
+ buf = fs.readFileSync(file);
188
+ } catch (e) {
189
+ return readFailure(e);
190
+ }
191
+ if (buf.length >= 2 && (buf[0] === 255 && buf[1] === 254 || buf[0] === 254 && buf[1] === 255)) {
192
+ return { error: "is UTF-16, not UTF-8 \u2014 re-save it as UTF-8" };
193
+ }
194
+ let raw = buf.toString("utf8");
195
+ if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
196
+ let parsed;
197
+ try {
198
+ const value = JSON.parse(raw);
199
+ parsed = value;
200
+ } catch (e) {
201
+ return { error: `is not valid JSON \u2014 ${errMsg(e)}` };
202
+ }
203
+ if (!guard(parsed)) return { error: `is not ${guard.expected || "the expected kind of document"}` };
204
+ return { doc: parsed };
205
+ }
206
+
207
+ // design-to-code/catalog-input.ts
208
+ function readJsonFile(file, what, hint) {
209
+ return readDocFile(file, what, anyJson, hint);
210
+ }
211
+ function readDocFile(file, what, guard, hint) {
212
+ return docOrExit(loadDocFile(file, what, guard, hint));
213
+ }
214
+ function loadDocFile(file, what, guard, hint, readAt = file) {
215
+ const r = readJson(readAt, guard);
216
+ if ("doc" in r) return r;
217
+ const ioFailure = r.missing || /^(is a directory|is not readable|could not be read)/.test(r.error);
218
+ return { error: `${what}: '${file}' ${r.error}${ioFailure ? "." : ""}` + (ioFailure && hint ? `
219
+ ${hint}` : "") };
220
+ }
221
+ function docOrExit(r) {
222
+ if ("doc" in r) return r.doc;
223
+ console.error(`error ${r.error}`);
224
+ exitCli(2);
225
+ }
226
+
227
+ // bridge/src/json-util.ts
228
+ function nullProto() {
229
+ return /* @__PURE__ */ Object.create(null);
230
+ }
231
+ function isRecord(x) {
232
+ return typeof x === "object" && x !== null && !Array.isArray(x);
233
+ }
234
+
235
+ // design-to-code/map-validate-core.ts
236
+ var STATUSES = ["active", "deprecated", "needs-review"];
237
+ var KEYS = {
238
+ root: ["version", "figmaFileKey", "components"],
239
+ entry: ["figma", "code", "props", "variantOverrides", "childrenByLayer", "status", "note"],
240
+ figma: ["key", "id", "name", "unstable"],
241
+ code: ["module", "export", "targets"],
242
+ target: ["module", "export"],
243
+ vo: ["when", "code"],
244
+ voCode: ["module", "export"],
245
+ children: ["layerNamePattern", "slot"],
246
+ prop: Object.assign(nullProto(), {
247
+ enum: ["kind", "codeProp", "values", "default", "omitDefault"],
248
+ boolean: ["kind", "codeProp", "default", "omitDefault"],
249
+ string: ["kind", "codeProp"],
250
+ instance: ["kind", "codeProp", "slot"]
251
+ })
252
+ };
253
+ var PROP_KINDS = Object.keys(KEYS.prop);
254
+ var isStr = (v) => typeof v === "string";
255
+ var isBool = (v) => typeof v === "boolean";
256
+ var noExtra = (obj, allowed, at, err) => {
257
+ if (!Array.isArray(allowed)) return;
258
+ for (const k of Object.keys(obj)) if (!allowed.includes(k)) err(`${at}.${k}`, "unknown property (additionalProperties:false)");
259
+ };
260
+ var optStrings = (obj, keys, at, err) => {
261
+ for (const k of keys) if (obj[k] !== void 0 && !isStr(obj[k])) err(`${at}.${k}`, "must be a string");
262
+ };
263
+ function validateMap(map) {
264
+ const errors = [];
265
+ const err = (path2, message) => errors.push({ path: path2, message });
266
+ if (!isRecord(map)) return { ok: false, errors: [{ path: "", message: "map must be an object" }] };
267
+ noExtra(map, KEYS.root, "", err);
268
+ if (map.version !== 1) err("version", "must be 1");
269
+ if (map.figmaFileKey !== void 0 && !isStr(map.figmaFileKey)) err("figmaFileKey", "must be a string");
270
+ if (!isRecord(map.components)) {
271
+ err("components", "must be an object");
272
+ return { ok: false, errors };
273
+ }
274
+ for (const key of Object.keys(map.components)) {
275
+ const e = map.components[key];
276
+ const at = `components.${key}`;
277
+ if (!isRecord(e)) {
278
+ err(at, "entry must be an object");
279
+ continue;
280
+ }
281
+ noExtra(e, KEYS.entry, at, err);
282
+ if (!isRecord(e.figma)) err(`${at}.figma`, "required object");
283
+ else {
284
+ noExtra(e.figma, KEYS.figma, `${at}.figma`, err);
285
+ if (!isStr(e.figma.name)) err(`${at}.figma.name`, "required string");
286
+ optStrings(e.figma, ["key", "id"], `${at}.figma`, err);
287
+ if (e.figma.unstable !== void 0 && !isBool(e.figma.unstable)) err(`${at}.figma.unstable`, "must be a boolean");
288
+ }
289
+ if (!isRecord(e.code)) err(`${at}.code`, "required object");
290
+ else {
291
+ noExtra(e.code, KEYS.code, `${at}.code`, err);
292
+ if (!isStr(e.code.module)) err(`${at}.code.module`, "required string");
293
+ if (!isStr(e.code.export)) err(`${at}.code.export`, "required string");
294
+ if (e.code.targets !== void 0) {
295
+ if (!isRecord(e.code.targets)) err(`${at}.code.targets`, "must be an object");
296
+ else for (const t of Object.keys(e.code.targets)) {
297
+ const tv = e.code.targets[t], ta = `${at}.code.targets.${t}`;
298
+ if (!isRecord(tv)) err(ta, "must be an object");
299
+ else {
300
+ noExtra(tv, KEYS.target, ta, err);
301
+ optStrings(tv, ["module", "export"], ta, err);
302
+ }
303
+ }
304
+ }
305
+ }
306
+ if (e.status !== void 0 && (!isStr(e.status) || !STATUSES.includes(e.status))) err(`${at}.status`, `must be one of ${STATUSES.join("|")}`);
307
+ optStrings(e, ["note"], at, err);
308
+ if (e.props !== void 0) {
309
+ if (!isRecord(e.props)) err(`${at}.props`, "must be an object");
310
+ else for (const pn of Object.keys(e.props)) validateProp(e.props[pn], `${at}.props.${pn}`, err);
311
+ }
312
+ if (e.variantOverrides !== void 0) {
313
+ if (!Array.isArray(e.variantOverrides)) err(`${at}.variantOverrides`, "must be an array");
314
+ else e.variantOverrides.forEach((vo, i) => {
315
+ const va = `${at}.variantOverrides[${i}]`;
316
+ if (!isRecord(vo)) {
317
+ err(va, "must be an object");
318
+ return;
319
+ }
320
+ noExtra(vo, KEYS.vo, va, err);
321
+ if (!isRecord(vo.when)) err(`${va}.when`, "required object");
322
+ else for (const wk of Object.keys(vo.when)) if (!isStr(vo.when[wk])) err(`${va}.when.${wk}`, "value must be a string");
323
+ if (!isRecord(vo.code)) err(`${va}.code`, "required object");
324
+ else {
325
+ noExtra(vo.code, KEYS.voCode, `${va}.code`, err);
326
+ if (!isStr(vo.code.module)) err(`${va}.code.module`, "required string");
327
+ if (!isStr(vo.code.export)) err(`${va}.code.export`, "required string");
328
+ }
329
+ });
330
+ }
331
+ if (e.childrenByLayer !== void 0) {
332
+ if (!isRecord(e.childrenByLayer)) err(`${at}.childrenByLayer`, "must be an object");
333
+ else {
334
+ noExtra(e.childrenByLayer, KEYS.children, `${at}.childrenByLayer`, err);
335
+ optStrings(e.childrenByLayer, ["layerNamePattern", "slot"], `${at}.childrenByLayer`, err);
336
+ }
337
+ }
338
+ }
339
+ return { ok: errors.length === 0, errors };
340
+ }
341
+ function validateProp(p, at, err) {
342
+ if (!isRecord(p)) {
343
+ err(at, "prop must be an object");
344
+ return;
345
+ }
346
+ const allowed = typeof p.kind === "string" ? KEYS.prop[p.kind] : void 0;
347
+ if (!Array.isArray(allowed)) {
348
+ err(`${at}.kind`, `must be one of ${PROP_KINDS.join("|")}`);
349
+ return;
350
+ }
351
+ noExtra(p, allowed, at, err);
352
+ if (p.kind !== "instance" && !isStr(p.codeProp)) err(`${at}.codeProp`, "required string");
353
+ if (p.kind === "instance" && p.codeProp !== void 0 && !isStr(p.codeProp)) err(`${at}.codeProp`, "must be a string");
354
+ if (p.kind === "instance" && p.slot !== void 0 && !isStr(p.slot)) err(`${at}.slot`, "must be a string");
355
+ if (p.kind === "enum") {
356
+ if (p.values !== void 0 && !isRecord(p.values)) err(`${at}.values`, "must be an object (VARIANT option -> code value)");
357
+ else if (isRecord(p.values)) for (const k of Object.keys(p.values)) {
358
+ const t = typeof p.values[k];
359
+ if (p.values[k] !== null && !["string", "number", "boolean"].includes(t)) err(`${at}.values.${k}`, "value must be string|number|boolean|null");
360
+ }
361
+ if (p.default !== void 0 && !["string", "number", "boolean"].includes(typeof p.default)) err(`${at}.default`, "must be string|number|boolean");
362
+ }
363
+ if (p.kind === "boolean" && p.default !== void 0 && !isBool(p.default)) err(`${at}.default`, "must be a boolean");
364
+ if ((p.kind === "enum" || p.kind === "boolean") && p.omitDefault !== void 0 && !isBool(p.omitDefault)) err(`${at}.omitDefault`, "must be a boolean");
365
+ }
366
+ function isCodeConnectMap(x) {
367
+ return validateMap(x).ok;
368
+ }
369
+ isCodeConnectMap.expected = "a valid component map (the map-validate script lists what is wrong with it)";
370
+
371
+ // design-to-code/map-validate.ts
372
+ function main(argv) {
373
+ const file = argv[0];
374
+ const USAGE = `usage: ${scriptCmd("map-validate")} <map.json>`;
375
+ if (file === "--help" || file === "-h") {
376
+ console.log(USAGE);
377
+ return 0;
378
+ }
379
+ if (!file || file.startsWith("-")) {
380
+ console.error((file ? `map-validate: unknown flag ${file}
381
+ ` : "") + USAGE);
382
+ return 1;
383
+ }
384
+ const res = validateMap(readJsonFile(file, "component map"));
385
+ if (res.ok) {
386
+ console.log("map valid");
387
+ return 0;
388
+ }
389
+ res.errors.forEach((e) => console.error(` ${e.path || "(root)"}: ${e.message}`));
390
+ console.error(`
391
+ ${res.errors.length} error(s)`);
392
+ return 1;
393
+ }
394
+ if (import.meta.main) runCli(main, { name: "map-validate" });
395
+ export {
396
+ main
397
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design-twin/dtwin",
3
- "version": "0.1.0-alpha.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",
@@ -31,11 +31,15 @@
31
31
  },
32
32
  "files": [
33
33
  "src",
34
+ "layer",
35
+ "figma-plugin",
34
36
  "README.md",
35
37
  "LICENSE"
36
38
  ],
37
39
  "scripts": {
38
40
  "typecheck": "tsc --noEmit",
41
+ "prepack": "bun --no-install --no-env-file pack.ts",
42
+ "postpack": "bun --no-install --no-env-file pack.ts --clean",
39
43
  "pull": "bun src/figma-pull.ts",
40
44
  "pull:selection": "bun src/figma-pull.ts design --selection",
41
45
  "mcp": "bun src/figma-mcp.ts"
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) {
@@ -752,14 +781,16 @@ server.registerTool(
752
781
  // — without these the agent has the figma_* tools and no way in, and the map and the codegen skill drift
753
782
  // into two different formats. Registering them here is what makes that layer exist for its actual
754
783
  // consumer.
755
- // Loaded LAZILY, on call, and never at module scope. ../../design-to-code/ is a SIBLING of this package
756
- // root (bridge/), so npm — whose `files` cannot reach outside the package root — does not ship it.
757
- // Importing it eagerly would take the whole server down with a module-not-found on a `bun add -g @design-twin/dtwin`
758
- // install, killing the other twelve tools over two that merely cannot work. So the server always
759
- // starts and only these two report, precisely, that they need the repo checkout.
784
+ // Loaded LAZILY, on call, and never at module scope, from one of two places. In a repo checkout it is
785
+ // ../../design-to-code/<mod>.ts, a SIBLING of this package root (bridge/) — which npm's `files` cannot reach. So
786
+ // the npm package carries the layer as bundles instead: `prepack` (bridge/pack.ts) copies the committed,
787
+ // self-contained claude-plugin/scripts/<mod>.js into ../layer/. Neither there (a checkout without design-to-code/,
788
+ // a package packed without its prepack): importing eagerly would take the whole server down with a
789
+ // module-not-found, killing the other tools over two that merely cannot work — so the server always starts and only
790
+ // these two report, precisely, what is missing.
760
791
  // The slice of each layer module this server calls, declared HERE (over doc-types.ts shapes) rather
761
792
  // than as `typeof import("../../design-to-code/x.ts")`: even a type-only reference pulls that file and
762
- // its imports into the bridge program, and the published package (src/ alone) would then name files it
793
+ // its imports into the bridge program, and the published package (src/ is TypeScript, the layer only bundles) would name files it
763
794
  // does not ship (see doc-types.ts). So bridge/src has no dependency of any kind on the layer — only the
764
795
  // dynamic import below, which fails soft. Mirrors design-to-code/get-component.ts GetComponentResult and
765
796
  // design-to-code/drift-lint.ts runDriftLint(); keep them in step (test/mcp-smoke.test.ts checks the real modules still satisfy these at compile time).
@@ -775,27 +806,27 @@ export interface DriftLintModule {
775
806
  | { status: "linted"; errors: unknown[]; warnings: unknown[]; freshness: unknown; summary: unknown; catalogs: ReadonlyArray<{ file: string }>; coverage: { coverage: object; warnings: unknown[] } | null };
776
807
  }
777
808
  async function loadLayer<T>(mod: string, tool: string): Promise<T> {
778
- // The layer sits beside bridge/ in a repo checkout and nowhere in the npm package. Whether it is there
809
+ // The source in a checkout first (what the repo's tests run), else the package's bundle. Whether either is there
779
810
  // is asked of the file system, before the import: the import's own failure cannot say it — a missing
780
811
  // dependency OF the layer fails the same way, and the error's code and url fields differ between
781
812
  // runtimes (Bun reports neither the way Node did). Any error from an import of a file that IS there
782
813
  // is passed on as it is.
783
- const url = new URL("../../design-to-code/" + mod, import.meta.url);
784
- if (!fs.existsSync(url)) {
814
+ const url = [new URL(`../../design-to-code/${mod}.ts`, import.meta.url), new URL(`../layer/${mod}.js`, import.meta.url)].find((u) => fs.existsSync(u));
815
+ if (!url) {
785
816
  throw new Error(
786
- `${tool} needs the design-to-code layer, which is not present in this install. It ships with the ` +
787
- `Design Twin repository, not with the published npm package — run this MCP server from a repo ` +
788
- `checkout (bun bridge/src/figma-mcp.ts) to use it. Everything else on this server works either way.`
817
+ `${tool} needs the design-to-code layer, which is not present in this install: neither a repo checkout's ` +
818
+ `design-to-code/${mod}.ts nor the npm package's layer/${mod}.js. Reinstall @design-twin/dtwin, or run this MCP ` +
819
+ `server from a repo checkout (bun bridge/src/figma-mcp.ts). Everything else on this server works either way.`
789
820
  );
790
821
  }
791
822
  return (await import(url.href)) as T;
792
823
  }
793
824
  const getComponent = async (catalogFile: string, handle: string) =>
794
- (await loadLayer<GetComponentModule>("get-component.ts", "design_get_component")).getComponent(
825
+ (await loadLayer<GetComponentModule>("get-component", "design_get_component")).getComponent(
795
826
  catalogFile,
796
827
  handle
797
828
  );
798
- const driftLintLayer = () => loadLayer<DriftLintModule>("drift-lint.ts", "design_drift_lint");
829
+ const driftLintLayer = () => loadLayer<DriftLintModule>("drift-lint", "design_drift_lint");
799
830
 
800
831
  // The export's component catalog is export-reader.ts readComponentCatalog's: the `files.componentsLocal` pointer
801
832
  // (inside the export only), else an older export's inline components[] in design-system.json — the catalog `dtwin
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)"));