busa-cli 0.40.1 → 0.41.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.
@@ -17,6 +17,7 @@ import { parse, stringify } from "yaml";
17
17
  import { checkAirApp } from "busabase-sdk/airapp-check";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
20
+ import QRCode from "qrcode";
20
21
  const LocaleSchema = z.enum({
21
22
  defaultLocale: "en",
22
23
  locales: [
@@ -106,8 +107,8 @@ const iStringSchema = z.union([z.string(), iStringRecordSchema]).describe("i18n
106
107
  //#region ../../packages/busabase-contract/src/contract/node-agent-prompt-schemas.ts
107
108
  /**
108
109
  * Per-node custom scenario prompts (`node.metadata.agentPrompts`) — the single
109
- * schema shared by BOTH write and read paths, per
110
- * `apps/busabase-cloud/content/spec/node-agent-prompts-v2.md` §7.3:
110
+ * schema shared by BOTH write and read paths, per the node-agent-prompts
111
+ * design (v2) §7.3:
111
112
  *
112
113
  * - Write: `busabase-cli nodes set-agent-prompts` validates a file against this
113
114
  * schema client-side, before calling `nodes.updateMetadata` — a malformed file
@@ -219,7 +220,7 @@ const OPERATIONS$1 = [
219
220
  * audience, the exact silent-drop failure the server-side rejection exists to
220
221
  * end. `params.appliesWhen` still documents which operations accept it.
221
222
  */
222
- const mergeIntent$5 = (input) => {
223
+ const mergeIntent$6 = (input) => {
223
224
  if (input.requireReview) return { autoMerge: false };
224
225
  if (input.autoMerge) return { autoMerge: true };
225
226
  return {};
@@ -244,7 +245,7 @@ const baseFieldChangeTask = {
244
245
  name: "base_field_change_request",
245
246
  cliPath: ["bases", "field-change-request"],
246
247
  summary: "Propose a Base schema change (add / update / delete / convert / reorder / restore)",
247
- guidance: "Review is permission-aware for add / update / reorder / restore, decided server-side: the change merges immediately when your key has write access on the Base's node and lands as a pending ChangeRequest otherwise — check the response's `status`. Pass requireReview to always propose instead. `delete` and `convert` are ALWAYS review-first regardless of permission, by design: delete soft-deletes the field's stored values with it, and convert can drop values. Pick `operation` first, then supply only that operation's arguments — add needs slug+name; update needs fieldId+patch; delete/restore need fieldId; convert needs fieldId+newType; reorder needs the complete fieldIds order. Before a convert, `bases_preview_field_conversion` shows what the data would become.",
248
+ guidance: "Review is permission-aware for every operation, decided server-side: the change merges immediately when your key has write access on the Base's node and lands as a pending ChangeRequest otherwise — check the response's `status`. Pass requireReview to always propose instead. Worth doing on `delete` and `convert` when the data matters: delete soft-deletes the field's stored values with it (restore brings both back), and convert can drop values that do not fit the new type. Pick `operation` first, then supply only that operation's arguments — add needs slug+name; update needs fieldId+patch; delete/restore need fieldId; convert needs fieldId+newType; reorder needs the complete fieldIds order. Before a convert, `bases_preview_field_conversion` shows what the data would become.",
248
249
  annotations: {
249
250
  readOnly: false,
250
251
  destructive: false
@@ -372,30 +373,12 @@ const baseFieldChangeTask = {
372
373
  {
373
374
  name: "autoMerge",
374
375
  kind: "boolean",
375
- appliesWhen: {
376
- param: "operation",
377
- values: [
378
- "add",
379
- "update",
380
- "reorder",
381
- "restore"
382
- ]
383
- },
384
376
  description: "Skip review and apply the schema change immediately if you have write access. Not a permission override — a changeRequest-level key still gets a pending CR. Default is permission-aware: merge when you can, otherwise propose."
385
377
  },
386
378
  {
387
379
  name: "requireReview",
388
380
  kind: "boolean",
389
- appliesWhen: {
390
- param: "operation",
391
- values: [
392
- "add",
393
- "update",
394
- "reorder",
395
- "restore"
396
- ]
397
- },
398
- description: "Always propose a pending ChangeRequest, even with write access. delete and convert are review-first regardless."
381
+ description: "Always propose a pending ChangeRequest, even with write access. Worth passing on delete and convert when the field holds data you would not want dropped without a second look."
399
382
  }
400
383
  ],
401
384
  examples: [
@@ -409,7 +392,7 @@ const baseFieldChangeTask = {
409
392
  baseId: input.baseId,
410
393
  ...input.message ? { message: input.message } : {},
411
394
  ...input.submittedBy ? { submittedBy: input.submittedBy } : {},
412
- ...mergeIntent$5(input)
395
+ ...mergeIntent$6(input)
413
396
  };
414
397
  switch (input.operation) {
415
398
  case "add": return client.bases.fieldChangeRequest({
@@ -799,10 +782,10 @@ const nodeReadFileTask = {
799
782
  * off — but a CLI boolean flag is presence-only, so `--auto-merge` alone could
800
783
  * never express "forced off". Same two-flag shape as `node_create`.
801
784
  *
802
- * A batch containing a `delete` is review-first server-side no matter what this
803
- * sends, so don't imply otherwise in the flag's description.
785
+ * Applies to every operation kind, deletes included — a delete batch used to be
786
+ * pinned review-first server-side regardless of this flag, and no longer is.
804
787
  */
805
- const mergeIntent$4 = (input) => {
788
+ const mergeIntent$5 = (input) => {
806
789
  if (input.requireReview) return { autoMerge: false };
807
790
  if (input.autoMerge) return { autoMerge: true };
808
791
  return {};
@@ -828,7 +811,7 @@ const nodeFilesChangeRequestTask = {
828
811
  }
829
812
  ],
830
813
  summary: "Propose file changes inside a Skill, Drive, or AirApp node",
831
- guidance: "Review is permission-aware, decided server-side: the change merges immediately when your key has write access on the node and lands as a pending ChangeRequest otherwise — check the response's `status`. Pass requireReview to always propose instead. A batch containing a `delete` is ALWAYS review-first regardless of permission, by design — deleting a mounted file destroys content, and a batch is never partially merged. Each operation is one of create / update / delete / metadata_update. Include `baseContentHash` (from `node_file_read`) on an update so a concurrent edit is caught. For kind \"airapp\", keep the project runnable by `npm run dev` — editing package.json must leave a \"dev\" script starting a plain Node server, not a bundler dev server.",
814
+ guidance: "Review is permission-aware, decided server-side: the change merges immediately when your key has write access on the node and lands as a pending ChangeRequest otherwise — check the response's `status`. Pass requireReview to always propose instead — worth doing for a batch containing a `delete`, since that removes a mounted file (its previous bytes stay in the change request's history, and a batch is never partially merged). Each operation is one of create / update / delete / metadata_update. Include `baseContentHash` (from `node_file_read`) on an update so a concurrent edit is caught. For kind \"airapp\", keep the project runnable by `npm run dev` — editing package.json must leave a \"dev\" script starting a plain Node server, not a bundler dev server.",
832
815
  annotations: {
833
816
  readOnly: false,
834
817
  destructive: false
@@ -872,7 +855,7 @@ const nodeFilesChangeRequestTask = {
872
855
  operations: input.operations,
873
856
  ...input.message ? { message: input.message } : {},
874
857
  ...input.submittedBy ? { submittedBy: input.submittedBy } : {},
875
- ...mergeIntent$4(input)
858
+ ...mergeIntent$5(input)
876
859
  };
877
860
  return client.fileTrees.createChangeRequest(payload);
878
861
  }
@@ -1118,11 +1101,21 @@ const nodeShareTask = {
1118
1101
  };
1119
1102
  //#endregion
1120
1103
  //#region ../../packages/busabase-contract/src/tasks/node-archive.ts
1104
+ /**
1105
+ * `autoMerge` is tri-state — unset (permission-aware default), forced on, forced
1106
+ * off — but a CLI boolean flag is presence-only, so `--auto-merge` alone could
1107
+ * never express "forced off". Same two-flag shape as `node_create`.
1108
+ */
1109
+ const mergeIntent$4 = (input) => {
1110
+ if (input.requireReview) return { autoMerge: false };
1111
+ if (input.autoMerge) return { autoMerge: true };
1112
+ return {};
1113
+ };
1121
1114
  const nodeArchiveTask = {
1122
1115
  name: "node_archive",
1123
1116
  cliPath: ["nodes", "archive"],
1124
1117
  summary: "Archive a node (reversible; the node moves to Trash)",
1125
- guidance: "This is the only way to move a node into the archived state, and it is reversible — the node appears in the Trash view and can be restored. It is review-first by default: it proposes a ChangeRequest for a human unless you pass autoMerge. Do NOT use node_purge to remove a node: purge is permanent and only accepts a node that has ALREADY been archived by this task.",
1118
+ guidance: "This is the only way to move a node into the archived state, and it is reversible — the node appears in the Trash view and can be restored. Review is permission-aware, decided server-side: it archives immediately when your key has write access on the node and lands as a pending ChangeRequest otherwise. Pass requireReview to always propose instead. Do NOT use node_purge to remove a node: purge is permanent and only accepts a node that has ALREADY been archived by this task.",
1126
1119
  annotations: {
1127
1120
  readOnly: false,
1128
1121
  destructive: false
@@ -1147,14 +1140,19 @@ const nodeArchiveTask = {
1147
1140
  {
1148
1141
  name: "autoMerge",
1149
1142
  kind: "boolean",
1150
- description: "Archive immediately instead of proposing a ChangeRequest for review."
1143
+ description: "Archive immediately if you have write access. Not a permission override — a changeRequest-level key still gets a pending CR. Default is permission-aware: archive when you can, otherwise propose."
1144
+ },
1145
+ {
1146
+ name: "requireReview",
1147
+ kind: "boolean",
1148
+ description: "Always propose a pending ChangeRequest instead of archiving, even with write access."
1151
1149
  }
1152
1150
  ],
1153
- examples: ["busabase-cli nodes archive --node-id nod_123", "busabase-cli nodes archive --node-id nod_123 --auto-merge # skip review"],
1151
+ examples: ["busabase-cli nodes archive --node-id nod_123", "busabase-cli nodes archive --node-id nod_123 --require-review # leave it for a human"],
1154
1152
  execute: async (client, input) => client.nodes.createChangeRequest({
1155
1153
  message: input.message ?? "Archive node",
1156
1154
  submittedBy: input.submittedBy,
1157
- autoMerge: Boolean(input.autoMerge),
1155
+ ...mergeIntent$4(input),
1158
1156
  operations: [{
1159
1157
  kind: "delete",
1160
1158
  nodeId: input.nodeId
@@ -1383,7 +1381,7 @@ const folderNodeType = {
1383
1381
  /**
1384
1382
  * Form node: an agent-authored, sandboxed web page bound to a Base via an
1385
1383
  * explicit field-binding contract. A submission does NOT write a record
1386
- * directly — it produces an approval-first record-create ChangeRequest on the
1384
+ * directly — it produces a review-first record-create ChangeRequest on the
1387
1385
  * target Base (so the submitting act reuses the base's `record_create` op, not a
1388
1386
  * form-specific one). The form's own config (bindings/page/share) is owner-
1389
1387
  * managed and edited directly, so this node contributes no CR operations of its
@@ -1659,7 +1657,7 @@ const nodeCreateTask = {
1659
1657
  }
1660
1658
  ],
1661
1659
  summary: "Create a workspace node of any type",
1662
- guidance: "One call creates any of the 11 node types with its type-specific payload: `fields` for a Base, `body` for a Doc, `files` for a Skill/Drive/AirApp, `assetId` for a File. Busabase is approval-first: by default this proposes a ChangeRequest for a human to review, and only merges immediately when you already have write access. Pass requireReview to always propose instead of merging.",
1660
+ guidance: "One call creates any of the 11 node types with its type-specific payload: `fields` for a Base, `body` for a Doc, `files` for a Skill/Drive/AirApp, `assetId` for a File. Review is permission-aware, decided server-side: this merges immediately when you already have write access on the parent node, and proposes a ChangeRequest for a human otherwise. Pass requireReview to always propose instead of merging.",
1663
1661
  annotations: {
1664
1662
  readOnly: false,
1665
1663
  destructive: false
@@ -1969,7 +1967,7 @@ const recordChangeTask = {
1969
1967
  name: "record_change_request",
1970
1968
  cliPath: ["records", "change-request"],
1971
1969
  summary: "Propose a change to an existing record (update / delete / restore)",
1972
- guidance: "Review is permission-aware, decided server-side: `update` merges immediately when your key has write access on the Base's node and lands as a pending ChangeRequest otherwise — check the response's `materialized` field to see which happened. Pass explicit `autoMerge: false` to force review even when you could write directly. `delete` and `restore` are always review-first regardless of permission, by design. `delete` ARCHIVES the record — it is reversible with `restore`, not an erase. For update, `fields` is keyed by field slug and only needs the fields you are changing; if you set the Base's PRIMARY (first) field, keep it a short human-readable title.",
1970
+ guidance: "Review is permission-aware for all three operations, decided server-side: the change merges immediately when your key has write access on the Base's node and lands as a pending ChangeRequest otherwise — check the response's `materialized` field to see which happened. Pass explicit `autoMerge: false` to force review even when you could write directly. `delete` ARCHIVES the record — it is reversible with `restore`, not an erase. For update, `fields` is keyed by field slug and only needs the fields you are changing; if you set the Base's PRIMARY (first) field, keep it a short human-readable title.",
1973
1971
  annotations: {
1974
1972
  readOnly: false,
1975
1973
  destructive: false
@@ -2010,20 +2008,12 @@ const recordChangeTask = {
2010
2008
  {
2011
2009
  name: "autoMerge",
2012
2010
  kind: "boolean",
2013
- appliesWhen: {
2014
- param: "operation",
2015
- values: ["update"]
2016
- },
2017
- description: "Skip review and apply the update immediately if you have write access. Not a permission override — a changeRequest-level key still gets a pending CR. Default is permission-aware: merge when you can, otherwise propose."
2011
+ description: "Skip review and apply the change immediately if you have write access. Not a permission override — a changeRequest-level key still gets a pending CR. Default is permission-aware: merge when you can, otherwise propose."
2018
2012
  },
2019
2013
  {
2020
2014
  name: "requireReview",
2021
2015
  kind: "boolean",
2022
- appliesWhen: {
2023
- param: "operation",
2024
- values: ["update"]
2025
- },
2026
- description: "Always propose a pending ChangeRequest for this update, even with write access. delete/restore are review-first regardless."
2016
+ description: "Always propose a pending ChangeRequest instead of applying the change, even with write access."
2027
2017
  }
2028
2018
  ],
2029
2019
  examples: [
@@ -2049,11 +2039,13 @@ const recordChangeTask = {
2049
2039
  if (input.operation === "delete") return client.records.changeRequest({
2050
2040
  ...common,
2051
2041
  operation: "delete",
2052
- deleteMode: "archive"
2042
+ deleteMode: "archive",
2043
+ ...mergeIntent$1(input)
2053
2044
  });
2054
2045
  return client.records.changeRequest({
2055
2046
  ...common,
2056
- operation: "restore"
2047
+ operation: "restore",
2048
+ ...mergeIntent$1(input)
2057
2049
  });
2058
2050
  }
2059
2051
  };
@@ -3074,7 +3066,7 @@ z.object({ ok: z.boolean() });
3074
3066
  //#region src/retry.ts
3075
3067
  const BASE_DELAY_MS = 500;
3076
3068
  const MAX_DELAY_MS = 2e4;
3077
- const READ_METHODS = /* @__PURE__ */ new Set([
3069
+ const READ_METHODS$1 = /* @__PURE__ */ new Set([
3078
3070
  "GET",
3079
3071
  "HEAD",
3080
3072
  "OPTIONS"
@@ -3111,7 +3103,7 @@ function parseRetryAfter(header, now = Date.now()) {
3111
3103
  /** Whether this outcome is worth another attempt for a request using `method`. */
3112
3104
  function shouldRetry(method, status) {
3113
3105
  if (status === 429) return true;
3114
- if (!READ_METHODS.has(method.toUpperCase())) return false;
3106
+ if (!READ_METHODS$1.has(method.toUpperCase())) return false;
3115
3107
  return status === void 0 || status >= 500;
3116
3108
  }
3117
3109
  /**
@@ -4369,6 +4361,145 @@ function banner(baseUrl) {
4369
4361
  return out.join("\n");
4370
4362
  }
4371
4363
  //#endregion
4364
+ //#region src/contract-catalog.ts
4365
+ const isProcedure = (node) => typeof node === "object" && node !== null && typeof node["~orpc"]?.route?.method === "string";
4366
+ /** `spaceId` → `space-id`. Shared so command names and `schema` agree. */
4367
+ const kebab = (value) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
4368
+ /** Depth-first walk of the contract tree, yielding every procedure it holds. */
4369
+ function* contractProcedures(node = cloudContract, navPath = []) {
4370
+ for (const key of Object.keys(node)) {
4371
+ if (key === "~orpc") continue;
4372
+ const child = node[key];
4373
+ if (isProcedure(child)) yield {
4374
+ id: [...navPath, key].join("."),
4375
+ navPath,
4376
+ key,
4377
+ proc: child
4378
+ };
4379
+ else if (child && typeof child === "object") yield* contractProcedures(child, [...navPath, key]);
4380
+ }
4381
+ }
4382
+ /**
4383
+ * The CLI command a contract procedure is reachable as: the top-level group plus
4384
+ * a leaf whose name folds any deeper contract groups into itself, so
4385
+ * `nodes.principals.list` is `busabase-cli nodes principals-list` (not a third
4386
+ * command level). Mirrors `addLeaf` in `run.ts`.
4387
+ *
4388
+ * This is the *generated* spelling. A curated task command may supersede it under
4389
+ * a friendlier name, which is why `schema` labels the field "generated command".
4390
+ */
4391
+ function generatedCommandPath(navPath, key) {
4392
+ if (navPath.length === 0) return [kebab(key)];
4393
+ return [kebab(navPath[0]), [...navPath.slice(1), key].map(kebab).join("-")];
4394
+ }
4395
+ /**
4396
+ * Resolve a live request back to the contract endpoint that serves it, so an
4397
+ * error can name the endpoint even when the command that failed was a curated
4398
+ * task (which may call several endpoints and therefore declares none).
4399
+ *
4400
+ * Matches the route's `{param}` segments positionally. When more than one route
4401
+ * fits, the one with the most *literal* segments matched wins, so a concrete
4402
+ * `/nodes/favorites` is never shadowed by a parameterized `/nodes/{nodeId}`.
4403
+ * Ties are impossible: two routes with identical literals at identical positions
4404
+ * and the same length are the same route.
4405
+ */
4406
+ function matchRoute(method, pathname) {
4407
+ const wanted = pathname.split("?")[0].split("/").filter(Boolean);
4408
+ let best;
4409
+ for (const entry of contractProcedures()) {
4410
+ const { route } = entry.proc["~orpc"];
4411
+ if (route.method.toUpperCase() !== method.toUpperCase()) continue;
4412
+ const segments = route.path.split("/").filter(Boolean);
4413
+ if (segments.length !== wanted.length) continue;
4414
+ let literals = 0;
4415
+ if (segments.every((segment, index) => {
4416
+ if (segment.startsWith("{") && segment.endsWith("}")) return true;
4417
+ if (segment !== wanted[index]) return false;
4418
+ literals += 1;
4419
+ return true;
4420
+ }) && (!best || literals > best.literals)) best = {
4421
+ entry,
4422
+ literals
4423
+ };
4424
+ }
4425
+ return best?.entry;
4426
+ }
4427
+ //#endregion
4428
+ //#region src/dry-run.ts
4429
+ /** Methods that change nothing, and so run for real even under `--dry-run`. */
4430
+ const READ_METHODS = /* @__PURE__ */ new Set([
4431
+ "GET",
4432
+ "HEAD",
4433
+ "OPTIONS"
4434
+ ]);
4435
+ /**
4436
+ * Thrown by {@link dryRunFetch} instead of performing a write. Carries the plan
4437
+ * so the caller can print it. Deliberately not an `Error` subclass that any retry
4438
+ * or transient-failure predicate would match — see `retry.ts`'s `isTransientError`.
4439
+ */
4440
+ var DryRunInterception = class extends Error {
4441
+ plan;
4442
+ constructor(plan) {
4443
+ super(`dry run: ${plan.method} ${plan.url} was not sent`);
4444
+ this.name = "DryRunInterception";
4445
+ this.plan = plan;
4446
+ }
4447
+ };
4448
+ const readBody = (init) => {
4449
+ const body = init?.body;
4450
+ if (body === void 0 || body === null) return void 0;
4451
+ if (typeof body !== "string") return `<${typeof body} body>`;
4452
+ try {
4453
+ return JSON.parse(body);
4454
+ } catch {
4455
+ return body;
4456
+ }
4457
+ };
4458
+ /** Wrap a fetch so the first mutating request throws {@link DryRunInterception}. */
4459
+ function dryRunFetch(inner) {
4460
+ return async (input, init) => {
4461
+ const request = input instanceof Request ? input : void 0;
4462
+ const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
4463
+ if (READ_METHODS.has(method)) return inner(input, init);
4464
+ throw new DryRunInterception({
4465
+ method,
4466
+ url: request?.url ?? String(input),
4467
+ body: readBody(init)
4468
+ });
4469
+ };
4470
+ }
4471
+ /** What a human (or an agent) sees instead of the write happening. */
4472
+ function renderDryRunPlan(plan) {
4473
+ const lines = ["[busabase-cli] --dry-run: nothing was sent. This is the request that would have gone out:", ` ${plan.method} ${plan.url}`];
4474
+ if (plan.body !== void 0) lines.push(" body:", JSON.stringify(plan.body, null, 2));
4475
+ return lines.join("\n");
4476
+ }
4477
+ /**
4478
+ * Note printed when `--dry-run` was asked for but the command never tried to
4479
+ * write. Silence would read as "the write was suppressed"; it was not — the
4480
+ * output the command printed is real data from a real read.
4481
+ */
4482
+ const DRY_RUN_NO_WRITE_NOTE = "[busabase-cli] --dry-run: this command sent no write, so nothing was suppressed — the output above is real.";
4483
+ /**
4484
+ * Record every request's method and path. A curated task command may call several
4485
+ * endpoints, so the one that actually failed is only knowable from the wire —
4486
+ * which is what lets a `403` name the endpoint (and its required permission level)
4487
+ * for tasks too, not just for the generated endpoint commands.
4488
+ */
4489
+ function observeRequests(inner, sink) {
4490
+ return async (input, init) => {
4491
+ const request = input instanceof Request ? input : void 0;
4492
+ const url = request?.url ?? String(input);
4493
+ try {
4494
+ sink({
4495
+ method: (init?.method ?? request?.method ?? "GET").toUpperCase(),
4496
+ pathname: new URL(url).pathname
4497
+ });
4498
+ } catch {}
4499
+ return inner(input, init);
4500
+ };
4501
+ }
4502
+ //#endregion
4372
4503
  //#region src/errors.ts
4373
4504
  /**
4374
4505
  * Process exit code per class.
@@ -4481,7 +4612,8 @@ function errorEnvelope(classified) {
4481
4612
  ...classified.status !== void 0 ? { status: classified.status } : {},
4482
4613
  message: classified.message,
4483
4614
  ...classified.issues ? { issues: classified.issues } : {},
4484
- retryable: classified.retryable
4615
+ retryable: classified.retryable,
4616
+ ...classified.hint ? { hint: classified.hint } : {}
4485
4617
  };
4486
4618
  }
4487
4619
  /**
@@ -4686,6 +4818,13 @@ const BUSABASE_CLI_CLIENT_ID = "busabase-cli";
4686
4818
  *
4687
4819
  * Both end the same way: verify against `/api/v1/auth`, pick the target space, and
4688
4820
  * write `BUSABASE_BASE_URL` / `BUSABASE_API_KEY` / `BUSABASE_SPACE_ID`.
4821
+ *
4822
+ * Device authorization additionally splits into a **two-turn agent flow**:
4823
+ * `--no-wait` starts it and returns the verification URL plus a resume code at
4824
+ * once (no blocking, no credential written), and `--resume-code <code>` finishes
4825
+ * it in a later invocation. This exists because many agent harnesses only relay
4826
+ * a turn's FINAL message — a URL printed mid-command while this process blocks
4827
+ * polling never reaches the user, so the blocking flow silently times out.
4689
4828
  */
4690
4829
  const OAUTH_TIMEOUT_MS = 3e5;
4691
4830
  /** Env key holding the OAuth access-token expiry (ISO), used to drive built-in auto-refresh. */
@@ -4882,8 +5021,10 @@ const delay = (durationMs) => new Promise((resolve) => setTimeout(resolve, durat
4882
5021
  async function readDeviceResponse(response) {
4883
5022
  return await response.json().catch(() => ({}));
4884
5023
  }
4885
- /** RFC 8628 login: no local callback and no credential ever written to output. */
4886
- async function deviceLogin(baseUrl, useBrowser) {
5024
+ /** Fallback lifetime for a device code when the server does not declare one. */
5025
+ const DEVICE_CODE_TTL_SECONDS = 900;
5026
+ /** Step 1 of RFC 8628: ask the server for a device code + verification URL. */
5027
+ async function requestDeviceAuthorization(baseUrl) {
4887
5028
  const origin = new URL(baseUrl).origin;
4888
5029
  let codeResponse;
4889
5030
  try {
@@ -4904,17 +5045,26 @@ async function deviceLogin(baseUrl, useBrowser) {
4904
5045
  const code = await codeResponse.json().catch(() => ({}));
4905
5046
  if (!codeResponse.ok) throw new Error(`Could not start device sign-in (HTTP ${codeResponse.status})${code.error_description ? `: ${code.error_description}` : "."}`);
4906
5047
  if (!code.device_code || !code.user_code || !code.verification_uri) throw new Error("Device sign-in returned an incomplete authorization response.");
4907
- const verificationUrl = code.verification_uri_complete ?? code.verification_uri;
4908
- say("");
4909
- say("Authorize Busabase CLI in any browser:");
4910
- say(` ${code.verification_uri}`);
4911
- say(` Code: ${code.user_code}`);
4912
- say("");
4913
- if (useBrowser) openBrowser(verificationUrl);
4914
- say("Waiting for authorization…");
4915
- const expiresInSeconds = Math.max(1, code.expires_in ?? 900);
4916
- const deadline = Date.now() + expiresInSeconds * 1e3;
4917
- let intervalSeconds = Math.max(1, code.interval ?? 5);
5048
+ return {
5049
+ deviceCode: code.device_code,
5050
+ userCode: code.user_code,
5051
+ verificationUri: code.verification_uri,
5052
+ verificationUrl: code.verification_uri_complete ?? code.verification_uri,
5053
+ expiresInSeconds: Math.max(1, code.expires_in ?? DEVICE_CODE_TTL_SECONDS),
5054
+ intervalSeconds: Math.max(1, code.interval ?? 5)
5055
+ };
5056
+ }
5057
+ /**
5058
+ * Step 2 of RFC 8628: poll until the user approves, then finalize into an API
5059
+ * key. Callable on its own for `--resume-code` (the device code outlives this
5060
+ * process server-side, so resuming needs no new endpoint). `restartCommand`
5061
+ * names the exact command to rerun once a code has expired — a resumed sign-in
5062
+ * must not tell the user to plain "run login" and silently drop their flags.
5063
+ */
5064
+ async function pollDeviceApiKey(baseUrl, auth, restartCommand) {
5065
+ const origin = new URL(baseUrl).origin;
5066
+ const deadline = Date.now() + auth.expiresInSeconds * 1e3;
5067
+ let intervalSeconds = auth.intervalSeconds;
4918
5068
  let consecutiveNetworkFailures = 0;
4919
5069
  let pendingPollCount = 0;
4920
5070
  let reassuredAfterApprovalHint = false;
@@ -4931,14 +5081,14 @@ async function deviceLogin(baseUrl, useBrowser) {
4931
5081
  },
4932
5082
  body: JSON.stringify({
4933
5083
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
4934
- device_code: code.device_code,
5084
+ device_code: auth.deviceCode,
4935
5085
  client_id: BUSABASE_CLI_CLIENT_ID
4936
5086
  })
4937
5087
  });
4938
5088
  consecutiveNetworkFailures = 0;
4939
5089
  } catch {
4940
5090
  consecutiveNetworkFailures += 1;
4941
- if (consecutiveNetworkFailures >= 3) throw new Error("Device sign-in lost its network connection. Check connectivity and run `busabase-cli login` again.");
5091
+ if (consecutiveNetworkFailures >= 3) throw new Error(`Device sign-in lost its network connection. Check connectivity and run \`${restartCommand}\` again.`);
4942
5092
  continue;
4943
5093
  }
4944
5094
  const payload = await readDeviceResponse(tokenResponse);
@@ -4949,7 +5099,7 @@ async function deviceLogin(baseUrl, useBrowser) {
4949
5099
  authorization: `Bearer ${payload.access_token}`,
4950
5100
  "content-type": "application/json"
4951
5101
  },
4952
- body: JSON.stringify({ deviceCode: code.device_code })
5102
+ body: JSON.stringify({ deviceCode: auth.deviceCode })
4953
5103
  });
4954
5104
  const finalized = await finalizeResponse.json().catch(() => ({}));
4955
5105
  if (!finalizeResponse.ok || !finalized.apiKey || finalized.credentialType !== "api_key") {
@@ -4973,11 +5123,24 @@ async function deviceLogin(baseUrl, useBrowser) {
4973
5123
  intervalSeconds += 5;
4974
5124
  continue;
4975
5125
  case "access_denied": throw new Error("Device sign-in was denied. No credential was saved.");
4976
- case "expired_token": throw new Error("The device code expired. Run `busabase-cli login` to request a new one.");
5126
+ case "expired_token": throw new Error(`The device code expired. Run \`${restartCommand}\` to request a new one.`);
4977
5127
  default: throw new Error(`Device sign-in failed${payload.error_description ? `: ${payload.error_description}` : ` (HTTP ${tokenResponse.status})`}.`);
4978
5128
  }
4979
5129
  }
4980
- throw new Error("The device code expired. Run `busabase-cli login` to request a new one.");
5130
+ throw new Error(`The device code expired. Run \`${restartCommand}\` to request a new one.`);
5131
+ }
5132
+ /** RFC 8628 login: no local callback and no credential ever written to output. */
5133
+ async function deviceLogin(baseUrl, useBrowser, profile) {
5134
+ const auth = await requestDeviceAuthorization(baseUrl);
5135
+ say("");
5136
+ say("Authorize Busabase CLI in any browser:");
5137
+ say(` ${auth.verificationUri}`);
5138
+ say(` Code: ${auth.userCode}`);
5139
+ say("");
5140
+ if (useBrowser) openBrowser(auth.verificationUrl);
5141
+ if (!isInteractive()) say(`[AI agent] This command blocks (up to ${Math.round(auth.expiresInSeconds / 60)} minutes) until the user approves in a browser. If your harness only delivers a turn's final message, the user will never see the URL above. Instead run \`${buildLoginCommand(baseUrl, profile, "--no-wait --output json")}\`, send verification_url to the user as your final message, end the turn, and finish later with \`--resume-code\`. Do not restart login after showing the URL — a restart invalidates it.`);
5142
+ say("Waiting for authorization…");
5143
+ return pollDeviceApiKey(baseUrl, auth, buildLoginCommand(baseUrl, profile, ""));
4981
5144
  }
4982
5145
  /** Exchange a rotating refresh token for a new OAuth token set. */
4983
5146
  async function refreshToken(baseUrl, refreshToken) {
@@ -5020,14 +5183,37 @@ function pickSpaceId(verify, preselected) {
5020
5183
  return fallback;
5021
5184
  }
5022
5185
  /**
5186
+ * Assemble a copy-paste-runnable `busabase-cli login …` for THIS connection —
5187
+ * non-default base URL and active profile included — so an agent (or the JSON
5188
+ * error envelope) never hands the user a command that targets the wrong host
5189
+ * or the wrong account.
5190
+ */
5191
+ const buildLoginCommand = (baseUrl, profile, suffix) => {
5192
+ const parts = ["busabase-cli login"];
5193
+ if (normalizeBaseUrl(baseUrl) !== normalizeBaseUrl(DEFAULT_BASE_URL)) parts.push(`--base-url ${normalizeBaseUrl(baseUrl)}`);
5194
+ if (profile) parts.push(`--profile ${profile}`);
5195
+ if (suffix) parts.push(suffix);
5196
+ return parts.join(" ");
5197
+ };
5198
+ /**
5199
+ * Machine-readable next step for an auth failure, attached to the JSON error
5200
+ * envelope (the human prose in `explainError` stays as-is). Written for an
5201
+ * agent recovering without a terminal: the two-turn device flow is the one
5202
+ * path that works when only a turn's final message reaches the user.
5203
+ */
5204
+ function authRecoveryHint(code, baseUrl, profile, endpoint) {
5205
+ if (code === "FORBIDDEN") return "The credential was accepted but lacks permission for this action. " + (endpoint?.procedureId && endpoint.level ? `\`${endpoint.procedureId}\` requires an API key at level \`${endpoint.level}\`${endpoint.declared ? "" : " (no explicit policy for it; the server fails closed to this)"}. Check yours with \`busabase-cli whoami\`. ` : "") + "Confirm the target space (`busabase-cli space list`, then `busabase-cli space use <id>`), or ask a space admin to issue a key at the level above. Note a `changeRequest`-level key can propose but deliberately cannot merge its own proposal.";
5206
+ return `Run \`${buildLoginCommand(baseUrl, profile, "--no-wait --output json")}\` to get verification_url and resume_code. Show verification_url to the user EXACTLY as returned (an opaque string — never re-encode, trim, or re-wrap it) and end this turn so they can approve it on any device. After they confirm, run \`${buildLoginCommand(baseUrl, profile, "--resume-code <resume_code>")}\` to finish signing in, then retry this command. Do not restart login after showing the URL — a restart invalidates it. For headless automation, pass --api-key <token> from the environment instead.`;
5207
+ }
5208
+ /**
5023
5209
  * Interactive "where is your Busabase?" menu. Every option resolves to the same two
5024
5210
  * axes: which base URL, and how (if at all) to obtain a token — the rest of login just
5025
5211
  * writes that to ~/.busabase/.env.
5026
5212
  */
5027
5213
  async function chooseTarget() {
5028
5214
  const cloud = normalizeBaseUrl(DEFAULT_BASE_URL);
5029
- say("Busabase is an approval-first database and knowledge base for AI agents.");
5030
- say("Agents propose changes; humans review and merge what becomes trusted data.");
5215
+ say("Busabase is your AI agents' database, knowledge base, apps library, and skills registry.");
5216
+ say("Every change goes in as a change request, so it keeps a message, a diff, and a history.");
5031
5217
  say("This login connects the CLI to the Busabase instance you want to use.");
5032
5218
  say("");
5033
5219
  say("How should this CLI connect?");
@@ -5074,8 +5260,23 @@ async function chooseTarget() {
5074
5260
  async function runLogin(options) {
5075
5261
  let baseUrl = normalizeBaseUrl(options.baseUrl);
5076
5262
  let apiKey = options.apiKey;
5263
+ if (options.noWait && apiKey) throw new Error("--no-wait starts a device sign-in and cannot be combined with --api-key.");
5264
+ if (options.noWait && options.resumeCode) throw new Error("--no-wait and --resume-code are the two halves of one sign-in — pass one, not both.");
5265
+ if (options.noWait) {
5266
+ const auth = await requestDeviceAuthorization(baseUrl);
5267
+ const resume = buildLoginCommand(baseUrl, options.profile, `--resume-code ${auth.deviceCode}`);
5268
+ return {
5269
+ status: "authorization pending",
5270
+ verification_url: auth.verificationUrl,
5271
+ user_code: auth.userCode,
5272
+ resume_code: auth.deviceCode,
5273
+ expires_in: String(auth.expiresInSeconds),
5274
+ hint: `Show verification_url to the user EXACTLY as returned — it is an opaque string; never re-encode, trim, or re-wrap it — then END THIS TURN so they can approve it on any device (a QR code helps: \`busabase-cli qrcode <verification_url> --out-file qr.png\`). After the user confirms, run \`${resume}\` to finish signing in. Do NOT start a new login in the meantime — a fresh login invalidates this link.`
5275
+ };
5276
+ }
5077
5277
  let method;
5078
- if (apiKey) method = "api-key";
5278
+ if (options.resumeCode) method = "device-resume";
5279
+ else if (apiKey) method = "api-key";
5079
5280
  else if (options.deviceCode) method = "device";
5080
5281
  else if (options.oauth) method = "loopback-oauth";
5081
5282
  else if (isInteractive()) {
@@ -5104,7 +5305,16 @@ async function runLogin(options) {
5104
5305
  let apiKeyExpiresAt;
5105
5306
  let refreshTokenValue;
5106
5307
  if (method === "device") {
5107
- const result = await deviceLogin(baseUrl, options.browser);
5308
+ const result = await deviceLogin(baseUrl, options.browser, options.profile);
5309
+ token = result.token;
5310
+ apiKeyExpiresAt = result.apiKeyExpiresAt;
5311
+ } else if (method === "device-resume") {
5312
+ say("Resuming device sign-in…");
5313
+ const result = await pollDeviceApiKey(baseUrl, {
5314
+ deviceCode: options.resumeCode ?? "",
5315
+ intervalSeconds: 5,
5316
+ expiresInSeconds: DEVICE_CODE_TTL_SECONDS
5317
+ }, buildLoginCommand(baseUrl, options.profile, "--no-wait"));
5108
5318
  token = result.token;
5109
5319
  apiKeyExpiresAt = result.apiKeyExpiresAt;
5110
5320
  } else if (method === "loopback-oauth") {
@@ -5135,8 +5345,8 @@ async function runLogin(options) {
5135
5345
  say(`✓ Signed in and saved to ${activeCredentialPath()}`);
5136
5346
  return {
5137
5347
  status: "signed in",
5138
- method: method === "loopback-oauth" ? "oauth" : method,
5139
- credentialType: method === "device" ? "api_key" : expiresAt ? "oauth" : "api_key",
5348
+ method: method === "loopback-oauth" ? "oauth" : method === "device-resume" ? "device" : method,
5349
+ credentialType: method === "device" || method === "device-resume" ? "api_key" : expiresAt ? "oauth" : "api_key",
5140
5350
  user: verify.user?.email ?? verify.user?.name ?? verify.user?.id ?? "(unknown)",
5141
5351
  space: spaceId ?? "(server default)",
5142
5352
  availableSpaces: (verify.spaces ?? []).map((space) => `${space.name} [${space.id}]`).join(", "),
@@ -5698,24 +5908,30 @@ const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
5698
5908
  //#endregion
5699
5909
  //#region ../../packages/busabase-contract/src/contract/auto-merge.ts
5700
5910
  /**
5701
- * `autoMerge` on an operation that deliberately never auto-merges.
5702
- *
5703
- * These schemas are NOT `.strict()` — and making them strict is not the fix. The
5704
- * SDK and CLI ship on their own npm cadence (published ahead of the server as of
5705
- * writing), and busabase is self-hosted, so a newer client sending a newer
5706
- * optional field to an older server is normal traffic. Blanket strictness turns
5707
- * that graceful degradation into a hard 400 for every field, to catch one.
5708
- *
5709
- * So reject exactly the field that actually confused callers, and say WHY plus
5710
- * what to do instead — an agent reading "unrecognized key: autoMerge" learns
5711
- * nothing it can act on, which is how the original silent-drop bug survived.
5712
- *
5713
- * Rejects `true` only. An explicit `false` asks for exactly what these operations
5714
- * already do, so refusing it would be pedantry with a cost: every review-first
5715
- * call site in this repo passes a uniform `autoMerge: false`, and clients that do
5716
- * the same must not start collecting 400s on the subset of endpoints where the
5717
- * flag happens to be moot. Same forward-compatibility argument that rules out
5718
- * blanket `.strict()`.
5911
+ * `autoMerge` on a destructive-but-reversible operation.
5912
+ *
5913
+ * These six operations (record delete/restore, field delete/convert, Base
5914
+ * archive, mounted-asset content edits, and file batches containing a delete)
5915
+ * used to reject `autoMerge: true` outright: they queued for human review no
5916
+ * matter who called them. That made sense while the product was positioned as
5917
+ * approval-first, but it is not how Busabase is positioned or how the rest of
5918
+ * the write surface behaves — every other write is *permission-aware*
5919
+ * (`shouldAutoMerge`): unset + write access on the target node means "just do
5920
+ * it", and only an explicit `false` forces review.
5921
+ *
5922
+ * A single person running their own workspace hit the old behaviour as pure
5923
+ * friction: archiving one duplicate record left a change request they then had
5924
+ * to go and approve themselves. Reversibility is what makes the permission-aware
5925
+ * default safe here — delete ARCHIVES, archive is undone by restore, and a
5926
+ * field convert is preceded by `previewFieldConversion`. Callers who do want a
5927
+ * second pair of eyes still get it by passing an explicit `autoMerge: false`,
5928
+ * which is exactly how every other endpoint in this family expresses that.
5929
+ *
5930
+ * The schemas that use this are NOT `.strict()`, and making them strict is not
5931
+ * the fix: the SDK and CLI ship on their own npm cadence and busabase is
5932
+ * self-hosted, so a newer client sending a newer optional field to an older
5933
+ * server is normal traffic. Blanket strictness turns that graceful degradation
5934
+ * into a hard 400 for every field, to catch one.
5719
5935
  *
5720
5936
  * This lives in its own leaf module (zod only, no sibling imports) on purpose:
5721
5937
  * putting it in `contract/schemas.ts` created an import cycle with the domain
@@ -5723,7 +5939,7 @@ const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
5723
5939
  * `ReferenceError: Cannot access 'autoMergeNotAccepted' before initialization`
5724
5940
  * that `tsc` reported as clean.
5725
5941
  */
5726
- const autoMergeNotAccepted = (reason) => z.literal(false, { error: `\`autoMerge: true\` is not accepted here: ${reason}` }).optional().describe(`Only \`false\` (or omitted) is accepted. ${reason}`);
5942
+ const destructiveAutoMerge = (undoNote) => z.boolean().optional().describe(`Whether to approve and merge this change immediately. Omitted defaults to merging immediately if the actor has write access on the target node, otherwise falling back to a pending Change Request; pass explicit false to force review even with write access. ${undoNote}`);
5727
5943
  //#endregion
5728
5944
  //#region ../../packages/busabase-contract/src/domains/base/contract/base-schemas.ts
5729
5945
  const fieldNameSchema = z.union([z.string().min(1), iStringRecordSchema.refine((record) => Object.values(record).some((value) => value && value.trim().length > 0), "field name must have at least one non-empty locale value")]);
@@ -5870,16 +6086,13 @@ const createBaseFieldInputSchema = z.object({
5870
6086
  * node, otherwise falls back to a pending ChangeRequest. Explicit `false` forces
5871
6087
  * review even with write access.
5872
6088
  *
5873
- * Deliberately NOT on the `delete` and `convert` branches below. The line this
5874
- * codebase already draws — record `update` auto-merges, record `delete`/`restore`
5875
- * never do, and `assets.editContent` never does — is about whether CONTENT data
5876
- * is at stake, not about the verb. Adding, renaming, reordering, or un-deleting a
5877
- * field touches only the schema; deleting one soft-deletes its stored values with
5878
- * it, and converting one can drop values outright (which is why
5879
- * `previewFieldConversion` exists as a dry run). Those two stay in front of a
5880
- * human.
6089
+ * The `delete` and `convert` branches below used to reject `autoMerge` outright.
6090
+ * They no longer do — see `contract/auto-merge.ts` for why. They carry a
6091
+ * `destructiveAutoMerge` schema instead: the same tri-state, with a description
6092
+ * that says what the operation costs to undo, so a caller who genuinely wants a
6093
+ * second pair of eyes knows to pass `false`.
5881
6094
  */
5882
- const fieldAutoMergeSchema = z.boolean().optional().describe("Whether to approve and merge this field change immediately. Omitted defaults to merging immediately if the actor has write access on the Base's node, otherwise falling back to a pending Change Request; pass explicit false to force review even with write access. Not accepted by the delete and convert operations, which always require review.");
6095
+ const fieldAutoMergeSchema = z.boolean().optional().describe("Whether to approve and merge this field change immediately. Omitted defaults to merging immediately if the actor has write access on the Base's node, otherwise falling back to a pending Change Request; pass explicit false to force review even with write access.");
5883
6096
  const createFieldChangeRequestInputSchema = createBaseFieldInputSchema.extend({
5884
6097
  message: z.string().optional().default("Add field"),
5885
6098
  submittedBy: z.string().optional().default("local-editor"),
@@ -5889,7 +6102,7 @@ const deleteFieldChangeRequestInputSchema = z.object({
5889
6102
  fieldId: z.string().min(1),
5890
6103
  message: z.string().optional(),
5891
6104
  submittedBy: z.string().optional().default("local-editor"),
5892
- autoMerge: autoMergeNotAccepted("deleting a field soft-deletes its stored values with it, so it always requires review. Omit the flag.")
6105
+ autoMerge: destructiveAutoMerge("A deleted field is soft-deleted with its stored values and is brought back by the `restore` operation.")
5893
6106
  });
5894
6107
  const updateFieldChangeRequestInputSchema = z.object({
5895
6108
  fieldId: z.string().min(1),
@@ -5904,7 +6117,15 @@ const updateFieldChangeRequestInputSchema = z.object({
5904
6117
  });
5905
6118
  z.object({
5906
6119
  fieldId: z.string().min(1),
5907
- newType: fieldTypeSchema
6120
+ newType: fieldTypeSchema,
6121
+ /**
6122
+ * Mirrors `convertFieldChangeRequest.selectChoiceMode` so the dry run models the
6123
+ * conversion the caller is actually going to submit. Under `auto_create` a value with
6124
+ * no matching choice is not a conflict — the merge mints a choice for it — while under
6125
+ * `null_on_missing` the same value is dropped. Defaults to `null_on_missing`, matching
6126
+ * the mutation's own default.
6127
+ */
6128
+ selectChoiceMode: z.enum(["auto_create", "null_on_missing"]).default("null_on_missing")
5908
6129
  });
5909
6130
  z.object({
5910
6131
  totalCount: z.number(),
@@ -5921,7 +6142,7 @@ const convertFieldChangeRequestInputSchema = z.object({
5921
6142
  selectChoiceMode: z.enum(["auto_create", "null_on_missing"]).default("null_on_missing"),
5922
6143
  message: z.string().optional(),
5923
6144
  submittedBy: z.string().optional().default("local-editor"),
5924
- autoMerge: autoMergeNotAccepted("converting a field's type can drop values, so it always requires review. Run previewFieldConversion first to see what would change, then omit the flag.")
6145
+ autoMerge: destructiveAutoMerge("A convert can drop values that do not fit the new type — call `previewFieldConversion` first to see exactly which, and pass `autoMerge: false` if you want a human to sign off on that preview.")
5925
6146
  });
5926
6147
  const reorderFieldsChangeRequestInputSchema = z.object({
5927
6148
  fieldIds: z.array(z.string()).min(1),
@@ -5932,7 +6153,7 @@ const reorderFieldsChangeRequestInputSchema = z.object({
5932
6153
  const archiveBaseInputSchema = z.object({
5933
6154
  message: z.string().optional(),
5934
6155
  submittedBy: z.string().optional().default("local-editor"),
5935
- autoMerge: autoMergeNotAccepted("archiving a Base removes it and every record in it from every listing at once, so it always requires review. Omit the flag.")
6156
+ autoMerge: destructiveAutoMerge("Archiving takes the Base and every record in it out of every listing at once. That is reversible via `operation: \"restore\"`, but it is the widest-blast-radius write in this family — pass `autoMerge: false` when it should stop for a human.")
5936
6157
  });
5937
6158
  const restoreBaseInputSchema = z.object({
5938
6159
  message: z.string().optional(),
@@ -6037,7 +6258,8 @@ const nodeSchema = z.lazy(() => z.object({
6037
6258
  updatedAt: z.string(),
6038
6259
  baseId: z.string().nullable(),
6039
6260
  children: z.array(nodeSchema),
6040
- hasChildren: z.boolean().optional()
6261
+ hasChildren: z.boolean().optional(),
6262
+ shared: z.boolean().optional()
6041
6263
  }));
6042
6264
  z.object({
6043
6265
  id: z.string(),
@@ -6315,6 +6537,51 @@ const commentSchema = z.object({
6315
6537
  createdAt: z.string(),
6316
6538
  updatedAt: z.string()
6317
6539
  });
6540
+ /**
6541
+ * One row of the Inbox's Mentions tab: a comment the caller was `@`-mentioned
6542
+ * in, plus where to go to read it in context.
6543
+ *
6544
+ * Keyed by COMMENT, not by mention row — being named twice in one comment is
6545
+ * legitimate (each occurrence needs its own span to render its own chip), but
6546
+ * it is still one thing to go read, so it is one entry here.
6547
+ */
6548
+ const mentionInboxItemSchema = z.object({
6549
+ commentId: z.string(),
6550
+ subjectType: commentSubjectTypeSchema,
6551
+ /** Comment body, for the row's preview line. */
6552
+ body: z.string(),
6553
+ authorId: z.string(),
6554
+ author: userRefSchema.nullable().optional().default(null),
6555
+ createdAt: z.string(),
6556
+ /** Null once read. The newest unread stamp across this comment's mentions. */
6557
+ unread: z.boolean(),
6558
+ /**
6559
+ * Dashboard-relative path to the comment's context, or null when the subject
6560
+ * has no page of its own (a `commit`-scoped comment on a comment that is not
6561
+ * attached to a change request — the repo has no commit detail route).
6562
+ * A null href still renders a row: swallowing the notification because we
6563
+ * cannot link it would leave the recipient never knowing they were mentioned.
6564
+ */
6565
+ href: z.string().nullable()
6566
+ });
6567
+ z.object({
6568
+ items: z.array(mentionInboxItemSchema),
6569
+ total: z.number().int(),
6570
+ /** Distinct unread comments — what the tab badge shows. */
6571
+ unreadCount: z.number().int()
6572
+ });
6573
+ z.object({
6574
+ page: z.number().int().min(1).optional().default(1),
6575
+ pageSize: z.number().int().min(1).max(100).optional().default(50)
6576
+ });
6577
+ z.object({
6578
+ /** Stamps every unread mention row this caller has on that comment. */
6579
+ commentId: z.string() });
6580
+ z.object({
6581
+ /** How many rows were stamped; 0 when it was already read. */
6582
+ marked: z.number().int(),
6583
+ unreadCount: z.number().int()
6584
+ });
6318
6585
  const changeRequestStatusSchema = z.enum([
6319
6586
  "in_review",
6320
6587
  "changes_requested",
@@ -6530,7 +6797,7 @@ z.object({
6530
6797
  message: z.string().optional().default("Delete record").describe("Explanation shown to the human reviewer. Say what is being removed and why, e.g. \"Archive duplicate contact — merged into Acme Corp\"."),
6531
6798
  submittedBy: z.string().optional().default("local-producer"),
6532
6799
  deleteMode: z.enum(["archive"]).optional().default("archive"),
6533
- autoMerge: autoMergeNotAccepted("archiving a record removes user content from every listing, so it always requires review. Omit the flag.")
6800
+ autoMerge: destructiveAutoMerge("Archiving is reversible: the record leaves every listing but is restored intact by `operation: \"restore\"`.")
6534
6801
  });
6535
6802
  z.object({
6536
6803
  fields: z.record(z.string(), z.unknown()).describe("Updated field values keyed by field slug. If you set the base's PRIMARY field (its first field), keep it a short human-readable name — it is the record's display title everywhere."),
@@ -10700,6 +10967,379 @@ async function paginateAll(options) {
10700
10967
  return written;
10701
10968
  }
10702
10969
  //#endregion
10970
+ //#region src/qrcode-command.ts
10971
+ /**
10972
+ * `busabase-cli qrcode <url>` — render a URL as a QR code.
10973
+ *
10974
+ * Exists for one moment: an agent has a login `verification_url` and the human
10975
+ * who must approve it is holding a phone. A QR code removes the copy-a-long-URL
10976
+ * step entirely. The URL is treated as an OPAQUE string — encoded byte-for-byte,
10977
+ * never re-encoded, trimmed, or re-assembled — because a "helpfully" reworded
10978
+ * URL is a 404 on the user's phone.
10979
+ */
10980
+ /** Same shape as lark-cli's hint: generating the file alone is a classic agent miss. */
10981
+ const QR_DISPLAY_HINT = "You MUST include this QR image in your reply to the user — generating the file alone is not enough. Show the URL first (verbatim — it is an opaque string; never re-encode, trim, or re-wrap it) and place the QR image below it.";
10982
+ const QR_MAX_SIZE = 1024;
10983
+ /**
10984
+ * A `--out-file` path may live under the working directory, the OS temp
10985
+ * directory, or the user's home — the places an agent legitimately writes
10986
+ * artifacts. Anything else (e.g. `/etc/...`) is refused rather than written.
10987
+ */
10988
+ function safeQrOutputPath(raw) {
10989
+ const abs = resolve(raw);
10990
+ if (![
10991
+ process.cwd(),
10992
+ tmpdir(),
10993
+ homedir()
10994
+ ].some((root) => {
10995
+ const prefix = root.endsWith(sep) ? root : root + sep;
10996
+ return abs === root || abs.startsWith(prefix);
10997
+ })) throw new Error(`Unsafe --out-file path "${raw}" — write inside the current directory, ${tmpdir()}, or your home directory.`);
10998
+ return abs;
10999
+ }
11000
+ /** Encode `url` into a PNG at `filePath` (path must already be validated). */
11001
+ async function writeQrPng(url, filePath, size) {
11002
+ if (!Number.isInteger(size) || size < 64 || size > 1024) throw new Error(`--size must be an integer between 64 and ${QR_MAX_SIZE}.`);
11003
+ await QRCode.toFile(filePath, url, {
11004
+ type: "png",
11005
+ width: size,
11006
+ errorCorrectionLevel: "M"
11007
+ });
11008
+ }
11009
+ /** Encode `url` as a small terminal (ANSI half-block) QR code. */
11010
+ async function renderQrAscii(url) {
11011
+ return QRCode.toString(url, {
11012
+ type: "terminal",
11013
+ small: true,
11014
+ errorCorrectionLevel: "M"
11015
+ });
11016
+ }
11017
+ z.object({
11018
+ level: z.enum([
11019
+ "read",
11020
+ "changeRequest",
11021
+ "write",
11022
+ "manage"
11023
+ ]),
11024
+ nodeScope: z.array(z.string()).optional()
11025
+ }).nullable();
11026
+ z.object({ level: z.enum([
11027
+ "read",
11028
+ "changeRequest",
11029
+ "write",
11030
+ "manage"
11031
+ ]) }).strict().nullable();
11032
+ /**
11033
+ * Explicit path → level+scope table for every current procedure. Nothing is
11034
+ * inferred from HTTP method: POST grep/preview routes can be reads, while GET
11035
+ * Webhook/Vault configuration routes are manage. Any unknown path defaults to
11036
+ * workspace manage, and the parity test prevents contract additions from
11037
+ * remaining unknown in reviewed code. Keyed by the dotted path *relative to
11038
+ * `busabaseRouter`* (e.g. `"bases.create"`, `"changeRequests.merge"`) — see
11039
+ * `resolveRequiredLevel` for why the real oRPC `path` seen at runtime carries
11040
+ * a leading `"workbench"` mount-key segment that must be stripped first.
11041
+ *
11042
+ * Enumerated against the real `packages/busabase-core/src/router.ts` +
11043
+ * every `packages/busabase-contract/src/domains/*​/contract.ts` route
11044
+ * definition (not guessed) — see the implementation report for the full
11045
+ * cross-check, including known method/semantics deviations:
11046
+ * - `dump.exportTables` is a POST route in the real contract (the design
11047
+ * doc's prose called it a GET) — already force-classified to `manage`
11048
+ * below either way, so this doesn't change behavior, just the reasoning.
11049
+ * - The top-level `grep` route is POST because it takes a request body, but
11050
+ * is explicitly classified as a node-scoped read.
11051
+ */
11052
+ const node = (level) => ({
11053
+ level,
11054
+ scope: "node"
11055
+ });
11056
+ const workspace = (level) => ({
11057
+ level,
11058
+ scope: "workspace"
11059
+ });
11060
+ /**
11061
+ * Exhaustive policy for the current Busabase contract. A contract-parity test
11062
+ * fails when a procedure is added without an explicit decision here.
11063
+ */
11064
+ const PROCEDURE_PERMISSION_POLICY = {
11065
+ "auth.verify": workspace("read"),
11066
+ search: node("read"),
11067
+ grep: node("read"),
11068
+ "nodes.list": node("read"),
11069
+ "nodes.get": node("read"),
11070
+ "nodes.getAgentPrompts": node("read"),
11071
+ "nodes.searchByName": node("read"),
11072
+ "nodes.isDescendant": node("read"),
11073
+ "nodes.ancestors": node("read"),
11074
+ "nodes.resolveRouteState": node("read"),
11075
+ "nodes.createChangeRequest": node("changeRequest"),
11076
+ "operations.revise": node("changeRequest"),
11077
+ "bases.createChangeRequest": node("changeRequest"),
11078
+ "bases.createBulkChangeRequest": node("changeRequest"),
11079
+ "bases.createBulkUpdateChangeRequest": node("changeRequest"),
11080
+ "bases.fieldChangeRequest": node("changeRequest"),
11081
+ "bases.lifecycleChangeRequest": node("changeRequest"),
11082
+ "records.changeRequest": node("changeRequest"),
11083
+ "views.changeRequest": node("changeRequest"),
11084
+ "nodes.updateContent": node("changeRequest"),
11085
+ "fileTrees.createChangeRequest": node("changeRequest"),
11086
+ "assets.createUploadUrl": workspace("changeRequest"),
11087
+ "assets.confirm": workspace("changeRequest"),
11088
+ "nodes.icon.createUploadUrl": node("changeRequest"),
11089
+ "nodes.icon.confirm": node("changeRequest"),
11090
+ "assets.putText": node("write"),
11091
+ "assets.createTextUploadUrl": node("write"),
11092
+ "changeRequests.review": node("write"),
11093
+ "changeRequests.merge": node("write"),
11094
+ "changeRequests.close": node("write"),
11095
+ "bases.create": node("write"),
11096
+ "bases.createField": node("write"),
11097
+ "docs.create": node("write"),
11098
+ "fileTrees.create": node("write"),
11099
+ "files.create": node("write"),
11100
+ "nodes.move": node("write"),
11101
+ "nodes.updateMetadata": node("write"),
11102
+ "nodes.updateSettings": node("write"),
11103
+ "nodes.updateAgentPrompts": node("write"),
11104
+ "nodes.toggleFavorite": node("write"),
11105
+ "comments.create": node("write"),
11106
+ "auditEvents.create": workspace("manage"),
11107
+ "assets.updateMetadata": node("write"),
11108
+ "assets.delete": node("write"),
11109
+ "assets.editContent": node("write"),
11110
+ "webhooks.list": workspace("manage"),
11111
+ "webhooks.get": workspace("manage"),
11112
+ "webhooks.create": workspace("manage"),
11113
+ "webhooks.update": workspace("manage"),
11114
+ "webhooks.delete": workspace("manage"),
11115
+ "webhooks.deliveries": workspace("manage"),
11116
+ "webhooks.testFire": workspace("manage"),
11117
+ "vault.get": workspace("manage"),
11118
+ "vault.update": workspace("manage"),
11119
+ "vault.clear": workspace("manage"),
11120
+ "dump.exportTables": workspace("manage"),
11121
+ "dump.exportAssetText": workspace("manage"),
11122
+ "dump.exportDocBodies": workspace("manage"),
11123
+ "dump.importBegin": workspace("manage"),
11124
+ "dump.importTables": workspace("manage"),
11125
+ "dump.importCommit": workspace("manage"),
11126
+ "dump.importAbort": workspace("manage"),
11127
+ "nodes.purge": node("manage"),
11128
+ "nodes.updateVisibility": node("manage"),
11129
+ "nodes.principals.list": node("manage"),
11130
+ "nodes.principals.add": node("manage"),
11131
+ "nodes.principals.remove": node("manage"),
11132
+ "nodes.share.get": node("manage"),
11133
+ "nodes.share.set": node("manage"),
11134
+ "nodes.share.disable": node("manage"),
11135
+ "embedLinks.create": workspace("manage"),
11136
+ "embedLinks.list": workspace("manage"),
11137
+ "embedLinks.revoke": workspace("manage"),
11138
+ "nodes.listFavorites": node("read"),
11139
+ "auditEvents.list": node("read"),
11140
+ "activity.listPaged": node("read"),
11141
+ "activity.listForNode": node("read"),
11142
+ "activity.listForRecord": node("read"),
11143
+ "comments.list": node("read"),
11144
+ "comments.listMentions": node("read"),
11145
+ "comments.markMentionsRead": node("read"),
11146
+ "agent.listTasks": node("read"),
11147
+ "live.subscribe": workspace("manage"),
11148
+ "bases.list": node("read"),
11149
+ "bases.get": node("read"),
11150
+ "bases.listDeletedFields": node("read"),
11151
+ "bases.listViews": node("read"),
11152
+ "bases.previewFieldConversion": node("read"),
11153
+ "fileTrees.listFiles": node("read"),
11154
+ "fileTrees.readFile": node("read"),
11155
+ "airapps.runLocal": node("write"),
11156
+ "airapps.stopLocal": node("write"),
11157
+ "nodes.readLines": node("read"),
11158
+ "forms.list": node("read"),
11159
+ "forms.getByNode": node("read"),
11160
+ "forms.create": node("manage"),
11161
+ "forms.update": node("manage"),
11162
+ "forms.submit": node("changeRequest"),
11163
+ "assets.list": node("read"),
11164
+ "assets.get": node("read"),
11165
+ "assets.download": node("read"),
11166
+ "assets.readTextLines": node("read"),
11167
+ "install.planFromGithub": workspace("read"),
11168
+ "install.fromGithub": workspace("manage"),
11169
+ "templates.list": workspace("read"),
11170
+ "guides.list": workspace("read"),
11171
+ "guides.read": workspace("read"),
11172
+ "agents.catalog": workspace("manage"),
11173
+ "agents.connections.list": workspace("manage"),
11174
+ "agents.disconnect": workspace("manage"),
11175
+ "agents.sessions.list": workspace("manage"),
11176
+ "agents.sessions.create": workspace("manage"),
11177
+ "agents.sessions.prompt": workspace("manage"),
11178
+ "agents.sessions.cancel": workspace("manage"),
11179
+ "agents.sessions.close": workspace("manage"),
11180
+ "agents.sessions.subscribe": workspace("manage"),
11181
+ "agents.sessions.respondToPermission": workspace("manage"),
11182
+ "changeRequests.list": node("read"),
11183
+ "changeRequests.listPage": node("read"),
11184
+ "changeRequests.inboxSnapshot": node("read"),
11185
+ "changeRequests.counts": node("read"),
11186
+ "changeRequests.get": node("read"),
11187
+ "records.list": node("read"),
11188
+ "records.listPage": node("read"),
11189
+ "records.count": node("read"),
11190
+ "records.groupBy": node("read"),
11191
+ "records.get": node("read"),
11192
+ "records.search": node("read"),
11193
+ "records.listChangeRequests": node("read"),
11194
+ "records.listLinks": node("read")
11195
+ };
11196
+ /** Backward-compatible level-only export used by older tests/callers. */
11197
+ const PROCEDURE_LEVEL_OVERRIDES = Object.fromEntries(Object.entries(PROCEDURE_PERMISSION_POLICY).map(([path, policy]) => [path, policy.level]));
11198
+ /**
11199
+ * `workbenchProcedure.router(busabaseRouter)` is later spread into the
11200
+ * `workbench` key of the router the host actually serves, and oRPC resolves
11201
+ * `path` against the FULL served router tree, not the sub-router the
11202
+ * middleware was attached to — confirmed empirically against both
11203
+ * `OpenAPIHandler` and `RPCHandler` (the two handlers actually used in
11204
+ * production): a call to `workbench.bases.create` delivers
11205
+ * `path: ["workbench", "bases", "create"]` to the middleware, not
11206
+ * `["bases", "create"]` as originally assumed. Strip that one known mount-key
11207
+ * segment; leave any other shape untouched (e.g. a future direct mount, or a
11208
+ * unit test that passes a bare `busabaseRouter`-relative path).
11209
+ */
11210
+ const normalizePath = (path) => path[0] === "workbench" ? path.slice(1) : [...path];
11211
+ /**
11212
+ * Backward-compatible level-only resolver. HTTP method is deliberately ignored:
11213
+ * known paths use the explicit semantic policy and unknown paths fail closed to
11214
+ * manage regardless of whether they happen to be GET, POST, or RPC-only.
11215
+ */
11216
+ function resolveRequiredLevel(path, _routeMethod) {
11217
+ const key = normalizePath(path).join(".");
11218
+ const override = PROCEDURE_LEVEL_OVERRIDES[key];
11219
+ if (override) return override;
11220
+ return "manage";
11221
+ }
11222
+ //#endregion
11223
+ //#region src/schema-command.ts
11224
+ /**
11225
+ * The level an endpoint needs, from the contract's own policy table — the same
11226
+ * table the server enforces with, so this can never disagree with a real 403.
11227
+ *
11228
+ * A handful of endpoints have no explicit entry and the policy fails closed to
11229
+ * `manage`. That is the right answer for the gate, but a *guess* as far as advice
11230
+ * goes, so callers that phrase it for a human should know which it is.
11231
+ */
11232
+ function requiredLevelFor(id) {
11233
+ return {
11234
+ level: resolveRequiredLevel(id.split("."), void 0),
11235
+ declared: id in PROCEDURE_PERMISSION_POLICY
11236
+ };
11237
+ }
11238
+ /** Zod → JSON Schema. `io` matters: defaults/coercion make input and output differ. */
11239
+ function toJsonSchema(schema, io) {
11240
+ if (schema === void 0) return void 0;
11241
+ try {
11242
+ return z.toJSONSchema(schema, {
11243
+ io,
11244
+ unrepresentable: "any"
11245
+ });
11246
+ } catch {
11247
+ return;
11248
+ }
11249
+ }
11250
+ const describe = (entry) => {
11251
+ const { route } = entry.proc["~orpc"];
11252
+ return {
11253
+ id: entry.id,
11254
+ method: route.method,
11255
+ path: route.path,
11256
+ ...route.summary ? { summary: route.summary } : {},
11257
+ ...route.successDescription ? { description: route.successDescription } : {},
11258
+ command: `busabase-cli ${generatedCommandPath(entry.navPath, entry.key).join(" ")}`,
11259
+ requiredLevel: requiredLevelFor(entry.id).level
11260
+ };
11261
+ };
11262
+ /** Every endpoint the contract carries, in contract order. */
11263
+ function listEndpoints() {
11264
+ return [...contractProcedures()].map(describe);
11265
+ }
11266
+ /**
11267
+ * Resolve a user-typed id. Accepts the dotted contract path (`bases.list`), the
11268
+ * space-separated form (`bases list`, already joined by the caller), and the
11269
+ * kebab spelling the command tree uses (`change-requests.list`), so an agent can
11270
+ * paste back whatever it saw without translating.
11271
+ */
11272
+ function findEntry(query) {
11273
+ const normalize = (value) => value.replace(/[\s/]+/g, ".").replace(/-/g, "").toLowerCase();
11274
+ const wanted = normalize(query);
11275
+ const entries = [...contractProcedures()];
11276
+ return entries.find((entry) => entry.id === query) ?? entries.find((entry) => normalize(entry.id) === wanted);
11277
+ }
11278
+ /** Ids closest to a miss, so the error can suggest instead of just refusing. */
11279
+ function suggestions(query, limit = 5) {
11280
+ const needle = query.replace(/[\s./-]+/g, "").toLowerCase();
11281
+ if (!needle) return [];
11282
+ const scored = [];
11283
+ for (const entry of contractProcedures()) {
11284
+ const hay = entry.id.replace(/[.-]/g, "").toLowerCase();
11285
+ if (hay.includes(needle)) scored.push({
11286
+ id: entry.id,
11287
+ score: 0
11288
+ });
11289
+ else if (needle.length >= 3 && hay.includes(needle.slice(0, 3))) scored.push({
11290
+ id: entry.id,
11291
+ score: 1
11292
+ });
11293
+ }
11294
+ return scored.sort((a, b) => a.score - b.score || a.id.length - b.id.length).slice(0, limit).map((s) => s.id);
11295
+ }
11296
+ /**
11297
+ * A misspelled id is a usage error, not a runtime failure: it exits `2` like any
11298
+ * other bad argument, so a script can tell "you typed it wrong" from "the call failed".
11299
+ */
11300
+ var SchemaNotFoundError = class extends CliOutcomeError {
11301
+ constructor(query, near) {
11302
+ super("USAGE", near.length ? `Unknown endpoint "${query}". Did you mean: ${near.join(", ")}? Run \`busabase-cli schema\` for the full list.` : `Unknown endpoint "${query}". Run \`busabase-cli schema\` for the full list.`);
11303
+ this.name = "SchemaNotFoundError";
11304
+ }
11305
+ };
11306
+ /** One endpoint, with the halves the caller asked for. */
11307
+ function describeEndpoint(query, io) {
11308
+ const entry = findEntry(query);
11309
+ if (!entry) throw new SchemaNotFoundError(query, suggestions(query));
11310
+ const { inputSchema, outputSchema } = entry.proc["~orpc"];
11311
+ return {
11312
+ ...describe(entry),
11313
+ ...io === "output" ? {} : { input: toJsonSchema(inputSchema, "input") },
11314
+ ...io === "input" ? {} : { output: toJsonSchema(outputSchema, "output") }
11315
+ };
11316
+ }
11317
+ /** Human-readable rendering for a terminal; `--output json` prints the object instead. */
11318
+ function renderEndpoint(endpoint) {
11319
+ const lines = [
11320
+ `${endpoint.id}`,
11321
+ ` ${endpoint.method} ${endpoint.path}`,
11322
+ ...endpoint.summary ? [` ${endpoint.summary}`] : [],
11323
+ ...endpoint.description ? [` ${endpoint.description}`] : [],
11324
+ ` command: ${endpoint.command}`,
11325
+ ` requires API key level: ${endpoint.requiredLevel}`
11326
+ ];
11327
+ if (endpoint.input !== void 0) lines.push("", "input:", JSON.stringify(endpoint.input, null, 2));
11328
+ if (endpoint.output !== void 0) lines.push("", "output:", JSON.stringify(endpoint.output, null, 2));
11329
+ return lines.join("\n");
11330
+ }
11331
+ /** The endpoint index, one line each — the map before you pick a target. */
11332
+ function renderList(entries) {
11333
+ const width = Math.max(...entries.map((e) => e.id.length));
11334
+ return [
11335
+ ...entries.map((e) => `${e.id.padEnd(width)} ${e.method.padEnd(6)} ${e.path}${e.summary ? ` — ${e.summary}` : ""}`),
11336
+ "",
11337
+ `${entries.length} endpoints. \`busabase-cli schema <id>\` prints one endpoint's input and output schema.`
11338
+ ].join("\n");
11339
+ }
11340
+ /** Exported for the help text, so the example ids can never name a dead endpoint. */
11341
+ const sampleEndpointId = () => findEntry("bases.list")?.id ?? [...contractProcedures()][0]?.id ?? "bases.list";
11342
+ //#endregion
10703
11343
  //#region src/task-command.ts
10704
11344
  const collect = (value, previous = []) => [...previous, value];
10705
11345
  /** Range-checks before the request goes out, so a cap surfaces as a usage error. */
@@ -10794,6 +11434,7 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
10794
11434
  }
10795
11435
  applyParam(cmd, param);
10796
11436
  }
11437
+ if (!task.annotations.readOnly) cmd.option("--dry-run", "print the request this would send and send nothing");
10797
11438
  if (task.guidance) cmd.addHelpText("after", `\n${wrapGuidance(rewriteTaskNames(task.guidance))}`);
10798
11439
  if (task.examples && task.examples.length > 0 && Object.keys(preset).length === 0) cmd.addHelpText("after", `\nExamples:\n ${task.examples.join("\n ")}`);
10799
11440
  cmd.action(deps.runAction(async (client, opts) => task.execute(client, {
@@ -11062,10 +11703,10 @@ function parseJsonValue(raw, flagName) {
11062
11703
  * Render a validation error's per-issue detail as `path: message`.
11063
11704
  *
11064
11705
  * Load-bearing, not cosmetic: a schema that refuses a field explains WHY in the
11065
- * issue message (e.g. "`autoMerge` is not accepted here: deleting a field
11066
- * soft-deletes its stored values with it… Omit the flag."). Without this the
11067
- * caller sees only the generic envelope "Input validation failed" and is no
11068
- * better off than when the field was silently dropped.
11706
+ * issue message (e.g. "deleteMode: Invalid option: expected \"archive\"" — hard
11707
+ * delete after retention was never implemented). Without this the caller sees
11708
+ * only the generic envelope "Input validation failed" and is no better off than
11709
+ * when the field was silently dropped.
11069
11710
  */
11070
11711
  function formatIssues(issues) {
11071
11712
  const parsed = toIssues(issues);
@@ -11222,14 +11863,28 @@ function runAction(state, handler) {
11222
11863
  if (refreshedToken) config.apiKey = refreshedToken;
11223
11864
  state.config = config;
11224
11865
  assertCredentialNotExpired(config.apiKey);
11866
+ const dryRun = Boolean(opts.dryRun);
11867
+ config.fetch = observeRequests(config.fetch ?? fetch, (seen) => {
11868
+ state.lastRequest = seen;
11869
+ });
11870
+ if (dryRun) config.fetch = dryRunFetch(config.fetch);
11225
11871
  const client = createBusabaseClient(config);
11226
- if (opts.all) {
11227
- await streamAllPages(client, opts, config, handler);
11228
- return;
11872
+ try {
11873
+ if (opts.all) {
11874
+ await streamAllPages(client, opts, config, handler);
11875
+ return;
11876
+ }
11877
+ const result = await handler(client, opts, config);
11878
+ console.log(render(opts.slim ? slim(result) : result, config.output));
11879
+ if (dryRun) console.error(DRY_RUN_NO_WRITE_NOTE);
11880
+ if (opts.wait) await awaitReview(client, opts, config, result);
11881
+ } catch (error) {
11882
+ if (!(error instanceof DryRunInterception)) throw error;
11883
+ console.log(config.output === "json" ? render({
11884
+ dryRun: true,
11885
+ wouldSend: error.plan
11886
+ }, config.output) : renderDryRunPlan(error.plan));
11229
11887
  }
11230
- const result = await handler(client, opts, config);
11231
- console.log(render(opts.slim ? slim(result) : result, config.output));
11232
- if (opts.wait) await awaitReview(client, opts, config, result);
11233
11888
  };
11234
11889
  }
11235
11890
  /** Default `--wait` budget: long enough for a reviewer who is at their desk. */
@@ -11316,9 +11971,6 @@ function pkgVersion() {
11316
11971
  return "0.0.0";
11317
11972
  }
11318
11973
  }
11319
- /** camelCase → kebab-case for flag and command names (createChangeRequest → create-change-request). */
11320
- const kebab = (value) => value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
11321
- const isProcedure = (node) => typeof node === "object" && node !== null && typeof node["~orpc"]?.route?.method === "string";
11322
11974
  /** Peel ZodOptional/ZodDefault/ZodNullable wrappers; a default or optional wrapper means not-required. */
11323
11975
  function unwrapSchema(schema) {
11324
11976
  let inner = schema;
@@ -11474,6 +12126,12 @@ const GENERATED_SKIP = /* @__PURE__ */ new Set([
11474
12126
  "fileTrees.readFile",
11475
12127
  "fileTrees.createChangeRequest"
11476
12128
  ]);
12129
+ /** Routes that change nothing — they run for real under `--dry-run`, so no flag is offered. */
12130
+ const READ_ROUTE_METHODS = /* @__PURE__ */ new Set([
12131
+ "GET",
12132
+ "HEAD",
12133
+ "OPTIONS"
12134
+ ]);
11477
12135
  /** Walk `cloudContract` and register a leaf command per procedure not already covered by hand. */
11478
12136
  function registerGeneratedCommands(program, state) {
11479
12137
  const getGroup = (displayName, tag) => program.commands.find((c) => c.name() === displayName) ?? program.command(displayName).description(tag ?? `${displayName} endpoints`);
@@ -11524,8 +12182,11 @@ function registerGeneratedCommands(program, state) {
11524
12182
  }
11525
12183
  }
11526
12184
  addGlobalFlags(leaf);
11527
- leaf.addHelpText("after", `\nOpenAPI: ${route.method} ${route.path}`);
12185
+ if (!READ_ROUTE_METHODS.has(route.method.toUpperCase())) leaf.option("--dry-run", "print the request this would send and send nothing");
12186
+ const procedureId = [...navPath, procKey].join(".");
12187
+ leaf.addHelpText("after", `\nOpenAPI: ${route.method} ${route.path}\nRequires API key level: ${requiredLevelFor(procedureId).level}`);
11528
12188
  leaf.action(runAction(state, (client, opts) => {
12189
+ state.procedureId = procedureId;
11529
12190
  let input = {};
11530
12191
  for (const f of fields) {
11531
12192
  if (f.pinned !== void 0) {
@@ -11544,19 +12205,10 @@ function registerGeneratedCommands(program, state) {
11544
12205
  return fields.length > 0 ? target[procKey](input) : target[procKey]();
11545
12206
  }));
11546
12207
  };
11547
- const walk = (node, navPath) => {
11548
- for (const key of Object.keys(node)) {
11549
- if (key === "~orpc") continue;
11550
- const child = node[key];
11551
- const id = [...navPath, key].join(".");
11552
- if (isProcedure(child)) {
11553
- if (GENERATED_SKIP.has(id)) continue;
11554
- const parent = navPath.length ? getGroup(kebab(navPath[0]), child["~orpc"].route.tags?.[0]) : program;
11555
- addLeaf(parent, navPath, key, child);
11556
- } else if (child && typeof child === "object") walk(child, [...navPath, key]);
11557
- }
11558
- };
11559
- walk(cloudContract, []);
12208
+ for (const { id, navPath, key, proc } of contractProcedures()) {
12209
+ if (GENERATED_SKIP.has(id)) continue;
12210
+ addLeaf(navPath.length ? getGroup(kebab(navPath[0]), proc["~orpc"].route.tags?.[0]) : program, navPath, key, proc);
12211
+ }
11560
12212
  }
11561
12213
  /**
11562
12214
  * Per-command flag signature derived from the actual option definitions, e.g.
@@ -11628,14 +12280,42 @@ function buildProgram(state = {}) {
11628
12280
  addGlobalFlags(program);
11629
12281
  addGlobalFlags(program.command("health")).description("Server health check (GET /api/health)").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/health")));
11630
12282
  addGlobalFlags(program.command("openapi")).description("Fetch the OpenAPI document").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/v1/openapi.json")));
12283
+ addGlobalFlags(program.command("schema")).argument("[endpoint...]", "contract id, e.g. `bases.list` or `bases list`; omit to list all").description("Print one endpoint's input/output schema — offline, no credential").addOption(new Option("--io <half>", "print only one half of the schema").choices(["input", "output"])).addHelpText("after", `
12284
+ Answers "what do I send, and what comes back" without fetching anything. The
12285
+ contract is compiled into this CLI, so \`schema\` works offline and unauthenticated.
12286
+
12287
+ Prefer it over \`busabase-cli openapi\`: that returns the WHOLE document — 936 KB
12288
+ across 103 endpoints as of 2026-09-04, with every type inlined per endpoint — so
12289
+ reading one endpoint's arguments out of it costs roughly 234k tokens. One
12290
+ endpoint through \`schema\` is a few KB.
12291
+
12292
+ busabase-cli schema # every endpoint: id, method, path
12293
+ busabase-cli schema ${sampleEndpointId()} # input + output schema
12294
+ busabase-cli schema records.changeRequest --io input # just what to send
12295
+ busabase-cli schema records list --output json # machine-readable
12296
+
12297
+ Ids are the contract path. \`schema\` also accepts the space-separated and kebab
12298
+ spellings, so you can paste back whatever you saw.`).action((endpoint, _opts, cmd) => {
12299
+ const opts = cmd.optsWithGlobals();
12300
+ const config = resolveConfig(opts);
12301
+ state.config = config;
12302
+ const io = opts.io;
12303
+ if (!endpoint || endpoint.length === 0) {
12304
+ const entries = listEndpoints();
12305
+ console.log(config.output === "json" ? render(entries, config.output) : renderList(entries));
12306
+ return;
12307
+ }
12308
+ const described = describeEndpoint(endpoint.join("."), io);
12309
+ console.log(config.output === "json" ? render(described, config.output) : renderEndpoint(described));
12310
+ });
11631
12311
  addGlobalFlags(program.command("whoami")).description("Active space, user, and membership").action(runAction(state, (client) => client.auth.verify()));
11632
- addGlobalFlags(program.command("guide")).argument("[topic]", "which guide to read; omit to list the topics this server serves").description("Read the Busabase operating manual (approval-first rules, AirApp contract)").addHelpText("after", `
12312
+ addGlobalFlags(program.command("guide")).argument("[topic]", "which guide to read; omit to list the topics this server serves").description("Read the Busabase operating manual (workspace conventions, AirApp contract)").addHelpText("after", `
11633
12313
  Read this BEFORE unfamiliar work. It carries the conventions this workspace
11634
12314
  expects, which no flag or schema can express on its own.
11635
12315
 
11636
12316
  Examples:
11637
12317
  busabase-cli guide # what is available here
11638
- busabase-cli guide workspace # the approval-first workflow
12318
+ busabase-cli guide workspace # the change-request workflow
11639
12319
  busabase-cli guide airapp # REQUIRED before writing any AirApp file`).action(async (topic, _opts, cmd) => {
11640
12320
  const config = resolveConfig(cmd.optsWithGlobals());
11641
12321
  state.config = config;
@@ -11652,9 +12332,9 @@ Examples:
11652
12332
  console.log(guide.content);
11653
12333
  console.error(`\n[busabase-cli] other topics: ${guide.otherTopics.join(", ") || "(none)"}`);
11654
12334
  });
11655
- addGlobalFlags(program.command("login")).description("Connect the CLI to Busabase — Personal/local, Cloud, or self-hosted").option("--device-code", "force device authorization and skip the method prompt").option("--oauth", "legacy same-machine OAuth callback flow").option("--no-browser", "print the sign-in URL instead of opening a browser").option("--refresh", "rotate the saved OAuth token set (no browser, no re-login)").addHelpText("after", `
11656
- Busabase is an approval-first database and knowledge base for AI agents: agents
11657
- propose changes, humans review and merge what becomes trusted data.
12335
+ addGlobalFlags(program.command("login")).description("Connect the CLI to Busabase — Personal/local, Cloud, or self-hosted").option("--device-code", "force device authorization and skip the method prompt").option("--oauth", "legacy same-machine OAuth callback flow").option("--no-browser", "print the sign-in URL instead of opening a browser").option("--refresh", "rotate the saved OAuth token set (no browser, no re-login)").option("--no-wait", "start a device sign-in and return verification_url + resume_code at once (for agents)").option("--resume-code <code>", "finish a device sign-in started earlier with --no-wait").addHelpText("after", `
12336
+ Busabase is your AI agents' database, knowledge base, apps library, and skills registry.
12337
+ Every change goes in as a change request, so it keeps a message, a diff, and a history.
11658
12338
 
11659
12339
  Run interactively, login explains the connection choices and writes the selected
11660
12340
  Busabase instance to ~/.busabase/.env:
@@ -11672,10 +12352,20 @@ The flags below skip the menu (handy for scripts / CI):
11672
12352
  busabase-cli login --base-url http://localhost:15419 # connect to a local server (no auth)
11673
12353
  busabase-cli login --refresh # rotate the OAuth token set (auto-runs too)
11674
12354
  busabase-cli login --profile work # add a SECOND account, then switch to it
12355
+ busabase-cli login --no-wait --output json # agent: URL + resume code, returns at once
12356
+ busabase-cli login --resume-code <code> # agent: finish after the user authorized
11675
12357
 
11676
12358
  Login never blocks on a Space question, interactive or not: with more than one Space it always
11677
12359
  accepts the server-resolved default (the one you were most recently active in) and prints the
11678
- full list — switch anytime with \`busabase-cli space use <id>\`.`).action(async (_opts, cmd) => {
12360
+ full list — switch anytime with \`busabase-cli space use <id>\`.
12361
+
12362
+ For AI agents: the blocking flows above print the verification URL mid-command, and
12363
+ a harness that only relays a turn's final message will drop it — the user never
12364
+ sees the URL and login silently times out. Use the two-turn flow instead: run
12365
+ \`--no-wait --output json\` now, send verification_url to the user as your final
12366
+ message (verbatim — never re-encode or re-wrap it; \`busabase-cli qrcode\` renders
12367
+ it scannable), end the turn, then run \`--resume-code <code>\` after they confirm.
12368
+ Do not restart login after showing the URL — a restart invalidates it.`).action(async (_opts, cmd) => {
11679
12369
  const opts = cmd.optsWithGlobals();
11680
12370
  const config = resolveConfig(opts);
11681
12371
  state.config = config;
@@ -11688,7 +12378,10 @@ full list — switch anytime with \`busabase-cli space use <id>\`.`).action(asyn
11688
12378
  spaceId: config.spaceId,
11689
12379
  deviceCode: Boolean(opts.deviceCode),
11690
12380
  oauth: Boolean(opts.oauth),
11691
- browser: opts.browser !== false
12381
+ browser: opts.browser !== false,
12382
+ noWait: opts.wait === false,
12383
+ resumeCode: opts.resumeCode,
12384
+ profile: opts.profile
11692
12385
  });
11693
12386
  const profile = opts.profile;
11694
12387
  if (profile && !opts.refresh) activateLoggedInProfile(profile);
@@ -11703,6 +12396,33 @@ full list — switch anytime with \`busabase-cli space use <id>\`.`).action(asyn
11703
12396
  });
11704
12397
  console.log(render(summary, config.output));
11705
12398
  });
12399
+ addGlobalFlags(program.command("qrcode")).description("Render a URL (e.g. a login verification URL) as a QR code").argument("<url>", "URL to encode — treated as an opaque string, byte-for-byte").option("-o, --out-file <path>", "write a PNG here (under cwd, the temp dir, or your home)").option("--ascii", "print a terminal QR code to stdout instead of writing a PNG").option("--size <px>", `PNG size in pixels (64-${QR_MAX_SIZE}, default 256)`, parsePositiveInt).addHelpText("after", `
12400
+ Made for the two-turn login: \`login --no-wait\` returns a verification_url the
12401
+ user often approves on a phone — a QR code removes the copy-a-long-URL step.
12402
+
12403
+ busabase-cli qrcode "<verification_url>" --out-file qr.png
12404
+ busabase-cli qrcode "<verification_url>" --ascii
12405
+
12406
+ Forward URLs verbatim: never re-encode, trim, or re-wrap a verification URL —
12407
+ a reworded URL is a 404 on the user's phone.`).action(async (url, _opts, cmd) => {
12408
+ const opts = cmd.optsWithGlobals();
12409
+ const config = resolveConfig(opts);
12410
+ state.config = config;
12411
+ if (opts.ascii) {
12412
+ console.log(await renderQrAscii(url));
12413
+ return;
12414
+ }
12415
+ const outFile = opts.outFile;
12416
+ if (!outFile) throw new Error("Pass --out-file <path> for a PNG, or --ascii for terminal output.");
12417
+ const filePath = safeQrOutputPath(outFile);
12418
+ await writeQrPng(url, filePath, opts.size ?? 256);
12419
+ console.log(render({
12420
+ status: "qr code written",
12421
+ filePath,
12422
+ url,
12423
+ hint: QR_DISPLAY_HINT
12424
+ }, config.output));
12425
+ });
11706
12426
  const auth = program.command("auth").description("Accounts (profiles) and CLI settings");
11707
12427
  const authStatus = (cmd) => cmd.action(async (_opts, sub) => {
11708
12428
  const config = resolveConfig(sub.optsWithGlobals());
@@ -11767,11 +12487,11 @@ than as a 403 later. The choice is stored in the active profile and mirrored to
11767
12487
  });
11768
12488
  const nodes = program.command("nodes").description("Workspace node tree");
11769
12489
  addGlobalFlags(nodes.command("list")).description("Workspace node tree").action(runAction(state, (client) => client.nodes.list()));
11770
- addGlobalFlags(nodes.command("purge")).description("Permanently delete an ALREADY-archived node and its subtree (irreversible). Archive it first with `nodes archive` if it isn't archived yet.").requiredOption("--node-id <id>", "archived node id to purge").action(runAction(state, (client, opts) => client.nodes.purge({ nodeId: opts.nodeId })));
12490
+ addGlobalFlags(nodes.command("purge")).description("Permanently delete an ALREADY-archived node and its subtree (irreversible). Archive it first with `nodes archive` if it isn't archived yet.").requiredOption("--node-id <id>", "archived node id to purge").option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => client.nodes.purge({ nodeId: opts.nodeId })));
11771
12491
  addGlobalFlags(nodes.command("move")).description("Move or reorder a node (applied immediately, no review needed)").requiredOption("--node-id <id>", "node id to move").option("--parent-node-id <id>", "new parent folder node id; omit to keep the current parent").option("--position <n>", "new position among the target parent's children", (v) => Number.parseInt(v, 10)).option("--message <text>", "Change Request message").option("--submitted-by <name>", "producer label").addHelpText("after", `
11772
12492
  Examples:
11773
12493
  busabase-cli nodes move --node-id nod_123 --position 0
11774
- busabase-cli nodes move --node-id nod_123 --parent-node-id nod_456 --position 2`).action(runAction(state, (client, opts) => client.nodes.move({
12494
+ busabase-cli nodes move --node-id nod_123 --parent-node-id nod_456 --position 2`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => client.nodes.move({
11775
12495
  nodeId: opts.nodeId,
11776
12496
  parentNodeId: opts.parentNodeId,
11777
12497
  position: opts.position,
@@ -11793,7 +12513,7 @@ locale) is reported entry-by-entry and nothing is written. \`--file\`'s
11793
12513
  contents ARE the array to store; this command does not reshape it.
11794
12514
 
11795
12515
  Examples:
11796
- busabase-cli nodes set-agent-prompts --node-id nod_123 --file prompts.json`).action(runAction(state, async (client, opts) => {
12516
+ busabase-cli nodes set-agent-prompts --node-id nod_123 --file prompts.json`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, async (client, opts) => {
11797
12517
  const filePath = opts.file;
11798
12518
  let raw;
11799
12519
  try {
@@ -11828,7 +12548,7 @@ Examples:
11828
12548
  if (!found) throw new Error(`no Base with slug "${slug}"`);
11829
12549
  return found;
11830
12550
  }));
11831
- addGlobalFlags(bases.command("create-field")).description("Add a field to a Base").requiredOption("--base-id <id>", "Base id").requiredOption("--slug <slug>", "field slug").requiredOption("--name <name>", "field name").option("--field-type <type>", "field type (default text)").option("--required", "mark the field as required").option("--max-files <n>", "attachment option: max files", parsePositiveInt).option("--max-file-size <bytes>", "attachment option: max size in bytes", parsePositiveInt).option("--allowed-mime <mime>", "attachment option: allowed MIME type, repeatable", collectValues).action(runAction(state, (client, opts) => client.bases.createField({
12551
+ addGlobalFlags(bases.command("create-field")).description("Add a field to a Base").requiredOption("--base-id <id>", "Base id").requiredOption("--slug <slug>", "field slug").requiredOption("--name <name>", "field name").option("--field-type <type>", "field type (default text)").option("--required", "mark the field as required").option("--max-files <n>", "attachment option: max files", parsePositiveInt).option("--max-file-size <bytes>", "attachment option: max size in bytes", parsePositiveInt).option("--allowed-mime <mime>", "attachment option: allowed MIME type, repeatable", collectValues).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => client.bases.createField({
11832
12552
  baseId: opts.baseId,
11833
12553
  slug: opts.slug,
11834
12554
  name: opts.name,
@@ -11839,7 +12559,7 @@ Examples:
11839
12559
  addGlobalFlags(bases.command("update-field-change-request")).description("Propose updating field metadata/options via a Change Request").requiredOption("--base-id <id>", "Base id").requiredOption("--field-id <id>", "field id").option("--name <name>", "new field name").option("--required <true|false>", "set required flag", parseBoolean).option("--options-json <json|@file>", "full field options JSON, or @file.json").option("--max-files <n>", "attachment option: max files", parsePositiveInt).option("--max-file-size <bytes>", "attachment option: max size in bytes", parsePositiveInt).option("--allowed-mime <mime>", "attachment option: allowed MIME type, repeatable", collectValues).option("--message <text>", "reviewer-facing Change Request message").option("--submitted-by <name>", "producer label").addHelpText("after", `
11840
12560
  Examples:
11841
12561
  busabase-cli bases update-field-change-request --base-id bse_123 --field-id bsf_123 --name "封面 Cover Image" --max-files 1 --allowed-mime image/png --allowed-mime image/svg+xml
11842
- busabase-cli bases update-field-change-request --base-id bse_123 --field-id bsf_123 --options-json @field-options.json`).action(runAction(state, (client, opts) => {
12562
+ busabase-cli bases update-field-change-request --base-id bse_123 --field-id bsf_123 --options-json @field-options.json`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => {
11843
12563
  const patch = {};
11844
12564
  if (opts.name) patch.name = opts.name;
11845
12565
  if (opts.required !== void 0) patch.required = opts.required;
@@ -11861,7 +12581,7 @@ Examples:
11861
12581
  busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello","status":"draft"}'
11862
12582
  busabase-cli bases create-change-request --base-id bse_123 --fields-json @record.json
11863
12583
  busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello"}' --auto-merge # skip review, create immediately
11864
- busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello"}' --require-review # always propose a Change Request`).action(runAction(state, (client, opts) => client.bases.createChangeRequest({
12584
+ busabase-cli bases create-change-request --base-id bse_123 --fields-json '{"title":"Hello"}' --require-review # always propose a Change Request`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (client, opts) => client.bases.createChangeRequest({
11865
12585
  baseId: opts.baseId,
11866
12586
  fields: parseJsonValue(opts.fieldsJson, "fields-json"),
11867
12587
  message: opts.message,
@@ -11919,7 +12639,7 @@ Examples:
11919
12639
  Example:
11920
12640
  busabase-cli assets upload --file ./cover.png --output json
11921
12641
 
11922
- Use the JSON output directly in an attachment field value, e.g. {"cover_image":[<output>]}.`).action(runAction(state, (_client, opts, config) => uploadAsset(config, opts)));
12642
+ Use the JSON output directly in an attachment field value, e.g. {"cover_image":[<output>]}.`).option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (_client, opts, config) => uploadAsset(config, opts)));
11923
12643
  const assetsCommand = program.command("assets").description("Assets");
11924
12644
  addUploadCommand(assetsCommand);
11925
12645
  addGlobalFlags(assetsCommand.command("put-text")).description("Write (or mark none) an asset's Drive Grep Retrieval text slot").requiredOption("--asset-id <id>", "asset id").option("--text <string>", "inline text (≤1MB)").option("--file <path>", "read text from a local file instead of --text").option("--none", "mark as having no extractable text (e.g. a scanned, image-only PDF)").addHelpText("after", `
@@ -12090,7 +12810,7 @@ Examples:
12090
12810
 
12091
12811
  To add content to a space that is already in use, use \`busabase-cli install\` instead.
12092
12812
  To copy a still-live space's current content into another space, use
12093
- \`busabase-cli export\` + \`busabase-cli install\` instead of backup/restore.`).action(runArgAction(state, (file, client, opts, config) => runRestore(client, {
12813
+ \`busabase-cli export\` + \`busabase-cli install\` instead of backup/restore.`).option("--dry-run", "print the request this would send and send nothing").action(runArgAction(state, (file, client, opts, config) => runRestore(client, {
12094
12814
  file,
12095
12815
  resume: Boolean(opts.resume),
12096
12816
  intoFolder: opts.intoFolder,
@@ -12101,7 +12821,7 @@ To copy a still-live space's current content into another space, use
12101
12821
  "post",
12102
12822
  "put",
12103
12823
  "delete"
12104
- ]).makeOptionMandatory()).requiredOption("--path <p>", "path under /api/v1, e.g. /bases").option("--query <k=v...>", "query-string param, repeatable").option("--body-json <json>", "JSON request body").action(runAction(state, (_client, opts, config) => {
12824
+ ]).makeOptionMandatory()).requiredOption("--path <p>", "path under /api/v1, e.g. /bases").option("--query <k=v...>", "query-string param, repeatable").option("--body-json <json>", "JSON request body").option("--dry-run", "print the request this would send and send nothing").action(runAction(state, (_client, opts, config) => {
12105
12825
  const query = new URLSearchParams();
12106
12826
  for (const pair of opts.query ?? []) {
12107
12827
  const [key, ...rest] = pair.split("=");
@@ -12191,6 +12911,15 @@ async function runCli(argv) {
12191
12911
  return EXIT_CODES.USAGE;
12192
12912
  }
12193
12913
  const classified = classifyError(error);
12914
+ if (classified.code === "UNAUTHORIZED" || classified.code === "FORBIDDEN") {
12915
+ const hintConfig = state.config ?? resolveConfig({});
12916
+ const profile = isMultiProfile() ? activeCredentialTarget().profile : void 0;
12917
+ const procedureId = state.procedureId ?? (state.lastRequest ? matchRoute(state.lastRequest.method, state.lastRequest.pathname)?.id : void 0);
12918
+ classified.hint = authRecoveryHint(classified.code, hintConfig.baseUrl, profile, {
12919
+ procedureId,
12920
+ ...procedureId ? requiredLevelFor(procedureId) : {}
12921
+ });
12922
+ }
12194
12923
  if (json) console.log(JSON.stringify(errorEnvelope(classified), null, 2));
12195
12924
  console.error(explainError(error, state.config ?? resolveConfig({})));
12196
12925
  return classified.exitCode;