busa-cli 0.18.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.
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { basename, dirname, extname, join } from "node:path";
3
+ import { basename, dirname, extname, join, relative, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { DEFAULT_BASE_URL, cloudContract, createBusabaseClient, getRecordByField, grepAssets, normalizeBaseUrl } from "busabase-sdk";
6
6
  import { Command, CommanderError, InvalidArgumentError, Option } from "commander";
@@ -12,9 +12,9 @@ import { ZstdCompress, ZstdDecompress } from "node:zlib";
12
12
  import * as tar from "tar-stream";
13
13
  import { spawn } from "node:child_process";
14
14
  import { createServer } from "node:http";
15
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
16
- import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
15
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
17
16
  import { parse, stringify } from "yaml";
17
+ import { Uint8ArrayReader, ZipReader } from "@zip.js/zip.js";
18
18
  //#region ../../packages/busabase-contract/src/tasks/base-field.ts
19
19
  /**
20
20
  * Changing a Base's schema, as one task with an `operation` instead of six
@@ -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. */
@@ -3958,6 +4272,210 @@ async function runLogout(options) {
3958
4272
  config: activeCredentialPath()
3959
4273
  };
3960
4274
  }
4275
+ //#endregion
4276
+ //#region ../../packages/busabase-contract/src/domains/package/template.ts
4277
+ /**
4278
+ * Template extension of the `busabase-package@1` format (pure zod, client-safe).
4279
+ *
4280
+ * A **template** is a package that is ALSO an Agent Skill: the same directory
4281
+ * carries a root `SKILL.md` (what an agent reads) next to `busabase.json` +
4282
+ * `content/` (what installs into a space). Nothing under `content/` changes —
4283
+ * the whole increment lives OUTSIDE it:
4284
+ *
4285
+ * my-app/
4286
+ * ├── SKILL.md ← root skill entry (+ references/ agents/ scripts/)
4287
+ * ├── busabase.json ← manifest, now with an optional `template` object
4288
+ * └── content/ ← byte-identical to a plain busabase-package@1 tree
4289
+ *
4290
+ * Consequences worth stating once, because they are what make the format
4291
+ * "multiplexed" rather than a second format:
4292
+ * - A template directory is already a VALID package. Today's `install` can
4293
+ * install it (it just won't land the Skill node, stamp ownership, or merge
4294
+ * the sample records).
4295
+ * - Any package becomes a template by adding a root `SKILL.md` plus the
4296
+ * `template` object — no migration, no v2.
4297
+ * - `export --template` emits a `content/` byte-identical to plain `export`.
4298
+ *
4299
+ * See `apps/busabase/content/spec/template-center.md` (§5, §6.2a, §13).
4300
+ */
4301
+ /**
4302
+ * The root skill entry file. Deliberately the same name a `skill` NODE uses as
4303
+ * its entry file, because it becomes exactly that on install: the reader lifts
4304
+ * it (plus the sidecar dirs below) into a `skill` node inside the target
4305
+ * folder, so the manual travels with the resources instead of staying on the
4306
+ * publisher's disk.
4307
+ */
4308
+ const PACKAGE_SKILL_ENTRY = "SKILL.md";
4309
+ /**
4310
+ * Directories carried into the Skill node alongside `SKILL.md`.
4311
+ *
4312
+ * `scripts/` is included on purpose even though Busabase never executes it: the
4313
+ * Skill node is a file tree, a shell agent that pulls the skill back out gets a
4314
+ * working copy, and dropping the scripts would make the round-trip lossy. The
4315
+ * safety property comes from *nothing in Busabase running them*, not from
4316
+ * refusing to store them.
4317
+ */
4318
+ const PACKAGE_SKILL_SIDECAR_DIRS = [
4319
+ "references",
4320
+ "agents",
4321
+ "scripts"
4322
+ ];
4323
+ /**
4324
+ * Per-AirApp ignore file (gitignore syntax), read at
4325
+ * `content/<airapp>/.busabaseignore`.
4326
+ *
4327
+ * An AirApp node IS the project — nodepod runs `npm run dev` against it — so the
4328
+ * package cannot simply carry "the deployable subset" as a separate copy without
4329
+ * reintroducing the two-sources-of-truth problem the template format exists to
4330
+ * remove. The ignore file lets one directory be both: the repo keeps `test/`,
4331
+ * lockfiles and coverage; the node receives only what has to run.
4332
+ *
4333
+ * `package.json` and the entry it declares can never be ignored — a validator
4334
+ * error rather than a warning, because the resulting AirApp would install fine
4335
+ * and then fail to boot, which is the failure mode hardest to attribute.
4336
+ */
4337
+ const PACKAGE_AIRAPP_IGNORE = ".busabaseignore";
4338
+ const TemplateAirAppRefSchema = z.object({
4339
+ /** Slug of the `content/<dir>` holding the AirApp. */
4340
+ slug: z.string().min(1),
4341
+ role: z.enum([
4342
+ "primary",
4343
+ "admin",
4344
+ "public",
4345
+ "tool"
4346
+ ]),
4347
+ label: z.string().optional()
4348
+ });
4349
+ /**
4350
+ * Secrets the app expects to find in the Vault.
4351
+ *
4352
+ * DECLARED, never created: the package format has no slot for secret values and
4353
+ * must not grow one (the same "you cannot leak what the format cannot express"
4354
+ * rule the whole format is built on). Install surfaces these as a post-install
4355
+ * prompt; the user fills them in the Vault themselves.
4356
+ */
4357
+ const TemplateSecretSchema = z.object({
4358
+ key: z.string().min(1),
4359
+ description: z.string().default(""),
4360
+ required: z.boolean().default(true)
4361
+ });
4362
+ const TemplateManifestSchema = z.object({
4363
+ /** Template Center category, e.g. `"crm"`, `"email"`, `"content"`. */
4364
+ category: z.string().min(1),
4365
+ tags: z.array(z.string()).default([]),
4366
+ /** Card/detail screenshots, package-relative (`assets/screenshots/overview.webp`). */
4367
+ screenshots: z.array(z.string()).default([]),
4368
+ /**
4369
+ * Ready-made prompts shown after install ("Ask agent" prefills the first).
4370
+ *
4371
+ * They are the difference between a folder of tables and something a user can
4372
+ * *use*: the point of a template is that the agent already knows the job, and
4373
+ * these are how that is made visible rather than left for the user to guess.
4374
+ */
4375
+ agentPrompts: z.array(z.string()).default([]),
4376
+ /** Single-AirApp shorthand. Mutually exclusive with `airapps`. */
4377
+ airapp: z.string().optional(),
4378
+ /** Multi-AirApp form. Exactly one entry must have `role: "primary"`. */
4379
+ airapps: z.array(TemplateAirAppRefSchema).optional(),
4380
+ /**
4381
+ * Bumped by the author when the declared resource shape changes.
4382
+ *
4383
+ * Part of the ownership stamp, so BOTH doors must agree on it: the installer
4384
+ * writes it, and a skill's own `setup.mjs` compares against it to decide
4385
+ * whether a node it finds is its own current shape or an older one to repair.
4386
+ * Defaulted rather than required so an author who never versions their app
4387
+ * still gets a stamp both sides recognise.
4388
+ */
4389
+ schemaVersion: z.number().int().nonnegative().default(1),
4390
+ vaultNamespace: z.string().optional(),
4391
+ secrets: z.array(TemplateSecretSchema).default([]),
4392
+ requires: z.object({ airapp: z.boolean().optional() }).default({})
4393
+ });
4394
+ /**
4395
+ * `metadata.busabase` inside the root `SKILL.md`'s YAML frontmatter.
4396
+ *
4397
+ * `template: true` is an EXPLICIT opt-in, not an inference from "this skill
4398
+ * happens to contain a package". Publishing a template means accepting that
4399
+ * installers will run its AirApp code and feed its SKILL.md to their agent; that
4400
+ * deserves a deliberate flag rather than a side effect of directory shape.
4401
+ */
4402
+ const SkillBusabaseMetadataSchema = z.object({
4403
+ template: z.boolean().default(false),
4404
+ folderSlug: z.string().optional(),
4405
+ /** Resource keys the manual talks about; each must exist under `content/`. */
4406
+ resources: z.array(z.string()).default([]),
4407
+ risk: z.string().optional()
4408
+ });
4409
+ /** Root `SKILL.md` frontmatter, as far as the template format cares about it. */
4410
+ const SkillFrontmatterSchema = z.object({
4411
+ name: z.string().min(1),
4412
+ description: z.string().default(""),
4413
+ metadata: z.object({ busabase: SkillBusabaseMetadataSchema.optional() }).passthrough().optional()
4414
+ });
4415
+ /**
4416
+ * Resolve which AirApp is the app, or explain why it cannot be resolved.
4417
+ *
4418
+ * Order matters and mirrors §5.4: an explicit declaration always wins, a single
4419
+ * candidate is unambiguous enough to accept, and anything else is an error the
4420
+ * publisher must resolve. Ambiguity is never resolved by picking the first
4421
+ * alphabetically — a template that silently opens the wrong app is worse than
4422
+ * one that refuses to publish.
4423
+ */
4424
+ const resolvePrimaryAirApp = (template, airAppSlugsInContent) => {
4425
+ if (template?.airapp && template.airapps?.length) return { error: "`template.airapp` and `template.airapps` are mutually exclusive." };
4426
+ if (template?.airapp) return airAppSlugsInContent.includes(template.airapp) ? { slug: template.airapp } : { error: `template.airapp "${template.airapp}" has no matching AirApp under content/.` };
4427
+ if (template?.airapps?.length) {
4428
+ const primaries = template.airapps.filter((entry) => entry.role === "primary");
4429
+ if (primaries.length !== 1) return { error: `template.airapps must declare exactly one entry with role "primary" (found ${primaries.length}).` };
4430
+ const [primary] = primaries;
4431
+ const unknown = template.airapps.filter((entry) => !airAppSlugsInContent.includes(entry.slug));
4432
+ if (unknown.length) return { error: `template.airapps references AirApps missing from content/: ${unknown.map((entry) => entry.slug).join(", ")}.` };
4433
+ return { slug: primary.slug };
4434
+ }
4435
+ if (airAppSlugsInContent.length === 1) return { slug: airAppSlugsInContent[0] };
4436
+ if (airAppSlugsInContent.length === 0) return { error: "This package contains no AirApp." };
4437
+ return { error: `content/ has ${airAppSlugsInContent.length} AirApps; declare which one is primary via template.airapp or template.airapps.` };
4438
+ };
4439
+ /** Stamp on every resource node (Base, Drive, AirApp, …) an app owns. */
4440
+ const AppResourceOwnershipSchema = z.object({
4441
+ appId: z.string().min(1),
4442
+ /** Stable internal handle (`"contacts"`), NOT the installed slug. */
4443
+ resourceKey: z.string().min(1),
4444
+ schemaVersion: z.number().int().nonnegative()
4445
+ });
4446
+ /**
4447
+ * The `resourceKey` reserved for an app's root Folder.
4448
+ *
4449
+ * `busabase-sdk` recognises an app's own Folder by looking for exactly this
4450
+ * value (`ownsAppRoot`), so the installer must write it too — a Folder stamped
4451
+ * with anything else reads as a stranger's, and the skill's own `setup.mjs`
4452
+ * then refuses to touch its own workspace with `SETUP_CONFLICT`. Exported so
4453
+ * neither side carries the string literal privately.
4454
+ */
4455
+ const APP_ROOT_RESOURCE_KEY = "app-root";
4456
+ AppResourceOwnershipSchema.extend({
4457
+ resourceKey: z.literal(APP_ROOT_RESOURCE_KEY),
4458
+ version: z.string().optional(),
4459
+ source: z.object({
4460
+ repo: z.string().optional(),
4461
+ ref: z.string().optional(),
4462
+ subdir: z.string().optional()
4463
+ }).optional(),
4464
+ installedAt: z.string().optional()
4465
+ });
4466
+ /**
4467
+ * Marks the Skill node lifted from the package root.
4468
+ *
4469
+ * Needed in both directions: export lifts exactly this node back out to the
4470
+ * package root instead of writing it under `content/` (which would create the
4471
+ * duplicate the format exists to avoid), and the agent wiring lists exactly
4472
+ * these nodes as "app manuals available in this space".
4473
+ */
4474
+ const TEMPLATE_SKILL_METADATA_KEY = "isTemplateSkill";
4475
+ z.object({
4476
+ appId: z.string().min(1),
4477
+ [TEMPLATE_SKILL_METADATA_KEY]: z.literal(true)
4478
+ });
3961
4479
  const LocaleSchema = z.enum({
3962
4480
  defaultLocale: "en",
3963
4481
  locales: [
@@ -4341,8 +4859,7 @@ z.discriminatedUnion("operation", [archiveBaseInputSchema.extend({
4341
4859
  * when a node carries no `icon`, every host falls back to the type icon, same
4342
4860
  * as before this field existed.
4343
4861
  *
4344
- * The `attachment` variant mirrors buda's `NodeLogo` shape (see
4345
- * `apps/buda/src/domains/agent-controller/components/use-logo-crop-upload.tsx`):
4862
+ * The `attachment` variant follows the shared avatar-cropping model:
4346
4863
  * `url`/`attachmentId` are the CROPPED display image actually rendered, while
4347
4864
  * `originalUrl`/`originalAttachmentId` + `crop` are kept so the crop dialog can
4348
4865
  * re-open non-destructively against the untouched source image instead of
@@ -5080,7 +5597,15 @@ const PackageManifestSchema = z.object({
5080
5597
  author: z.string().optional(),
5081
5598
  license: z.string().optional(),
5082
5599
  homepage: z.string().url().optional(),
5083
- tags: z.array(z.string()).default([])
5600
+ tags: z.array(z.string()).default([]),
5601
+ /**
5602
+ * Present ⇢ this package is also an Agent Skill (a "template"): see
5603
+ * `./template.ts`. Optional and additive — a package without it behaves
5604
+ * exactly as before, and a reader that ignores it still installs correctly.
5605
+ * The flag alone is not enough to be treated as a template; the root
5606
+ * `SKILL.md` must opt in too (`validateTemplate`).
5607
+ */
5608
+ template: TemplateManifestSchema.optional()
5084
5609
  });
5085
5610
  /** `_node.json` — marks a directory as a verbatim-content file-tree node. */
5086
5611
  const PackageFileTreeNodeMetaSchema = z.object({
@@ -5327,6 +5852,18 @@ const compareNodes = (a, b) => {
5327
5852
  const sortNodes = (nodes) => [...nodes].sort(compareNodes);
5328
5853
  /** `getting-started` → `Getting Started`; the default name for a folder with no `_folder.json`. */
5329
5854
  const humanizeSlug = (slug) => slug.split("-").filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
5855
+ /**
5856
+ * Extension → mime type, which decides one thing that matters: a text file goes
5857
+ * INLINE into a file-tree create, and anything else has to be uploaded as an
5858
+ * asset first (see `isTextMimeType`). Getting it wrong on a source file means
5859
+ * the app's own code takes the binary path — it needs an upload endpoint to
5860
+ * install at all, and it lands as an opaque blob rather than something a
5861
+ * reviewer or an agent can read.
5862
+ *
5863
+ * The module-flavoured JavaScript and TypeScript extensions are here because
5864
+ * real projects use them: `scripts/setup.mjs` is the ordinary name for a Node
5865
+ * script, and installing kelly-email surfaced exactly that gap.
5866
+ */
5330
5867
  const MIME_BY_EXT$1 = {
5331
5868
  ".css": "text/css",
5332
5869
  ".csv": "text/csv",
@@ -5335,7 +5872,12 @@ const MIME_BY_EXT$1 = {
5335
5872
  ".html": "text/html",
5336
5873
  ".jpeg": "image/jpeg",
5337
5874
  ".jpg": "image/jpeg",
5875
+ ".cjs": "text/javascript",
5876
+ ".cts": "text/typescript",
5338
5877
  ".js": "text/javascript",
5878
+ ".jsx": "text/javascript",
5879
+ ".mjs": "text/javascript",
5880
+ ".mts": "text/typescript",
5339
5881
  ".json": "application/json",
5340
5882
  ".md": "text/markdown",
5341
5883
  ".pdf": "application/pdf",
@@ -5343,6 +5885,7 @@ const MIME_BY_EXT$1 = {
5343
5885
  ".py": "text/x-python",
5344
5886
  ".svg": "image/svg+xml",
5345
5887
  ".ts": "text/typescript",
5888
+ ".tsx": "text/typescript",
5346
5889
  ".txt": "text/plain",
5347
5890
  ".webp": "image/webp",
5348
5891
  ".yaml": "text/yaml",
@@ -5470,6 +6013,7 @@ const applyInstall = async (client, plan, options) => {
5470
6013
  const fieldIds = /* @__PURE__ */ new Map();
5471
6014
  const bases = [];
5472
6015
  const progress = options.onProgress ?? (() => {});
6016
+ const app = resolveAppContext(plan, options);
5473
6017
  const docAssetIds = await uploadDocAssets(client, plan.tree.assets ?? [], state, progress, options.serverUrl);
5474
6018
  progress("Pass 1/5: creating the node tree…");
5475
6019
  if (plan.existingTargetFolderNodeId) state.targetFolderNodeId = plan.existingTargetFolderNodeId;
@@ -5479,11 +6023,23 @@ const applyInstall = async (client, plan, options) => {
5479
6023
  slug: plan.targetFolderSlug,
5480
6024
  name: plan.tree.manifest.name,
5481
6025
  description: plan.tree.manifest.description,
5482
- submittedBy: options.submittedBy
6026
+ submittedBy: options.submittedBy,
6027
+ metadata: app?.rootMetadata
5483
6028
  });
5484
6029
  state.created.folders++;
5485
6030
  }
5486
- await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds);
6031
+ await createStructure(client, plan.tree.nodes, state.targetFolderNodeId, options, state, bases, fieldIds, docAssetIds, app);
6032
+ if (app?.rootSkill) {
6033
+ progress("Pass 1/5: installing the app skill…");
6034
+ await createFileTreeNode(client, {
6035
+ type: "skill",
6036
+ slug: app.rootSkill.slug,
6037
+ name: app.rootSkill.name,
6038
+ description: app.rootSkill.description,
6039
+ position: void 0,
6040
+ files: app.rootSkill.files
6041
+ }, state.targetFolderNodeId, options, state, app, { [TEMPLATE_SKILL_METADATA_KEY]: true });
6042
+ }
5487
6043
  const deferred = bases.filter((base) => base.node.base.fields.some(isDeferredField));
5488
6044
  if (deferred.length > 0) {
5489
6045
  progress("Pass 2/5: adding relation and AI fields…");
@@ -5526,7 +6082,7 @@ const applyInstall = async (client, plan, options) => {
5526
6082
  const withRecords = bases.filter((base) => base.node.records.length > 0);
5527
6083
  if (withRecords.length > 0) {
5528
6084
  progress("Pass 4/5: creating records…");
5529
- for (const base of withRecords) await createRecords(client, base, plan, options, state, recordIdsByKey);
6085
+ for (const base of withRecords) await createRecords(client, base, plan, options, state, recordIdsByKey, app);
5530
6086
  }
5531
6087
  const relationFieldSlugs = /* @__PURE__ */ new Map();
5532
6088
  for (const base of bases) {
@@ -5543,6 +6099,58 @@ const applyInstall = async (client, plan, options) => {
5543
6099
  }
5544
6100
  return state;
5545
6101
  };
6102
+ const resolveAppContext = (plan, options) => {
6103
+ if (!plan.isTemplate) return void 0;
6104
+ const manifest = plan.tree.manifest;
6105
+ const schemaVersion = manifest.template?.schemaVersion ?? 1;
6106
+ const now = options.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
6107
+ const rootStamp = {
6108
+ appId: manifest.name,
6109
+ resourceKey: APP_ROOT_RESOURCE_KEY,
6110
+ schemaVersion,
6111
+ ...manifest.version ? { version: manifest.version } : {},
6112
+ ...options.source ? { source: options.source } : {},
6113
+ installedAt: now()
6114
+ };
6115
+ return {
6116
+ appId: manifest.name,
6117
+ schemaVersion,
6118
+ rootMetadata: rootStamp,
6119
+ rootSkill: plan.tree.rootSkill,
6120
+ resourceKeysBySlug: plan.resourceKeysBySlug,
6121
+ mergeSampleRecords: options.installSampleRecords ?? true
6122
+ };
6123
+ };
6124
+ /**
6125
+ * Stamp a node the installer just created.
6126
+ *
6127
+ * Separate from creation because `bases.create` and `fileTrees.create` take no
6128
+ * metadata — only `nodes.createChangeRequest` does, which is why the Folder can
6129
+ * be stamped inline and these cannot. A stamp that fails is a warning, never a
6130
+ * failed install: the resources are already there and useful, and the only loss
6131
+ * is that the skill's own `setup.mjs` will later offer to re-claim them, which
6132
+ * it is built to do (that is its "repair" path).
6133
+ */
6134
+ const resourceStamp = (app, installedSlug, extra = {}) => {
6135
+ return {
6136
+ appId: app.appId,
6137
+ resourceKey: app.resourceKeysBySlug[installedSlug] ?? installedSlug,
6138
+ schemaVersion: app.schemaVersion,
6139
+ ...extra
6140
+ };
6141
+ };
6142
+ const stampResource = async (client, app, nodeId, installedSlug, state, extra = {}) => {
6143
+ if (!app) return;
6144
+ const stamp = resourceStamp(app, installedSlug, extra);
6145
+ try {
6146
+ await client.nodes.updateMetadata({
6147
+ nodeId,
6148
+ metadata: stamp
6149
+ });
6150
+ } catch (error) {
6151
+ state.warnings.push(`Could not record app ownership on "${installedSlug}": ${error.message}. The node installed fine; running the app's own setup will claim it.`);
6152
+ }
6153
+ };
5546
6154
  /**
5547
6155
  * Re-create the package's carried Doc images on THIS host and return the
5548
6156
  * old id → new id map the Doc bodies are rewritten through.
@@ -5566,7 +6174,7 @@ const uploadDocAssets = async (client, assets, state, progress, serverUrl) => {
5566
6174
  }
5567
6175
  return idMap;
5568
6176
  };
5569
- const createStructure = async (client, nodes, parentNodeId, options, state, bases, fieldIds, docAssetIds) => {
6177
+ const createStructure = async (client, nodes, parentNodeId, options, state, bases, fieldIds, docAssetIds, app) => {
5570
6178
  for (const node of nodes) switch (node.type) {
5571
6179
  case "folder": {
5572
6180
  const nodeId = await createFolderNode(client, {
@@ -5577,7 +6185,7 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
5577
6185
  submittedBy: options.submittedBy
5578
6186
  });
5579
6187
  state.created.folders++;
5580
- await createStructure(client, node.children, nodeId, options, state, bases, fieldIds, docAssetIds);
6188
+ await createStructure(client, node.children, nodeId, options, state, bases, fieldIds, docAssetIds, app);
5581
6189
  break;
5582
6190
  }
5583
6191
  case "doc": {
@@ -5595,12 +6203,12 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
5595
6203
  break;
5596
6204
  }
5597
6205
  case "base":
5598
- await createBase(client, node, parentNodeId, options, state, bases, fieldIds);
6206
+ await createBase(client, node, parentNodeId, options, state, bases, fieldIds, app);
5599
6207
  break;
5600
6208
  case "skill":
5601
6209
  case "airapp":
5602
6210
  case "drive":
5603
- await createFileTreeNode(client, node, parentNodeId, options, state);
6211
+ await createFileTreeNode(client, node, parentNodeId, options, state, app);
5604
6212
  break;
5605
6213
  case "file": {
5606
6214
  let asset;
@@ -5639,13 +6247,14 @@ const createFolderNode = async (client, input) => {
5639
6247
  slug: input.slug,
5640
6248
  name: input.name,
5641
6249
  description: input.description,
5642
- parentNodeId: input.parentNodeId
6250
+ parentNodeId: input.parentNodeId,
6251
+ ...input.metadata ? { metadata: input.metadata } : {}
5643
6252
  }]
5644
6253
  })).operations[0]?.nodeId;
5645
6254
  if (!nodeId) throw new Error(`Folder "${input.slug}" was created but the server returned no node id, so nothing could be installed inside it.`);
5646
6255
  return nodeId;
5647
6256
  };
5648
- const createBase = async (client, node, parentNodeId, options, state, bases, fieldIds) => {
6257
+ const createBase = async (client, node, parentNodeId, options, state, bases, fieldIds, app) => {
5649
6258
  const immediateFields = node.base.fields.filter((field) => !isDeferredField(field)).map((field) => ({
5650
6259
  slug: field.slug,
5651
6260
  name: field.name,
@@ -5668,6 +6277,7 @@ const createBase = async (client, node, parentNodeId, options, state, bases, fie
5668
6277
  baseId: result.id
5669
6278
  });
5670
6279
  indexFields(fieldIds, node.slug, result.fields);
6280
+ await stampResource(client, app, result.nodeId, node.slug, state);
5671
6281
  for (const view of node.base.views) {
5672
6282
  if (!(await client.views.changeRequest({
5673
6283
  operation: "create",
@@ -5682,7 +6292,7 @@ const createBase = async (client, node, parentNodeId, options, state, bases, fie
5682
6292
  state.created.views++;
5683
6293
  }
5684
6294
  };
5685
- const createFileTreeNode = async (client, node, parentNodeId, options, state) => {
6295
+ const createFileTreeNode = async (client, node, parentNodeId, options, state, app, extraMetadata = {}) => {
5686
6296
  const files = (await Promise.all(node.files.map(async (entry) => {
5687
6297
  const mimeType = guessMimeType$1(entry.path);
5688
6298
  if (isTextMimeType(mimeType)) return {
@@ -5709,6 +6319,7 @@ const createFileTreeNode = async (client, node, parentNodeId, options, state) =>
5709
6319
  name: node.name,
5710
6320
  description: node.description,
5711
6321
  files,
6322
+ ...app ? { metadata: resourceStamp(app, node.slug, extraMetadata) } : {},
5712
6323
  mergeMode: "replace",
5713
6324
  autoMerge: options.autoMerge
5714
6325
  })).materialized) {
@@ -5856,7 +6467,8 @@ const toCreatableFields = (node, fields) => {
5856
6467
  }
5857
6468
  return result;
5858
6469
  };
5859
- const createRecords = async (client, base, plan, options, state, recordIdsByKey) => {
6470
+ const createRecords = async (client, base, plan, options, state, recordIdsByKey, app) => {
6471
+ const mergeRecords = options.autoMerge || (app?.mergeSampleRecords ?? false);
5860
6472
  const records = [...base.node.records].sort((a, b) => a.key.localeCompare(b.key, "en"));
5861
6473
  for (let offset = 0; offset < records.length; offset += 200) {
5862
6474
  const batch = records.slice(offset, offset + 200);
@@ -5866,9 +6478,9 @@ const createRecords = async (client, base, plan, options, state, recordIdsByKey)
5866
6478
  message: `Install ${batch.length} record(s) into ${base.node.slug}`,
5867
6479
  submittedBy: options.submittedBy,
5868
6480
  idempotencyKey: batchIdempotencyKey(plan.tree.manifest.name, batch[0].key),
5869
- autoMerge: options.autoMerge
6481
+ autoMerge: mergeRecords
5870
6482
  });
5871
- if (!options.autoMerge) {
6483
+ if (!mergeRecords) {
5872
6484
  state.pendingChangeRequests++;
5873
6485
  continue;
5874
6486
  }
@@ -5970,19 +6582,115 @@ const findSourceNode = (nodes, slugOrId) => {
5970
6582
  };
5971
6583
  const collectPackageTree = async (client, root, options) => {
5972
6584
  const children = root.children ?? [];
5973
- const nodes = await collectNodes(client, children, options);
6585
+ const { skillSource, rest } = partitionTemplateSkill(children);
6586
+ const nodes = await collectNodes(client, rest, options);
5974
6587
  const tree = {
5975
6588
  manifest: {
5976
6589
  ...options.manifest,
5977
6590
  format: PACKAGE_FORMAT
5978
6591
  },
5979
- nodes,
6592
+ nodes: restorePackageSlugs(nodes, children),
6593
+ ...skillSource ? { rootSkill: await collectRootSkill(client, skillSource, options) } : {},
5980
6594
  assets: await collectDocAssets(client, nodes, options)
5981
6595
  };
5982
6596
  assertSelfContained(tree, options);
5983
6597
  return tree;
5984
6598
  };
5985
6599
  /**
6600
+ * Undo the slugs INSTALL chose, restoring the ones the package declared.
6601
+ *
6602
+ * Install prefixes a template's Base slugs with the target folder (`contacts` →
6603
+ * `kelly-crm-contacts`) and records the original as the node's `resourceKey`.
6604
+ * Exporting the installed slug would make the package say something its author
6605
+ * never wrote — and re-installing it would prefix the prefix, or collide
6606
+ * outright. Reading the stamp back makes export→install→export a fixed point,
6607
+ * which is the property that lets a user install a template, adjust it, and
6608
+ * publish the result without the names drifting a little further each time.
6609
+ *
6610
+ * The root folder's own stamp is skipped: its `resourceKey` is the reserved
6611
+ * `app-root` marker, not a slug anything should be named after.
6612
+ */
6613
+ const restorePackageSlugs = (nodes, sources) => {
6614
+ const renames = /* @__PURE__ */ new Map();
6615
+ const index = (list) => {
6616
+ for (const source of list) {
6617
+ const key = source.metadata?.resourceKey;
6618
+ if (typeof key === "string" && key !== "app-root" && key !== source.slug && NODE_SLUG_PATTERN.test(key)) renames.set(source.slug, key);
6619
+ if (source.children) index(source.children);
6620
+ }
6621
+ };
6622
+ index(sources);
6623
+ if (renames.size === 0) return [...nodes];
6624
+ const rewriteField = (field) => {
6625
+ const target = field.options.targetBaseSlug;
6626
+ if (!target) return field;
6627
+ const renamed = renames.get(target);
6628
+ return renamed ? {
6629
+ ...field,
6630
+ options: {
6631
+ ...field.options,
6632
+ targetBaseSlug: renamed
6633
+ }
6634
+ } : field;
6635
+ };
6636
+ const rewrite = (node) => {
6637
+ const slug = renames.get(node.slug) ?? node.slug;
6638
+ if (node.type === "folder") return {
6639
+ ...node,
6640
+ slug,
6641
+ children: node.children.map(rewrite)
6642
+ };
6643
+ if (node.type === "base") return {
6644
+ ...node,
6645
+ slug,
6646
+ base: {
6647
+ ...node.base,
6648
+ fields: node.base.fields.map(rewriteField)
6649
+ }
6650
+ };
6651
+ return {
6652
+ ...node,
6653
+ slug
6654
+ };
6655
+ };
6656
+ return nodes.map(rewrite);
6657
+ };
6658
+ /**
6659
+ * Separate the app's manual from its resources.
6660
+ *
6661
+ * The Skill node stamped `isTemplateSkill` is the one an install lifted out of a
6662
+ * package root, so export has to put it back there rather than under `content/`.
6663
+ * Writing it as a normal node would make every export→install cycle add another
6664
+ * copy of the manual beside the previous one.
6665
+ *
6666
+ * Only the FIRST such node is lifted: two of them in one folder is a state no
6667
+ * install can produce, and silently merging them would be a guess. The extra one
6668
+ * is exported as an ordinary Skill node, which is visible and fixable, rather
6669
+ * than dropped.
6670
+ */
6671
+ const partitionTemplateSkill = (children) => {
6672
+ const index = children.findIndex((node) => node.type === "skill" && node.metadata?.["isTemplateSkill"] === true);
6673
+ if (index === -1) return { rest: [...children] };
6674
+ return {
6675
+ skillSource: children[index],
6676
+ rest: children.filter((_, position) => position !== index)
6677
+ };
6678
+ };
6679
+ const collectRootSkill = async (client, source, options) => {
6680
+ const node = await collectFileTree(client, source, "skill", {
6681
+ slug: source.slug,
6682
+ name: source.name,
6683
+ description: source.description ?? "",
6684
+ position: source.position
6685
+ }, options);
6686
+ return {
6687
+ slug: source.slug,
6688
+ name: source.name,
6689
+ description: source.description ?? "",
6690
+ files: node.files
6691
+ };
6692
+ };
6693
+ /**
5986
6694
  * The bytes behind every `![](/api/assets/{id}/raw)` in every exported Doc.
5987
6695
  *
5988
6696
  * A Doc's images are the one part of a Doc that is not in its markdown, and
@@ -6303,175 +7011,10 @@ const assertSelfContained = (tree, options) => {
6303
7011
  }
6304
7012
  };
6305
7013
  //#endregion
6306
- //#region ../../packages/busabase-package/src/github.ts
7014
+ //#region ../../packages/busabase-package/src/frontmatter.ts
6307
7015
  /**
6308
- * GitHub source for `busabase-cli install`: parse a repo URL, fetch the ref's
6309
- * zipball, and extract it into memory.
6310
- *
6311
- * Ported from Buda's GitHub skill importer (Buda stays as-is; a shared package
6312
- * is warranted only once the Phase-2 server consumer actually exists). Three
6313
- * deliberate divergences from that original:
6314
- *
6315
- * 1. **Host is parsed, not pattern-matched.** buda's regex matches `github.com/…`
6316
- * anywhere in the string, so `https://evil.example/github.com/o/r` passes. This
6317
- * parses with `URL` and checks `hostname` against an allowlist.
6318
- * 2. **Any git ref, not just branches.** buda fetches `/archive/refs/heads/<b>.zip`,
6319
- * which resolves branches only — tags (the package format's version scheme)
6320
- * would 404. This uses codeload's `/zip/<ref>`, which takes a branch, tag, or SHA.
6321
- * 3. **Extraction is to memory with the caps applied first**, so an oversized or
6322
- * zip-bomb archive never reaches the disk.
6323
- */
6324
- /** Hosts the installer may ever connect to. Also the Phase-2 server guard's allowlist. */
6325
- const GITHUB_ALLOWED_HOSTS = [
6326
- "github.com",
6327
- "api.github.com",
6328
- "codeload.github.com"
6329
- ];
6330
- /** Hosts accepted in a user-supplied install URL (the web UI form of a repo). */
6331
- const GITHUB_URL_HOSTS = ["github.com", "www.github.com"];
6332
- const isAllowedGithubHost = (hostname) => GITHUB_ALLOWED_HOSTS.includes(hostname.toLowerCase());
6333
- /**
6334
- * Parse a GitHub repo URL.
6335
- *
6336
- * Supported forms:
6337
- * https://github.com/owner/repo
6338
- * https://github.com/owner/repo.git
6339
- * https://github.com/owner/repo/tree/<ref>
6340
- * https://github.com/owner/repo/tree/<ref>/<subdir…>
6341
- *
6342
- * A ref containing `/` (e.g. `feature/x`) is indistinguishable from `<ref>/<subdir>`
6343
- * in a `/tree/` URL — GitHub's own web URLs have the same ambiguity — so the first
6344
- * segment after `/tree/` is taken as the ref and the rest as the subdir.
6345
- */
6346
- const parseGithubUrl = (rawUrl) => {
6347
- let url;
6348
- try {
6349
- url = new URL(rawUrl.trim());
6350
- } catch {
6351
- throw new Error(`Not a valid URL: "${rawUrl}". Expected a GitHub repo URL, e.g. https://github.com/acme/support-kb-template`);
6352
- }
6353
- if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`Unsupported URL scheme "${url.protocol}" — expected an https:// GitHub URL.`);
6354
- if (!GITHUB_URL_HOSTS.includes(url.hostname.toLowerCase())) throw new Error(`Not a GitHub URL: host "${url.hostname}" is not github.com. Only GitHub packages can be installed.`);
6355
- const [owner, rawRepo, marker, ref, ...rest] = url.pathname.split("/").filter(Boolean);
6356
- if (!owner || !rawRepo) throw new Error(`GitHub URL is missing owner/repo: "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
6357
- const repo = rawRepo.replace(/\.git$/, "");
6358
- if (!marker) return {
6359
- owner,
6360
- repo,
6361
- ref: void 0,
6362
- subdir: void 0
6363
- };
6364
- if (marker !== "tree") throw new Error(`Unsupported GitHub URL form "/${marker}/" in "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
6365
- if (!ref) throw new Error(`GitHub URL has "/tree/" but no ref: "${rawUrl}".`);
6366
- return {
6367
- owner,
6368
- repo,
6369
- ref: decodeURIComponent(ref),
6370
- subdir: rest.length > 0 ? rest.map((segment) => decodeURIComponent(segment)).join("/") : void 0
6371
- };
6372
- };
6373
- /** Encode a ref for a URL path while keeping its `/` separators (e.g. `feature/x`). */
6374
- const encodeRefPath = (ref) => ref.split("/").map(encodeURIComponent).join("/");
6375
- /**
6376
- * Download the zipball for `owner/repo` at `ref` (default branch when `ref` is
6377
- * undefined). Tries the public codeload host first, then the authenticated API when
6378
- * a token is available — so a private repo works iff `GITHUB_TOKEN` is set.
6379
- */
6380
- const downloadGithubZip = async (source, options = {}) => {
6381
- const fetcher = options.fetcher ?? fetch;
6382
- const { owner, repo, ref } = source;
6383
- const slug = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
6384
- const label = `${owner}/${repo}${ref ? `@${ref}` : ""}`;
6385
- const publicUrl = `https://codeload.github.com/${slug}/zip/${ref ? encodeRefPath(ref) : "HEAD"}`;
6386
- assertAllowedUrl(publicUrl);
6387
- const publicResponse = await fetcher(publicUrl);
6388
- if (publicResponse.ok) return Buffer.from(await publicResponse.arrayBuffer());
6389
- if (!options.githubToken) throw new Error(publicResponse.status === 404 ? `GitHub repo or ref not found: ${label} (HTTP 404).\n • Check the URL, and that the branch/tag exists.\n • Private repo? Set GITHUB_TOKEN=<a token with repo read access> and re-run.` : `Failed to download ${label} from GitHub (HTTP ${publicResponse.status} ${publicResponse.statusText}).`);
6390
- const apiUrl = `https://api.github.com/repos/${slug}/zipball${ref ? `/${encodeRefPath(ref)}` : ""}`;
6391
- assertAllowedUrl(apiUrl);
6392
- const authedResponse = await fetcher(apiUrl, { headers: {
6393
- Accept: "application/vnd.github+json",
6394
- Authorization: `Bearer ${options.githubToken}`,
6395
- "X-GitHub-Api-Version": "2022-11-28"
6396
- } });
6397
- if (authedResponse.ok) return Buffer.from(await authedResponse.arrayBuffer());
6398
- if (authedResponse.status === 401 || authedResponse.status === 403) throw new Error(`GitHub rejected GITHUB_TOKEN for ${label} (HTTP ${authedResponse.status}). The token is invalid, expired, or lacks read access to this repo.`);
6399
- throw new Error(`GitHub repo or ref not found: ${label} (HTTP ${authedResponse.status}), even with GITHUB_TOKEN. Check the owner, repo, and ref.`);
6400
- };
6401
- /** SSRF guard: refuse anything off the GitHub allowlist before a connection is made. */
6402
- const assertAllowedUrl = (rawUrl) => {
6403
- const url = new URL(rawUrl);
6404
- if (url.protocol !== "https:" || !isAllowedGithubHost(url.hostname)) throw new Error(`Refusing to fetch ${rawUrl} — only https on ${GITHUB_ALLOWED_HOSTS.join(", ")} is allowed.`);
6405
- };
6406
- /**
6407
- * Zip-slip guard. Normalizes an archive entry path to a repo-relative path and
6408
- * rejects anything that could escape the extraction root: `..`/`.` segments,
6409
- * and Windows drive prefixes. A leading `/` is stripped rather than rejected —
6410
- * that makes the path relative, which is already safe.
6411
- */
6412
- const normalizeArchivePath = (value) => {
6413
- const normalized = value.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
6414
- const segments = normalized.split("/");
6415
- if (!normalized || segments.some((segment) => segment === "." || segment === ".." || /^[a-zA-Z]:$/.test(segment))) throw new Error(`Unsafe archive path rejected: ${value}`);
6416
- return normalized;
6417
- };
6418
- /**
6419
- * Extract a GitHub zipball into memory, keyed by repo-relative path (the archive's
6420
- * single generated root directory — `owner-repo-<sha>/` — is stripped).
6421
- *
6422
- * Caps are enforced against the archive's declared entry sizes BEFORE any bytes are
6423
- * read, so an oversized archive or zip bomb is refused up front and nothing is ever
6424
- * written to disk.
6425
- */
6426
- const extractZip = async (zipBuffer, options = {}) => {
6427
- const maxFiles = options.maxFiles ?? 5e3;
6428
- const maxTotalBytes = options.maxTotalBytes ?? 262144e3;
6429
- const maxFileBytes = options.maxFileBytes ?? 26214400;
6430
- const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(zipBuffer)));
6431
- try {
6432
- const files = (await reader.getEntries()).filter((entry) => !entry.directory);
6433
- const archiveRoot = files[0]?.filename.split("/")[0];
6434
- if (!archiveRoot) throw new Error("The downloaded repository archive is empty.");
6435
- const subdir = options.subdir ? normalizeArchivePath(options.subdir) : void 0;
6436
- const prefix = `${archiveRoot}/${subdir ? `${subdir}/` : ""}`;
6437
- for (const entry of files) normalizeArchivePath(entry.filename);
6438
- const selected = files.filter((entry) => entry.filename.startsWith(prefix));
6439
- if (selected.length > maxFiles) throw new Error(`Package has ${selected.length} files, above the ${maxFiles}-file limit. Nothing was installed.`);
6440
- let totalBytes = 0;
6441
- for (const entry of selected) {
6442
- const size = entry.uncompressedSize ?? 0;
6443
- const relativePath = entry.filename.slice(prefix.length);
6444
- if (size > maxFileBytes) throw new Error(`File "${relativePath}" is ${formatBytes(size)}, above the ${formatBytes(maxFileBytes)} per-file limit. Nothing was installed.`);
6445
- totalBytes += size;
6446
- if (totalBytes > maxTotalBytes) throw new Error(`Package unpacks to more than the ${formatBytes(maxTotalBytes)} total limit. Nothing was installed.`);
6447
- }
6448
- const result = /* @__PURE__ */ new Map();
6449
- for (const entry of selected) {
6450
- const relativePath = normalizeArchivePath(entry.filename.slice(prefix.length));
6451
- if (!entry.getData) continue;
6452
- const data = await entry.arrayBuffer();
6453
- result.set(relativePath, Buffer.from(data));
6454
- }
6455
- return result;
6456
- } finally {
6457
- await reader.close();
6458
- }
6459
- };
6460
- const formatBytes = (bytes) => bytes >= 1048576 ? `${Math.round(bytes / 1048576)}MB` : `${Math.round(bytes / 1024)}KB`;
6461
- /** Parse, fetch, and extract in one step — the `install` command's entry point. */
6462
- const fetchGithubPackageFiles = async (repoUrl, options = {}) => {
6463
- const source = parseGithubUrl(repoUrl);
6464
- const zipBuffer = await downloadGithubZip(source, options);
6465
- return {
6466
- source,
6467
- files: await extractZip(zipBuffer, { subdir: source.subdir })
6468
- };
6469
- };
6470
- //#endregion
6471
- //#region ../../packages/busabase-package/src/frontmatter.ts
6472
- /**
6473
- * YAML frontmatter for doc nodes (§6.3): `name`, `description?`, `position?` above
6474
- * the markdown body.
7016
+ * YAML frontmatter for doc nodes (§6.3): `name`, `description?`, `position?` above
7017
+ * the markdown body.
6475
7018
  *
6476
7019
  * Writing is deterministic (§6.6): a fixed key order and LF endings, so re-exporting
6477
7020
  * an unchanged doc is byte-identical.
@@ -6523,6 +7066,78 @@ const serializeDoc = (fields, body) => {
6523
7066
  return `${DELIMITER}\n${yaml}\n${DELIMITER}\n\n${normalizeDocBody(body)}\n`;
6524
7067
  };
6525
7068
  //#endregion
7069
+ //#region ../../packages/busabase-package/src/ignore.ts
7070
+ const escapeLiteral = (value) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
7071
+ /**
7072
+ * Translate one gitignore-ish pattern into a regex over a POSIX relative path.
7073
+ *
7074
+ * The three globs are handled in one pass so that `**` is recognised before the
7075
+ * `*` it starts with — splitting on `*` first would turn `**` into two
7076
+ * segment-local wildcards and quietly stop matching across directories.
7077
+ */
7078
+ const patternToRegex = (pattern, anchored) => {
7079
+ let source = "";
7080
+ for (let index = 0; index < pattern.length; index += 1) {
7081
+ const char = pattern[index];
7082
+ if (char === "*") {
7083
+ if (pattern[index + 1] === "*") {
7084
+ index += 1;
7085
+ if (pattern[index + 1] === "/") {
7086
+ index += 1;
7087
+ source += "(?:.*/)?";
7088
+ } else source += ".*";
7089
+ } else source += "[^/]*";
7090
+ continue;
7091
+ }
7092
+ if (char === "?") {
7093
+ source += "[^/]";
7094
+ continue;
7095
+ }
7096
+ if (char === "/") {
7097
+ source += "/";
7098
+ continue;
7099
+ }
7100
+ source += escapeLiteral(char);
7101
+ }
7102
+ return new RegExp(`${anchored ? "^" : "^(?:.*/)?"}${source}(?:/.*)?$`);
7103
+ };
7104
+ const parseRule = (line) => {
7105
+ let pattern = line.trim();
7106
+ if (!pattern || pattern.startsWith("#")) return void 0;
7107
+ const negated = pattern.startsWith("!");
7108
+ if (negated) pattern = pattern.slice(1);
7109
+ const directoryOnly = pattern.endsWith("/");
7110
+ if (directoryOnly) pattern = pattern.slice(0, -1);
7111
+ const anchored = pattern.startsWith("/");
7112
+ if (anchored) pattern = pattern.slice(1);
7113
+ if (!pattern) return void 0;
7114
+ return {
7115
+ regex: patternToRegex(pattern, anchored),
7116
+ negated,
7117
+ directoryOnly
7118
+ };
7119
+ };
7120
+ const parseIgnoreFile = (contents) => {
7121
+ const rules = contents.split(/\r?\n/).map(parseRule).filter((rule) => rule !== void 0);
7122
+ return {
7123
+ isEmpty: rules.length === 0,
7124
+ ignores: (relativePath) => {
7125
+ let ignored = false;
7126
+ for (const rule of rules) {
7127
+ if (rule.directoryOnly && !rule.regex.test(`${relativePath}/`)) continue;
7128
+ if (!rule.regex.test(relativePath)) continue;
7129
+ ignored = !rule.negated;
7130
+ }
7131
+ return ignored;
7132
+ }
7133
+ };
7134
+ };
7135
+ /** A matcher that installs everything — used when no ignore file is present. */
7136
+ const IGNORE_NOTHING = {
7137
+ ignores: () => false,
7138
+ isEmpty: true
7139
+ };
7140
+ //#endregion
6526
7141
  //#region ../../packages/busabase-package/src/layout-read.ts
6527
7142
  /**
6528
7143
  * Layout reader: a package's files (from a repo dir, or an extracted zip held in
@@ -6537,6 +7152,12 @@ const serializeDoc = (fields, body) => {
6537
7152
  *
6538
7153
  * Outside `content/`, the optional `assets/` directory carries the bytes for the
6539
7154
  * images the Docs embed — see {@link readDocAssets}. Nothing in there is a node.
7155
+ *
7156
+ * Rule 0 sits ahead of all of them but reads a different place: a `SKILL.md` at
7157
+ * the package ROOT (beside the manifest, not under `content/`) makes this
7158
+ * package a *template*, and is lifted into `tree.rootSkill` — see
7159
+ * {@link readRootSkill}. `content/` itself is untouched by that rule, which is
7160
+ * what keeps a template byte-identical to a plain package below the root.
6540
7161
  */
6541
7162
  const decodeText = (bytes, filePath) => {
6542
7163
  const text = bytes.toString("utf8");
@@ -6602,13 +7223,65 @@ const readPackageTree = (files, options = {}) => {
6602
7223
  assertKnownFormat(rawManifest, manifestPath);
6603
7224
  const manifest = zodParse(PackageManifestSchema, rawManifest, manifestPath);
6604
7225
  const contentDir = `${root}${PACKAGE_CONTENT_DIRNAME}/`;
7226
+ const nodes = readNodes(files, contentDir);
7227
+ const rootSkill = readRootSkill(files, root, manifest.name, manifest.description);
6605
7228
  return {
6606
7229
  manifest,
6607
- nodes: readNodes(files, contentDir),
7230
+ nodes,
7231
+ ...rootSkill ? { rootSkill } : {},
6608
7232
  assets: readDocAssets(files, root)
6609
7233
  };
6610
7234
  };
6611
7235
  /**
7236
+ * Rule 0 — the package root's `SKILL.md` plus its sidecar directories.
7237
+ *
7238
+ * Absent → a plain package, and every downstream consumer behaves exactly as it
7239
+ * did before. Present → the manual travels with the resources: the installer
7240
+ * turns this into a Skill node inside the target folder, which is what lets the
7241
+ * agent that a user talks to after installing actually know what the app is for.
7242
+ *
7243
+ * The node's slug comes from the MANIFEST name rather than the skill
7244
+ * frontmatter's, because the manifest name is already the default target folder
7245
+ * name and is validated as a slug elsewhere; the validator separately requires
7246
+ * the two names to agree, so this is not a silent tie-break.
7247
+ */
7248
+ const readRootSkill = (files, root, manifestName, manifestDescription) => {
7249
+ const entryPath = `${root}${PACKAGE_SKILL_ENTRY}`;
7250
+ const entryBytes = files.get(entryPath);
7251
+ if (!entryBytes) return void 0;
7252
+ const entries = [{
7253
+ path: PACKAGE_SKILL_ENTRY,
7254
+ bytes: entryBytes
7255
+ }];
7256
+ for (const dirName of PACKAGE_SKILL_SIDECAR_DIRS) {
7257
+ const dir = `${root}${dirName}/`;
7258
+ for (const entry of collectSubtree(files, dir)) entries.push({
7259
+ path: `${dirName}/${entry.path}`,
7260
+ bytes: entry.bytes
7261
+ });
7262
+ }
7263
+ assertNoReservedNames(entries.map((entry) => entry.path), `root skill "${manifestName}"`);
7264
+ const parsed = parseSkillFrontmatterSafely(decodeText(entryBytes, entryPath), entryPath);
7265
+ return {
7266
+ slug: manifestName,
7267
+ name: parsed?.name ?? manifestName,
7268
+ description: parsed?.description ?? manifestDescription,
7269
+ files: entries.sort((a, b) => a.path.localeCompare(b.path, "en"))
7270
+ };
7271
+ };
7272
+ const parseSkillFrontmatterSafely = (text, filePath) => {
7273
+ try {
7274
+ const { data } = parseFrontmatter(text, filePath);
7275
+ const record = data;
7276
+ return {
7277
+ name: typeof record.name === "string" ? record.name : void 0,
7278
+ description: typeof record.description === "string" ? record.description : void 0
7279
+ };
7280
+ } catch {
7281
+ return;
7282
+ }
7283
+ };
7284
+ /**
6612
7285
  * `assets/` — the bytes for the images the Docs embed. Driven off the sidecars,
6613
7286
  * not off the byte files: the sidecar is what carries the source asset id, so a
6614
7287
  * stray file with no `.asset.json` beside it is simply not an asset this package
@@ -6667,8 +7340,10 @@ const readDirNode = (files, dir, slug) => {
6667
7340
  if (files.has(`${dir}_node.json`)) {
6668
7341
  const metaPath = `${dir}${PACKAGE_NODE_META_FILENAME}`;
6669
7342
  const meta = zodParse(PackageFileTreeNodeMetaSchema, parseJsonFile(files, metaPath), metaPath);
6670
- const entries = collectSubtree(files, dir).filter((entry) => entry.path !== PACKAGE_NODE_META_FILENAME);
7343
+ const ignore = readIgnoreFile(files, dir);
7344
+ const entries = collectSubtree(files, dir).filter((entry) => entry.path !== "_node.json" && entry.path !== ".busabaseignore" && !ignore.ignores(entry.path));
6671
7345
  assertNoReservedNames(entries.map((entry) => entry.path), `${meta.type} "${slug}"`);
7346
+ if (meta.type === "airapp" && !entries.some((entry) => entry.path === "package.json")) throw new Error(`AirApp "${slug}" has no package.json to install${ignore.isEmpty ? "" : ` — check ${dir}${PACKAGE_AIRAPP_IGNORE}, which must never exclude it`}. Nothing was installed.`);
6672
7347
  return {
6673
7348
  type: meta.type,
6674
7349
  slug,
@@ -6707,6 +7382,19 @@ const readDirNode = (files, dir, slug) => {
6707
7382
  children: readNodes(files, dir)
6708
7383
  };
6709
7384
  };
7385
+ /**
7386
+ * `.busabaseignore` inside a file-tree node directory.
7387
+ *
7388
+ * Read here rather than at the package level because the exclusions are a
7389
+ * property of one project (an AirApp's `test/` is noise; a Drive's files are
7390
+ * the point), and a package-wide ignore list would make it possible to
7391
+ * accidentally strip another node's content from a distance.
7392
+ */
7393
+ const readIgnoreFile = (files, dir) => {
7394
+ const bytes = files.get(`${dir}${PACKAGE_AIRAPP_IGNORE}`);
7395
+ if (!bytes) return IGNORE_NOTHING;
7396
+ return parseIgnoreFile(decodeText(bytes, `${dir}${PACKAGE_AIRAPP_IGNORE}`));
7397
+ };
6710
7398
  const readRecords = (files, dir, slug) => {
6711
7399
  const recordsPath = `${dir}${PACKAGE_RECORDS_FILENAME}`;
6712
7400
  const bytes = files.get(recordsPath);
@@ -6782,199 +7470,157 @@ const stripExtension = (fileName) => {
6782
7470
  return dot > 0 ? fileName.slice(0, dot) : fileName;
6783
7471
  };
6784
7472
  //#endregion
6785
- //#region ../../packages/busabase-package/src/layout-write.ts
7473
+ //#region ../../packages/busabase-package/src/template.ts
6786
7474
  /**
6787
- * Layout writer: the in-memory tree → a repo directory (§6.1), deterministically
6788
- * (§6.6). "Clean diffs" is a feature: for an unchanged space, `export` twice must
6789
- * produce byte-identical output, so re-exporting after one record edit diffs as
6790
- * exactly one changed NDJSON line.
7475
+ * Is this package a **template** — a package that is also an Agent Skill?
6791
7476
  *
6792
- * Determinism rules, all enforced here:
6793
- * • 2-space-indented JSON with a schema-defined key order
6794
- * • `options` blobs (carried verbatim from the server, whose JSONB key order is
6795
- * not a contract) are deep key-sorted
6796
- * • records sorted by `key`, one per line
6797
- * • LF endings, trailing newline on every file
7477
+ * One function answers that for all four consumers, which is the point: the
7478
+ * Template Center's index CI, `busabase-cli install`, the dashboard's install
7479
+ * preview, and an agent checking its own output must never disagree about what
7480
+ * counts. A skill that shows up as a card but installs as a plain package (or
7481
+ * the reverse) is a trust bug, not a cosmetic one.
7482
+ *
7483
+ * Purely static: no AirApp is started, no script is executed, no server is
7484
+ * contacted. That is what lets it run in CI over a whole repo and inline in an
7485
+ * install preview without a timeout budget.
7486
+ *
7487
+ * **Hard conditions** (any failure ⇒ not a template). They are deliberately few,
7488
+ * and every one of them is something that would otherwise become a confusing
7489
+ * runtime failure for the installing user rather than a publishing error for
7490
+ * the author.
7491
+ *
7492
+ * **Soft conditions** are warnings: the package still installs and still lists,
7493
+ * the card is just honest about what is missing. A data-only template is a
7494
+ * legitimate thing to publish; one that silently claims to have an app is not.
7495
+ *
7496
+ * Crucially, a package that fails validation is NOT rejected from installing —
7497
+ * it installs as a plain `busabase-package@1`, exactly as it did before
7498
+ * templates existed. Failing to be a template is never a reason to refuse a
7499
+ * valid package.
7500
+ *
7501
+ * Spec: `apps/busabase/content/spec/template-center.md` §5.3.
6798
7502
  */
6799
7503
  /**
6800
- * Deep key-sort. Applied to blobs the server owns the key order of (field
6801
- * `options`), never to arrays — `choices`, `filters`, and `sorts` are ordered data.
7504
+ * `PackageFileTreeNode.type` is the whole skill|airapp|drive union rather than a
7505
+ * literal, so a discriminated-union `Extract` narrows to `never` here — the
7506
+ * predicate does the narrowing instead.
6802
7507
  */
6803
- const sortKeysDeep = (value) => {
6804
- if (Array.isArray(value)) return value.map(sortKeysDeep);
6805
- if (value === null || typeof value !== "object") return value;
6806
- const entries = Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b, "en"));
6807
- return Object.fromEntries(entries.map(([key, item]) => [key, sortKeysDeep(item)]));
6808
- };
6809
- /** 2-space JSON + trailing LF. Key order is the insertion order of `value`. */
6810
- const toJsonFile = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
6811
- /** Drop `undefined` so optional keys vanish rather than serializing inconsistently. */
6812
- const compact = (value) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
6813
- const serializeManifest = (manifest) => toJsonFile(compact({
6814
- format: manifest.format,
6815
- name: manifest.name,
6816
- description: manifest.description || void 0,
6817
- version: manifest.version,
6818
- author: manifest.author,
6819
- license: manifest.license,
6820
- homepage: manifest.homepage,
6821
- tags: manifest.tags.length > 0 ? manifest.tags : void 0
6822
- }));
6823
- const serializeField = (field) => compact({
6824
- slug: field.slug,
6825
- name: field.name,
6826
- type: field.type,
6827
- required: field.required,
6828
- position: field.position,
6829
- options: sortKeysDeep(field.options)
6830
- });
6831
- const serializeView = (view) => compact({
6832
- slug: view.slug,
6833
- name: view.name,
6834
- description: view.description || void 0,
6835
- type: view.type,
6836
- config: compact({
6837
- filters: view.config.filters.map((filter) => compact({
6838
- fieldSlug: filter.fieldSlug,
6839
- operator: filter.operator,
6840
- value: filter.value
6841
- })),
6842
- sorts: view.config.sorts.map((sort) => ({
6843
- direction: sort.direction,
6844
- fieldSlug: sort.fieldSlug
6845
- })),
6846
- visibleFieldSlugs: view.config.visibleFieldSlugs ?? void 0,
6847
- fieldWidths: view.config.fieldWidths
6848
- })
6849
- });
6850
- const serializeBase = (base, position) => toJsonFile(compact({
6851
- name: base.name,
6852
- description: base.description || void 0,
6853
- position,
6854
- reviewPolicy: base.reviewPolicy,
6855
- fields: base.fields.map(serializeField),
6856
- views: base.views.map(serializeView)
6857
- }));
6858
- /** One JSON object per line, sorted by `key` — determinism (§6.4). */
6859
- const serializeRecords = (records) => {
6860
- const lines = [...records].sort((a, b) => a.key.localeCompare(b.key, "en")).map((record) => JSON.stringify({
6861
- key: record.key,
6862
- fields: record.fields
6863
- }));
6864
- return Buffer.from(lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
6865
- };
6866
- /**
6867
- * Render a tree to package-relative paths + bytes, validating §6.3 as it goes.
6868
- * Pure: no disk access, so the round-trip and determinism tests exercise it directly.
6869
- */
6870
- const renderPackageTree = (tree) => {
6871
- const files = /* @__PURE__ */ new Map();
6872
- files.set(PACKAGE_MANIFEST_FILENAME, serializeManifest(tree.manifest));
6873
- renderNodes(tree.nodes, `${PACKAGE_CONTENT_DIRNAME}/`, files);
6874
- renderDocAssets(tree.assets ?? [], files);
6875
- return files;
7508
+ const isAirApp = (node) => node.type === "airapp";
7509
+ const airAppSlugs = (tree) => [...walkNodes(tree.nodes)].filter(isAirApp).map((node) => node.slug);
7510
+ const baseSlugs = (tree) => new Set([...walkNodes(tree.nodes)].filter((node) => node.type === "base").map((node) => node.slug));
7511
+ const validateTemplate = (tree) => {
7512
+ const errors = [];
7513
+ const warnings = [];
7514
+ const template = tree.manifest.template;
7515
+ if (!tree.rootSkill) return {
7516
+ ok: false,
7517
+ errors: [`No ${PACKAGE_SKILL_ENTRY} at the package root — this is a plain package.`],
7518
+ warnings
7519
+ };
7520
+ const entry = tree.rootSkill.files.find((file) => file.path === PACKAGE_SKILL_ENTRY);
7521
+ let frontmatter;
7522
+ if (entry) try {
7523
+ const { data } = parseFrontmatter(entry.bytes.toString("utf8"), PACKAGE_SKILL_ENTRY);
7524
+ frontmatter = SkillFrontmatterSchema.safeParse(data);
7525
+ if (!frontmatter.success) errors.push(`${PACKAGE_SKILL_ENTRY} frontmatter is not a valid skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
7526
+ } catch (error) {
7527
+ errors.push(`${PACKAGE_SKILL_ENTRY}: ${error.message}`);
7528
+ }
7529
+ const skill = frontmatter?.success ? frontmatter.data : void 0;
7530
+ const busabaseMeta = skill?.metadata?.busabase;
7531
+ if (skill && busabaseMeta?.template !== true) errors.push(`${PACKAGE_SKILL_ENTRY} does not opt in as a template. Add \`metadata.busabase.template: true\` to publish it in the Template Center.`);
7532
+ if (!template) errors.push(`${PACKAGE_SKILL_ENTRY} declares a template but busabase.json has no \`template\` object (at least \`category\` is required).`);
7533
+ if (skill && skill.name !== tree.manifest.name) errors.push(`Name mismatch: ${PACKAGE_SKILL_ENTRY} declares "${skill.name}" but busabase.json declares "${tree.manifest.name}". They are the same app and must agree.`);
7534
+ const bases = baseSlugs(tree);
7535
+ for (const key of busabaseMeta?.resources ?? []) if (!bases.has(key)) errors.push(`${PACKAGE_SKILL_ENTRY} lists resource "${key}", but content/ has no Base with that slug. An agent told about a table that does not exist will write to the wrong one.`);
7536
+ const airapps = airAppSlugs(tree);
7537
+ const resolved = resolvePrimaryAirApp(template, airapps);
7538
+ let primaryAirApp;
7539
+ if ("slug" in resolved) primaryAirApp = resolved.slug;
7540
+ else if (airapps.length > 0 || template?.airapp || template?.airapps?.length) errors.push(resolved.error);
7541
+ for (const node of walkNodes(tree.nodes)) {
7542
+ if (!isAirApp(node)) continue;
7543
+ const manifestFile = node.files.find((file) => file.path === "package.json");
7544
+ if (!manifestFile) continue;
7545
+ let scripts;
7546
+ try {
7547
+ scripts = JSON.parse(manifestFile.bytes.toString("utf8")).scripts;
7548
+ } catch (error) {
7549
+ errors.push(`AirApp "${node.slug}": package.json is not valid JSON (${error.message}).`);
7550
+ continue;
7551
+ }
7552
+ if (typeof scripts?.dev !== "string") errors.push(`AirApp "${node.slug}": package.json has no \`dev\` script. Busabase starts an AirApp with \`npm run dev\`, so without it the app installs and then never boots.`);
7553
+ }
7554
+ if (airapps.length === 0) warnings.push("No AirApp — this template installs data only. Users will see tables, not an app.");
7555
+ if (template?.requires?.airapp && airapps.length === 0) errors.push("template.requires.airapp is true, but content/ contains no AirApp.");
7556
+ let hasSamples = false;
7557
+ for (const node of walkNodes(tree.nodes)) {
7558
+ if (node.type !== "base") continue;
7559
+ if (node.records.length > 0) hasSamples = true;
7560
+ if (node.records.length > 50) errors.push(`Base "${node.slug}" carries ${node.records.length} sample records, above the 50 per-Base limit for templates. A template seeds a demo; it does not ship a dataset.`);
7561
+ }
7562
+ if (!hasSamples) warnings.push("No sample records — the app will be empty on first open.");
7563
+ if (!template?.screenshots.length) warnings.push("No screenshots declared — the Template Center card will use a placeholder.");
7564
+ if (!template?.agentPrompts.length) warnings.push("No agentPrompts declared — \"Ask agent\" will open with an empty prompt.");
7565
+ return {
7566
+ ok: errors.length === 0,
7567
+ errors,
7568
+ warnings,
7569
+ ...primaryAirApp ? { primaryAirApp } : {},
7570
+ ...template ? { template } : {}
7571
+ };
6876
7572
  };
6877
7573
  /**
6878
- * `assets/<assetId><ext>` + its `.asset.json` sidecar, for every image the Docs
6879
- * embed. Deduped by asset id (one image in three Docs is one pair of files) and
6880
- * emitted in a fixed order, so a re-export of an unchanged space is still
6881
- * byte-identical (§6.6).
7574
+ * A starting SKILL.md for a folder that has resources but no manual yet.
7575
+ *
7576
+ * Deterministic, never generated by a model: this text becomes instructions an
7577
+ * agent will act on, and a plausible-sounding invention about what a table means
7578
+ * is worse than an obvious blank to fill in. Everything here is read off the
7579
+ * structure that actually exists — table names, their fields, whether there is
7580
+ * an app — and every judgement the author has to make is left as an explicit
7581
+ * TODO rather than guessed at.
7582
+ *
7583
+ * Used by `export --template` and by "Publish as template" in the dashboard.
6882
7584
  */
6883
- const renderDocAssets = (assets, files) => {
6884
- const byId = /* @__PURE__ */ new Map();
6885
- for (const asset of assets) if (!byId.has(asset.assetId)) byId.set(asset.assetId, asset);
6886
- for (const asset of [...byId.values()].sort((a, b) => a.assetId.localeCompare(b.assetId, "en"))) {
6887
- const bytesPath = docAssetBytesPath(asset);
6888
- assertSafeFilePath(bytesPath, `doc asset "${asset.assetId}"`);
6889
- files.set(bytesPath, asset.bytes);
6890
- files.set(`${bytesPath}${PACKAGE_ASSET_META_SUFFIX}`, toJsonFile(compact({
6891
- assetId: asset.assetId,
6892
- fileName: asset.fileName,
6893
- mimeType: asset.mimeType,
6894
- contentHash: asset.contentHash
6895
- })));
6896
- }
6897
- };
6898
- const renderNodes = (nodes, dir, files) => {
6899
- const ordered = sortNodes(nodes);
6900
- assertNoSiblingCaseCollisions(ordered.map((node) => node.slug), `in ${dir}`);
6901
- const seen = /* @__PURE__ */ new Set();
6902
- for (const node of ordered) {
6903
- assertSafeNodeSlug(node.slug, `${node.type} node`);
6904
- if (seen.has(node.slug)) throw new Error(`Two nodes in ${dir} would be written as "${node.slug}". Sibling slugs must be unique.`);
6905
- seen.add(node.slug);
6906
- renderNode(node, dir, files);
6907
- }
6908
- };
6909
- const renderNode = (node, dir, files) => {
6910
- switch (node.type) {
6911
- case "folder":
6912
- if (node.children.length === 0 || node.description !== "" || node.position !== void 0 || node.name !== void 0) files.set(`${dir}${node.slug}/${PACKAGE_FOLDER_META_FILENAME}`, toJsonFile(compact({
6913
- name: node.name,
6914
- description: node.description || void 0,
6915
- position: node.position
6916
- })));
6917
- renderNodes(node.children, `${dir}${node.slug}/`, files);
6918
- return;
6919
- case "doc":
6920
- files.set(`${dir}${node.slug}.md`, Buffer.from(serializeDoc({
6921
- name: node.name,
6922
- description: node.description,
6923
- position: node.position
6924
- }, node.body), "utf8"));
6925
- return;
6926
- case "base": {
6927
- files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position));
6928
- const records = serializeRecords(node.records);
6929
- if (records.byteLength > 0) files.set(`${dir}${node.slug}/${PACKAGE_RECORDS_FILENAME}`, records);
6930
- return;
6931
- }
6932
- case "skill":
6933
- case "airapp":
6934
- case "drive": {
6935
- const nodeDir = `${dir}${node.slug}/`;
6936
- assertNoReservedNames(node.files.map((entry) => entry.path), `${node.type} "${node.slug}"`);
6937
- files.set(`${nodeDir}${PACKAGE_NODE_META_FILENAME}`, toJsonFile(compact({
6938
- type: node.type,
6939
- name: node.name,
6940
- description: node.description || void 0,
6941
- position: node.position
6942
- })));
6943
- for (const entry of [...node.files].sort((a, b) => a.path.localeCompare(b.path, "en"))) {
6944
- assertSafeFilePath(entry.path, `${node.type} "${node.slug}"`);
6945
- files.set(`${nodeDir}${entry.path}`, entry.bytes);
6946
- }
6947
- return;
6948
- }
6949
- case "file":
6950
- assertSafeFilePath(node.fileName, `file node "${node.slug}"`);
6951
- files.set(`${dir}${node.fileName}`, node.bytes);
6952
- if (node.name !== node.fileName || node.description !== "" || node.position !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
6953
- name: node.name,
6954
- description: node.description || void 0,
6955
- position: node.position
6956
- })));
6957
- return;
6958
- }
6959
- };
6960
- /** Write rendered files under `outDir`, creating directories as needed. */
6961
- const writePackageFiles = async (files, outDir, options = {}) => {
6962
- if (options.clean) {
6963
- await rm(join(outDir, PACKAGE_CONTENT_DIRNAME), {
6964
- recursive: true,
6965
- force: true
6966
- });
6967
- await rm(join(outDir, PACKAGE_ASSETS_DIRNAME), {
6968
- recursive: true,
6969
- force: true
6970
- });
6971
- await rm(join(outDir, PACKAGE_MANIFEST_FILENAME), { force: true });
6972
- }
6973
- for (const [filePath, bytes] of [...files].sort(([a], [b]) => a.localeCompare(b, "en"))) {
6974
- const target = join(outDir, filePath);
6975
- await mkdir(dirname(target), { recursive: true });
6976
- await writeFile(target, bytes);
6977
- }
7585
+ const deriveSkillDraft = (tree) => {
7586
+ const nodes = [...walkNodes(tree.nodes)];
7587
+ const bases = nodes.filter((node) => node.type === "base");
7588
+ const airapps = nodes.filter(isAirApp);
7589
+ const name = tree.manifest.name;
7590
+ const description = tree.manifest.description || `TODO: one line on what ${name} does.`;
7591
+ const yamlString = (value) => JSON.stringify(value);
7592
+ const resourceLines = bases.map((node) => ` - ${yamlString(node.slug)}`).join("\n");
7593
+ const resourceDocs = bases.map((node) => {
7594
+ if (node.type !== "base") return "";
7595
+ const fields = node.base.fields.map((field) => field.slug).join(", ");
7596
+ return `- \`${node.slug}\`: ${node.description || "TODO: what this table is for."}${fields ? ` Fields: ${fields}.` : ""}`;
7597
+ }).filter(Boolean).join("\n");
7598
+ return `---
7599
+ name: ${yamlString(name)}
7600
+ description: ${yamlString(description)}
7601
+ metadata:
7602
+ busabase:
7603
+ template: true
7604
+ ${bases.length ? ` resources:\n${resourceLines}\n` : ""}---
7605
+
7606
+ # ${name}
7607
+
7608
+ TODO: describe the job this app does, and when an agent should reach for it.
7609
+
7610
+ ## Busabase resources
7611
+
7612
+ ${resourceDocs || "TODO: list this app's tables and what each one holds."}
7613
+ ${airapps.length ? `\n## App\n\n\`${airapps[0].slug}\` is the app's interface. Point the user at it rather than describing rows back to them.\n` : ""}
7614
+ ## Boundary
7615
+
7616
+ TODO: state what this app must never do — send messages, call external APIs,
7617
+ merge its own proposals. Be explicit; an agent follows what is written here.
7618
+
7619
+ ## Workflow
7620
+
7621
+ Propose changes as Change Requests and wait for a human to review them. Never
7622
+ review or merge your own proposal.
7623
+ `;
6978
7624
  };
6979
7625
  //#endregion
6980
7626
  //#region ../../packages/busabase-package/src/plan.ts
@@ -7025,9 +7671,16 @@ const buildInstallPlan = (tree, target, options = {}) => {
7025
7671
  const targetFolderSlug = options.intoFolder ?? suggestSlug(tree.manifest.name);
7026
7672
  const warnings = [];
7027
7673
  assertPackageNodeIdentitiesUnique(tree);
7028
- const collisions = findCollisions(tree, targetFolderSlug, target);
7029
- let planned = tree;
7030
- if (options.rename && collisions.length > 0) planned = applyRenames(tree, collisions, target.existingNodeSlugsByType);
7674
+ const isTemplate = validateTemplate(tree).ok;
7675
+ const prefixed = options.prefixBaseSlugs ?? isTemplate ? prefixBaseSlugs(tree, targetFolderSlug) : tree;
7676
+ const resourceKeysBySlug = {};
7677
+ for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, prefixed)) resourceKeysBySlug[installedSlug] = originalSlug;
7678
+ const collisions = findCollisions(prefixed, targetFolderSlug, target);
7679
+ let planned = prefixed;
7680
+ if (options.rename && collisions.length > 0) {
7681
+ planned = applyRenames(prefixed, collisions, target.existingNodeSlugsByType);
7682
+ for (const [installedSlug, originalSlug] of collectSlugRewrites(tree, planned)) resourceKeysBySlug[installedSlug] = originalSlug;
7683
+ }
7031
7684
  collectWarnings(planned, warnings);
7032
7685
  const counts = countTree(planned);
7033
7686
  if (counts.files > 5e3) throw new Error(`Package has ${counts.files} files, above the ${PACKAGE_MAX_FILE_COUNT}-file limit. Nothing was installed.`);
@@ -7038,8 +7691,79 @@ const buildInstallPlan = (tree, target, options = {}) => {
7038
7691
  collisions,
7039
7692
  warnings,
7040
7693
  counts,
7041
- requiresAutoMerge: collectBaseNodes(planned.nodes).some(hasUnlinkableRelationValues)
7694
+ requiresAutoMerge: collectBaseNodes(planned.nodes).some(hasUnlinkableRelationValues) && !(isTemplate && (options.installSampleRecords ?? true)),
7695
+ resourceKeysBySlug,
7696
+ isTemplate
7697
+ };
7698
+ };
7699
+ /**
7700
+ * Rewrite every Base slug to `<folder>-<slug>`, following relation targets.
7701
+ *
7702
+ * A Base already carrying the prefix is left alone, so re-planning an installed
7703
+ * package (an upgrade, a retry) does not stack `kelly-email-kelly-email-…`.
7704
+ * Only Bases are prefixed: every other node type is scoped to its parent
7705
+ * folder, so only Bases can collide across folders in the first place.
7706
+ */
7707
+ const prefixBaseSlugs = (tree, folderSlug) => {
7708
+ const renames = /* @__PURE__ */ new Map();
7709
+ for (const node of walkNodes(tree.nodes)) {
7710
+ if (node.type !== "base") continue;
7711
+ if (node.slug === folderSlug || node.slug.startsWith(`${folderSlug}-`)) continue;
7712
+ renames.set(node.slug, `${folderSlug}-${node.slug}`);
7713
+ }
7714
+ if (renames.size === 0) return tree;
7715
+ const rewriteField = (field) => {
7716
+ const target = field.options.targetBaseSlug;
7717
+ if (!target) return field;
7718
+ const renamed = renames.get(target);
7719
+ return renamed ? {
7720
+ ...field,
7721
+ options: {
7722
+ ...field.options,
7723
+ targetBaseSlug: renamed
7724
+ }
7725
+ } : field;
7726
+ };
7727
+ const rewrite = (node) => {
7728
+ if (node.type === "folder") return {
7729
+ ...node,
7730
+ children: node.children.map(rewrite)
7731
+ };
7732
+ if (node.type !== "base") return node;
7733
+ return {
7734
+ ...node,
7735
+ slug: renames.get(node.slug) ?? node.slug,
7736
+ base: {
7737
+ ...node.base,
7738
+ fields: node.base.fields.map(rewriteField)
7739
+ }
7740
+ };
7741
+ };
7742
+ return {
7743
+ ...tree,
7744
+ nodes: tree.nodes.map(rewrite)
7745
+ };
7746
+ };
7747
+ /**
7748
+ * Pair up two trees that differ only in slugs, walking them in lockstep.
7749
+ *
7750
+ * Position-based rather than slug-based on purpose: after a rewrite the slugs
7751
+ * are exactly what no longer match, so they cannot be the join key. Both trees
7752
+ * come from the same source and every rewrite preserves order, so position is
7753
+ * stable.
7754
+ */
7755
+ const collectSlugRewrites = (original, rewritten) => {
7756
+ const pairs = [];
7757
+ const walk = (before, after) => {
7758
+ for (const [index, beforeNode] of before.entries()) {
7759
+ const afterNode = after[index];
7760
+ if (!afterNode) continue;
7761
+ pairs.push([afterNode.slug, beforeNode.slug]);
7762
+ if (beforeNode.type === "folder" && afterNode.type === "folder") walk(beforeNode.children, afterNode.children);
7763
+ }
7042
7764
  };
7765
+ walk(original.nodes, rewritten.nodes);
7766
+ return pairs;
7043
7767
  };
7044
7768
  /**
7045
7769
  * The ONLY thing that genuinely can't survive a review-first install: a record that
@@ -7183,65 +7907,632 @@ const countTree = (tree) => {
7183
7907
  counts.records += node.records.length;
7184
7908
  break;
7185
7909
  case "skill":
7186
- counts.skills++;
7187
- counts.files += node.files.length;
7188
- break;
7910
+ counts.skills++;
7911
+ counts.files += node.files.length;
7912
+ break;
7913
+ case "airapp":
7914
+ counts.airapps++;
7915
+ counts.files += node.files.length;
7916
+ break;
7917
+ case "drive":
7918
+ counts.drives++;
7919
+ counts.files += node.files.length;
7920
+ break;
7921
+ case "file": counts.files++;
7922
+ }
7923
+ counts.files += (tree.assets ?? []).length;
7924
+ return counts;
7925
+ };
7926
+ /** §4: "What will this do?" — the `--dry-run` report. Creates nothing. */
7927
+ const renderPlan = (plan) => {
7928
+ const lines = [];
7929
+ lines.push(`Package: ${plan.tree.manifest.name}`);
7930
+ if (plan.tree.manifest.description) lines.push(` ${plan.tree.manifest.description}`);
7931
+ lines.push("");
7932
+ lines.push(`Target folder: ${plan.targetFolderSlug}`);
7933
+ lines.push("");
7934
+ lines.push("Node tree:");
7935
+ for (const line of renderNodeTree(plan.tree.nodes, " ")) lines.push(line);
7936
+ lines.push("");
7937
+ lines.push(`Totals: ${plan.counts.folders} folder(s), ${plan.counts.docs} doc(s), ${plan.counts.bases} base(s) with ${plan.counts.records} record(s), ${plan.counts.skills} skill(s), ${plan.counts.airapps} airapp(s), ${plan.counts.drives} drive(s), ${plan.counts.files} file(s)`);
7938
+ if (plan.collisions.length > 0) {
7939
+ lines.push("");
7940
+ lines.push("Collisions:");
7941
+ for (const collision of plan.collisions) lines.push(collision.renamedTo ? ` • ${collision.kind} "${collision.slug}" already exists → will be created as "${collision.renamedTo}"` : ` • ${collision.kind} "${collision.slug}" already exists (${collision.path})`);
7942
+ }
7943
+ if (plan.warnings.length > 0) {
7944
+ lines.push("");
7945
+ lines.push("Warnings:");
7946
+ for (const warning of plan.warnings) lines.push(` • ${warning}`);
7947
+ }
7948
+ return lines.join("\n");
7949
+ };
7950
+ const renderNodeTree = (nodes, indent) => {
7951
+ const lines = [];
7952
+ for (const node of nodes) {
7953
+ const detail = node.type === "base" ? ` (base, ${node.base.fields.length} field(s), ${node.records.length} record(s))` : node.type === "folder" ? " (folder)" : node.type === "doc" ? " (doc)" : node.type === "file" ? " (file)" : ` (${node.type}, ${node.files.length} file(s))`;
7954
+ lines.push(`${indent}${node.slug}${detail}`);
7955
+ if (node.type === "folder") lines.push(...renderNodeTree(node.children, `${indent} `));
7956
+ }
7957
+ return lines;
7958
+ };
7959
+ /**
7960
+ * §4 + §12: without `--auto-merge` an unmerged base has no field ids to patch and no
7961
+ * records to link, so passes 2-5 cannot run. Fail fast and say exactly why.
7962
+ */
7963
+ const assertPlanIsApplicable = (plan, autoMerge) => {
7964
+ if (plan.collisions.some((collision) => !collision.renamedTo)) {
7965
+ const list = plan.collisions.filter((collision) => !collision.renamedTo).map((collision) => ` • ${collision.kind} "${collision.slug}" (${collision.path})`).join("\n");
7966
+ throw new Error(`Install would collide with content that already exists:\n${list}\n\nNothing was created. Re-run with --rename to install the colliding items under suffixed slugs (e.g. "-2"), or pick a different --into-folder.`);
7967
+ }
7968
+ if (plan.requiresAutoMerge && !autoMerge) throw new Error("This package's records carry relation values, and a relation stores the ids of the records it points at — which only exist once the records are merged. Installing review-first would leave every relation empty.\n\nRe-run with --auto-merge to install it (this trusts the package author: records are created immediately instead of landing as change requests you review first).");
7969
+ };
7970
+ //#endregion
7971
+ //#region ../../packages/busabase-package/src/discover.ts
7972
+ /**
7973
+ * What is at the end of this URL — one package, one template, or a repository
7974
+ * full of them?
7975
+ *
7976
+ * A user pasting a GitHub URL does not think in terms of "package roots". They
7977
+ * paste the repo they were looking at. Sometimes that repo IS the package;
7978
+ * sometimes it is `busabase/skills`, which holds dozens under `skills/*`. Before
7979
+ * this, the second case was only ever an error message with a list of
7980
+ * subdirectories to paste instead — correct, and a dead end for anyone who did
7981
+ * not want to edit a URL by hand.
7982
+ *
7983
+ * Discovery answers it once for every caller: the CLI turns the result into a
7984
+ * pick-one list, the dashboard's install dialog into a chooser, the Template
7985
+ * Center's index CI into the set of cards to publish. All three must agree on
7986
+ * what counts as a template, which is why the verdict comes from the same
7987
+ * `validateTemplate` they would each otherwise re-implement.
7988
+ *
7989
+ * Spec: `apps/busabase/content/spec/template-center.md` §6.2a.
7990
+ */
7991
+ /**
7992
+ * Only one level deep, matching the `skills/<name>/` layout the skills CLI
7993
+ * already established and the Template Center reads. Walking arbitrarily deep
7994
+ * would turn one paste into an unbounded scan of a monorepo and surface vendored
7995
+ * copies inside `node_modules` as installable products.
7996
+ */
7997
+ const MAX_DISCOVERY_DEPTH = 2;
7998
+ const manifestDirectories = (files) => {
7999
+ const dirs = /* @__PURE__ */ new Set();
8000
+ for (const filePath of files.keys()) {
8001
+ if (filePath === "busabase.json") {
8002
+ dirs.add("");
8003
+ continue;
8004
+ }
8005
+ if (!filePath.endsWith(`/busabase.json`)) continue;
8006
+ const dir = filePath.slice(0, -14);
8007
+ if (dir.split("/").length > MAX_DISCOVERY_DEPTH) continue;
8008
+ dirs.add(dir);
8009
+ }
8010
+ return [...dirs].sort((a, b) => a.localeCompare(b, "en"));
8011
+ };
8012
+ /**
8013
+ * Every package at or below the fetched root, root first.
8014
+ *
8015
+ * A directory whose manifest or content does not parse is SKIPPED rather than
8016
+ * failing the whole discovery: one malformed skill in a repository of fifty must
8017
+ * not make the other forty-nine unreachable. The user is choosing from a list,
8018
+ * and an entry that cannot be read has nothing to offer that list.
8019
+ */
8020
+ const discoverPackages = (files) => {
8021
+ const found = [];
8022
+ for (const subdir of manifestDirectories(files)) {
8023
+ if (found.length >= 200) break;
8024
+ let tree;
8025
+ try {
8026
+ tree = readPackageTree(files, subdir ? { root: subdir } : {});
8027
+ } catch {
8028
+ continue;
8029
+ }
8030
+ const validation = validateTemplate(tree);
8031
+ const claimsTemplate = Boolean(tree.rootSkill) || Boolean(tree.manifest.template);
8032
+ found.push({
8033
+ subdir,
8034
+ name: tree.manifest.name,
8035
+ description: tree.manifest.description,
8036
+ isTemplate: validation.ok,
8037
+ templateErrors: validation.ok || !claimsTemplate ? [] : validation.errors,
8038
+ counts: countTree(tree),
8039
+ ...validation.primaryAirApp ? { primaryAirApp: validation.primaryAirApp } : {},
8040
+ ...tree.manifest.template?.category ? { category: tree.manifest.template.category } : {},
8041
+ screenshots: tree.manifest.template?.screenshots ?? [],
8042
+ tags: [.../* @__PURE__ */ new Set([...tree.manifest.tags, ...tree.manifest.template?.tags ?? []])],
8043
+ agentPrompts: tree.manifest.template?.agentPrompts ?? [],
8044
+ ...tree.manifest.version ? { version: tree.manifest.version } : {},
8045
+ ...tree.manifest.author ? { author: tree.manifest.author } : {},
8046
+ ...tree.manifest.license ? { license: tree.manifest.license } : {}
8047
+ });
8048
+ }
8049
+ return found;
8050
+ };
8051
+ /**
8052
+ * Resolve a pasted URL to the ONE package to install, or explain the choice.
8053
+ *
8054
+ * `wanted` is a name or subdir the caller already picked (the CLI's `--skill`,
8055
+ * the dialog's selection). Matching on either is deliberate: a user reading a
8056
+ * list sees names, a user reading a repo sees directories, and asking them to
8057
+ * know which one the tool wants is a distinction without a purpose.
8058
+ */
8059
+ const resolvePackageToInstall = (files, wanted) => {
8060
+ const discovered = discoverPackages(files);
8061
+ if (discovered.length === 0) return { kind: "none" };
8062
+ if (wanted) {
8063
+ const match = discovered.find((entry) => entry.name === wanted || entry.subdir === wanted || entry.subdir.endsWith(`/${wanted}`));
8064
+ if (match) return {
8065
+ kind: "package",
8066
+ subdir: match.subdir,
8067
+ discovered: match
8068
+ };
8069
+ return {
8070
+ kind: "choose",
8071
+ candidates: discovered
8072
+ };
8073
+ }
8074
+ const root = discovered.find((entry) => entry.subdir === "");
8075
+ if (root) return {
8076
+ kind: "package",
8077
+ subdir: "",
8078
+ discovered: root
8079
+ };
8080
+ if (discovered.length === 1) return {
8081
+ kind: "package",
8082
+ subdir: discovered[0].subdir,
8083
+ discovered: discovered[0]
8084
+ };
8085
+ return {
8086
+ kind: "choose",
8087
+ candidates: discovered
8088
+ };
8089
+ };
8090
+ //#endregion
8091
+ //#region ../../packages/busabase-package/src/github.ts
8092
+ /**
8093
+ * GitHub source for `busabase-cli install`: parse a repo URL, fetch the ref's
8094
+ * zipball, and extract it into memory.
8095
+ *
8096
+ * Ported from Buda's GitHub skill importer (Buda stays as-is; a shared package
8097
+ * is warranted only once the Phase-2 server consumer actually exists). Three
8098
+ * deliberate divergences from that original:
8099
+ *
8100
+ * 1. **Host is parsed, not pattern-matched.** buda's regex matches `github.com/…`
8101
+ * anywhere in the string, so `https://evil.example/github.com/o/r` passes. This
8102
+ * parses with `URL` and checks `hostname` against an allowlist.
8103
+ * 2. **Any git ref, not just branches.** buda fetches `/archive/refs/heads/<b>.zip`,
8104
+ * which resolves branches only — tags (the package format's version scheme)
8105
+ * would 404. This uses codeload's `/zip/<ref>`, which takes a branch, tag, or SHA.
8106
+ * 3. **Extraction is to memory with the caps applied first**, so an oversized or
8107
+ * zip-bomb archive never reaches the disk.
8108
+ */
8109
+ /** Hosts the installer may ever connect to. Also the Phase-2 server guard's allowlist. */
8110
+ const GITHUB_ALLOWED_HOSTS = [
8111
+ "github.com",
8112
+ "api.github.com",
8113
+ "codeload.github.com"
8114
+ ];
8115
+ /** Hosts accepted in a user-supplied install URL (the web UI form of a repo). */
8116
+ const GITHUB_URL_HOSTS = ["github.com", "www.github.com"];
8117
+ const isAllowedGithubHost = (hostname) => GITHUB_ALLOWED_HOSTS.includes(hostname.toLowerCase());
8118
+ /**
8119
+ * Parse a GitHub repo URL.
8120
+ *
8121
+ * Supported forms:
8122
+ * https://github.com/owner/repo
8123
+ * https://github.com/owner/repo.git
8124
+ * https://github.com/owner/repo/tree/<ref>
8125
+ * https://github.com/owner/repo/tree/<ref>/<subdir…>
8126
+ *
8127
+ * A ref containing `/` (e.g. `feature/x`) is indistinguishable from `<ref>/<subdir>`
8128
+ * in a `/tree/` URL — GitHub's own web URLs have the same ambiguity — so the first
8129
+ * segment after `/tree/` is taken as the ref and the rest as the subdir.
8130
+ */
8131
+ const parseGithubUrl = (rawUrl) => {
8132
+ let url;
8133
+ try {
8134
+ url = new URL(rawUrl.trim());
8135
+ } catch {
8136
+ throw new Error(`Not a valid URL: "${rawUrl}". Expected a GitHub repo URL, e.g. https://github.com/acme/support-kb-template`);
8137
+ }
8138
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`Unsupported URL scheme "${url.protocol}" — expected an https:// GitHub URL.`);
8139
+ if (!GITHUB_URL_HOSTS.includes(url.hostname.toLowerCase())) throw new Error(`Not a GitHub URL: host "${url.hostname}" is not github.com. Only GitHub packages can be installed.`);
8140
+ const [owner, rawRepo, marker, ref, ...rest] = url.pathname.split("/").filter(Boolean);
8141
+ if (!owner || !rawRepo) throw new Error(`GitHub URL is missing owner/repo: "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
8142
+ const repo = rawRepo.replace(/\.git$/, "");
8143
+ if (!marker) return {
8144
+ owner,
8145
+ repo,
8146
+ ref: void 0,
8147
+ subdir: void 0
8148
+ };
8149
+ if (marker !== "tree") throw new Error(`Unsupported GitHub URL form "/${marker}/" in "${rawUrl}". Expected https://github.com/<owner>/<repo>[/tree/<ref>[/<subdir>]]`);
8150
+ if (!ref) throw new Error(`GitHub URL has "/tree/" but no ref: "${rawUrl}".`);
8151
+ return {
8152
+ owner,
8153
+ repo,
8154
+ ref: decodeURIComponent(ref),
8155
+ subdir: rest.length > 0 ? rest.map((segment) => decodeURIComponent(segment)).join("/") : void 0
8156
+ };
8157
+ };
8158
+ /** Encode a ref for a URL path while keeping its `/` separators (e.g. `feature/x`). */
8159
+ const encodeRefPath = (ref) => ref.split("/").map(encodeURIComponent).join("/");
8160
+ /**
8161
+ * Download the zipball for `owner/repo` at `ref` (default branch when `ref` is
8162
+ * undefined). Tries the public codeload host first, then the authenticated API when
8163
+ * a token is available — so a private repo works iff `GITHUB_TOKEN` is set.
8164
+ */
8165
+ const downloadGithubZip = async (source, options = {}) => {
8166
+ const fetcher = options.fetcher ?? fetch;
8167
+ const { owner, repo, ref } = source;
8168
+ const slug = `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
8169
+ const label = `${owner}/${repo}${ref ? `@${ref}` : ""}`;
8170
+ const publicUrl = `https://codeload.github.com/${slug}/zip/${ref ? encodeRefPath(ref) : "HEAD"}`;
8171
+ assertAllowedUrl(publicUrl);
8172
+ const publicResponse = await fetcher(publicUrl);
8173
+ if (publicResponse.ok) return Buffer.from(await publicResponse.arrayBuffer());
8174
+ if (!options.githubToken) throw new Error(publicResponse.status === 404 ? `GitHub repo or ref not found: ${label} (HTTP 404).\n • Check the URL, and that the branch/tag exists.\n • Private repo? Set GITHUB_TOKEN=<a token with repo read access> and re-run.` : `Failed to download ${label} from GitHub (HTTP ${publicResponse.status} ${publicResponse.statusText}).`);
8175
+ const apiUrl = `https://api.github.com/repos/${slug}/zipball${ref ? `/${encodeRefPath(ref)}` : ""}`;
8176
+ assertAllowedUrl(apiUrl);
8177
+ const authedResponse = await fetcher(apiUrl, { headers: {
8178
+ Accept: "application/vnd.github+json",
8179
+ Authorization: `Bearer ${options.githubToken}`,
8180
+ "X-GitHub-Api-Version": "2022-11-28"
8181
+ } });
8182
+ if (authedResponse.ok) return Buffer.from(await authedResponse.arrayBuffer());
8183
+ if (authedResponse.status === 401 || authedResponse.status === 403) throw new Error(`GitHub rejected GITHUB_TOKEN for ${label} (HTTP ${authedResponse.status}). The token is invalid, expired, or lacks read access to this repo.`);
8184
+ throw new Error(`GitHub repo or ref not found: ${label} (HTTP ${authedResponse.status}), even with GITHUB_TOKEN. Check the owner, repo, and ref.`);
8185
+ };
8186
+ /** SSRF guard: refuse anything off the GitHub allowlist before a connection is made. */
8187
+ const assertAllowedUrl = (rawUrl) => {
8188
+ const url = new URL(rawUrl);
8189
+ if (url.protocol !== "https:" || !isAllowedGithubHost(url.hostname)) throw new Error(`Refusing to fetch ${rawUrl} — only https on ${GITHUB_ALLOWED_HOSTS.join(", ")} is allowed.`);
8190
+ };
8191
+ /**
8192
+ * Zip-slip guard. Normalizes an archive entry path to a repo-relative path and
8193
+ * rejects anything that could escape the extraction root: `..`/`.` segments,
8194
+ * and Windows drive prefixes. A leading `/` is stripped rather than rejected —
8195
+ * that makes the path relative, which is already safe.
8196
+ */
8197
+ const normalizeArchivePath = (value) => {
8198
+ const normalized = value.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "");
8199
+ const segments = normalized.split("/");
8200
+ if (!normalized || segments.some((segment) => segment === "." || segment === ".." || /^[a-zA-Z]:$/.test(segment))) throw new Error(`Unsafe archive path rejected: ${value}`);
8201
+ return normalized;
8202
+ };
8203
+ /**
8204
+ * Extract a GitHub zipball into memory, keyed by repo-relative path (the archive's
8205
+ * single generated root directory — `owner-repo-<sha>/` — is stripped).
8206
+ *
8207
+ * Caps are enforced against the archive's declared entry sizes BEFORE any bytes are
8208
+ * read, so an oversized archive or zip bomb is refused up front and nothing is ever
8209
+ * written to disk.
8210
+ */
8211
+ const extractZip = async (zipBuffer, options = {}) => {
8212
+ const maxFiles = options.maxFiles ?? 5e3;
8213
+ const maxTotalBytes = options.maxTotalBytes ?? 262144e3;
8214
+ const maxFileBytes = options.maxFileBytes ?? 26214400;
8215
+ const reader = new ZipReader(new Uint8ArrayReader(new Uint8Array(zipBuffer)));
8216
+ try {
8217
+ const files = (await reader.getEntries()).filter((entry) => !entry.directory);
8218
+ const archiveRoot = files[0]?.filename.split("/")[0];
8219
+ if (!archiveRoot) throw new Error("The downloaded repository archive is empty.");
8220
+ const subdir = options.subdir ? normalizeArchivePath(options.subdir) : void 0;
8221
+ const prefix = `${archiveRoot}/${subdir ? `${subdir}/` : ""}`;
8222
+ for (const entry of files) normalizeArchivePath(entry.filename);
8223
+ const selected = files.filter((entry) => entry.filename.startsWith(prefix));
8224
+ if (selected.length > maxFiles) throw new Error(`Package has ${selected.length} files, above the ${maxFiles}-file limit. Nothing was installed.`);
8225
+ let totalBytes = 0;
8226
+ for (const entry of selected) {
8227
+ const size = entry.uncompressedSize ?? 0;
8228
+ const relativePath = entry.filename.slice(prefix.length);
8229
+ if (size > maxFileBytes) throw new Error(`File "${relativePath}" is ${formatBytes(size)}, above the ${formatBytes(maxFileBytes)} per-file limit. Nothing was installed.`);
8230
+ totalBytes += size;
8231
+ if (totalBytes > maxTotalBytes) throw new Error(`Package unpacks to more than the ${formatBytes(maxTotalBytes)} total limit. Nothing was installed.`);
8232
+ }
8233
+ const result = /* @__PURE__ */ new Map();
8234
+ for (const entry of selected) {
8235
+ const relativePath = normalizeArchivePath(entry.filename.slice(prefix.length));
8236
+ if (!entry.getData) continue;
8237
+ const data = await entry.arrayBuffer();
8238
+ result.set(relativePath, Buffer.from(data));
8239
+ }
8240
+ return result;
8241
+ } finally {
8242
+ await reader.close();
8243
+ }
8244
+ };
8245
+ const formatBytes = (bytes) => bytes >= 1048576 ? `${Math.round(bytes / 1048576)}MB` : `${Math.round(bytes / 1024)}KB`;
8246
+ /** Parse, fetch, and extract in one step — the `install` command's entry point. */
8247
+ const fetchGithubPackageFiles = async (repoUrl, options = {}) => {
8248
+ const source = parseGithubUrl(repoUrl);
8249
+ const zipBuffer = await downloadGithubZip(source, options);
8250
+ return {
8251
+ source,
8252
+ files: await extractZip(zipBuffer, { subdir: source.subdir })
8253
+ };
8254
+ };
8255
+ //#endregion
8256
+ //#region ../../packages/busabase-package/src/index-build.ts
8257
+ /**
8258
+ * Build the Template Center's catalog from a directory of skills.
8259
+ *
8260
+ * The catalog is not hand-maintained. A card that says "6 tables" for a package
8261
+ * that installs five is a trust bug, not a typo — so every field here is read
8262
+ * off the package itself, and whether an entry appears at all is decided by the
8263
+ * SAME `validateTemplate` the installer uses. A skill that would install as a
8264
+ * plain package can never be listed as a template, because one function answers
8265
+ * that question for both.
8266
+ *
8267
+ * Deliberately a plain function over a file map rather than something that
8268
+ * walks a disk: the CLI hands it a checkout, and a future CI job could hand it
8269
+ * a fetched zipball, without either needing its own notion of what counts.
8270
+ *
8271
+ * Spec: `apps/busabase/content/spec/template-center.md` §6.4.
8272
+ */
8273
+ /** Bumped when a consumer would misread an older file. */
8274
+ const TEMPLATE_INDEX_FORMAT = "busabase-template-index@1";
8275
+ const toEntry = (found, airapps) => ({
8276
+ subdir: found.subdir,
8277
+ name: found.name,
8278
+ description: found.description,
8279
+ category: found.category ?? "uncategorized",
8280
+ tags: found.tags ?? [],
8281
+ screenshots: found.screenshots,
8282
+ agentPrompts: found.agentPrompts ?? [],
8283
+ ...found.version ? { version: found.version } : {},
8284
+ ...found.author ? { author: found.author } : {},
8285
+ ...found.license ? { license: found.license } : {},
8286
+ stats: {
8287
+ folders: found.counts.folders,
8288
+ docs: found.counts.docs,
8289
+ bases: found.counts.bases,
8290
+ records: found.counts.records,
8291
+ files: found.counts.files,
8292
+ airapps,
8293
+ skill: true
8294
+ }
8295
+ });
8296
+ const buildTemplateIndex = (files, options) => {
8297
+ const discovered = discoverPackages(files);
8298
+ const templates = [];
8299
+ const rejected = [];
8300
+ for (const found of discovered) {
8301
+ if (found.isTemplate) {
8302
+ templates.push(toEntry(found, found.primaryAirApp ? 1 : 0));
8303
+ continue;
8304
+ }
8305
+ if (found.templateErrors.length > 0) rejected.push({
8306
+ subdir: found.subdir,
8307
+ name: found.name,
8308
+ errors: found.templateErrors
8309
+ });
8310
+ }
8311
+ return {
8312
+ format: TEMPLATE_INDEX_FORMAT,
8313
+ repo: options.repo,
8314
+ ref: options.ref,
8315
+ templates: templates.sort((a, b) => a.name.localeCompare(b.name, "en")),
8316
+ rejected: rejected.sort((a, b) => a.name.localeCompare(b.name, "en"))
8317
+ };
8318
+ };
8319
+ const renderTemplateIndex = (index) => `${JSON.stringify(index, null, 2)}\n`;
8320
+ //#endregion
8321
+ //#region ../../packages/busabase-package/src/layout-write.ts
8322
+ /**
8323
+ * Layout writer: the in-memory tree → a repo directory (§6.1), deterministically
8324
+ * (§6.6). "Clean diffs" is a feature: for an unchanged space, `export` twice must
8325
+ * produce byte-identical output, so re-exporting after one record edit diffs as
8326
+ * exactly one changed NDJSON line.
8327
+ *
8328
+ * Determinism rules, all enforced here:
8329
+ * • 2-space-indented JSON with a schema-defined key order
8330
+ * • `options` blobs (carried verbatim from the server, whose JSONB key order is
8331
+ * not a contract) are deep key-sorted
8332
+ * • records sorted by `key`, one per line
8333
+ * • LF endings, trailing newline on every file
8334
+ */
8335
+ /**
8336
+ * Deep key-sort. Applied to blobs the server owns the key order of (field
8337
+ * `options`), never to arrays — `choices`, `filters`, and `sorts` are ordered data.
8338
+ */
8339
+ const sortKeysDeep = (value) => {
8340
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
8341
+ if (value === null || typeof value !== "object") return value;
8342
+ const entries = Object.entries(value).filter(([, item]) => item !== void 0).sort(([a], [b]) => a.localeCompare(b, "en"));
8343
+ return Object.fromEntries(entries.map(([key, item]) => [key, sortKeysDeep(item)]));
8344
+ };
8345
+ /** 2-space JSON + trailing LF. Key order is the insertion order of `value`. */
8346
+ const toJsonFile = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8");
8347
+ /** Drop `undefined` so optional keys vanish rather than serializing inconsistently. */
8348
+ const compact = (value) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
8349
+ const serializeManifest = (manifest) => toJsonFile(compact({
8350
+ format: manifest.format,
8351
+ name: manifest.name,
8352
+ description: manifest.description || void 0,
8353
+ version: manifest.version,
8354
+ author: manifest.author,
8355
+ license: manifest.license,
8356
+ homepage: manifest.homepage,
8357
+ tags: manifest.tags.length > 0 ? manifest.tags : void 0,
8358
+ template: manifest.template
8359
+ }));
8360
+ const serializeField = (field) => compact({
8361
+ slug: field.slug,
8362
+ name: field.name,
8363
+ type: field.type,
8364
+ required: field.required,
8365
+ position: field.position,
8366
+ options: sortKeysDeep(field.options)
8367
+ });
8368
+ const serializeView = (view) => compact({
8369
+ slug: view.slug,
8370
+ name: view.name,
8371
+ description: view.description || void 0,
8372
+ type: view.type,
8373
+ config: compact({
8374
+ filters: view.config.filters.map((filter) => compact({
8375
+ fieldSlug: filter.fieldSlug,
8376
+ operator: filter.operator,
8377
+ value: filter.value
8378
+ })),
8379
+ sorts: view.config.sorts.map((sort) => ({
8380
+ direction: sort.direction,
8381
+ fieldSlug: sort.fieldSlug
8382
+ })),
8383
+ visibleFieldSlugs: view.config.visibleFieldSlugs ?? void 0,
8384
+ fieldWidths: view.config.fieldWidths
8385
+ })
8386
+ });
8387
+ const serializeBase = (base, position) => toJsonFile(compact({
8388
+ name: base.name,
8389
+ description: base.description || void 0,
8390
+ position,
8391
+ reviewPolicy: base.reviewPolicy,
8392
+ fields: base.fields.map(serializeField),
8393
+ views: base.views.map(serializeView)
8394
+ }));
8395
+ /** One JSON object per line, sorted by `key` — determinism (§6.4). */
8396
+ const serializeRecords = (records) => {
8397
+ const lines = [...records].sort((a, b) => a.key.localeCompare(b.key, "en")).map((record) => JSON.stringify({
8398
+ key: record.key,
8399
+ fields: record.fields
8400
+ }));
8401
+ return Buffer.from(lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
8402
+ };
8403
+ /**
8404
+ * Render a tree to package-relative paths + bytes, validating §6.3 as it goes.
8405
+ * Pure: no disk access, so the round-trip and determinism tests exercise it directly.
8406
+ */
8407
+ const renderPackageTree = (tree) => {
8408
+ const files = /* @__PURE__ */ new Map();
8409
+ files.set(PACKAGE_MANIFEST_FILENAME, serializeManifest(tree.manifest));
8410
+ renderRootSkill(tree.rootSkill, files);
8411
+ renderNodes(tree.nodes, `${PACKAGE_CONTENT_DIRNAME}/`, files);
8412
+ renderDocAssets(tree.assets ?? [], files);
8413
+ return files;
8414
+ };
8415
+ /**
8416
+ * The root skill's files, back at the package root — NOT under `content/`.
8417
+ *
8418
+ * This asymmetry with every other node is the whole point: writing it as
8419
+ * `content/<slug>/` would produce a package whose next install created a second
8420
+ * Skill node beside the first, and each export→install cycle would add another.
8421
+ * Round-tripping through the root is what keeps exactly one copy in existence.
8422
+ */
8423
+ const renderRootSkill = (rootSkill, files) => {
8424
+ if (!rootSkill) return;
8425
+ for (const entry of rootSkill.files) {
8426
+ assertSafeFilePath(entry.path, `root skill "${rootSkill.slug}"`);
8427
+ files.set(entry.path, entry.bytes);
8428
+ }
8429
+ };
8430
+ /**
8431
+ * `assets/<assetId><ext>` + its `.asset.json` sidecar, for every image the Docs
8432
+ * embed. Deduped by asset id (one image in three Docs is one pair of files) and
8433
+ * emitted in a fixed order, so a re-export of an unchanged space is still
8434
+ * byte-identical (§6.6).
8435
+ */
8436
+ const renderDocAssets = (assets, files) => {
8437
+ const byId = /* @__PURE__ */ new Map();
8438
+ for (const asset of assets) if (!byId.has(asset.assetId)) byId.set(asset.assetId, asset);
8439
+ for (const asset of [...byId.values()].sort((a, b) => a.assetId.localeCompare(b.assetId, "en"))) {
8440
+ const bytesPath = docAssetBytesPath(asset);
8441
+ assertSafeFilePath(bytesPath, `doc asset "${asset.assetId}"`);
8442
+ files.set(bytesPath, asset.bytes);
8443
+ files.set(`${bytesPath}${PACKAGE_ASSET_META_SUFFIX}`, toJsonFile(compact({
8444
+ assetId: asset.assetId,
8445
+ fileName: asset.fileName,
8446
+ mimeType: asset.mimeType,
8447
+ contentHash: asset.contentHash
8448
+ })));
8449
+ }
8450
+ };
8451
+ const renderNodes = (nodes, dir, files) => {
8452
+ const ordered = sortNodes(nodes);
8453
+ assertNoSiblingCaseCollisions(ordered.map((node) => node.slug), `in ${dir}`);
8454
+ const seen = /* @__PURE__ */ new Set();
8455
+ for (const node of ordered) {
8456
+ assertSafeNodeSlug(node.slug, `${node.type} node`);
8457
+ if (seen.has(node.slug)) throw new Error(`Two nodes in ${dir} would be written as "${node.slug}". Sibling slugs must be unique.`);
8458
+ seen.add(node.slug);
8459
+ renderNode(node, dir, files);
8460
+ }
8461
+ };
8462
+ const renderNode = (node, dir, files) => {
8463
+ switch (node.type) {
8464
+ case "folder":
8465
+ if (node.children.length === 0 || node.description !== "" || node.position !== void 0 || node.name !== void 0) files.set(`${dir}${node.slug}/${PACKAGE_FOLDER_META_FILENAME}`, toJsonFile(compact({
8466
+ name: node.name,
8467
+ description: node.description || void 0,
8468
+ position: node.position
8469
+ })));
8470
+ renderNodes(node.children, `${dir}${node.slug}/`, files);
8471
+ return;
8472
+ case "doc":
8473
+ files.set(`${dir}${node.slug}.md`, Buffer.from(serializeDoc({
8474
+ name: node.name,
8475
+ description: node.description,
8476
+ position: node.position
8477
+ }, node.body), "utf8"));
8478
+ return;
8479
+ case "base": {
8480
+ files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position));
8481
+ const records = serializeRecords(node.records);
8482
+ if (records.byteLength > 0) files.set(`${dir}${node.slug}/${PACKAGE_RECORDS_FILENAME}`, records);
8483
+ return;
8484
+ }
8485
+ case "skill":
7189
8486
  case "airapp":
7190
- counts.airapps++;
7191
- counts.files += node.files.length;
7192
- break;
7193
- case "drive":
7194
- counts.drives++;
7195
- counts.files += node.files.length;
7196
- break;
7197
- case "file": counts.files++;
7198
- }
7199
- counts.files += (tree.assets ?? []).length;
7200
- return counts;
7201
- };
7202
- /** §4: "What will this do?" — the `--dry-run` report. Creates nothing. */
7203
- const renderPlan = (plan) => {
7204
- const lines = [];
7205
- lines.push(`Package: ${plan.tree.manifest.name}`);
7206
- if (plan.tree.manifest.description) lines.push(` ${plan.tree.manifest.description}`);
7207
- lines.push("");
7208
- lines.push(`Target folder: ${plan.targetFolderSlug}`);
7209
- lines.push("");
7210
- lines.push("Node tree:");
7211
- for (const line of renderNodeTree(plan.tree.nodes, " ")) lines.push(line);
7212
- lines.push("");
7213
- lines.push(`Totals: ${plan.counts.folders} folder(s), ${plan.counts.docs} doc(s), ${plan.counts.bases} base(s) with ${plan.counts.records} record(s), ${plan.counts.skills} skill(s), ${plan.counts.airapps} airapp(s), ${plan.counts.drives} drive(s), ${plan.counts.files} file(s)`);
7214
- if (plan.collisions.length > 0) {
7215
- lines.push("");
7216
- lines.push("Collisions:");
7217
- for (const collision of plan.collisions) lines.push(collision.renamedTo ? ` • ${collision.kind} "${collision.slug}" already exists → will be created as "${collision.renamedTo}"` : ` • ${collision.kind} "${collision.slug}" already exists (${collision.path})`);
7218
- }
7219
- if (plan.warnings.length > 0) {
7220
- lines.push("");
7221
- lines.push("Warnings:");
7222
- for (const warning of plan.warnings) lines.push(` • ${warning}`);
8487
+ case "drive": {
8488
+ const nodeDir = `${dir}${node.slug}/`;
8489
+ assertNoReservedNames(node.files.map((entry) => entry.path), `${node.type} "${node.slug}"`);
8490
+ files.set(`${nodeDir}${PACKAGE_NODE_META_FILENAME}`, toJsonFile(compact({
8491
+ type: node.type,
8492
+ name: node.name,
8493
+ description: node.description || void 0,
8494
+ position: node.position
8495
+ })));
8496
+ for (const entry of [...node.files].sort((a, b) => a.path.localeCompare(b.path, "en"))) {
8497
+ assertSafeFilePath(entry.path, `${node.type} "${node.slug}"`);
8498
+ files.set(`${nodeDir}${entry.path}`, entry.bytes);
8499
+ }
8500
+ return;
8501
+ }
8502
+ case "file":
8503
+ assertSafeFilePath(node.fileName, `file node "${node.slug}"`);
8504
+ files.set(`${dir}${node.fileName}`, node.bytes);
8505
+ if (node.name !== node.fileName || node.description !== "" || node.position !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
8506
+ name: node.name,
8507
+ description: node.description || void 0,
8508
+ position: node.position
8509
+ })));
8510
+ return;
7223
8511
  }
7224
- return lines.join("\n");
7225
8512
  };
7226
- const renderNodeTree = (nodes, indent) => {
7227
- const lines = [];
7228
- for (const node of nodes) {
7229
- const detail = node.type === "base" ? ` (base, ${node.base.fields.length} field(s), ${node.records.length} record(s))` : node.type === "folder" ? " (folder)" : node.type === "doc" ? " (doc)" : node.type === "file" ? " (file)" : ` (${node.type}, ${node.files.length} file(s))`;
7230
- lines.push(`${indent}${node.slug}${detail}`);
7231
- if (node.type === "folder") lines.push(...renderNodeTree(node.children, `${indent} `));
8513
+ /** Write rendered files under `outDir`, creating directories as needed. */
8514
+ const writePackageFiles = async (files, outDir, options = {}) => {
8515
+ if (options.clean) {
8516
+ await rm(join(outDir, PACKAGE_CONTENT_DIRNAME), {
8517
+ recursive: true,
8518
+ force: true
8519
+ });
8520
+ await rm(join(outDir, PACKAGE_ASSETS_DIRNAME), {
8521
+ recursive: true,
8522
+ force: true
8523
+ });
8524
+ await rm(join(outDir, PACKAGE_MANIFEST_FILENAME), { force: true });
8525
+ await rm(join(outDir, PACKAGE_SKILL_ENTRY), { force: true });
8526
+ for (const dirName of PACKAGE_SKILL_SIDECAR_DIRS) await rm(join(outDir, dirName), {
8527
+ recursive: true,
8528
+ force: true
8529
+ });
7232
8530
  }
7233
- return lines;
7234
- };
7235
- /**
7236
- * §4 + §12: without `--auto-merge` an unmerged base has no field ids to patch and no
7237
- * records to link, so passes 2-5 cannot run. Fail fast and say exactly why.
7238
- */
7239
- const assertPlanIsApplicable = (plan, autoMerge) => {
7240
- if (plan.collisions.some((collision) => !collision.renamedTo)) {
7241
- const list = plan.collisions.filter((collision) => !collision.renamedTo).map((collision) => ` • ${collision.kind} "${collision.slug}" (${collision.path})`).join("\n");
7242
- throw new Error(`Install would collide with content that already exists:\n${list}\n\nNothing was created. Re-run with --rename to install the colliding items under suffixed slugs (e.g. "-2"), or pick a different --into-folder.`);
8531
+ for (const [filePath, bytes] of [...files].sort(([a], [b]) => a.localeCompare(b, "en"))) {
8532
+ const target = join(outDir, filePath);
8533
+ await mkdir(dirname(target), { recursive: true });
8534
+ await writeFile(target, bytes);
7243
8535
  }
7244
- if (plan.requiresAutoMerge && !autoMerge) throw new Error("This package's records carry relation values, and a relation stores the ids of the records it points at — which only exist once the records are merged. Installing review-first would leave every relation empty.\n\nRe-run with --auto-merge to install it (this trusts the package author: records are created immediately instead of landing as change requests you review first).");
7245
8536
  };
7246
8537
  //#endregion
7247
8538
  //#region src/package/commands.ts
@@ -7257,11 +8548,26 @@ const runInstall = async (client, repoUrl, options) => {
7257
8548
  reportProgress(`Fetching ${repoUrl} …`);
7258
8549
  const { source, files } = await fetchGithubPackageFiles(repoUrl, { githubToken: options.githubToken });
7259
8550
  reportProgress(`Downloaded ${files.size} file(s) from ${source.owner}/${source.repo}.`);
7260
- const tree = readPackageTree(files);
8551
+ const resolved = resolvePackageToInstall(files, options.skill);
8552
+ if (resolved.kind === "none") {
8553
+ readPackageTree(files);
8554
+ throw new Error("Not a Busabase package.");
8555
+ }
8556
+ if (resolved.kind === "choose") {
8557
+ const report = renderPackageChoices(repoUrl, resolved.candidates, options.skill);
8558
+ if (options.json) return {
8559
+ chooseOne: true,
8560
+ candidates: resolved.candidates.map(toCandidateSummary)
8561
+ };
8562
+ return report;
8563
+ }
8564
+ const tree = readPackageTree(files, resolved.subdir ? { root: resolved.subdir } : {});
8565
+ if (resolved.subdir) reportProgress(`Installing "${resolved.discovered.name}" from ${resolved.subdir}/ …`);
7261
8566
  const targetFolderSlug = options.intoFolder ?? suggestSlug(tree.manifest.name);
7262
8567
  const plan = buildInstallPlan(tree, await resolveTargetState(client, targetFolderSlug), {
7263
8568
  intoFolder: options.intoFolder,
7264
- rename: options.rename
8569
+ rename: options.rename,
8570
+ installSampleRecords: !options.noSampleRecords
7265
8571
  });
7266
8572
  if (options.dryRun) {
7267
8573
  const report = `${renderPlan(plan)}\n\nDry run — nothing was created.`;
@@ -7275,7 +8581,13 @@ const runInstall = async (client, repoUrl, options) => {
7275
8581
  autoMerge: Boolean(options.autoMerge),
7276
8582
  submittedBy: `busabase-cli install (${tree.manifest.name})`,
7277
8583
  onProgress: reportProgress,
7278
- serverUrl: options.serverUrl
8584
+ serverUrl: options.serverUrl,
8585
+ installSampleRecords: !options.noSampleRecords,
8586
+ source: {
8587
+ repo: `${source.owner}/${source.repo}`,
8588
+ ref: source.ref,
8589
+ ...resolved.subdir ? { subdir: resolved.subdir } : source.subdir ? { subdir: source.subdir } : {}
8590
+ }
7279
8591
  });
7280
8592
  if (options.json) return {
7281
8593
  installed: true,
@@ -7283,6 +8595,45 @@ const runInstall = async (client, repoUrl, options) => {
7283
8595
  };
7284
8596
  return renderInstallReport(plan.targetFolderSlug, result);
7285
8597
  };
8598
+ const toCandidateSummary = (candidate) => ({
8599
+ name: candidate.name,
8600
+ subdir: candidate.subdir,
8601
+ description: candidate.description,
8602
+ isTemplate: candidate.isTemplate,
8603
+ counts: candidate.counts
8604
+ });
8605
+ /**
8606
+ * The list a user sees when their URL pointed at a repository of packages.
8607
+ *
8608
+ * Templates are listed first and labelled, because that is the difference the
8609
+ * user actually cares about: one installs an app, the other installs tables. A
8610
+ * package that TRIED to be a template and failed says so with its reasons —
8611
+ * that reader is usually its author.
8612
+ */
8613
+ const renderPackageChoices = (repoUrl, candidates, wanted) => {
8614
+ const lines = [];
8615
+ if (wanted) lines.push(`No package named "${wanted}" in ${repoUrl}. It contains:`);
8616
+ else lines.push(`${repoUrl} contains ${candidates.length} packages. Pick one:`);
8617
+ lines.push("");
8618
+ const ordered = [...candidates].sort((a, b) => {
8619
+ if (a.isTemplate !== b.isTemplate) return a.isTemplate ? -1 : 1;
8620
+ return a.name.localeCompare(b.name, "en");
8621
+ });
8622
+ for (const candidate of ordered) {
8623
+ const shape = [
8624
+ candidate.isTemplate ? "template" : "package",
8625
+ candidate.counts.bases > 0 ? `${candidate.counts.bases} base(s)` : void 0,
8626
+ candidate.primaryAirApp ? "app" : void 0
8627
+ ].filter(Boolean).join(" · ");
8628
+ lines.push(` ${candidate.name} (${shape})`);
8629
+ if (candidate.description) lines.push(` ${candidate.description}`);
8630
+ for (const error of candidate.templateErrors) lines.push(` not a template: ${error}`);
8631
+ }
8632
+ lines.push("");
8633
+ lines.push("Install one with:");
8634
+ lines.push(` busabase-cli install ${repoUrl} --skill ${ordered[0]?.name ?? "<name>"}`);
8635
+ return lines.join("\n");
8636
+ };
7286
8637
  const toPlanSummary = (plan) => ({
7287
8638
  targetFolder: plan.targetFolderSlug,
7288
8639
  package: plan.tree.manifest.name,
@@ -7320,6 +8671,27 @@ const runExport = async (client, nodeSlugOrId, options) => {
7320
8671
  warn: (message) => warnings.push(message),
7321
8672
  baseUrl: options.baseUrl
7322
8673
  });
8674
+ if (options.template) {
8675
+ if (!tree.rootSkill) {
8676
+ warnings.push(`No Skill node in "${source.slug}", so a ${PACKAGE_SKILL_ENTRY} draft was written from the folder's structure. Fill in its TODOs before publishing — an agent acts on what it says.`);
8677
+ tree.rootSkill = {
8678
+ slug: manifest.name,
8679
+ name: manifest.name,
8680
+ description: manifest.description,
8681
+ files: [{
8682
+ path: PACKAGE_SKILL_ENTRY,
8683
+ bytes: Buffer.from(deriveSkillDraft(tree), "utf8")
8684
+ }]
8685
+ };
8686
+ }
8687
+ if (!tree.manifest.template) tree.manifest = {
8688
+ ...tree.manifest,
8689
+ template: TemplateManifestSchema.parse({ category: "uncategorized" })
8690
+ };
8691
+ const validation = validateTemplate(tree);
8692
+ for (const warning of validation.warnings) warnings.push(warning);
8693
+ for (const error of validation.errors) warnings.push(`Not yet a valid template: ${error}`);
8694
+ }
7323
8695
  const files = renderPackageTree(tree);
7324
8696
  if (!options.dryRun) await writePackageFiles(files, options.outDir, { clean: true });
7325
8697
  if (options.json) return {
@@ -7327,6 +8699,7 @@ const runExport = async (client, nodeSlugOrId, options) => {
7327
8699
  package: manifest.name,
7328
8700
  outDir: options.outDir,
7329
8701
  files: [...files.keys()].sort(),
8702
+ ...options.template ? { isTemplate: validateTemplate(tree).ok } : {},
7330
8703
  warnings
7331
8704
  };
7332
8705
  return renderExportReport(options, manifest, files, warnings);
@@ -7378,6 +8751,207 @@ const renderExportReport = (options, manifest, files, warnings) => {
7378
8751
  }
7379
8752
  return lines.join("\n");
7380
8753
  };
8754
+ /**
8755
+ * Build the Template Center's catalog from a checkout.
8756
+ *
8757
+ * Lives here rather than in the skills repository so the catalog and the
8758
+ * installer share one answer to "is this a template". A repository that decided
8759
+ * for itself could list a card whose install then behaves differently — which
8760
+ * is a trust problem, not a formatting one.
8761
+ */
8762
+ const runIndex = async (options) => {
8763
+ const files = await readDirectoryFiles(options.dir);
8764
+ const index = buildTemplateIndex(files, {
8765
+ repo: options.repo,
8766
+ ref: options.ref
8767
+ });
8768
+ const rendered = renderTemplateIndex(index);
8769
+ if (options.check) {
8770
+ if (!options.out) throw new Error("--check needs --out: there is nothing to compare against.");
8771
+ if (await readFile(options.out, "utf8").catch(() => null) !== rendered) throw new Error(`${options.out} is out of date. Run the same command without --check to rebuild it.`);
8772
+ return options.json ? {
8773
+ upToDate: true,
8774
+ templates: index.templates.length
8775
+ } : `${options.out} is up to date (${index.templates.length} template(s)).`;
8776
+ }
8777
+ if (options.out) {
8778
+ await mkdir(dirname(options.out), { recursive: true });
8779
+ await writeFile(options.out, rendered);
8780
+ }
8781
+ if (options.json) return index;
8782
+ if (!options.out) return rendered.trimEnd();
8783
+ const lines = [`Wrote ${options.out}: ${index.templates.length} template(s).`];
8784
+ for (const entry of index.templates) lines.push(` ${entry.name} (${entry.category} · ${entry.stats.bases} base(s))`);
8785
+ if (index.rejected.length > 0) {
8786
+ lines.push("", "Declared a template but did not qualify:");
8787
+ for (const entry of index.rejected) {
8788
+ lines.push(` ${entry.name}`);
8789
+ for (const error of entry.errors) lines.push(` ${error}`);
8790
+ }
8791
+ }
8792
+ return lines.join("\n");
8793
+ };
8794
+ /** Every file under `dir`, keyed by its POSIX path relative to it. */
8795
+ const readDirectoryFiles = async (dir) => {
8796
+ const files = /* @__PURE__ */ new Map();
8797
+ const walk = async (current) => {
8798
+ for (const entry of await readdir(current, { withFileTypes: true })) {
8799
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
8800
+ const absolute = join(current, entry.name);
8801
+ const info = await stat(absolute).catch(() => null);
8802
+ if (!info) continue;
8803
+ if (info.isDirectory()) await walk(absolute);
8804
+ else if (info.isFile()) files.set(relative(dir, absolute).split(sep).join("/"), await readFile(absolute));
8805
+ }
8806
+ };
8807
+ await walk(dir);
8808
+ return files;
8809
+ };
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
+ }
7381
8955
  //#endregion
7382
8956
  //#region src/task-command.ts
7383
8957
  const collect = (value, previous = []) => [...previous, value];
@@ -7417,10 +8991,13 @@ const applyParam = (cmd, param) => {
7417
8991
  case "stringArray":
7418
8992
  cmd.option(`${flag} <value>`, `${description} (repeatable)`, collect);
7419
8993
  return;
7420
- case "json":
7421
- 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);
7422
8998
  if (param.cliShorthand) cmd.option(`${param.cliShorthand.flag} ${param.cliShorthand.placeholder}`, param.cliShorthand.description, collect);
7423
8999
  return;
9000
+ }
7424
9001
  default: if (param.required) cmd.requiredOption(`${flag} <value>`, description);
7425
9002
  else cmd.option(`${flag} <value>`, description);
7426
9003
  }
@@ -7470,6 +9047,7 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
7470
9047
  }
7471
9048
  applyParam(cmd, param);
7472
9049
  }
9050
+ if (task.guidance) cmd.addHelpText("after", `\n${wrapGuidance(rewriteTaskNames(task.guidance))}`);
7473
9051
  if (task.examples && task.examples.length > 0 && Object.keys(preset).length === 0) cmd.addHelpText("after", `\nExamples:\n ${task.examples.join("\n ")}`);
7474
9052
  cmd.action(deps.runAction(async (client, opts) => task.execute(client, {
7475
9053
  ...buildInput(task, opts, deps),
@@ -7477,11 +9055,108 @@ const registerOne = (program, task, deps, path, preset = {}, summaryOverride) =>
7477
9055
  })));
7478
9056
  };
7479
9057
  const registerTaskCommands = (program, tasks, deps) => {
9058
+ for (const task of tasks) cliPathByTaskName.set(task.name, `busabase-cli ${task.cliPath.join(" ")}`);
7480
9059
  for (const task of tasks) {
7481
9060
  registerOne(program, task, deps, task.cliPath);
7482
9061
  for (const variant of task.cliVariants ?? []) registerOne(program, task, deps, variant.path, variant.preset, variant.summary);
7483
9062
  }
7484
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
+ }
7485
9160
  //#endregion
7486
9161
  //#region src/run.ts
7487
9162
  /** Public docs page covering every error below, for both Cloud and local. Linked from each error. */
@@ -7513,9 +9188,12 @@ function resolveConfig(opts) {
7513
9188
  webUrl: process.env.BUSABASE_WEB_URL ?? file.BUSABASE_WEB_URL ?? baseUrl,
7514
9189
  apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
7515
9190
  spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
7516
- output: opts.output ?? configuredOutput() ?? "text"
9191
+ output: opts.output ?? configuredOutput() ?? "text",
9192
+ fetch: withRetry(fetch, { retries: opts.retries ?? DEFAULT_RETRIES })
7517
9193
  };
7518
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;
7519
9197
  /**
7520
9198
  * Config flags accepted anywhere on the line (`busabase-cli --output json bases list` and
7521
9199
  * `busabase-cli bases list --output json` both work): declared on the root program AND on every
@@ -7528,14 +9206,20 @@ const GLOBAL_LONG_FLAGS = /* @__PURE__ */ new Set([
7528
9206
  "--space-id",
7529
9207
  "--output",
7530
9208
  "--profile",
7531
- "--config"
9209
+ "--config",
9210
+ "--slim",
9211
+ "--retries",
9212
+ "--all",
9213
+ "--max-items",
9214
+ "--wait",
9215
+ "--wait-timeout"
7532
9216
  ]);
7533
9217
  function addGlobalFlags(cmd) {
7534
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([
7535
9219
  "text",
7536
9220
  "table",
7537
9221
  "json"
7538
- ]));
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);
7539
9223
  }
7540
9224
  const parseNum = (value) => {
7541
9225
  const parsed = Number(value);
@@ -7547,6 +9231,11 @@ const parsePositiveInt = (value) => {
7547
9231
  if (!Number.isInteger(parsed) || parsed < 1) throw new InvalidArgumentError("expected a positive integer");
7548
9232
  return parsed;
7549
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
+ };
7550
9239
  const parseBoolean = (value) => {
7551
9240
  if (value === "true") return true;
7552
9241
  if (value === "false") return false;
@@ -7623,13 +9312,9 @@ function parseJsonValue(raw, flagName) {
7623
9312
  * better off than when the field was silently dropped.
7624
9313
  */
7625
9314
  function formatIssues(issues) {
7626
- if (!Array.isArray(issues) || issues.length === 0) return void 0;
7627
- return issues.map((issue) => {
7628
- if (!issue || typeof issue !== "object") return String(issue);
7629
- const { path, message } = issue;
7630
- const pathLabel = Array.isArray(path) && path.length > 0 ? path.join(".") : void 0;
7631
- return pathLabel ? `${pathLabel}: ${message}` : String(message ?? issue);
7632
- }).join("; ");
9315
+ const parsed = toIssues(issues);
9316
+ if (!parsed) return void 0;
9317
+ return parsed.map(({ path, message }) => path ? `${path}: ${message}` : message).join("; ");
7633
9318
  }
7634
9319
  function formatRawErrorBody(status, statusText, text) {
7635
9320
  try {
@@ -7644,7 +9329,7 @@ function formatRawErrorBody(status, statusText, text) {
7644
9329
  /** Raw fetch for endpoints outside the typed contract (health / openapi / api passthrough). */
7645
9330
  async function rawFetch(config, method, path, body) {
7646
9331
  const url = `${normalizeBaseUrl(config.baseUrl)}${path}`;
7647
- const res = await fetch(url, {
9332
+ const res = await (config.fetch ?? fetch)(url, {
7648
9333
  method: method.toUpperCase(),
7649
9334
  headers: {
7650
9335
  ...body !== void 0 ? { "content-type": "application/json" } : {},
@@ -7762,10 +9447,60 @@ function runAction(state, handler) {
7762
9447
  if (refreshedToken) config.apiKey = refreshedToken;
7763
9448
  state.config = config;
7764
9449
  assertCredentialNotExpired(config.apiKey);
7765
- const result = await handler(createBusabaseClient(config), opts, config);
7766
- 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);
7767
9458
  };
7768
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
+ }
7769
9504
  /**
7770
9505
  * {@link runAction} for a command with one positional argument. Commander passes
7771
9506
  * `(arg, opts, cmd)` to such an action, so the arity differs from a flags-only leaf.
@@ -7779,7 +9514,24 @@ function runArgAction(state, handler) {
7779
9514
  state.config = config;
7780
9515
  assertCredentialNotExpired(config.apiKey);
7781
9516
  const result = await handler(arg, createBusabaseClient(config), opts, config);
7782
- console.log(render(result, config.output));
9517
+ console.log(render(opts.slim ? slim(result) : result, config.output));
9518
+ };
9519
+ }
9520
+ /**
9521
+ * A command that touches only the local filesystem.
9522
+ *
9523
+ * `runArgAction` resolves credentials and builds a client before it calls the
9524
+ * handler, which is right for everything that talks to a workspace — and wrong
9525
+ * for `index`, whose whole job is to read a checkout. A CI runner building the
9526
+ * catalog has no Busabase server and no key, and demanding one would make the
9527
+ * catalog impossible to build in exactly the place it is meant to be built.
9528
+ */
9529
+ function runLocalArgAction(handler) {
9530
+ return async (arg, _opts, cmd) => {
9531
+ const opts = cmd.optsWithGlobals();
9532
+ const config = resolveConfig(opts);
9533
+ const result = await handler(arg, opts, config);
9534
+ console.log(render(opts.slim ? slim(result) : result, config.output));
7783
9535
  };
7784
9536
  }
7785
9537
  function pkgVersion() {
@@ -7912,11 +9664,33 @@ function unionVariants(inputSchema, discriminator = "operation") {
7912
9664
  return variants;
7913
9665
  }
7914
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
+ /**
7915
9687
  * Procedures a curated command already exposes under a DIFFERENT name (so the
7916
9688
  * name-collision guard wouldn't catch them). Keyed `group.procKey`.
7917
9689
  */
7918
9690
  const GENERATED_SKIP = /* @__PURE__ */ new Set([
7919
9691
  "search",
9692
+ "guides.list",
9693
+ "guides.read",
7920
9694
  "auth.verify",
7921
9695
  "records.search",
7922
9696
  "records.listChangeRequests",
@@ -7946,7 +9720,8 @@ function registerGeneratedCommands(program, state) {
7946
9720
  const addOneLeaf = (parent, navPath, procKey, proc, name, fields, description) => {
7947
9721
  const { route } = proc["~orpc"];
7948
9722
  if (parent.commands.some((c) => c.name() === name || c.aliases().includes(name))) return;
7949
- 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}`);
7950
9725
  for (const f of fields) {
7951
9726
  if (f.pinned !== void 0) continue;
7952
9727
  const flag = `--${kebab(f.key)}`;
@@ -8021,14 +9796,26 @@ function flagSignature(cmd) {
8021
9796
  }
8022
9797
  return parts.join(" ");
8023
9798
  }
8024
- 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;
8025
9811
  /**
8026
9812
  * Root help "Commands:" section: every leaf with its full flag signature (the flat
8027
9813
  * commander listing would only show group names). Generated from the command tree,
8028
9814
  * never hand-written.
8029
9815
  */
8030
- function commandsSection(program) {
9816
+ function commandsSection(program, showAll = false) {
8031
9817
  const lines = ["Commands:"];
9818
+ const skip = (cmd) => cmd.name() === "help" || !showAll && isHelpHidden(cmd);
8032
9819
  const entry = (prefix, cmd) => {
8033
9820
  const sig = [prefix, flagSignature(cmd)].filter(Boolean).join(" ");
8034
9821
  const desc = cmd.description();
@@ -8037,12 +9824,12 @@ function commandsSection(program) {
8037
9824
  return ` ${sig}\n${" ".repeat(44)}${desc}`;
8038
9825
  };
8039
9826
  for (const cmd of program.commands) {
8040
- if (cmd.name() === "help" || isHelpHidden(cmd)) continue;
8041
- 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));
8042
9829
  if (leaves.length > 0) {
8043
9830
  lines.push("");
8044
9831
  for (const leaf of leaves) lines.push(entry(`${cmd.name()} ${leaf.name()}`, leaf));
8045
- } else lines.push(entry(cmd.name(), cmd));
9832
+ } else if (cmd.commands.length === 0) lines.push(entry(cmd.name(), cmd));
8046
9833
  }
8047
9834
  return lines.join("\n");
8048
9835
  }
@@ -8054,7 +9841,12 @@ Several accounts: \`login --profile <name>\` adds one, \`auth status\` lists the
8054
9841
  \`auth switch\` changes which is active. Several spaces on one account (the norm on
8055
9842
  Cloud): \`space list\` / \`space use\`.
8056
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
+
8057
9848
  Docs: https://busabase.com/docs · Troubleshooting: ${DOCS_TROUBLESHOOTING}`;
9849
+ /** Exported for the help-example test, which walks the command tree in-process. */
8058
9850
  function buildProgram(state = {}) {
8059
9851
  const program = new Command("busabase-cli");
8060
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));
@@ -8062,6 +9854,29 @@ function buildProgram(state = {}) {
8062
9854
  addGlobalFlags(program.command("health")).description("Server health check (GET /api/health)").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/health")));
8063
9855
  addGlobalFlags(program.command("openapi")).description("Fetch the OpenAPI document").action(runAction(state, (_client, _opts, config) => rawFetch(config, "GET", "/api/v1/openapi.json")));
8064
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
+ });
8065
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", `
8066
9881
  Busabase is an approval-first database and knowledge base for AI agents: agents
8067
9882
  propose changes, humans review and merge what becomes trusted data.
@@ -8311,7 +10126,7 @@ Examples:
8311
10126
  startLine: opts.startLine,
8312
10127
  endLine: opts.endLine
8313
10128
  })));
8314
- addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo into this space").argument("<github-url>", "GitHub repo URL, optionally /tree/<ref>[/<subdir>]").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option("--auto-merge", "merge the package's records and docs on the spot instead of leaving them as change requests to review — this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)").option("--rename", "install colliding items under suffixed slugs (-2, -3, …) instead of failing").addHelpText("after", `
10129
+ addGlobalFlags(program.command("install")).description("Install a Busabase package from a GitHub repo into this space").argument("<github-url>", "GitHub repo URL, optionally /tree/<ref>[/<subdir>]").option("--into-folder <name>", "target folder slug (default: the package's manifest name)").option("--dry-run", "print the plan (tree, record counts, collisions) and create nothing").option("--auto-merge", "merge the package's records and docs on the spot instead of leaving them as change requests to review — this TRUSTS THE PACKAGE AUTHOR, since skills and AirApps carry code your agents will run (default: review first)").option("--rename", "install colliding items under suffixed slugs (-2, -3, …) instead of failing").option("--skill <name>", "which package to install when the URL is a repository holding several (e.g. busabase/skills)").option("--no-sample-records", "propose a template's sample rows for review instead of merging them (default: merged, so the app is not empty on first open)").addHelpText("after", `
8315
10130
  The URL's git ref is the version pin — a tag installs that tag's content forever,
8316
10131
  even after the branch moves on. GITHUB_TOKEN is honored for private repos.
8317
10132
 
@@ -8325,6 +10140,16 @@ Folders, Bases, their fields and their views are structure and are always create
8325
10140
  immediately. Records are content: by default they land as change requests for you
8326
10141
  to review, and --auto-merge merges them on the spot instead.
8327
10142
 
10143
+ A TEMPLATE (a package that also carries a SKILL.md) installs as an app: its
10144
+ manual lands as a Skill node, its nodes are stamped as belonging to that app, its
10145
+ Base slugs are prefixed with the target folder so two templates cannot collide on
10146
+ a name like "settings", and its sample rows are merged so the app is not empty
10147
+ when you open it (--no-sample-records opts out). Its AirApp code and its Skill
10148
+ still wait for your review.
10149
+
10150
+ If the URL is a repository that holds several packages rather than being one,
10151
+ install lists them and you pick with --skill <name>.
10152
+
8328
10153
  A package whose records carry relation values requires --auto-merge — a relation
8329
10154
  stores the ids of the records it points at, and those exist only once the records
8330
10155
  are merged, so review-first would install every relation empty. Defining a relation
@@ -8333,20 +10158,46 @@ field with nothing linked yet does not trigger this.`).action(runArgAction(state
8333
10158
  dryRun: Boolean(opts.dryRun),
8334
10159
  autoMerge: Boolean(opts.autoMerge),
8335
10160
  rename: Boolean(opts.rename),
10161
+ skill: opts.skill,
10162
+ noSampleRecords: opts.sampleRecords === false,
8336
10163
  json: config.output === "json",
8337
10164
  githubToken: process.env.GITHUB_TOKEN,
8338
10165
  serverUrl: config.baseUrl
8339
10166
  })));
8340
- addGlobalFlags(program.command("export")).description("Export a node subtree as a Busabase package directory you can push to GitHub").argument("<node-slug-or-id>", "the folder/node to export").requiredOption("-o, --out-dir <dir>", "output directory for the package").option("--name <name>", "package name (default: reuse busabase.json, else the node slug)").option("--dry-run", "list the files that would be written and write nothing").addHelpText("after", `
10167
+ addGlobalFlags(program.command("index")).description("Build the Template Center catalog from a directory of skills").argument("<dir>", "directory to scan (a checkout of the skills repository)").requiredOption("--repo <owner/repo>", "repository the entries' subdirs are relative to").option("--ref <ref>", "git ref the entries are read at", "main").option("-o, --out <file>", "write the catalog here (default: print it)").option("--check", "exit non-zero if the file on disk is not what would be written").addHelpText("after", `
10168
+ Whether a skill is listed is decided by the same rules that decide how it
10169
+ installs, so a card can never promise something its install does not do. A skill
10170
+ that declared itself a template and did not qualify is reported with the reason
10171
+ rather than silently left out.
10172
+
10173
+ Examples:
10174
+ busabase-cli index . --repo busabase/skills -o templates.json
10175
+ busabase-cli index . --repo busabase/skills -o templates.json --check`).action(runLocalArgAction((dir, opts, config) => runIndex({
10176
+ dir,
10177
+ repo: opts.repo,
10178
+ ref: opts.ref || "main",
10179
+ out: opts.out,
10180
+ check: Boolean(opts.check),
10181
+ json: config.output === "json"
10182
+ })));
10183
+ addGlobalFlags(program.command("export")).description("Export a node subtree as a Busabase package directory you can push to GitHub").argument("<node-slug-or-id>", "the folder/node to export").requiredOption("-o, --out-dir <dir>", "output directory for the package").option("--name <name>", "package name (default: reuse busabase.json, else the node slug)").option("--dry-run", "list the files that would be written and write nothing").option("--template", "export as a template (an installable app): check it against the template rules and write a SKILL.md draft if the folder has no Skill node yet").addHelpText("after", `
8341
10184
  Output is deterministic: exporting twice from an unchanged space produces
8342
10185
  byte-identical files, so a GitHub diff shows exactly what changed.
8343
10186
 
8344
10187
  Examples:
8345
10188
  busabase-cli export support-kb -o ./support-kb-template
8346
- busabase-cli export support-kb -o ./support-kb-template --name "Support KB" --dry-run`).action(runArgAction(state, (nodeSlugOrId, client, opts, config) => runExport(client, nodeSlugOrId, {
10189
+ busabase-cli export support-kb -o ./support-kb-template --name "Support KB" --dry-run
10190
+ busabase-cli export kelly-email -o ./kelly-email --template
10191
+
10192
+ --template makes the result installable as an APP rather than as a pile of
10193
+ tables: the folder's Skill node is lifted to the package root as SKILL.md (or a
10194
+ draft is written from the folder's structure for you to fill in), and anything
10195
+ still missing is reported. The same directory then works both as an Agent Skill
10196
+ and as something \`busabase-cli install\` can install.`).action(runArgAction(state, (nodeSlugOrId, client, opts, config) => runExport(client, nodeSlugOrId, {
8347
10197
  outDir: opts.outDir,
8348
10198
  name: opts.name,
8349
10199
  dryRun: Boolean(opts.dryRun),
10200
+ template: Boolean(opts.template),
8350
10201
  json: config.output === "json",
8351
10202
  baseUrl: config.baseUrl
8352
10203
  })));
@@ -8445,7 +10296,7 @@ To add content to a space that is already in use, use \`busabase-cli install\` i
8445
10296
  leaf.usage(`${sig ? `${sig} ` : ""}[global flags]`);
8446
10297
  }
8447
10298
  }
8448
- program.addHelpText("after", () => `\n${commandsSection(program)}\n${HELP_FOOTER}`);
10299
+ program.addHelpText("after", () => `\n${commandsSection(program, showAllCommands)}\n${HELP_FOOTER}`);
8449
10300
  program.configureHelp({ visibleCommands: () => [] });
8450
10301
  return program;
8451
10302
  }
@@ -8460,17 +10311,48 @@ async function runCli(argv) {
8460
10311
  program.outputHelp();
8461
10312
  return 0;
8462
10313
  }
10314
+ if (argv.includes("--help-all")) {
10315
+ console.log(renderedHelp({ showAll: true }));
10316
+ return 0;
10317
+ }
8463
10318
  try {
8464
10319
  await program.parseAsync(argv, { from: "user" });
8465
10320
  return 0;
8466
10321
  } catch (error) {
8467
- 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));
8468
10335
  console.error(explainError(error, state.config ?? resolveConfig({})));
8469
- return 1;
10336
+ return classified.exitCode;
8470
10337
  }
8471
10338
  }
8472
- 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 } = {}) {
8473
10354
  let out = "";
10355
+ showAllCommands = showAll;
8474
10356
  const program = buildProgram();
8475
10357
  program.configureOutput({
8476
10358
  writeOut: (str) => {
@@ -8479,10 +10361,12 @@ function renderedHelp() {
8479
10361
  writeErr: () => {}
8480
10362
  });
8481
10363
  program.outputHelp();
10364
+ showAllCommands = false;
8482
10365
  return out;
8483
10366
  }
8484
10367
  /** Full root help text (what `busabase-cli --help` prints). Generated from the command tree. */
8485
10368
  const HELP = renderedHelp();
10369
+ renderedHelp({ showAll: true });
8486
10370
  /**
8487
10371
  * Turn a low-level transport/HTTP error into an actionable message: which host was tried, the
8488
10372
  * concrete next step (set a key for Cloud, point at a local server, or check connectivity), and a
@@ -8492,7 +10376,7 @@ function explainError(error, config) {
8492
10376
  const base = config.baseUrl;
8493
10377
  const rawMessage = error instanceof Error ? error.message : String(error);
8494
10378
  const issueDetail = formatIssues(error.data?.issues);
8495
- const msg = issueDetail ? `${rawMessage} — ${issueDetail}` : rawMessage;
10379
+ const msg = issueDetail && !rawMessage.includes(issueDetail) ? `${rawMessage} — ${issueDetail}` : rawMessage;
8496
10380
  const status = error.status;
8497
10381
  const lower = msg.toLowerCase();
8498
10382
  let body;