breakpoint-mcp 1.74.0 โ†’ 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,18 @@
14
14
  * may legitimately not be running when a user checks their install; pass
15
15
  * `--require-live` when you expect them up (e.g. after opening the editor).
16
16
  *
17
+ * ๐Ÿ”ด THE FOUR BRIDGES DO NOT COME UP TOGETHER, AND `--require-live` USED TO
18
+ * PRETEND THEY DID. Opening the editor starts three of them โ€” the addon's
19
+ * bridge on 9080 and Godot's own LSP/DAP. The runtime bridge on 9081 lives
20
+ * INSIDE THE GAME and binds only while the project is running. So the one
21
+ * instruction `docs/USER_GUIDE.md` ยง3.0, `README.md` and `init`'s own closing
22
+ * line all give a new user โ€” open the editor, then run `doctor --require-live`
23
+ * โ€” exited 1 on a completely correct install, every time, because it demanded a
24
+ * game nobody had been told to launch. `LIVE_LEVELS` is that distinction made
25
+ * nameable: bare `--require-live` asserts what opening the editor makes true,
26
+ * and `--require-live=all` is the old four-bridge contract, still reachable and
27
+ * now something a caller asks for on purpose.
28
+ *
17
29
  * Configuration is read via loadConfig(), so the same env overrides the server
18
30
  * honours (GODOT_BIN, GODOT_PROJECT, and the BREAKPOINT_ / GODOT_ ports) apply here.
19
31
  */
@@ -23,10 +35,28 @@ import path from "node:path";
23
35
  import { spawnSync } from "node:child_process";
24
36
  import { loadConfig } from "../config.js";
25
37
  import { CAPABILITY_GROUPS, droppedTools, selectPrivilegedGroups } from "../capabilities.js";
26
- import { parseArgs } from "./args.js";
38
+ import { parseArgs, preflight } from "./args.js";
39
+ import { DOCTOR_FLAGS, DOCTOR_USAGE } from "./usage.js";
27
40
  import { BridgeClient } from "../bridge.js";
28
41
  import { resolveBridgeSecret } from "../secret.js";
29
- const ADDON_REL = "addons/breakpoint_mcp";
42
+ import { ADDON_REL, ADDON_SKEW_HINT, compareAddonVersions, readAddonVersion, } from "../addon-version.js";
43
+ import { resolveBundledAddon } from "./init.js";
44
+ /**
45
+ * Which bridges a run insists on.
46
+ *
47
+ * none โ€” every bridge is informational (the default: a user checking an
48
+ * install has neither the editor nor the game up, necessarily)
49
+ * editor โ€” the three the EDITOR brings up: editor bridge, GDScript LSP, DAP
50
+ * runtime โ€” the one the GAME brings up: the runtime bridge on 9081
51
+ * all โ€” all four
52
+ *
53
+ * The order matters to nothing; the tier a bridge belongs to is declared on the
54
+ * bridge, so adding a fifth is one field and no new branch.
55
+ */
56
+ export const LIVE_LEVELS = ["none", "editor", "runtime", "all"];
57
+ export function severityFor(level, tier) {
58
+ return level === "all" || level === tier ? "required" : "info";
59
+ }
30
60
  const PLUGIN_CFG_RES = "res://addons/breakpoint_mcp/plugin.cfg";
31
61
  /** Resolve after a TCP connect succeeds (true) or the port is closed/times out (false). */
32
62
  function probeTcp(host, port, timeoutMs) {
@@ -89,6 +119,57 @@ function checkGodotBinary(bin, timeoutMs) {
89
119
  detail: `${bin} โ†’ ${version}`,
90
120
  };
91
121
  }
122
+ /**
123
+ * The pair nothing compared until 258 ยง2: the addon a project has INSTALLED
124
+ * against the addon this host SHIPS.
125
+ *
126
+ * ๐Ÿ”ด WHY `info` AND NOT `required`, ON THE RECORD. `doctor`'s exit code is a
127
+ * pre-flight contract, and 252 spent a whole row fixing `--require-live` for
128
+ * exactly one reason: it exited 1 on a correct install. An addon a release or two
129
+ * behind answers almost every method the host calls โ€” an addon at 1.9.1 answers
130
+ * 260 of the current 265 โ€” so a red exit would be wrong for most of the population
131
+ * that has one. The skew is REPORTED, loudly and with the command that clears it;
132
+ * it does not fail the run. Promoting it is one word if that turns out to be the
133
+ * wrong call.
134
+ *
135
+ * `newer` is not folded into `older`. An installed addon ahead of the host is a
136
+ * developer running the repo copy against an older published host, and telling
137
+ * that person to overwrite it with `--force` would destroy the newer one.
138
+ */
139
+ function addonVersionCheck(installed) {
140
+ const bundledDir = resolveBundledAddon();
141
+ const bundled = bundledDir === null ? null : readAddonVersion(bundledDir);
142
+ const skew = compareAddonVersions(installed, bundled);
143
+ const pair = `installed ${installed ?? "?"} ยท this host ships ${bundled ?? "?"}`;
144
+ if (skew === "same") {
145
+ return { name: "addon-version", status: "ok", severity: "info", detail: `matches this host (${installed})` };
146
+ }
147
+ if (skew === "unknown") {
148
+ return {
149
+ name: "addon-version",
150
+ status: "skip",
151
+ severity: "info",
152
+ detail: `not comparable (${pair})`,
153
+ hint: "Neither version parsed as a dotted number, so no direction can be reported without guessing at it.",
154
+ };
155
+ }
156
+ if (skew === "newer") {
157
+ return {
158
+ name: "addon-version",
159
+ status: "ok",
160
+ severity: "info",
161
+ detail: `ahead of this host (${pair})`,
162
+ hint: "The project's addon is newer than the one bundled here. Upgrade the host ('npm i -g breakpoint-mcp@latest') rather than overwriting the addon.",
163
+ };
164
+ }
165
+ return {
166
+ name: "addon-version",
167
+ status: "fail",
168
+ severity: "info",
169
+ detail: `older than this host (${pair})`,
170
+ hint: ADDON_SKEW_HINT,
171
+ };
172
+ }
92
173
  function checkAddon(projectPath) {
93
174
  const projText = readText(path.join(projectPath, "project.godot"));
94
175
  if (projText === null) {
@@ -114,13 +195,14 @@ function checkAddon(projectPath) {
114
195
  });
115
196
  }
116
197
  else {
117
- const m = /version\s*=\s*"([^"]*)"/.exec(cfgText);
198
+ const installed = readAddonVersion(path.join(projectPath, ADDON_REL));
118
199
  checks.push({
119
200
  name: "addon-installed",
120
201
  status: "ok",
121
202
  severity: "required",
122
- detail: `${ADDON_REL} (version ${m ? m[1] : "?"})`,
203
+ detail: `${ADDON_REL} (version ${installed ?? "?"})`,
123
204
  });
205
+ checks.push(addonVersionCheck(installed));
124
206
  }
125
207
  checks.push(isPluginEnabled(projText)
126
208
  ? {
@@ -197,9 +279,12 @@ export function checkCapabilities(config) {
197
279
  detail: dropped.length === 0
198
280
  ? `${state} โ€” full 292-tool surface`
199
281
  : `${state} (secure default) โ€” ${dropped.length} higher-trust tool(s) dropped from the surface`,
282
+ // The token list is DERIVED โ€” `code-execution,network` was printed here
283
+ // for as long as the `network` group has been deleted, so the remedy
284
+ // named a token `selectPrivilegedGroups` reports as unknown and drops.
200
285
  hint: dropped.length === 0
201
286
  ? undefined
202
- : "Enable with BREAKPOINT_PRIVILEGED_GROUPS=code-execution,network (or `breakpoint-mcp init --trust full`). See the godot://capabilities resource for the exact tool list.",
287
+ : `Enable with BREAKPOINT_PRIVILEGED_GROUPS=${CAPABILITY_GROUPS.join(",")} (or \`breakpoint-mcp init --trust full\`). See the godot://capabilities resource for the exact tool list.`,
203
288
  },
204
289
  ];
205
290
  const assetGenConfigured = (config.assetGenBackend && config.assetGenBackend !== "none") ||
@@ -224,20 +309,61 @@ export function checkCapabilities(config) {
224
309
  * secret that no longer matches the one the addon minted. `probeTcp` can see
225
310
  * neither, which is what made "editor-bridge reachable" a check that could pass
226
311
  * while every tool failed.
312
+ *
313
+ * ๐Ÿ”ด AND IT NOW KEEPS THE ANSWER. `ping` has replied with `addon_version` since the
314
+ * addon had one, and all four of its consumers threw it away โ€” this one returned
315
+ * `boolean`, `readiness.ts` returns `boolean`, and the two tool paths hand it to the
316
+ * model and forget it. That is the third of 258 ยง2's four compounding parts: the
317
+ * version of the addon ACTUALLY LOADED never reached anything that could compare it.
318
+ * Files on disk are not what is running โ€” Godot reads the addon at project load, so
319
+ * `init --force` fixes the disk and changes nothing until the editor restarts, and
320
+ * that gap is exactly where a user concludes the fix did not work.
227
321
  */
228
322
  async function handshakeOk(config, b, timeoutMs) {
229
323
  const client = new BridgeClient(b.host, b.port, timeoutMs, "bridge", undefined, () => resolveBridgeSecret(config.projectPath, b.secretEnv ?? []));
230
324
  try {
231
- await client.request("ping", {}, timeoutMs);
232
- return true;
325
+ const reply = (await client.request("ping", {}, timeoutMs));
326
+ const v = reply?.addon_version;
327
+ return { ok: true, addonVersion: typeof v === "string" && v !== "" ? v : null };
233
328
  }
234
329
  catch {
235
- return false;
330
+ return { ok: false, addonVersion: null };
236
331
  }
237
332
  finally {
238
333
  client.close();
239
334
  }
240
335
  }
336
+ /**
337
+ * The live half of the pair: the addon a bridge REPORTS against the one this host
338
+ * ships. Emitted only when a bridge actually answered with a version, so a closed
339
+ * editor produces no row rather than a row about nothing.
340
+ */
341
+ export function addonRunningCheck(bridge, running, bundled) {
342
+ if (running === null)
343
+ return null;
344
+ // One row per answering bridge, named after it: a stale editor and a stale game are
345
+ // two different states with two different next actions โ€” reopen the project, versus
346
+ // relaunch it โ€” and collapsing them into one `addon-running` row would report
347
+ // whichever process happened to be asked first.
348
+ const name = `addon-running-${bridge.replace(/-bridge$/, "")}`;
349
+ const skew = compareAddonVersions(running, bundled);
350
+ const pair = `${bridge} reports ${running} ยท this host ships ${bundled ?? "?"}`;
351
+ if (skew === "older") {
352
+ return {
353
+ name,
354
+ status: "fail",
355
+ severity: "info",
356
+ detail: `older than this host (${pair})`,
357
+ hint: ADDON_SKEW_HINT,
358
+ };
359
+ }
360
+ return {
361
+ name,
362
+ status: "ok",
363
+ severity: "info",
364
+ detail: skew === "same" ? `matches this host (${running}, live on ${bridge})` : `(${pair})`,
365
+ };
366
+ }
241
367
  export async function runDoctorChecks(config, opts) {
242
368
  const checks = [];
243
369
  // Capability groups โ€” the secure-default surface + how to widen it (info only).
@@ -247,21 +373,25 @@ export async function runDoctorChecks(config, opts) {
247
373
  // Editor addon install + enable.
248
374
  checks.push(...checkAddon(config.projectPath));
249
375
  // The four bridges.
250
- const severity = opts.requireLive ? "required" : "info";
251
376
  // `secretEnv` marks the two bridges that speak OUR protocol, so a TCP connect
252
377
  // is not the end of the story for them (see handshakeOk). The LSP and DAP
253
378
  // entries have none: those ports belong to Godot, and anything we could send
254
379
  // to prove liveness would be us implementing a foreign protocol inside doctor.
380
+ //
381
+ // `tier` is what the bridge NEEDS RUNNING, not what it talks to โ€” the one
382
+ // fact `--require-live` was missing.
255
383
  const bridges = [
256
384
  {
257
385
  name: "editor-bridge",
386
+ tier: "editor",
258
387
  host: config.bridgeHost,
259
388
  port: config.bridgePort,
260
- hint: 'Open the editor with the "Breakpoint MCP" plugin enabled.',
389
+ hint: 'Open the editor with the "Breakpoint MCP" plugin enabled โ€” and if it was already open when you ran `breakpoint-mcp init`, close and reopen the project (Godot reads the enabled-plugin list only at project load).',
261
390
  secretEnv: ["BREAKPOINT_BRIDGE_SECRET"],
262
391
  },
263
392
  {
264
393
  name: "runtime-bridge",
394
+ tier: "runtime",
265
395
  host: config.runtimeHost,
266
396
  port: config.runtimePort,
267
397
  hint: "Launch the project (godot_run_project / dbg_launch) with the plugin enabled โ€” it auto-registers the runtime autoload.",
@@ -269,24 +399,35 @@ export async function runDoctorChecks(config, opts) {
269
399
  },
270
400
  {
271
401
  name: "gdscript-lsp",
402
+ tier: "editor",
272
403
  host: config.lspHost,
273
404
  port: config.lspPort,
274
405
  hint: "Godot's language server runs while the editor is open (Editor โ†’ Editor Settings โ†’ Network โ†’ Language Server).",
275
406
  },
276
407
  {
277
408
  name: "gdscript-dap",
409
+ tier: "editor",
278
410
  host: config.dapHost,
279
411
  port: config.dapPort,
280
412
  hint: "Godot's debug adapter runs while the editor is open (Editor โ†’ Editor Settings โ†’ Network โ†’ Debug Adapter).",
281
413
  },
282
414
  ];
415
+ // Filled by the handshakes below, read once after them: which bridge reported which
416
+ // addon version. A map and not a single variable because both the editor and the
417
+ // runtime plane answer `ping` with one, and they are two different processes that
418
+ // can legitimately disagree โ€” the editor holds the addon it loaded at project open,
419
+ // the game holds the one on disk when it launched.
420
+ const liveAddonVersions = new Map();
283
421
  const bridgeChecks = await Promise.all(bridges.map(async (b) => {
422
+ const severity = severityFor(opts.liveLevel, b.tier);
423
+ const liveness = true; // every row in this map is one
284
424
  const ok = await probeTcp(b.host, b.port, opts.timeoutMs);
285
425
  if (!ok) {
286
426
  return {
287
427
  name: b.name,
288
428
  status: "fail",
289
429
  severity,
430
+ liveness,
290
431
  detail: `${b.host}:${b.port} unreachable`,
291
432
  hint: b.hint,
292
433
  };
@@ -299,11 +440,14 @@ export async function runDoctorChecks(config, opts) {
299
440
  // Both then reported "reachable" here and failed on every real call.
300
441
  if (b.secretEnv) {
301
442
  const live = await handshakeOk(config, b, opts.timeoutMs);
302
- if (!live) {
443
+ if (live.addonVersion !== null)
444
+ liveAddonVersions.set(b.name, live.addonVersion);
445
+ if (!live.ok) {
303
446
  return {
304
447
  name: b.name,
305
448
  status: "fail",
306
449
  severity,
450
+ liveness,
307
451
  detail: `${b.host}:${b.port} open, but no Breakpoint bridge answered`,
308
452
  hint: "Something holds that port without speaking the bridge protocol, or the shared secret is stale. Close other Godot instances, or delete res://.godot/breakpoint_mcp.secret and reopen the editor to remint it.",
309
453
  };
@@ -313,10 +457,19 @@ export async function runDoctorChecks(config, opts) {
313
457
  name: b.name,
314
458
  status: "ok",
315
459
  severity,
460
+ liveness,
316
461
  detail: `${b.host}:${b.port} reachable${b.secretEnv ? " ยท bridge answered" : ""}`,
317
462
  };
318
463
  }));
319
464
  checks.push(...bridgeChecks);
465
+ // The running half of the pair, one row per bridge that answered.
466
+ const bundledDir = resolveBundledAddon();
467
+ const bundledVersion = bundledDir === null ? null : readAddonVersion(bundledDir);
468
+ for (const [bridge, running] of liveAddonVersions) {
469
+ const c = addonRunningCheck(bridge, running, bundledVersion);
470
+ if (c)
471
+ checks.push(c);
472
+ }
320
473
  // Optional C# tooling.
321
474
  if (opts.includeCsharp) {
322
475
  checks.push(checkCsharpTool("csharp-lsp", config.csLspCmd));
@@ -325,34 +478,102 @@ export async function runDoctorChecks(config, opts) {
325
478
  const ok = checks.every((c) => c.severity !== "required" || c.status !== "fail");
326
479
  return { checks, ok };
327
480
  }
328
- function glyph(status) {
329
- return status === "ok" ? "โœ“" : status === "fail" ? "โœ—" : "โ€“";
481
+ /**
482
+ * ๐Ÿ”ด `โœ—` IS RESERVED FOR A CHECK THAT FAILED THE RUN. An informational check
483
+ * that did not pass gets `!`, because the alternative was the contradiction this
484
+ * function used to print: four `โœ—` glyphs, and then `All required checks
485
+ * passed.` Both halves were true and defensible on their own โ€” the bridges were
486
+ * genuinely down, and no REQUIRED check had genuinely failed โ€” and a tool whose
487
+ * whole job is telling a user whether they are okay cannot say those two things
488
+ * in one breath and leave them to work out which one is the answer.
489
+ */
490
+ function glyph(c) {
491
+ if (c.status === "ok")
492
+ return "โœ“";
493
+ if (c.status === "skip")
494
+ return "โ€“";
495
+ return c.severity === "required" ? "โœ—" : "!";
496
+ }
497
+ /**
498
+ * The last line, which is the only line many readers read. It says what failed
499
+ * AND what that means for the exit code, because those are two facts and the
500
+ * summary that named only one of them was the defect.
501
+ */
502
+ export function summaryLine(report) {
503
+ const failed = report.checks.filter((c) => c.status === "fail");
504
+ const noted = failed.filter((c) => c.severity === "info");
505
+ if (!report.ok)
506
+ return "Some required checks failed โ€” see the โ†ณ hints above.";
507
+ if (noted.length === 0)
508
+ return "All checks passed.";
509
+ const names = noted.map((c) => c.name).join(", ");
510
+ const head = `All required checks passed. ${noted.length} informational check(s) did not โ€” ${names} (marked !, not counted against the exit code).`;
511
+ const live = noted.filter((c) => c.liveness);
512
+ const standing = noted.filter((c) => !c.liveness);
513
+ // Only the liveness rows get the "expected when nothing is running" sentence, and
514
+ // only the standing rows get "this will not clear on its own". Saying either of
515
+ // those about the whole set is how the line contradicted the glyphs the first time.
516
+ const parts = [head];
517
+ if (live.length) {
518
+ parts.push(`${live.map((c) => c.name).join(", ")} ${live.length === 1 ? "is" : "are"} expected when the editor ` +
519
+ `or the game is not running; re-run with --require-live once the project is open in Godot.`);
520
+ }
521
+ if (standing.length) {
522
+ parts.push(`${standing.map((c) => c.name).join(", ")} will NOT clear by starting anything โ€” see the โ†ณ hint${standing.length === 1 ? "" : "s"} above.`);
523
+ }
524
+ return parts.join(" ");
330
525
  }
331
526
  function printReport(report) {
332
527
  const width = Math.max(...report.checks.map((c) => c.name.length));
333
528
  const out = ["breakpoint-mcp doctor", ""];
334
529
  for (const c of report.checks) {
335
- out.push(` ${glyph(c.status)} ${c.name.padEnd(width)} ${c.detail}`);
530
+ out.push(` ${glyph(c)} ${c.name.padEnd(width)} ${c.detail}`);
336
531
  if (c.status === "fail" && c.hint)
337
532
  out.push(` โ†ณ ${c.hint}`);
338
533
  }
339
534
  out.push("");
340
- out.push(report.ok
341
- ? "All required checks passed."
342
- : "Some required checks failed โ€” see the โ†ณ hints above.");
535
+ out.push(summaryLine(report));
343
536
  process.stdout.write(out.join("\n") + "\n");
344
537
  }
538
+ /**
539
+ * Read `--require-live` in both its forms. Bare (`true`) is `editor`; a value is
540
+ * taken literally. An unrecognised value is an ERROR, never a silent default โ€”
541
+ * `--require-live=yes` quietly meaning "editor" would be a flag that agrees with
542
+ * whatever you typed and asserts something else.
543
+ */
544
+ export function parseLiveLevel(raw) {
545
+ if (raw === undefined || raw === false)
546
+ return "none";
547
+ if (raw === true)
548
+ return "editor";
549
+ const v = raw.trim().toLowerCase();
550
+ if (LIVE_LEVELS.includes(v) && v !== "none")
551
+ return v;
552
+ return {
553
+ error: `doctor: --require-live=${raw} is not a level. Use --require-live (the editor's three bridges), ` +
554
+ `--require-live=runtime (the game's), or --require-live=all.`,
555
+ };
556
+ }
345
557
  /** Entry point for `breakpoint-mcp doctor`. Returns the process exit code. */
346
558
  export async function runDoctor(argv) {
347
- const { flags } = parseArgs(argv, ["json", "require-live", "include-csharp"]);
559
+ const parsed = parseArgs(argv, ["json", "require-live", "include-csharp", "help", "h"], DOCTOR_FLAGS);
560
+ const pre = preflight(parsed, "doctor", DOCTOR_USAGE);
561
+ if (pre !== null)
562
+ return pre;
563
+ const { flags } = parsed;
348
564
  if (typeof flags.project === "string")
349
565
  process.env.GODOT_PROJECT = flags.project;
350
566
  const timeoutRaw = typeof flags.timeout === "string" ? Number.parseInt(flags.timeout, 10) : NaN;
351
567
  const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : 1500;
568
+ const liveLevel = parseLiveLevel(flags["require-live"]);
569
+ if (typeof liveLevel !== "string") {
570
+ process.stderr.write(liveLevel.error + "\n");
571
+ return 2;
572
+ }
352
573
  const config = loadConfig();
353
574
  const report = await runDoctorChecks(config, {
354
575
  timeoutMs,
355
- requireLive: flags["require-live"] === true,
576
+ liveLevel,
356
577
  includeCsharp: flags["include-csharp"] === true,
357
578
  });
358
579
  if (flags.json === true) {
package/dist/cli/init.js CHANGED
@@ -12,16 +12,20 @@ import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
- import { parseArgs } from "./args.js";
15
+ import { parseArgs, preflight } from "./args.js";
16
+ import { INIT_FLAGS, INIT_USAGE } from "./usage.js";
17
+ import { ADDON_REL, ADDON_SKEW_HINT, compareAddonVersions, readAddonVersion, } from "../addon-version.js";
16
18
  import { CLIENT_IDS, clientInfo, mergeClientConfig, serverEntry, snippet } from "./clients.js";
17
19
  import { DEFAULT_REPO, fetchAddonFromGitHub } from "./github.js";
18
20
  import { CAPABILITY_GROUPS, PRIVILEGED_TOOL_COUNT, parsePrivilegedGroups, selectPrivilegedGroups, } from "../capabilities.js";
19
21
  /**
20
22
  * Resolve the guided trust preset into a validated BREAKPOINT_PRIVILEGED_GROUPS
21
- * value ("" = the safe default โ€” `code-execution` + `network` off). Accepts either
22
- * `--trust safe|full` (friendly presets) or `--privileged-groups <list>`
23
- * (`code-execution`, `network`, `all`, comma-separated). Unknown tokens are dropped
24
- * with a warning so a typo never silently enables a group.
23
+ * value ("" = the safe default โ€” every group in `CAPABILITY_GROUPS` off). Accepts
24
+ * either `--trust safe|full` (friendly presets) or `--privileged-groups <list>`
25
+ * (any of `CAPABILITY_GROUPS`, or `all`, comma-separated). Unknown tokens are
26
+ * dropped with a warning so a typo never silently enables a group โ€” which is why
27
+ * every printed example of this flag is derived from `CAPABILITY_GROUPS` rather
28
+ * than restated: a restated one named `network` long after it was deleted.
25
29
  */
26
30
  export function resolvePrivilegedGroups(flags) {
27
31
  const trustAlias = { safe: "", none: "", off: "", full: "all", all: "all" };
@@ -39,7 +43,7 @@ export function resolvePrivilegedGroups(flags) {
39
43
  const value = [...enabled].sort().join(",");
40
44
  return warn ? { value, warn } : { value };
41
45
  }
42
- const PLUGIN_REL = "addons/breakpoint_mcp";
46
+ const PLUGIN_REL = ADDON_REL;
43
47
  const PLUGIN_CFG_RES = "res://addons/breakpoint_mcp/plugin.cfg";
44
48
  const SERVER_NAME = "godot";
45
49
  /**
@@ -135,19 +139,52 @@ export function enablePlugin(projectGodotText) {
135
139
  export function installAddon(addonSource, projectPath, opts) {
136
140
  const dest = path.join(projectPath, PLUGIN_REL);
137
141
  const exists = fs.existsSync(path.join(dest, "plugin.cfg"));
142
+ const installed = exists ? readAddonVersion(dest) : null;
143
+ const source = readAddonVersion(addonSource);
144
+ const skew = exists ? compareAddonVersions(installed, source) : "unknown";
138
145
  if (exists && !opts.force)
139
- return { action: "skipped", dest };
146
+ return { action: "skipped", dest, installed, source, skew };
140
147
  fs.mkdirSync(path.dirname(dest), { recursive: true });
141
148
  fs.cpSync(addonSource, dest, { recursive: true });
142
- return { action: exists ? "overwritten" : "installed", dest };
149
+ return { action: exists ? "overwritten" : "installed", dest, installed, source, skew };
150
+ }
151
+ /**
152
+ * POSIX-quote one word of a command line this CLI prints for a human to paste.
153
+ *
154
+ * ๐Ÿ”ด A GENERATED COMMAND LINE IS AN INPUT YOU WROTE. The JSON snippet beside this
155
+ * one has been correct since it shipped, and for a reason that has nothing to do
156
+ * with care: JSON quotes for you. The shell line was assembled by interpolation,
157
+ * so `~/Godot Projects/My Game` became four words โ€” `claude mcp add` read
158
+ * `--env GODOT_PROJECT=/Users/x/Godot` and then `Projects/My`, `Game` as stray
159
+ * positionals, and the server it configured pointed at a directory that does not
160
+ * exist. Nothing failed loudly; the path was simply truncated at the first space.
161
+ *
162
+ * The safe set is `shlex.quote`'s: characters no shell reinterprets, so an
163
+ * ordinary path stays readable and only a path that needs quoting gets it. The
164
+ * `'\''` idiom closes the quote, escapes a literal apostrophe, and reopens โ€”
165
+ * correct in every POSIX shell, and the only way to carry a `'` inside `'โ€ฆ'`.
166
+ */
167
+ export function shellQuote(word) {
168
+ if (word.length > 0 && /^[A-Za-z0-9_@%+=:,./-]+$/.test(word))
169
+ return word;
170
+ return `'${word.replace(/'/g, `'\\''`)}'`;
143
171
  }
144
172
  function claudeCodeCommand(projectPath, privilegedGroups) {
145
- const groups = privilegedGroups ? ` --env BREAKPOINT_PRIVILEGED_GROUPS=${privilegedGroups}` : "";
146
- return `claude mcp add godot --env GODOT_PROJECT=${projectPath}${groups} -- npx -y breakpoint-mcp`;
173
+ // Each `--env` value is ONE word to the shell, so the whole `KEY=value` token is
174
+ // quoted rather than the path alone: `GODOT_PROJECT='/a b'` would reach the
175
+ // process as the literal string it looks like, quotes and all.
176
+ const groups = privilegedGroups
177
+ ? ` --env ${shellQuote(`BREAKPOINT_PRIVILEGED_GROUPS=${privilegedGroups}`)}`
178
+ : "";
179
+ return `claude mcp add godot --env ${shellQuote(`GODOT_PROJECT=${projectPath}`)}${groups} -- npx -y breakpoint-mcp`;
147
180
  }
148
181
  /** Entry point for `breakpoint-mcp init`. Returns the process exit code. */
149
182
  export async function runInit(argv, deps = {}) {
150
- const { flags } = parseArgs(argv, ["force", "dry-run"]);
183
+ const parsed = parseArgs(argv, ["force", "dry-run", "help", "h"], INIT_FLAGS);
184
+ const pre = preflight(parsed, "init", INIT_USAGE);
185
+ if (pre !== null)
186
+ return pre;
187
+ const { flags } = parsed;
151
188
  const projectPath = typeof flags.project === "string"
152
189
  ? path.resolve(flags.project)
153
190
  : process.env.GODOT_PROJECT ?? process.cwd();
@@ -220,7 +257,18 @@ export async function runInit(argv, deps = {}) {
220
257
  const r = installAddon(addonSource, projectPath, { force });
221
258
  if (tmpFetchDir)
222
259
  fs.rmSync(tmpFetchDir, { recursive: true, force: true });
223
- say(` addon: ${r.action} โ†’ ${PLUGIN_REL}/`);
260
+ const versionNote = r.action === "skipped" ? ` (kept ${r.installed ?? "?"})` : r.source ? ` (${r.source})` : "";
261
+ say(` addon: ${r.action} โ†’ ${PLUGIN_REL}/${versionNote}`);
262
+ // ๐Ÿ”ด THE LINE THAT MAKES THE SKIP HONEST. A skip over an addon that is already
263
+ // current is correct and silent; a skip over an OLDER one is the moment the skew
264
+ // becomes permanent, and until now the two read identically. It goes to stderr
265
+ // because it is not part of the install transcript a user pastes โ€” it is the thing
266
+ // that went wrong inside a command that exits 0.
267
+ if (r.action === "skipped" && r.skew === "older") {
268
+ process.stderr.write(`init: the addon already in this project is OLDER than the one this host ships ` +
269
+ `(${r.installed ?? "?"} vs ${r.source ?? "?"}), and it was left in place.\n` +
270
+ ` ${ADDON_SKEW_HINT}\n`);
271
+ }
224
272
  }
225
273
  // 2. Enable it in project.godot.
226
274
  const before = fs.readFileSync(projGodot, "utf8");
@@ -283,16 +331,39 @@ export async function runInit(argv, deps = {}) {
283
331
  }
284
332
  else {
285
333
  say("Higher-trust tool groups are OFF by default (the secure default). The assistant loads the");
286
- say(`everyday authoring/debug surface and drops the ${PRIVILEGED_TOOL_COUNT} code-execution + network tools (e.g.`);
287
- say("godot_run_headless_script, the *_call_method / dbg_evaluate tools, asset-gen, backend_*).");
288
- say("Enable them by re-running with `--trust full` (or `--privileged-groups code-execution,network`),");
334
+ // ๐Ÿ”ด THE GROUP NAME AND THE EXAMPLES ARE BOTH CORRECTIONS, NOT POLISH.
335
+ // This said "code-execution + network tools" and named `backend_*` among
336
+ // them: `network` has been deleted from capabilities.ts, and
337
+ // backend_detect / backend_configure are DELIBERATELY unprivileged โ€” so the
338
+ // first thing a new user reads named a group that does not exist and two
339
+ // tools that are not dropped. The suggested flag below carried the same
340
+ // dead token, which `init`'s own resolver answers with
341
+ // `ignoring unknown trust group(s): network`: the tool warned about the
342
+ // command the tool told them to run.
343
+ say(`everyday authoring/debug surface and drops the ${PRIVILEGED_TOOL_COUNT} ${CAPABILITY_GROUPS.join(" + ")} tools (e.g.`);
344
+ say("godot_run_headless_script, the *_call_method / dbg_evaluate tools, asset-gen).");
345
+ say(`Enable them by re-running with \`--trust full\` (or \`--privileged-groups ${CAPABILITY_GROUPS.join(",")}\`),`);
289
346
  say("or set BREAKPOINT_PRIVILEGED_GROUPS in the server env. The `godot://capabilities` resource and");
290
347
  say("`breakpoint-mcp doctor` list exactly what each group gates.");
291
348
  }
292
349
  if (privilegedWarn)
293
350
  say(` note: ${privilegedWarn}`);
294
351
  say("");
295
- say("Next: open the project in Godot, then run `breakpoint-mcp doctor --require-live` to verify.");
352
+ // ๐Ÿ”ด THE REOPEN TRAP. Godot reads project.godot's [editor_plugins] at project load and
353
+ // does not reload it, so an editor that was already running when we wrote the section
354
+ // keeps the plugin DISABLED โ€” the bridge never starts and every editor-plane tool is
355
+ // dark, with nothing on screen saying why. The line is conditioned on `en.changed`
356
+ // because a project whose plugin was already enabled has nothing to reopen for, and a
357
+ // warning printed on every run is a warning nobody reads.
358
+ if (!dryRun && en.changed) {
359
+ say("Next: open the project in Godot โ€” and if it is ALREADY OPEN, close and reopen it.");
360
+ say(" Godot reads project.godot's [editor_plugins] at project load and does not reload");
361
+ say(" it, so an editor that was already running keeps the plugin disabled.");
362
+ say("Then run `breakpoint-mcp doctor --require-live` to verify.");
363
+ }
364
+ else {
365
+ say("Next: open the project in Godot, then run `breakpoint-mcp doctor --require-live` to verify.");
366
+ }
296
367
  process.stdout.write(out.join("\n") + "\n");
297
368
  return 0;
298
369
  }
package/dist/cli/tools.js CHANGED
@@ -22,7 +22,8 @@
22
22
  * `--surface` picks which one populates `tools[]`; both counts are always in the
23
23
  * header so a consumer cannot report one as the other by accident.
24
24
  */
25
- import { parseArgs } from "./args.js";
25
+ import { parseArgs, preflight } from "./args.js";
26
+ import { TOOLS_FLAGS, TOOLS_USAGE } from "./usage.js";
26
27
  import { buildToolsets } from "../toolsets.js";
27
28
  import { applyOutputSchemas } from "../schemas.js";
28
29
  import { applyAnnotations, annotationsFor } from "../annotations.js";
@@ -148,20 +149,16 @@ function renderText(report) {
148
149
  return lines.join("\n");
149
150
  }
150
151
  export async function runTools(argv) {
151
- const { flags } = parseArgs(argv, ["json", "help", "h"]);
152
- if (flags.help || flags.h) {
153
- process.stdout.write([
154
- "breakpoint-mcp tools โ€” export the tool surface (name, risk annotations, capability group).",
155
- "",
156
- " --json Emit JSON (stable, no timestamp โ€” safe to diff between releases).",
157
- " --surface <which> full | secure-default (default: secure-default, what an",
158
- " untouched install actually advertises).",
159
- "",
160
- "Reads no Godot install and starts no server โ€” the registry is built statically.",
161
- "",
162
- ].join("\n"));
163
- return 0;
164
- }
152
+ const parsed = parseArgs(argv, ["json", "help", "h"], TOOLS_FLAGS);
153
+ const pre = preflight(parsed, "tools", [
154
+ ...TOOLS_USAGE,
155
+ "",
156
+ "Reads no Godot install and starts no server โ€” the registry is built statically.",
157
+ "",
158
+ ]);
159
+ if (pre !== null)
160
+ return pre;
161
+ const { flags } = parsed;
165
162
  const raw = typeof flags.surface === "string" ? flags.surface : "secure-default";
166
163
  if (raw !== "full" && raw !== "secure-default") {
167
164
  process.stderr.write(`unknown --surface "${raw}" (expected: full | secure-default)\n`);