@cloudgrid-io/mcp 0.3.1 → 0.3.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.js +260 -53
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudgrid-io/mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "MCP server for CloudGrid. Two editions: a local stdio server (full toolset) and a hosted web server (light, CLI-free toolset) over MCP Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/tools.js CHANGED
@@ -23,6 +23,14 @@ export const API_BASE = (process.env.CLOUDGRID_API_URL || "https://api.cloudgrid
23
23
  );
24
24
 
25
25
  const ANON_HTML_MAX_BYTES = 2_000_000;
26
+ const CONSOLE_URL = "https://console.cloudgrid.io/";
27
+ const VISIBILITY_LABELS = {
28
+ private: "Only you",
29
+ org: "Your org",
30
+ authenticated: "Anyone signed in",
31
+ space: "A space",
32
+ link: "Anyone with the link",
33
+ };
26
34
 
27
35
  function ok(text) {
28
36
  return { content: [{ type: "text", text }] };
@@ -30,6 +38,9 @@ function ok(text) {
30
38
  function fail(text) {
31
39
  return { content: [{ type: "text", text }], isError: true };
32
40
  }
41
+ function okResult({ text, structured }) {
42
+ return { content: [{ type: "text", text }], structuredContent: structured };
43
+ }
33
44
 
34
45
  // ── CLI wrapping (local edition only) ──────────────────────────────────────────
35
46
  async function runCloudgrid(args) {
@@ -74,6 +85,28 @@ function tryOpenBrowser(url) {
74
85
  }
75
86
  }
76
87
 
88
+ // ── Org listing (bearer-authed, web edition) ──────────────────────────────────
89
+ // Fetches the signed-in user's orgs via GET /api/v2/orgs. The JWT does not
90
+ // carry orgs (claims: sub, email, name, iat, exp), so the API is the canonical
91
+ // source. Returns [{slug, name, role}].
92
+ async function fetchUserOrgs(token) {
93
+ try {
94
+ const res = await fetch(`${API_BASE}/api/v2/orgs`, {
95
+ headers: { Authorization: `Bearer ${token}` },
96
+ });
97
+ if (!res.ok) return [];
98
+ const data = await res.json();
99
+ const orgs = Array.isArray(data?.orgs) ? data.orgs : Array.isArray(data) ? data : [];
100
+ return orgs.map((o) => ({
101
+ slug: o.slug ?? "",
102
+ name: o.name ?? o.slug ?? "",
103
+ role: o.role ?? "member",
104
+ }));
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
77
110
  // ── Direct-API tools (both editions) ───────────────────────────────────────────
78
111
  function looksLikeFullHtml(s) {
79
112
  const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
@@ -185,23 +218,61 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
185
218
 
186
219
  if (res.status === 202) {
187
220
  // Idempotent no-op — the bytes matched the live version exactly.
188
- return `No change — this exact content is already live: ${data.url ?? ctx.state.lastDrop?.url ?? ""}`.trim();
221
+ const url = (data.url ?? ctx.state.lastDrop?.url ?? "").trim();
222
+ return {
223
+ text: `No change — this exact content is already live: ${url}`,
224
+ structured: { url, status: "unchanged" },
225
+ };
189
226
  }
190
227
 
191
228
  if (res.status === 200) {
192
229
  // Updated in place: same URL, new version, views/reactions intact.
193
- const lines = [`Updated in place — same link: ${data.url ?? ctx.state.lastDrop?.url ?? ""}`.trim()];
194
- if (data.owned_by === "authenticated") lines.push("Owned by you.");
230
+ const url = (data.url ?? ctx.state.lastDrop?.url ?? "").trim();
231
+ const lines = ctx.edition === "web"
232
+ ? [`Your app is live: ${url}`]
233
+ : [`Updated in place — same link: ${url}`];
234
+ if (ctx.edition !== "web" && data.owned_by === "authenticated") lines.push("Owned by you.");
195
235
  if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
196
- return lines.join("\n");
236
+ const structured = {
237
+ url,
238
+ status: "updated",
239
+ ...(data.owned_by ? { owned_by: data.owned_by } : {}),
240
+ ...(data.expires_at ? { expires_at: data.expires_at } : {}),
241
+ };
242
+ if (ctx.edition === "web") {
243
+ lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
244
+ const vis = data.visibility || "link";
245
+ lines.push(`Visible to: ${VISIBILITY_LABELS[vis] || vis}. Want to change who can see it? I can set it to only you, your org, or anyone with the link.`);
246
+ structured.console_url = CONSOLE_URL;
247
+ structured.current_visibility = vis;
248
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
249
+ }
250
+ return { text: lines.join("\n"), structured };
197
251
  }
198
252
 
199
253
  if (data.owned_by === "authenticated") {
200
254
  ctx.state.lastAnonClaim = null;
201
- const lines = [`Published to your org: ${data.url}`, "Owned by you."];
255
+ const lines = ctx.edition === "web"
256
+ ? [`Your app is live: ${data.url}`]
257
+ : [`Published to your org: ${data.url}`, "Owned by you."];
202
258
  if (data.expires_at) lines.push(`Expires ${data.expires_at}.`);
203
- lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
204
- return lines.join("\n");
259
+ const structured = {
260
+ url: data.url,
261
+ status: "created",
262
+ owned_by: "authenticated",
263
+ ...(data.expires_at ? { expires_at: data.expires_at } : {}),
264
+ };
265
+ if (ctx.edition === "web") {
266
+ lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
267
+ const vis = data.visibility || "link";
268
+ lines.push(`Visible to: ${VISIBILITY_LABELS[vis] || vis}. Want to change who can see it? I can set it to only you, your org, or anyone with the link.`);
269
+ structured.console_url = CONSOLE_URL;
270
+ structured.current_visibility = vis;
271
+ structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
272
+ } else {
273
+ lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
274
+ }
275
+ return { text: lines.join("\n"), structured };
205
276
  }
206
277
 
207
278
  // 201 — created new (first drop, fresh: true, or the server fell back to create).
@@ -216,11 +287,18 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
216
287
  ctx.state.lastAnonClaim = null;
217
288
  }
218
289
  }
219
- const lines = [`Live: ${data.url}`];
290
+ const lines = [ctx.edition === "web" ? `Your app is live: ${data.url}` : `Live: ${data.url}`];
220
291
  if (data.expires_at) lines.push(`Expires ${data.expires_at} — anonymous drops last 7 days.`);
221
292
  if (data.claim_url) lines.push("Sign in, then run cloudgrid_claim to keep it past 7 days.");
222
293
  lines.push("Drop again in this session to update it in place (same link); pass fresh to start a new one.");
223
- return lines.join("\n");
294
+ return {
295
+ text: lines.join("\n"),
296
+ structured: {
297
+ url: data.url,
298
+ status: "created",
299
+ ...(data.expires_at ? { expires_at: data.expires_at } : {}),
300
+ },
301
+ };
224
302
  }
225
303
 
226
304
  async function runClaim(ctx, { claim_token, claim_url }) {
@@ -264,13 +342,24 @@ async function runClaim(ctx, { claim_token, claim_url }) {
264
342
  throw new Error(`Claim failed (HTTP ${res.status}): ${msg}`);
265
343
  }
266
344
  const claimed = Array.isArray(data?.claimed) ? data.claimed : [];
267
- if (claimed.length === 0) return "Nothing to claim — it may already be claimed or expired.";
345
+ if (claimed.length === 0) {
346
+ return {
347
+ text: "Nothing to claim — it may already be claimed or expired.",
348
+ structured: { claimed: 0, urls: [] },
349
+ };
350
+ }
268
351
  ctx.state.lastAnonClaim = null;
269
352
  const lines = [`Claimed ${claimed.length}, now yours:`];
270
353
  for (const c of claimed) {
271
354
  lines.push(`${c.url}${c.new_expires_at ? ` (expires ${c.new_expires_at})` : ""}`);
272
355
  }
273
- return lines.join("\n");
356
+ return {
357
+ text: lines.join("\n"),
358
+ structured: {
359
+ claimed: claimed.length,
360
+ urls: claimed.map((c) => c.url),
361
+ },
362
+ };
274
363
  }
275
364
 
276
365
 
@@ -309,7 +398,13 @@ async function runVisibility(ctx, { target, visibility, org }) {
309
398
  }
310
399
  const lines = [`Visibility is now ${visibility}.`];
311
400
  if (data?.url) lines.push(data.url);
312
- return lines.join("\n");
401
+ return {
402
+ text: lines.join("\n"),
403
+ structured: {
404
+ visibility,
405
+ ...(data?.url ? { url: data.url } : {}),
406
+ },
407
+ };
313
408
  }
314
409
 
315
410
  // ── Registration ───────────────────────────────────────────────────────────────
@@ -319,24 +414,73 @@ export function registerTools(server, ctx) {
319
414
  // ── Direct-API tools (both editions) ──────────────────────────────────────
320
415
 
321
416
  // Drop — both editions.
322
- server.tool(
417
+ server.registerTool(
323
418
  "cloudgrid_drop",
324
- "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session update the existing drop in place — same link, new version; pass fresh: true to force a new one. If signed in, it publishes into the user's org as an owned inspiration (30-day expiry); if not, it drops anonymously (7-day expiry, claimable later). Calls the API directly.",
325
419
  {
326
- html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
327
- path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
328
- filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
329
- anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
330
- org: z.string().optional().describe("Org slug to publish into when signed in. Defaults to the active org."),
331
- fresh: z
332
- .boolean()
333
- .optional()
334
- .describe("Force a new drop even if you already dropped in this session (default: update in place)."),
420
+ description: "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session update the existing drop in place — same link, new version; pass fresh: true to force a new one. If signed in, it publishes into the user's org as an owned inspiration (30-day expiry); if not, it drops anonymously (7-day expiry, claimable later). Calls the API directly.",
421
+ inputSchema: {
422
+ html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
423
+ path: z.string().optional().describe("Path to a local file to upload instead of inline HTML."),
424
+ filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
425
+ anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
426
+ org: z.string().optional().describe("Org slug to publish into when signed in. Defaults to the active org."),
427
+ fresh: z
428
+ .boolean()
429
+ .optional()
430
+ .describe("Force a new drop even if you already dropped in this session (default: update in place)."),
431
+ },
432
+ outputSchema: {
433
+ url: z.string().optional().describe("The public URL of the drop."),
434
+ status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
435
+ owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
436
+ expires_at: z.string().optional().describe("Expiry timestamp, if any."),
437
+ console_url: z.string().optional().describe("URL to manage all apps in the grid."),
438
+ current_visibility: z.string().optional().describe("Current visibility of the drop."),
439
+ visibility_options: z.array(z.object({
440
+ value: z.string().describe("Visibility value to pass to cloudgrid_visibility."),
441
+ label: z.string().describe("Human-readable label."),
442
+ })).optional().describe("Available visibility levels."),
443
+ needs_org: z.boolean().optional().describe("True when the user must choose an org before dropping."),
444
+ orgs: z.array(z.object({
445
+ slug: z.string().describe("Org slug to pass as the org parameter."),
446
+ name: z.string().describe("Human-readable org name."),
447
+ role: z.string().describe("User's role in the org."),
448
+ })).optional().describe("The user's orgs, when org choice is needed."),
449
+ needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
450
+ login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
451
+ },
452
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
335
453
  },
336
- { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
337
454
  async (input) => {
338
455
  try {
339
- return ok(await runDrop(ctx, input || {}));
456
+ // Web edition: sign-in guidance when unauthenticated.
457
+ if (ctx.edition === "web" && input?.anonymous !== true) {
458
+ const token = await ctx.getToken();
459
+ if (!token) {
460
+ const url = buildLoginUrl(newLoginCode());
461
+ return okResult({
462
+ text: `Sign in to publish to your org.\n${url}`,
463
+ structured: { needs_sign_in: true, login_url: url },
464
+ });
465
+ }
466
+ // Org disambiguation: if signed in, no org arg, list the user's orgs.
467
+ if (!input?.org) {
468
+ const orgs = await fetchUserOrgs(token);
469
+ if (orgs.length > 1) {
470
+ const lines = ["Which org should this be published to?"];
471
+ for (const o of orgs) lines.push(` ${o.slug} — ${o.name} (${o.role})`);
472
+ lines.push("Pass the org slug in the org parameter to publish.");
473
+ return okResult({
474
+ text: lines.join("\n"),
475
+ structured: { needs_org: true, orgs },
476
+ });
477
+ }
478
+ if (orgs.length === 1) {
479
+ input = { ...(input || {}), org: orgs[0].slug };
480
+ }
481
+ }
482
+ }
483
+ return okResult(await runDrop(ctx, input || {}));
340
484
  } catch (err) {
341
485
  return fail(err.message);
342
486
  }
@@ -344,17 +488,23 @@ export function registerTools(server, ctx) {
344
488
  );
345
489
 
346
490
  // Claim — both editions.
347
- server.tool(
491
+ server.registerTool(
348
492
  "cloudgrid_claim",
349
- "Claim an anonymous drop into the signed-in account, so it becomes owned and stops expiring in 7 days. Use after the user signs in to keep something they dropped anonymously. The public URL does not change. Requires sign-in (cloudgrid_login). Calls the API directly.",
350
493
  {
351
- claim_token: z.string().optional().describe("The claim token from an anonymous drop."),
352
- claim_url: z.string().optional().describe("The claim_url from an anonymous drop; the token is read from it."),
494
+ description: "Claim an anonymous drop into the signed-in account, so it becomes owned and stops expiring in 7 days. Use after the user signs in to keep something they dropped anonymously. The public URL does not change. Requires sign-in (cloudgrid_login). Calls the API directly.",
495
+ inputSchema: {
496
+ claim_token: z.string().optional().describe("The claim token from an anonymous drop."),
497
+ claim_url: z.string().optional().describe("The claim_url from an anonymous drop; the token is read from it."),
498
+ },
499
+ outputSchema: {
500
+ claimed: z.number().describe("Number of drops claimed."),
501
+ urls: z.array(z.string()).describe("URLs of the claimed drops."),
502
+ },
503
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
353
504
  },
354
- { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
355
505
  async (input) => {
356
506
  try {
357
- return ok(await runClaim(ctx, input || {}));
507
+ return okResult(await runClaim(ctx, input || {}));
358
508
  } catch (err) {
359
509
  return fail(err.message);
360
510
  }
@@ -363,30 +513,44 @@ export function registerTools(server, ctx) {
363
513
 
364
514
  // Login — both editions. Local opens a browser and saves to the credentials
365
515
  // file; web returns the URL and saves to the session.
366
- server.tool(
516
+ server.registerTool(
367
517
  "cloudgrid_login",
368
- "Start a CLI-free CloudGrid sign-in. Use when the user wants to log in, sign in, or authenticate, or to claim an anonymous drop. Returns a URL to open in the browser; then call cloudgrid_login_status to finish. Uses CloudGrid's existing OAuth.",
369
- {},
370
- { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
518
+ {
519
+ description: "Start a CLI-free CloudGrid sign-in. Use when the user wants to log in, sign in, or authenticate, or to claim an anonymous drop. Returns a URL to open in the browser; then call cloudgrid_login_status to finish. Uses CloudGrid's existing OAuth.",
520
+ inputSchema: {},
521
+ outputSchema: {
522
+ login_url: z.string().describe("URL to open in a browser to complete sign-in."),
523
+ },
524
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
525
+ },
371
526
  async () => {
372
527
  const code = newLoginCode();
373
528
  ctx.state.pendingLoginCode = code;
374
529
  const url = buildLoginUrl(code);
375
530
  if (ctx.canOpenBrowser) tryOpenBrowser(url);
376
- return ok(
377
- `To sign in, open this URL in your browser and finish with Google:\n${url}\n\n` +
531
+ return {
532
+ content: [{ type: "text", text:
533
+ `To sign in, open this URL in your browser and finish with Google:\n${url}\n\n` +
378
534
  `After you complete it, run cloudgrid_login_status to finish signing in.`,
379
- );
535
+ }],
536
+ structuredContent: { login_url: url },
537
+ };
380
538
  },
381
539
  );
382
540
 
383
- server.tool(
541
+ server.registerTool(
384
542
  "cloudgrid_login_status",
385
- "Finish a sign-in started by cloudgrid_login. Polls once: if you have completed the browser sign-in, it saves your session; otherwise it tells you to finish and try again.",
386
543
  {
387
- code: z.string().optional().describe("The sign-in code. Defaults to the most recent cloudgrid_login."),
544
+ description: "Finish a sign-in started by cloudgrid_login. Polls once: if you have completed the browser sign-in, it saves your session; otherwise it tells you to finish and try again.",
545
+ inputSchema: {
546
+ code: z.string().optional().describe("The sign-in code. Defaults to the most recent cloudgrid_login."),
547
+ },
548
+ outputSchema: {
549
+ status: z.enum(["authenticated", "pending"]).describe("Current sign-in state."),
550
+ email: z.string().optional().describe("Signed-in email, when authenticated."),
551
+ },
552
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
388
553
  },
389
- { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
390
554
  async (input) => {
391
555
  const code = input?.code || ctx.state.pendingLoginCode;
392
556
  if (!code) return fail("No sign-in is in progress. Run cloudgrid_login first.");
@@ -405,36 +569,79 @@ export function registerTools(server, ctx) {
405
569
  }
406
570
  ctx.state.pendingLoginCode = null;
407
571
  const who = info?.email ? ` as ${info.email}` : "";
408
- return ok(`Signed in${who}. ${ctx.savedLocationNote()}`);
572
+ return {
573
+ content: [{ type: "text", text: `Signed in${who}. ${ctx.savedLocationNote()}` }],
574
+ structuredContent: { status: "authenticated", ...(info?.email ? { email: info.email } : {}) },
575
+ };
409
576
  }
410
577
  if (status.status === "pending" || status.status === "not_started") {
411
- return ok(
412
- "Still waiting for you to finish signing in. Open the URL from cloudgrid_login " +
578
+ return {
579
+ content: [{ type: "text", text:
580
+ "Still waiting for you to finish signing in. Open the URL from cloudgrid_login " +
413
581
  "in your browser, complete it with Google, then run cloudgrid_login_status again.",
414
- );
582
+ }],
583
+ structuredContent: { status: "pending" },
584
+ };
415
585
  }
416
586
  return fail("The sign-in window expired (5 minutes). Run cloudgrid_login to start again.");
417
587
  },
418
588
  );
419
589
 
420
- server.tool(
590
+ server.registerTool(
421
591
  "cloudgrid_visibility",
422
- "Change who can see a CloudGrid inspiration: private, space, authenticated, org, or link (anyone with the URL). Use when the user wants to make a drop private, restrict who sees it, or open it up. Defaults to the drop made in this session. Requires sign-in. Calls the API directly.",
423
592
  {
424
- visibility: z.enum(["private", "space", "authenticated", "org", "link"]).describe("The new scope."),
425
- target: z.string().optional().describe("Entity id. Defaults to this session's last drop."),
426
- org: z.string().optional().describe("Org of the entity. Defaults to the active org."),
593
+ description: "Change who can see a CloudGrid inspiration: private, space, authenticated, org, or link (anyone with the URL). Use when the user wants to make a drop private, restrict who sees it, or open it up — including right after a drop, with no target id needed. Defaults to the drop made in this session. Requires sign-in. Calls the API directly.",
594
+ inputSchema: {
595
+ visibility: z.enum(["private", "space", "authenticated", "org", "link"]).describe("The new scope."),
596
+ target: z.string().optional().describe("Entity id. Defaults to this session's last drop."),
597
+ org: z.string().optional().describe("Org of the entity. Defaults to the active org."),
598
+ },
599
+ outputSchema: {
600
+ visibility: z.string().describe("The visibility that was set."),
601
+ url: z.string().optional().describe("URL of the entity, if returned."),
602
+ },
603
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
427
604
  },
428
- { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
429
605
  async (input) => {
430
606
  try {
431
- return ok(await runVisibility(ctx, input || {}));
607
+ return okResult(await runVisibility(ctx, input || {}));
432
608
  } catch (err) {
433
609
  return fail(err.message);
434
610
  }
435
611
  },
436
612
  );
437
613
 
614
+ // Org listing — web edition only (local edition uses cloudgrid_whoami).
615
+ if (ctx.edition === "web") {
616
+ server.registerTool(
617
+ "cloudgrid_orgs",
618
+ {
619
+ description: "List the signed-in user's organizations. Returns each org's slug, name, and the user's role. Use to discover which org to publish to. Requires sign-in.",
620
+ inputSchema: {},
621
+ outputSchema: {
622
+ orgs: z.array(z.object({
623
+ slug: z.string().describe("Org slug."),
624
+ name: z.string().describe("Human-readable org name."),
625
+ role: z.string().describe("User's role in the org."),
626
+ })).describe("The user's org memberships."),
627
+ },
628
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
629
+ },
630
+ async () => {
631
+ const token = await ctx.getToken();
632
+ if (!token) {
633
+ return fail("You are not signed in. Run cloudgrid_login first.");
634
+ }
635
+ const orgs = await fetchUserOrgs(token);
636
+ if (orgs.length === 0) {
637
+ return okResult({ text: "No organizations found.", structured: { orgs: [] } });
638
+ }
639
+ const lines = orgs.map((o) => `${o.slug} — ${o.name} (${o.role})`);
640
+ return okResult({ text: lines.join("\n"), structured: { orgs } });
641
+ },
642
+ );
643
+ }
644
+
438
645
  if (ctx.edition !== "local") return; // web edition stops here — no CLI tools
439
646
 
440
647
  // ── CLI-wrapping tools (local edition only) ───────────────────────────────