busa-cli 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as runCli } from "./run-FjzYcGKi.js";
2
+ import { n as runCli } from "./run--bA-jLSP.js";
3
3
  //#region src/cli.ts
4
4
  process.exit(await runCli(process.argv.slice(2)));
5
5
  //#endregion
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { n as runCli, r as render, t as HELP } from "./run-FjzYcGKi.js";
1
+ import { n as runCli, r as render, t as HELP } from "./run--bA-jLSP.js";
2
2
  import { DEFAULT_BASE_URL, createBusabaseClient, normalizeBaseUrl } from "busabase-sdk";
3
3
  export { DEFAULT_BASE_URL, HELP, createBusabaseClient, normalizeBaseUrl, render, runCli };
@@ -56,7 +56,7 @@ const OPERATIONS$1 = [
56
56
  * audience, the exact silent-drop failure the server-side rejection exists to
57
57
  * end. `params.appliesWhen` still documents which operations accept it.
58
58
  */
59
- const mergeIntent$4 = (input) => {
59
+ const mergeIntent$5 = (input) => {
60
60
  if (input.requireReview) return { autoMerge: false };
61
61
  if (input.autoMerge) return { autoMerge: true };
62
62
  return {};
@@ -246,7 +246,7 @@ const baseFieldChangeTask = {
246
246
  baseId: input.baseId,
247
247
  ...input.message ? { message: input.message } : {},
248
248
  ...input.submittedBy ? { submittedBy: input.submittedBy } : {},
249
- ...mergeIntent$4(input)
249
+ ...mergeIntent$5(input)
250
250
  };
251
251
  switch (input.operation) {
252
252
  case "add": return client.bases.fieldChangeRequest({
@@ -629,7 +629,7 @@ const nodeReadFileTask = {
629
629
  * A batch containing a `delete` is review-first server-side no matter what this
630
630
  * sends, so don't imply otherwise in the flag's description.
631
631
  */
632
- const mergeIntent$3 = (input) => {
632
+ const mergeIntent$4 = (input) => {
633
633
  if (input.requireReview) return { autoMerge: false };
634
634
  if (input.autoMerge) return { autoMerge: true };
635
635
  return {};
@@ -699,7 +699,7 @@ const nodeFilesChangeRequestTask = {
699
699
  operations: input.operations,
700
700
  ...input.message ? { message: input.message } : {},
701
701
  ...input.submittedBy ? { submittedBy: input.submittedBy } : {},
702
- ...mergeIntent$3(input)
702
+ ...mergeIntent$4(input)
703
703
  };
704
704
  return client.fileTrees.createChangeRequest(payload);
705
705
  }
@@ -1424,7 +1424,7 @@ const rejectMismatchedPayload = (input) => {
1424
1424
  if (wrong.length > 0) throw new Error(`${wrong.join(", ")} ${wrong.length > 1 ? "are" : "is"} not valid for --type ${type}.`);
1425
1425
  };
1426
1426
  /** `autoMerge` is tri-state: unset (permission-aware default), forced on, forced off. */
1427
- const mergeIntent$2 = (input) => {
1427
+ const mergeIntent$3 = (input) => {
1428
1428
  if (input.requireReview) return { autoMerge: false };
1429
1429
  if (input.autoMerge) return { autoMerge: true };
1430
1430
  return {};
@@ -1637,7 +1637,7 @@ const nodeCreateTask = {
1637
1637
  name: input.name,
1638
1638
  description: input.description,
1639
1639
  parentNodeId: input.parentNodeId,
1640
- ...mergeIntent$2(input)
1640
+ ...mergeIntent$3(input)
1641
1641
  };
1642
1642
  if (FILE_TREE_TYPES.has(type)) {
1643
1643
  const payload = {
@@ -1675,7 +1675,7 @@ const nodeCreateTask = {
1675
1675
  return client.nodes.createChangeRequest({
1676
1676
  message: input.message ?? `Create ${type} ${input.name}`,
1677
1677
  submittedBy: input.submittedBy,
1678
- ...mergeIntent$2(input),
1678
+ ...mergeIntent$3(input),
1679
1679
  operations: [{
1680
1680
  kind: "create",
1681
1681
  nodeType: type,
@@ -1688,6 +1688,74 @@ const nodeCreateTask = {
1688
1688
  }
1689
1689
  };
1690
1690
  //#endregion
1691
+ //#region ../../packages/busabase-contract/src/tasks/record-bulk-update.ts
1692
+ const mergeIntent$2 = (input) => {
1693
+ if (input.requireReview) return { autoMerge: false };
1694
+ if (input.autoMerge) return { autoMerge: true };
1695
+ return {};
1696
+ };
1697
+ const recordBulkUpdateTask = {
1698
+ name: "record_bulk_update_change_request",
1699
+ cliPath: ["records", "bulk-update-change-request"],
1700
+ summary: "Propose partial updates to many records in one ChangeRequest",
1701
+ guidance: "All updates must target active records in the same Base. Each recordId may appear once. Each fields object is a partial update: omitted keys stay unchanged and null clears a field. The batch is reviewed and merged atomically. Use baseCommitId per update when the caller must pin the version it read. Review is permission-aware; pass requireReview to force a pending ChangeRequest even when the key has write access.",
1702
+ annotations: {
1703
+ readOnly: false,
1704
+ destructive: false
1705
+ },
1706
+ params: [
1707
+ {
1708
+ name: "baseId",
1709
+ kind: "string",
1710
+ required: true,
1711
+ description: "Base owning every record."
1712
+ },
1713
+ {
1714
+ name: "updates",
1715
+ kind: "json",
1716
+ required: true,
1717
+ description: "JSON array of {recordId, fields, baseCommitId?, message?}, e.g. [{\"recordId\":\"rec_1\",\"fields\":{\"status\":\"published\"}}]."
1718
+ },
1719
+ {
1720
+ name: "message",
1721
+ kind: "string",
1722
+ description: "Reviewer-facing message for the batch."
1723
+ },
1724
+ {
1725
+ name: "submittedBy",
1726
+ kind: "string",
1727
+ description: "Producer label recorded on the change."
1728
+ },
1729
+ {
1730
+ name: "idempotencyKey",
1731
+ kind: "string",
1732
+ description: "Retry key scoped to this Base and submitter."
1733
+ },
1734
+ {
1735
+ name: "autoMerge",
1736
+ kind: "boolean",
1737
+ description: "Apply immediately when the actor has write access."
1738
+ },
1739
+ {
1740
+ name: "requireReview",
1741
+ kind: "boolean",
1742
+ description: "Force one pending ChangeRequest even when the actor has write access."
1743
+ }
1744
+ ],
1745
+ examples: ["busabase-cli records bulk-update-change-request --base-id bas_1 --updates-json '[{\"recordId\":\"rec_1\",\"fields\":{\"status\":\"published\"}}]' --require-review", "busabase-cli records bulk-update-change-request --base-id bas_1 --updates-json @updates.json --idempotency-key august-review-v1"],
1746
+ execute: async (client, input) => {
1747
+ if (!Array.isArray(input.updates)) throw new Error("`updates` must be a JSON array.");
1748
+ return client.bases.createBulkUpdateChangeRequest({
1749
+ baseId: input.baseId,
1750
+ updates: input.updates,
1751
+ ...input.message ? { message: input.message } : {},
1752
+ ...input.submittedBy ? { submittedBy: input.submittedBy } : {},
1753
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
1754
+ ...mergeIntent$2(input)
1755
+ });
1756
+ }
1757
+ };
1758
+ //#endregion
1691
1759
  //#region ../../packages/busabase-contract/src/tasks/record-change.ts
1692
1760
  /**
1693
1761
  * Changing an existing record: update, delete (archive), or restore — one task
@@ -1905,7 +1973,7 @@ const recordQueryTask = {
1905
1973
  examples: [
1906
1974
  "busabase-cli records query --base-id bas_123 --limit 100",
1907
1975
  "busabase-cli records count --base-id bas_123",
1908
- "busabase-cli records count --base-id bas_123 --filters '[{\"fieldSlug\":\"branch\",\"fieldType\":\"text\",\"operator\":\"equals\",\"value\":\"main\"}]'",
1976
+ "busabase-cli records count --base-id bas_123 --filters-json '[{\"fieldSlug\":\"branch\",\"fieldType\":\"text\",\"operator\":\"equals\",\"value\":\"main\"}]'",
1909
1977
  "busabase-cli records count --base-id bas_123 --view-id viw_456"
1910
1978
  ],
1911
1979
  execute: async (client, input) => {
@@ -2120,11 +2188,60 @@ const BUSABASE_TASKS = [
2120
2188
  changeRequestMergeTask,
2121
2189
  baseFieldChangeTask,
2122
2190
  recordChangeTask,
2191
+ recordBulkUpdateTask,
2123
2192
  viewChangeTask,
2124
2193
  nodePermissionTask,
2125
2194
  nodeShareTask,
2126
2195
  listArchivedTask
2127
2196
  ];
2197
+ /**
2198
+ * Endpoint-level MCP tools fully covered by a task above, keyed by the tool name
2199
+ * the OpenAPI generator produces. The MCP adapter excludes these so a catalog
2200
+ * carries one way to do each job rather than two.
2201
+ *
2202
+ * Kept as tool names (not contract paths) because that is what the MCP layer
2203
+ * filters on, and it is the name an agent would actually have seen.
2204
+ *
2205
+ * NOT listed on purpose:
2206
+ * - `nodes_create_change_request` — `node_create` covers only a single `create`
2207
+ * operation. The endpoint also does `rename`, and composes several operations
2208
+ * into one change request (`ref`/`parentNodeRef` builds a whole subtree
2209
+ * atomically). Excluding it would be a capability regression, not a cleanup.
2210
+ * - `forms_create` — configures an existing node, so it is a second step after
2211
+ * `node_create --type form`, not a duplicate of it.
2212
+ * - `nodes_purge` — permanent delete is a genuinely different operation from
2213
+ * `node_archive`, and `node_archive`'s guidance now tells an agent when each
2214
+ * one applies.
2215
+ */
2216
+ const TASK_SUPERSEDED_MCP_TOOLS = [
2217
+ "file_trees_create",
2218
+ "docs_create",
2219
+ "bases_create",
2220
+ "files_create",
2221
+ "records_search",
2222
+ "file_trees_list_files",
2223
+ "file_trees_read_file",
2224
+ "file_trees_create_change_request",
2225
+ "records_list",
2226
+ "records_count",
2227
+ "change_requests_list",
2228
+ "change_requests_counts",
2229
+ "change_requests_review",
2230
+ "change_requests_review_many",
2231
+ "change_requests_merge",
2232
+ "change_requests_merge_many",
2233
+ "bases_field_change_request",
2234
+ "records_change_request",
2235
+ "bases_create_bulk_update_change_request",
2236
+ "views_change_request",
2237
+ "nodes_principals_list",
2238
+ "nodes_principals_add",
2239
+ "nodes_principals_remove",
2240
+ "nodes_share_get",
2241
+ "nodes_share_set",
2242
+ "nodes_share_disable",
2243
+ "bases_list_deleted_fields"
2244
+ ];
2128
2245
  //#endregion
2129
2246
  //#region src/config-file.ts
2130
2247
  /**
@@ -3269,15 +3386,168 @@ function banner(baseUrl) {
3269
3386
  return out.join("\n");
3270
3387
  }
3271
3388
  //#endregion
3389
+ //#region src/errors.ts
3390
+ /**
3391
+ * Process exit code per class.
3392
+ *
3393
+ * `1` stays the code for an unclassified error, so a script testing `!= 0` is
3394
+ * unaffected and only a script testing `== 1` sees a change.
3395
+ */
3396
+ const EXIT_CODES = {
3397
+ ERROR: 1,
3398
+ USAGE: 2,
3399
+ UNAUTHORIZED: 3,
3400
+ FORBIDDEN: 3,
3401
+ NOT_FOUND: 4,
3402
+ VALIDATION: 5,
3403
+ CONFLICT: 6,
3404
+ RATE_LIMITED: 7,
3405
+ SERVER_ERROR: 8,
3406
+ NETWORK: 9,
3407
+ REVIEW_REJECTED: 10,
3408
+ TIMEOUT: 11
3409
+ };
3410
+ const RETRYABLE = /* @__PURE__ */ new Set([
3411
+ "RATE_LIMITED",
3412
+ "SERVER_ERROR",
3413
+ "NETWORK"
3414
+ ]);
3415
+ /** `data.issues` as the server sends it, flattened. Shared with `explainError`'s prose rendering. */
3416
+ function toIssues(raw) {
3417
+ if (!Array.isArray(raw) || raw.length === 0) return void 0;
3418
+ const issues = raw.map((issue) => {
3419
+ if (!issue || typeof issue !== "object") return {
3420
+ path: "",
3421
+ message: String(issue)
3422
+ };
3423
+ const { path, message } = issue;
3424
+ return {
3425
+ path: Array.isArray(path) ? path.join(".") : typeof path === "string" ? path : "",
3426
+ message: String(message ?? issue)
3427
+ };
3428
+ });
3429
+ return issues.length > 0 ? issues : void 0;
3430
+ }
3431
+ const codeForStatus = (status) => {
3432
+ if (status === 401) return "UNAUTHORIZED";
3433
+ if (status === 403) return "FORBIDDEN";
3434
+ if (status === 404) return "NOT_FOUND";
3435
+ if (status === 409) return "CONFLICT";
3436
+ if (status === 429) return "RATE_LIMITED";
3437
+ if (status === 400 || status === 422) return "VALIDATION";
3438
+ if (status >= 500) return "SERVER_ERROR";
3439
+ return "ERROR";
3440
+ };
3441
+ /**
3442
+ * A transport failure never reaches the server, so it has no status to read.
3443
+ * Node reports every one of them as a bare `TypeError: fetch failed` with the
3444
+ * real reason on `.cause`, which is why this matches on text rather than type.
3445
+ */
3446
+ const isNetworkFailure = (message) => /fetch failed|econnrefused|enotfound|getaddrinfo|econnreset|etimedout|socket hang up|network/i.test(message);
3447
+ /**
3448
+ * `rawFetch` (health / openapi / `api` passthrough) throws a plain `Error`
3449
+ * carrying `HTTP <status> <statusText>: …` rather than an `ORPCError`, so the
3450
+ * status has to be read back out of the message for those paths.
3451
+ */
3452
+ const statusFromMessage = (message) => {
3453
+ const match = /\bHTTP (\d{3})\b/.exec(message);
3454
+ return match ? Number(match[1]) : void 0;
3455
+ };
3456
+ /** Classify any thrown value into the stable shape above. Never throws. */
3457
+ function classifyError(error) {
3458
+ const message = error instanceof Error ? error.message : String(error);
3459
+ const outcome = error?.cliCode;
3460
+ if (typeof outcome === "string" && outcome in EXIT_CODES) {
3461
+ const code = outcome;
3462
+ return {
3463
+ code,
3464
+ message,
3465
+ retryable: RETRYABLE.has(code),
3466
+ exitCode: EXIT_CODES[code]
3467
+ };
3468
+ }
3469
+ const issues = toIssues(error?.data?.issues);
3470
+ const status = typeof error?.status === "number" ? error.status : statusFromMessage(message);
3471
+ let code;
3472
+ if (status !== void 0) code = codeForStatus(status);
3473
+ else if (isNetworkFailure(message)) code = "NETWORK";
3474
+ else code = "ERROR";
3475
+ const orpcCode = error?.code;
3476
+ if (code === "ERROR" && typeof orpcCode === "string") {
3477
+ if (orpcCode === "NOT_FOUND") code = "NOT_FOUND";
3478
+ else if (orpcCode === "UNAUTHORIZED") code = "UNAUTHORIZED";
3479
+ else if (orpcCode === "FORBIDDEN") code = "FORBIDDEN";
3480
+ else if (orpcCode === "CONFLICT") code = "CONFLICT";
3481
+ else if (orpcCode === "BAD_REQUEST" || orpcCode === "UNPROCESSABLE_CONTENT") code = "VALIDATION";
3482
+ else if (orpcCode === "TOO_MANY_REQUESTS") code = "RATE_LIMITED";
3483
+ }
3484
+ return {
3485
+ code,
3486
+ ...status !== void 0 ? { status } : {},
3487
+ message,
3488
+ ...issues ? { issues } : {},
3489
+ retryable: RETRYABLE.has(code),
3490
+ exitCode: EXIT_CODES[code]
3491
+ };
3492
+ }
3493
+ /** The stdout envelope for a failed command under `--output json`. */
3494
+ function errorEnvelope(classified) {
3495
+ return {
3496
+ ok: false,
3497
+ code: classified.code,
3498
+ ...classified.status !== void 0 ? { status: classified.status } : {},
3499
+ message: classified.message,
3500
+ ...classified.issues ? { issues: classified.issues } : {},
3501
+ retryable: classified.retryable
3502
+ };
3503
+ }
3504
+ /**
3505
+ * A command that ran fine but ended in an outcome the caller must branch on —
3506
+ * a proposal a reviewer turned down, or a `--wait` that ran out of budget.
3507
+ *
3508
+ * Carries its own class so {@link classifyError} does not have to guess from a
3509
+ * message, and so the envelope reports it exactly like any other failure.
3510
+ */
3511
+ var CliOutcomeError = class extends Error {
3512
+ cliCode;
3513
+ constructor(cliCode, message) {
3514
+ super(message);
3515
+ this.name = "CliOutcomeError";
3516
+ this.cliCode = cliCode;
3517
+ }
3518
+ };
3519
+ //#endregion
3272
3520
  //#region src/format.ts
3273
3521
  /** Render a command result as JSON, a compact text view, or an aligned table. */
3274
3522
  function render(value, output) {
3275
3523
  if (output === "json") return JSON.stringify(value, null, 2);
3276
3524
  if (output === "text") return renderText(value);
3277
3525
  if (Array.isArray(value)) return renderTable(value);
3526
+ const envelope = asEnvelope(value);
3527
+ if (envelope) return renderEnvelope(envelope, renderTable);
3278
3528
  if (value && typeof value === "object") return renderRecord(value);
3279
3529
  return String(value);
3280
3530
  }
3531
+ function asEnvelope(value) {
3532
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
3533
+ const entries = Object.entries(value);
3534
+ const arrays = entries.filter((entry) => Array.isArray(entry[1]));
3535
+ if (arrays.length !== 1) return void 0;
3536
+ const meta = entries.filter(([, item]) => !Array.isArray(item));
3537
+ if (!meta.every(([, item]) => item === null || item === void 0 || typeof item !== "object")) return void 0;
3538
+ return {
3539
+ key: arrays[0][0],
3540
+ rows: arrays[0][1],
3541
+ meta
3542
+ };
3543
+ }
3544
+ function renderEnvelope(envelope, renderRows) {
3545
+ const body = envelope.rows.length === 0 ? `(no ${envelope.key})` : renderRows(envelope.rows);
3546
+ const meta = envelope.meta.filter(([, item]) => item !== null && item !== void 0);
3547
+ if (meta.length === 0) return body;
3548
+ const width = Math.max(...meta.map(([key]) => key.length));
3549
+ return `${body}\n\n${meta.map(([key, item]) => `${key.padEnd(width)} ${cell(item)}`).join("\n")}`;
3550
+ }
3281
3551
  function cell(value) {
3282
3552
  if (value === null || value === void 0) return "";
3283
3553
  if (typeof value === "object") return compactJson(value);
@@ -3320,6 +3590,8 @@ function renderTable(rows) {
3320
3590
  ].join("\n");
3321
3591
  }
3322
3592
  function renderText(value) {
3593
+ const envelope = asEnvelope(value);
3594
+ if (envelope) return renderEnvelope(envelope, renderText);
3323
3595
  if (Array.isArray(value)) {
3324
3596
  if (value.length === 0) return "(no rows)";
3325
3597
  if (isNodeTree(value)) return renderNodeTree$1(value);
@@ -3369,6 +3641,48 @@ function nodeIcon(type) {
3369
3641
  default: return "[node]";
3370
3642
  }
3371
3643
  }
3644
+ /**
3645
+ * Drop hydrated parents from a result, keeping the id that points at them.
3646
+ *
3647
+ * A record VO is ~4.5 KB, of which 3.6 KB is a full copy of its parent `base`
3648
+ * — repeated identically on every row, so ten records cost 151 KB and nine
3649
+ * copies of that Base are pure waste to any caller that already knows which
3650
+ * Base it asked about. `createdByUser` and `headCommit` are the same pattern.
3651
+ *
3652
+ * The test for "this is a hydration, not content" is deliberately structural
3653
+ * rather than a list of field names, so it does not rot as VOs change: an
3654
+ * object-valued key is dropped only when a sibling STRING key already
3655
+ * identifies the same thing — `base`/`baseId`, `headCommit`/`headCommitId`,
3656
+ * `createdByUser`/`createdBy`. Nothing that lacks such a sibling is touched,
3657
+ * which is why a Base's `fields`, a node's `children`, and `whoami`'s `space`
3658
+ * all survive: dropping those would remove the answer, not a duplicate.
3659
+ */
3660
+ function slim(value) {
3661
+ if (Array.isArray(value)) return value.map(slim);
3662
+ if (!value || typeof value !== "object") return value;
3663
+ const row = value;
3664
+ const scalarKeys = Object.entries(row).filter(([, item]) => typeof item === "string").map(([key]) => key);
3665
+ const out = {};
3666
+ for (const [key, item] of Object.entries(row)) {
3667
+ if (isHydrated(key, item, scalarKeys)) continue;
3668
+ out[key] = slim(item);
3669
+ }
3670
+ return out;
3671
+ }
3672
+ /**
3673
+ * `carriesPayload` is the guard that stops this from eating the answer. A
3674
+ * record's `headCommit` is structurally indistinguishable from its `base` — an
3675
+ * object whose `id` is already on the row as `headCommitId` — but it is where
3676
+ * the record's FIELD VALUES live (`headCommit.payload`), so dropping it would
3677
+ * return ten rows of ids and no data. Every commit-shaped object in this
3678
+ * contract carries `payload`; no hydrated parent does.
3679
+ */
3680
+ const isHydrated = (key, item, scalarKeys) => {
3681
+ if (item === null || typeof item !== "object") return false;
3682
+ if (carriesPayload(item)) return false;
3683
+ return scalarKeys.some((scalar) => scalar !== key && (scalar === `${key}Id` || key.startsWith(scalar)));
3684
+ };
3685
+ const carriesPayload = (item) => Array.isArray(item) ? item.some((entry) => entry !== null && typeof entry === "object" && carriesPayload(entry)) : "payload" in item;
3372
3686
  //#endregion
3373
3687
  //#region ../../packages/busabase-contract/src/auth/device-authorization.ts
3374
3688
  /** Public client identifier shared by the Busabase CLI and Cloud auth server. */
@@ -4545,8 +4859,7 @@ z.discriminatedUnion("operation", [archiveBaseInputSchema.extend({
4545
4859
  * when a node carries no `icon`, every host falls back to the type icon, same
4546
4860
  * as before this field existed.
4547
4861
  *
4548
- * The `attachment` variant mirrors buda's `NodeLogo` shape (see
4549
- * `apps/buda/src/domains/agent-controller/components/use-logo-crop-upload.tsx`):
4862
+ * The `attachment` variant follows the shared avatar-cropping model:
4550
4863
  * `url`/`attachmentId` are the CROPPED display image actually rendered, while
4551
4864
  * `originalUrl`/`originalAttachmentId` + `crop` are kept so the crop dialog can
4552
4865
  * re-open non-destructively against the untouched source image instead of
@@ -8495,6 +8808,151 @@ const readDirectoryFiles = async (dir) => {
8495
8808
  return files;
8496
8809
  };
8497
8810
  //#endregion
8811
+ //#region src/paginate.ts
8812
+ /**
8813
+ * `--all`: follow the cursor to the end instead of making the caller loop.
8814
+ *
8815
+ * Every listing in this CLI is bounded — that is the point of the `limit` work —
8816
+ * but "bounded" moves the burden onto the caller, who now has to notice
8817
+ * `nextCursor`, thread it back in, and know when to stop. An agent that misses
8818
+ * that reports a truncated answer as the whole answer, which is the failure the
8819
+ * `record_query` task guidance warns about in prose. This does it in code.
8820
+ *
8821
+ * Rows stream out as NDJSON — one JSON object per line — rather than
8822
+ * accumulating into one array, so memory stays flat and a consumer can start
8823
+ * work on page 1 while page 9 is still in flight. That is why `--all` overrides
8824
+ * `--output`: a 40,000-row table is not a thing anyone wanted.
8825
+ */
8826
+ /**
8827
+ * Read a page out of whatever the command returned.
8828
+ *
8829
+ * Two protocols exist and both are honoured. Most paginated endpoints return
8830
+ * `{ rows, nextCursor }`. `GET /assets` returns a bare array and pages by the
8831
+ * last row's `id`, with a short page meaning the end — a shape chosen there to
8832
+ * keep the response backwards compatible for the web and mobile clients.
8833
+ */
8834
+ function readPage(result, limit) {
8835
+ const envelope = asEnvelope(result);
8836
+ if (envelope) {
8837
+ const cursor = envelope.meta.find(([key]) => key === "nextCursor")?.[1];
8838
+ if (cursor === void 0) return void 0;
8839
+ return {
8840
+ rows: envelope.rows,
8841
+ nextCursor: typeof cursor === "string" ? cursor : null
8842
+ };
8843
+ }
8844
+ if (!Array.isArray(result)) return void 0;
8845
+ if (limit === void 0 || result.length < limit) return {
8846
+ rows: result,
8847
+ nextCursor: null
8848
+ };
8849
+ const id = result[result.length - 1]?.id;
8850
+ return {
8851
+ rows: result,
8852
+ nextCursor: typeof id === "string" ? id : null
8853
+ };
8854
+ }
8855
+ /** Stops a broken server or a cursor that never advances from looping forever. */
8856
+ const MAX_PAGES = 1e4;
8857
+ /**
8858
+ * Stream every page as NDJSON. Returns the number of rows written.
8859
+ *
8860
+ * A cap that truncates is always announced on stderr: a silently short answer
8861
+ * that looks complete is the exact failure this is meant to prevent.
8862
+ */
8863
+ async function paginateAll(options) {
8864
+ const { fetchPage, limit, maxItems, write, notify } = options;
8865
+ let cursor;
8866
+ let written = 0;
8867
+ let seen = 0;
8868
+ for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
8869
+ const result = await fetchPage(cursor);
8870
+ const page = readPage(result, limit);
8871
+ if (!page) {
8872
+ write(JSON.stringify(result));
8873
+ return 1;
8874
+ }
8875
+ seen += page.rows.length;
8876
+ for (const row of page.rows) {
8877
+ if (maxItems !== void 0 && written >= maxItems) {
8878
+ notify(`[busabase-cli] --max-items ${maxItems} reached; stopped early with more rows available.`);
8879
+ return written;
8880
+ }
8881
+ write(JSON.stringify(row));
8882
+ written += 1;
8883
+ }
8884
+ if (!page.nextCursor) return written;
8885
+ if (page.nextCursor === cursor) {
8886
+ notify("[busabase-cli] the server returned the same cursor twice; stopping to avoid a loop.");
8887
+ return written;
8888
+ }
8889
+ cursor = page.nextCursor;
8890
+ }
8891
+ notify(`[busabase-cli] stopped after ${MAX_PAGES} pages (${seen} rows scanned).`);
8892
+ return written;
8893
+ }
8894
+ //#endregion
8895
+ //#region src/retry.ts
8896
+ const BASE_DELAY_MS = 500;
8897
+ const MAX_DELAY_MS = 2e4;
8898
+ const READ_METHODS = /* @__PURE__ */ new Set([
8899
+ "GET",
8900
+ "HEAD",
8901
+ "OPTIONS"
8902
+ ]);
8903
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
8904
+ const isNetworkError = (error) => error instanceof TypeError || /fetch failed|econnreset|econnrefused|etimedout|socket hang up/i.test(error instanceof Error ? error.message : String(error));
8905
+ /** `Retry-After` is either delay-seconds or an HTTP-date; both are legal. */
8906
+ function parseRetryAfter(header, now = Date.now()) {
8907
+ if (!header) return void 0;
8908
+ const seconds = Number(header.trim());
8909
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, MAX_DELAY_MS);
8910
+ const at = Date.parse(header);
8911
+ if (Number.isNaN(at)) return void 0;
8912
+ return Math.min(Math.max(at - now, 0), MAX_DELAY_MS);
8913
+ }
8914
+ /** Whether this outcome is worth another attempt for a request using `method`. */
8915
+ function shouldRetry(method, status) {
8916
+ if (status === 429) return true;
8917
+ if (!READ_METHODS.has(method.toUpperCase())) return false;
8918
+ return status === void 0 || status >= 500;
8919
+ }
8920
+ /**
8921
+ * Wrap a fetch so transient failures are retried with exponential backoff.
8922
+ * Returns `base` unchanged when `retries` is 0, so the no-retry path adds no
8923
+ * wrapper and no behaviour.
8924
+ */
8925
+ function withRetry(base, options) {
8926
+ const { retries } = options;
8927
+ if (retries <= 0) return base;
8928
+ const sleep = options.sleep ?? defaultSleep;
8929
+ const notify = options.notify ?? ((message) => console.error(message));
8930
+ const jitter = options.jitter ?? Math.random;
8931
+ return (async (input, init) => {
8932
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
8933
+ for (let attempt = 0;; attempt += 1) {
8934
+ let response;
8935
+ let failure;
8936
+ try {
8937
+ response = await base(input, init);
8938
+ } catch (error) {
8939
+ if (!isNetworkError(error)) throw error;
8940
+ failure = error;
8941
+ }
8942
+ const status = response?.status;
8943
+ const last = attempt >= retries;
8944
+ if (!shouldRetry(method, status) || last) {
8945
+ if (response) return response;
8946
+ throw failure;
8947
+ }
8948
+ const backoff = Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS);
8949
+ const delay = parseRetryAfter(response?.headers.get("retry-after") ?? null) ?? Math.round(backoff * (.5 + jitter() * .5));
8950
+ notify(`[busabase-cli] ${status ? `HTTP ${status}` : "connection failed"} — retrying in ${Math.round(delay / 1e3)}s (attempt ${attempt + 2} of ${retries + 1})`);
8951
+ await sleep(delay);
8952
+ }
8953
+ });
8954
+ }
8955
+ //#endregion
8498
8956
  //#region src/task-command.ts
8499
8957
  const collect = (value, previous = []) => [...previous, value];
8500
8958
  /** Range-checks before the request goes out, so a cap surfaces as a usage error. */
@@ -8533,10 +8991,13 @@ const applyParam = (cmd, param) => {
8533
8991
  case "stringArray":
8534
8992
  cmd.option(`${flag} <value>`, `${description} (repeatable)`, collect);
8535
8993
  return;
8536
- case "json":
8537
- cmd.option(`${jsonFlagFor(param)} <json|@file>`, description);
8994
+ case "json": {
8995
+ const jsonOption = `${jsonFlagFor(param)} <json|@file>`;
8996
+ if (param.required) cmd.requiredOption(jsonOption, description);
8997
+ else cmd.option(jsonOption, description);
8538
8998
  if (param.cliShorthand) cmd.option(`${param.cliShorthand.flag} ${param.cliShorthand.placeholder}`, param.cliShorthand.description, collect);
8539
8999
  return;
9000
+ }
8540
9001
  default: if (param.required) cmd.requiredOption(`${flag} <value>`, description);
8541
9002
  else cmd.option(`${flag} <value>`, description);
8542
9003
  }
@@ -8586,6 +9047,7 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
8586
9047
  }
8587
9048
  applyParam(cmd, param);
8588
9049
  }
9050
+ if (task.guidance) cmd.addHelpText("after", `\n${wrapGuidance(rewriteTaskNames(task.guidance))}`);
8589
9051
  if (task.examples && task.examples.length > 0 && Object.keys(preset).length === 0) cmd.addHelpText("after", `\nExamples:\n ${task.examples.join("\n ")}`);
8590
9052
  cmd.action(deps.runAction(async (client, opts) => task.execute(client, {
8591
9053
  ...buildInput(task, opts, deps),
@@ -8593,11 +9055,108 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
8593
9055
  })));
8594
9056
  };
8595
9057
  const registerTaskCommands = (program, tasks, deps) => {
9058
+ for (const task of tasks) cliPathByTaskName.set(task.name, `busabase-cli ${task.cliPath.join(" ")}`);
8596
9059
  for (const task of tasks) {
8597
9060
  registerOne(program, task, deps, task.cliPath);
8598
9061
  for (const variant of task.cliVariants ?? []) registerOne(program, task, deps, variant.path, variant.preset, variant.summary);
8599
9062
  }
8600
9063
  };
9064
+ /**
9065
+ * Guidance is written once and read by two surfaces, so it names sibling tasks
9066
+ * by their MCP tool name (`record_find_by_field`). That is not something a CLI
9067
+ * caller can type — the command is `records by-field-text` — so the shared text
9068
+ * is rewritten to this surface's vocabulary rather than forked.
9069
+ */
9070
+ const rewriteTaskNames = (text) => text.replace(/`([a-z][a-z0-9_]*)`/g, (match, name) => {
9071
+ const path = cliPathByTaskName.get(name);
9072
+ return path ? `\`${path}\`` : match;
9073
+ });
9074
+ /** Filled by {@link registerTaskCommands}; empty until the surface is built. */
9075
+ const cliPathByTaskName = /* @__PURE__ */ new Map();
9076
+ /** Reflow guidance to the width commander uses for the rest of the help screen. */
9077
+ const wrapGuidance = (text, width = 78) => {
9078
+ const lines = [];
9079
+ let line = "";
9080
+ for (const word of text.split(/\s+/).filter(Boolean)) if (line && `${line} ${word}`.length > width) {
9081
+ lines.push(line);
9082
+ line = word;
9083
+ } else line = line ? `${line} ${word}` : word;
9084
+ if (line) lines.push(line);
9085
+ return lines.join("\n");
9086
+ };
9087
+ //#endregion
9088
+ //#region src/wait.ts
9089
+ /**
9090
+ * `--wait`: block until a submitted proposal is actually decided.
9091
+ *
9092
+ * Review-first is the product's whole point, so the useful unit of work for a
9093
+ * caller is not "the change request was filed" — it is "the change request was
9094
+ * merged, or it wasn't". Without this every caller writes the same poll loop
9095
+ * against `change-requests get`, and an agent that skips it reports a proposal
9096
+ * nobody has looked at as a completed task.
9097
+ */
9098
+ /** Statuses that will not change again on their own. */
9099
+ const TERMINAL = /* @__PURE__ */ new Set([
9100
+ "merged",
9101
+ "rejected",
9102
+ "closed",
9103
+ "abandoned"
9104
+ ]);
9105
+ const isChangeRequest = (value) => {
9106
+ if (!value || typeof value !== "object") return false;
9107
+ const row = value;
9108
+ return typeof row.id === "string" && row.id.startsWith("crq") && typeof row.status === "string";
9109
+ };
9110
+ /**
9111
+ * Find the change request a command's result is about.
9112
+ *
9113
+ * The shape differs across the surface — some routes return the change request
9114
+ * itself, some wrap it (`{ changeRequest }`), some return the materialized thing
9115
+ * with `materialized: false` alongside — so this looks for the id rather than
9116
+ * asking every command to declare it.
9117
+ */
9118
+ function findChangeRequestId(result) {
9119
+ if (isChangeRequest(result)) return result.id;
9120
+ if (Array.isArray(result)) {
9121
+ for (const item of result) {
9122
+ const found = findChangeRequestId(item);
9123
+ if (found) return found;
9124
+ }
9125
+ return;
9126
+ }
9127
+ if (!result || typeof result !== "object") return void 0;
9128
+ const row = result;
9129
+ if (typeof row.changeRequestId === "string") return row.changeRequestId;
9130
+ for (const key of ["changeRequest", "changeRequests"]) {
9131
+ const found = findChangeRequestId(row[key]);
9132
+ if (found) return found;
9133
+ }
9134
+ }
9135
+ const DEFAULT_POLL_MS = 3e3;
9136
+ /** Poll until the change request settles or the budget runs out. */
9137
+ async function waitForChangeRequest(options) {
9138
+ const { changeRequestId, read, timeoutMs, pollMs = DEFAULT_POLL_MS, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now = Date.now, notify = () => {} } = options;
9139
+ const deadline = now() + timeoutMs;
9140
+ let latest;
9141
+ let status = "unknown";
9142
+ for (;;) {
9143
+ latest = await read(changeRequestId);
9144
+ status = isChangeRequest(latest) ? latest.status : "unknown";
9145
+ if (TERMINAL.has(status)) return {
9146
+ kind: "settled",
9147
+ status,
9148
+ changeRequest: latest
9149
+ };
9150
+ const remaining = deadline - now();
9151
+ if (remaining <= 0) return {
9152
+ kind: "timeout",
9153
+ status,
9154
+ changeRequest: latest
9155
+ };
9156
+ notify(`[busabase-cli] ${changeRequestId} is ${status}; waiting for a reviewer (${Math.round(remaining / 1e3)}s left)`);
9157
+ await sleep(Math.min(pollMs, remaining));
9158
+ }
9159
+ }
8601
9160
  //#endregion
8602
9161
  //#region src/run.ts
8603
9162
  /** Public docs page covering every error below, for both Cloud and local. Linked from each error. */
@@ -8629,9 +9188,12 @@ function resolveConfig(opts) {
8629
9188
  webUrl: process.env.BUSABASE_WEB_URL ?? file.BUSABASE_WEB_URL ?? baseUrl,
8630
9189
  apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
8631
9190
  spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
8632
- output: opts.output ?? configuredOutput() ?? "text"
9191
+ output: opts.output ?? configuredOutput() ?? "text",
9192
+ fetch: withRetry(fetch, { retries: opts.retries ?? DEFAULT_RETRIES })
8633
9193
  };
8634
9194
  }
9195
+ /** Enough to ride out a rate limiter or a proxy blip; not enough to hide a real outage. */
9196
+ const DEFAULT_RETRIES = 2;
8635
9197
  /**
8636
9198
  * Config flags accepted anywhere on the line (`busabase-cli --output json bases list` and
8637
9199
  * `busabase-cli bases list --output json` both work): declared on the root program AND on every
@@ -8644,14 +9206,20 @@ const GLOBAL_LONG_FLAGS = /* @__PURE__ */ new Set([
8644
9206
  "--space-id",
8645
9207
  "--output",
8646
9208
  "--profile",
8647
- "--config"
9209
+ "--config",
9210
+ "--slim",
9211
+ "--retries",
9212
+ "--all",
9213
+ "--max-items",
9214
+ "--wait",
9215
+ "--wait-timeout"
8648
9216
  ]);
8649
9217
  function addGlobalFlags(cmd) {
8650
9218
  return cmd.option("--base-url <url>", `server base URL (env BUSABASE_BASE_URL, default ${DEFAULT_BASE_URL})`).option("--api-key <token>", "bearer token for cloud hosts (env BUSABASE_API_KEY)").option("--space-id <id>", "target Busabase space (env BUSABASE_SPACE_ID)").option("--profile <name>", "account to use for this command (env BUSABASE_PROFILE)").option("--config <path>", "read credentials from this file instead (env BUSABASE_CONFIG)").addOption(new Option("--output <fmt>", "text | table | json (default text)").choices([
8651
9219
  "text",
8652
9220
  "table",
8653
9221
  "json"
8654
- ]));
9222
+ ])).option("--slim", "drop hydrated parents (a record's full `base`, `createdByUser`, `headCommit`) and keep their ids").option("--retries <n>", "extra attempts on 429 / 5xx / dropped connections (default 2, 0 disables)", parseNonNegativeInt).option("--all", "follow the cursor to the end and stream every row as NDJSON").option("--max-items <n>", "stop --all after this many rows", parsePositiveInt).option("--wait", "after submitting a Change Request, block until a reviewer merges or rejects it").option("--wait-timeout <seconds>", "budget for --wait (default 300)", parsePositiveInt);
8655
9223
  }
8656
9224
  const parseNum = (value) => {
8657
9225
  const parsed = Number(value);
@@ -8663,6 +9231,11 @@ const parsePositiveInt = (value) => {
8663
9231
  if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a positive integer");
8664
9232
  return parsed;
8665
9233
  };
9234
+ const parseNonNegativeInt = (value) => {
9235
+ const parsed = parseNum(value);
9236
+ if (!Number.isInteger(parsed) || parsed < 0) throw new InvalidArgumentError("expected a non-negative integer");
9237
+ return parsed;
9238
+ };
8666
9239
  const parseBoolean = (value) => {
8667
9240
  if (value === "true") return true;
8668
9241
  if (value === "false") return false;
@@ -8739,13 +9312,9 @@ function parseJsonValue(raw, flagName) {
8739
9312
  * better off than when the field was silently dropped.
8740
9313
  */
8741
9314
  function formatIssues(issues) {
8742
- if (!Array.isArray(issues) || issues.length === 0) return void 0;
8743
- return issues.map((issue) => {
8744
- if (!issue || typeof issue !== "object") return String(issue);
8745
- const { path, message } = issue;
8746
- const pathLabel = Array.isArray(path) && path.length > 0 ? path.join(".") : void 0;
8747
- return pathLabel ? `${pathLabel}: ${message}` : String(message ?? issue);
8748
- }).join("; ");
9315
+ const parsed = toIssues(issues);
9316
+ if (!parsed) return void 0;
9317
+ return parsed.map(({ path, message }) => path ? `${path}: ${message}` : message).join("; ");
8749
9318
  }
8750
9319
  function formatRawErrorBody(status, statusText, text) {
8751
9320
  try {
@@ -8760,7 +9329,7 @@ function formatRawErrorBody(status, statusText, text) {
8760
9329
  /** Raw fetch for endpoints outside the typed contract (health / openapi / api passthrough). */
8761
9330
  async function rawFetch(config, method, path, body) {
8762
9331
  const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
8763
- const res = await fetch(url, {
9332
+ const res = await (config.fetch ?? fetch)(url, {
8764
9333
  method: method.toUpperCase(),
8765
9334
  headers: {
8766
9335
  ...body !== void 0 ? { "content-type": "application/json" } : {},
@@ -8878,10 +9447,60 @@ function runAction(state, handler) {
8878
9447
  if (refreshedToken) config.apiKey = refreshedToken;
8879
9448
  state.config = config;
8880
9449
  assertCredentialNotExpired(config.apiKey);
8881
- const result = await handler(createBusabaseClient(config), opts, config);
8882
- console.log(render(result, config.output));
9450
+ const client = createBusabaseClient(config);
9451
+ if (opts.all) {
9452
+ await streamAllPages(client, opts, config, handler);
9453
+ return;
9454
+ }
9455
+ const result = await handler(client, opts, config);
9456
+ console.log(render(opts.slim ? slim(result) : result, config.output));
9457
+ if (opts.wait) await awaitReview(client, opts, config, result);
8883
9458
  };
8884
9459
  }
9460
+ /** Default `--wait` budget: long enough for a reviewer who is at their desk. */
9461
+ const DEFAULT_WAIT_SECONDS = 300;
9462
+ /**
9463
+ * Block until the Change Request this command just filed is decided.
9464
+ *
9465
+ * The result is printed first, so a caller that times out still has the id it
9466
+ * needs to come back to. Exit codes carry the outcome (see `errors.ts`): merged
9467
+ * is success, rejected is a decision the caller must branch on, and a timeout is
9468
+ * neither — nothing was decided, so the write must NOT be retried.
9469
+ */
9470
+ async function awaitReview(client, opts, config, result) {
9471
+ const changeRequestId = findChangeRequestId(result);
9472
+ if (!changeRequestId) {
9473
+ console.error("[busabase-cli] --wait: this command filed no Change Request; nothing to wait for.");
9474
+ return;
9475
+ }
9476
+ const outcome = await waitForChangeRequest({
9477
+ changeRequestId,
9478
+ read: (id) => client.changeRequests.get({ changeRequestId: id }),
9479
+ timeoutMs: (opts.waitTimeout ?? DEFAULT_WAIT_SECONDS) * 1e3,
9480
+ notify: (message) => console.error(message)
9481
+ });
9482
+ console.log(render(opts.slim ? slim(outcome.changeRequest) : outcome.changeRequest, config.output));
9483
+ if (outcome.kind === "timeout") throw new CliOutcomeError("TIMEOUT", `--wait timed out: ${changeRequestId} is still ${outcome.status}. Nothing was decided — do NOT resubmit; poll it with \`busabase-cli change-requests get --change-request-id ${changeRequestId}\`.`);
9484
+ if (outcome.status !== "merged") throw new CliOutcomeError("REVIEW_REJECTED", `${changeRequestId} was ${outcome.status}, not merged.`);
9485
+ }
9486
+ /**
9487
+ * `--all` re-runs the same handler with a moving cursor. The handler reads its
9488
+ * input off `opts`, so advancing the page is a matter of setting `opts.cursor`
9489
+ * before each call rather than teaching every command a second code path.
9490
+ */
9491
+ async function streamAllPages(client, opts, config, handler) {
9492
+ if (await paginateAll({
9493
+ fetchPage: async (cursor) => {
9494
+ opts.cursor = cursor;
9495
+ const result = await handler(client, opts, config);
9496
+ return opts.slim ? slim(result) : result;
9497
+ },
9498
+ limit: typeof opts.limit === "number" ? opts.limit : void 0,
9499
+ maxItems: typeof opts.maxItems === "number" ? opts.maxItems : void 0,
9500
+ write: (line) => console.log(line),
9501
+ notify: (message) => console.error(message)
9502
+ }) === 0) console.error("[busabase-cli] no rows.");
9503
+ }
8885
9504
  /**
8886
9505
  * {@link runAction} for a command with one positional argument. Commander passes
8887
9506
  * `(arg, opts, cmd)` to such an action, so the arity differs from a flags-only leaf.
@@ -8895,7 +9514,7 @@ function runArgAction(state, handler) {
8895
9514
  state.config = config;
8896
9515
  assertCredentialNotExpired(config.apiKey);
8897
9516
  const result = await handler(arg, createBusabaseClient(config), opts, config);
8898
- console.log(render(result, config.output));
9517
+ console.log(render(opts.slim ? slim(result) : result, config.output));
8899
9518
  };
8900
9519
  }
8901
9520
  /**
@@ -8912,7 +9531,7 @@ function runLocalArgAction(handler) {
8912
9531
  const opts = cmd.optsWithGlobals();
8913
9532
  const config = resolveConfig(opts);
8914
9533
  const result = await handler(arg, opts, config);
8915
- console.log(render(result, config.output));
9534
+ console.log(render(opts.slim ? slim(result) : result, config.output));
8916
9535
  };
8917
9536
  }
8918
9537
  function pkgVersion() {
@@ -9045,11 +9664,33 @@ function unionVariants(inputSchema, discriminator = "operation") {
9045
9664
  return variants;
9046
9665
  }
9047
9666
  /**
9667
+ * Words of an identifier, however it was spelled: `changeRequests` + `reviewMany`
9668
+ * and `change_requests_review_many` both reduce to
9669
+ * `change requests review many`. Lets the CLI compare its own generated command
9670
+ * paths against the MCP tool names in {@link TASK_SUPERSEDED_MCP_TOOLS} without
9671
+ * either side having to know how the other spells things.
9672
+ */
9673
+ const identifierWords = (parts) => parts.flatMap((part) => part.split(/[_\-\s]+/)).flatMap((part) => part.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(" ")).filter(Boolean).map((word) => word.toLowerCase()).join(" ");
9674
+ /**
9675
+ * Endpoint commands a curated task command already covers.
9676
+ *
9677
+ * The MCP adapter has always dropped these so "a catalog carries one way to do
9678
+ * each job rather than two" — and then the CLI published BOTH spellings, which
9679
+ * is a third of a 30 KB help screen an agent reads to orient itself. Same list,
9680
+ * same reason, one more consumer.
9681
+ *
9682
+ * Hidden, never removed: every one of them still runs, so no existing script
9683
+ * breaks, and `--help-all` prints them.
9684
+ */
9685
+ const SUPERSEDED_COMMAND_PATHS = new Set(TASK_SUPERSEDED_MCP_TOOLS.map((tool) => identifierWords([tool])));
9686
+ /**
9048
9687
  * Procedures a curated command already exposes under a DIFFERENT name (so the
9049
9688
  * name-collision guard wouldn't catch them). Keyed `group.procKey`.
9050
9689
  */
9051
9690
  const GENERATED_SKIP = /* @__PURE__ */ new Set([
9052
9691
  "search",
9692
+ "guides.list",
9693
+ "guides.read",
9053
9694
  "auth.verify",
9054
9695
  "records.search",
9055
9696
  "records.listChangeRequests",
@@ -9079,7 +9720,8 @@ function registerGeneratedCommands(program, state) {
9079
9720
  const addOneLeaf = (parent, navPath, procKey, proc, name, fields, description) => {
9080
9721
  const { route } = proc["~orpc"];
9081
9722
  if (parent.commands.some((c) => c.name() === name || c.aliases().includes(name))) return;
9082
- const leaf = parent.command(name).description(description ?? route.summary ?? `${route.method} ${route.path}`);
9723
+ const superseded = SUPERSEDED_COMMAND_PATHS.has(identifierWords([parent.name(), name]));
9724
+ const leaf = parent.command(name, superseded ? { hidden: true } : void 0).description(description ?? route.summary ?? `${route.method} ${route.path}`);
9083
9725
  for (const f of fields) {
9084
9726
  if (f.pinned !== void 0) continue;
9085
9727
  const flag = `--${kebab(f.key)}`;
@@ -9154,14 +9796,26 @@ function flagSignature(cmd) {
9154
9796
  }
9155
9797
  return parts.join(" ");
9156
9798
  }
9157
- const isHelpHidden = (cmd) => Boolean(cmd._noHelp);
9799
+ /**
9800
+ * Commander records `command(name, { hidden })` and `{ noHelp }` on `_hidden`;
9801
+ * this read `_noHelp`, which commander never sets, so it always answered false
9802
+ * and nothing was ever actually hidden from the root help.
9803
+ */
9804
+ const isHelpHidden = (cmd) => Boolean(cmd._hidden) || Boolean(cmd._noHelp);
9805
+ /**
9806
+ * Set for the duration of a `--help-all` render. A module-level flag rather than
9807
+ * a parameter because commander calls the help hook itself, with no way to
9808
+ * thread an argument through.
9809
+ */
9810
+ let showAllCommands = false;
9158
9811
  /**
9159
9812
  * Root help "Commands:" section: every leaf with its full flag signature (the flat
9160
9813
  * commander listing would only show group names). Generated from the command tree,
9161
9814
  * never hand-written.
9162
9815
  */
9163
- function commandsSection(program) {
9816
+ function commandsSection(program, showAll = false) {
9164
9817
  const lines = ["Commands:"];
9818
+ const skip = (cmd) => cmd.name() === "help" || !showAll && isHelpHidden(cmd);
9165
9819
  const entry = (prefix, cmd) => {
9166
9820
  const sig = [prefix, flagSignature(cmd)].filter(Boolean).join(" ");
9167
9821
  const desc = cmd.description();
@@ -9170,12 +9824,12 @@ function commandsSection(program) {
9170
9824
  return ` ${sig}\n${" ".repeat(44)}${desc}`;
9171
9825
  };
9172
9826
  for (const cmd of program.commands) {
9173
- if (cmd.name() === "help" || isHelpHidden(cmd)) continue;
9174
- const leaves = cmd.commands.filter((leaf) => leaf.name() !== "help" && !isHelpHidden(leaf));
9827
+ if (skip(cmd)) continue;
9828
+ const leaves = cmd.commands.filter((leaf) => !skip(leaf));
9175
9829
  if (leaves.length > 0) {
9176
9830
  lines.push("");
9177
9831
  for (const leaf of leaves) lines.push(entry(`${cmd.name()} ${leaf.name()}`, leaf));
9178
- } else lines.push(entry(cmd.name(), cmd));
9832
+ } else if (cmd.commands.length === 0) lines.push(entry(cmd.name(), cmd));
9179
9833
  }
9180
9834
  return lines.join("\n");
9181
9835
  }
@@ -9187,7 +9841,12 @@ Several accounts: \`login --profile <name>\` adds one, \`auth status\` lists the
9187
9841
  \`auth switch\` changes which is active. Several spaces on one account (the norm on
9188
9842
  Cloud): \`space list\` / \`space use\`.
9189
9843
 
9844
+ New here? \`busabase-cli guide\` is the operating manual — read \`workspace\` before
9845
+ proposing changes. Endpoint commands a task command already covers are hidden;
9846
+ \`--help-all\` prints them (they all still run).
9847
+
9190
9848
  Docs: https://busabase.com/docs · Troubleshooting: ${DOCS_TROUBLESHOOTING}`;
9849
+ /** Exported for the help-example test, which walks the command tree in-process. */
9191
9850
  function buildProgram(state = {}) {
9192
9851
  const program = new Command("busabase-cli");
9193
9852
  program.description("Client for the Busabase OpenAPI REST API — talks to `busabase server` or Cloud.").usage("[global flags] <command> [flags]").version(pkgVersion(), "-v, --version", "print busabase-cli version").exitOverride().showHelpAfterError("(run `busabase-cli --help` for the full command list)").addHelpText("beforeAll", () => banner(resolveConfig(program.opts()).baseUrl));
@@ -9195,6 +9854,29 @@ function buildProgram(state = {}) {
9195
9854
  addGlobalFlags(program.command("health")).description("Server health check (GET /api/health)").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/health")));
9196
9855
  addGlobalFlags(program.command("openapi")).description("Fetch the OpenAPI document").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/v1/openapi.json")));
9197
9856
  addGlobalFlags(program.command("whoami")).description("Active space, user, and membership").action(runAction(state, (client) => client.auth.verify()));
9857
+ 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", `
9858
+ Read this BEFORE unfamiliar work. It carries the conventions this workspace
9859
+ expects, which no flag or schema can express on its own.
9860
+
9861
+ Examples:
9862
+ busabase-cli guide # what is available here
9863
+ busabase-cli guide workspace # the approval-first workflow
9864
+ busabase-cli guide airapp # REQUIRED before writing any AirApp file`).action(async (topic, _opts, cmd) => {
9865
+ const config = resolveConfig(cmd.optsWithGlobals());
9866
+ state.config = config;
9867
+ const client = createBusabaseClient(config);
9868
+ if (!topic) {
9869
+ console.log(render(await client.guides.list(), config.output));
9870
+ return;
9871
+ }
9872
+ const guide = await client.guides.read({ topic });
9873
+ if (config.output === "json") {
9874
+ console.log(render(guide, config.output));
9875
+ return;
9876
+ }
9877
+ console.log(guide.content);
9878
+ console.error(`\n[busabase-cli] other topics: ${guide.otherTopics.join(", ") || "(none)"}`);
9879
+ });
9198
9880
  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", `
9199
9881
  Busabase is an approval-first database and knowledge base for AI agents: agents
9200
9882
  propose changes, humans review and merge what becomes trusted data.
@@ -9614,7 +10296,7 @@ To add content to a space that is already in use, use \`busabase-cli install\` i
9614
10296
  leaf.usage(`${sig ? `${sig} ` : ""}[global flags]`);
9615
10297
  }
9616
10298
  }
9617
- program.addHelpText("after", () => `\n${commandsSection(program)}\n${HELP_FOOTER}`);
10299
+ program.addHelpText("after", () => `\n${commandsSection(program, showAllCommands)}\n${HELP_FOOTER}`);
9618
10300
  program.configureHelp({ visibleCommands: () => [] });
9619
10301
  return program;
9620
10302
  }
@@ -9629,17 +10311,48 @@ async function runCli(argv) {
9629
10311
  program.outputHelp();
9630
10312
  return 0;
9631
10313
  }
10314
+ if (argv.includes("--help-all")) {
10315
+ console.log(renderedHelp({ showAll: true }));
10316
+ return 0;
10317
+ }
9632
10318
  try {
9633
10319
  await program.parseAsync(argv, { from: "user" });
9634
10320
  return 0;
9635
10321
  } catch (error) {
9636
- if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : 1;
10322
+ const json = errorOutputIsJson(state, argv);
10323
+ if (error instanceof CommanderError) {
10324
+ if (error.exitCode === 0) return 0;
10325
+ if (json) console.log(JSON.stringify({
10326
+ ok: false,
10327
+ code: "USAGE",
10328
+ message: error.message,
10329
+ retryable: false
10330
+ }, null, 2));
10331
+ return EXIT_CODES.USAGE;
10332
+ }
10333
+ const classified = classifyError(error);
10334
+ if (json) console.log(JSON.stringify(errorEnvelope(classified), null, 2));
9637
10335
  console.error(explainError(error, state.config ?? resolveConfig({})));
9638
- return 1;
10336
+ return classified.exitCode;
9639
10337
  }
9640
10338
  }
9641
- function renderedHelp() {
10339
+ /**
10340
+ * Whether a failed run should print the machine-readable envelope.
10341
+ *
10342
+ * `state.config` is set by the first command that resolves one, but a failure
10343
+ * can happen before that (an unknown option, a bad `--config` path), so the raw
10344
+ * argv is the fallback. Both spellings commander accepts are checked.
10345
+ */
10346
+ function errorOutputIsJson(state, argv) {
10347
+ if (state.config) return state.config.output === "json";
10348
+ const flag = argv.indexOf("--output");
10349
+ if (flag !== -1 && argv[flag + 1] === "json") return true;
10350
+ if (argv.includes("--output=json")) return true;
10351
+ return configuredOutput() === "json";
10352
+ }
10353
+ function renderedHelp({ showAll = false } = {}) {
9642
10354
  let out = "";
10355
+ showAllCommands = showAll;
9643
10356
  const program = buildProgram();
9644
10357
  program.configureOutput({
9645
10358
  writeOut: (str) => {
@@ -9648,10 +10361,12 @@ function renderedHelp() {
9648
10361
  writeErr: () => {}
9649
10362
  });
9650
10363
  program.outputHelp();
10364
+ showAllCommands = false;
9651
10365
  return out;
9652
10366
  }
9653
10367
  /** Full root help text (what `busabase-cli --help` prints). Generated from the command tree. */
9654
10368
  const HELP = renderedHelp();
10369
+ renderedHelp({ showAll: true });
9655
10370
  /**
9656
10371
  * Turn a low-level transport/HTTP error into an actionable message: which host was tried, the
9657
10372
  * concrete next step (set a key for Cloud, point at a local server, or check connectivity), and a
@@ -9661,7 +10376,7 @@ function explainError(error, config) {
9661
10376
  const base = config.baseUrl;
9662
10377
  const rawMessage = error instanceof Error ? error.message : String(error);
9663
10378
  const issueDetail = formatIssues(error.data?.issues);
9664
- const msg = issueDetail ? `${rawMessage} — ${issueDetail}` : rawMessage;
10379
+ const msg = issueDetail && !rawMessage.includes(issueDetail) ? `${rawMessage} — ${issueDetail}` : rawMessage;
9665
10380
  const status = error.status;
9666
10381
  const lower = msg.toLowerCase();
9667
10382
  let body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "busa-cli",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Command-line client for the Busabase OpenAPI REST API. Talks to a local or remote `busabase server`. Short-name alias for busabase-cli.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/busabase/busabase/tree/main/apps/busabase-cli",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@zip.js/zip.js": "^2.8.20",
35
35
  "busabase-package": "workspace:*",
36
- "busabase-sdk": "0.19.0",
36
+ "busabase-sdk": "0.19.1",
37
37
  "commander": "^14.0.0",
38
38
  "tar-stream": "^3.1.7",
39
39
  "yaml": "^2.9.0",