busa-cli 0.50.0 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { i as runCli, r as pkgVersion, t as checkForUpdate } from "./update-check-Cq2ouWym.js";
2
+ import { i as runCli, r as pkgVersion, t as checkForUpdate } from "./update-check-BWyS6qOd.js";
3
3
  //#region src/cli.ts
4
4
  const exitCode = await runCli(process.argv.slice(2));
5
5
  const notice = await checkForUpdate("busabase-cli", pkgVersion());
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as render, i as runCli, n as HELP, r as pkgVersion, t as checkForUpdate } from "./update-check-Cq2ouWym.js";
1
+ import { a as render, i as runCli, n as HELP, r as pkgVersion, t as checkForUpdate } from "./update-check-BWyS6qOd.js";
2
2
  import { DEFAULT_BASE_URL, createBusabaseClient, normalizeBaseUrl } from "busabase-sdk";
3
3
  export { DEFAULT_BASE_URL, HELP, checkForUpdate, createBusabaseClient, normalizeBaseUrl, pkgVersion, render, runCli };
@@ -103,6 +103,21 @@ const LocaleSchema = z.enum({
103
103
  */
104
104
  const iStringRecordSchema = z.partialRecord(LocaleSchema, z.string());
105
105
  const iStringSchema = z.union([z.string(), iStringRecordSchema]).describe("i18n string");
106
+ /**
107
+ * Quick parse to string
108
+ *
109
+ * @param str
110
+ * @param lang default en
111
+ * @returns
112
+ */
113
+ const iStringParse = (str, lang = "en") => {
114
+ if (!str) return "";
115
+ if (typeof str === "string") return str;
116
+ const locales = Object.keys(str || {});
117
+ const result = str[locales.find((locale) => locale === lang) || locales.find((locale) => locale !== "en") || "en"];
118
+ if (result === void 0) console.warn(`iStringParse: ${JSON.stringify(str)} has no '${lang}' or other locales`);
119
+ return result || "";
120
+ };
106
121
  //#endregion
107
122
  //#region ../../packages/busabase-contract/src/contract/node-agent-prompt-schemas.ts
108
123
  /**
@@ -5730,208 +5745,6 @@ z.object({
5730
5745
  [TEMPLATE_SKILL_METADATA_KEY]: z.literal(true)
5731
5746
  });
5732
5747
  //#endregion
5733
- //#region ../../packages/busabase-package/src/frontmatter.ts
5734
- /**
5735
- * YAML frontmatter for doc nodes (§6.3): `name`, `description?`, `position?` above
5736
- * the markdown body.
5737
- *
5738
- * Writing is deterministic (§6.6): a fixed key order and LF endings, so re-exporting
5739
- * an unchanged doc is byte-identical.
5740
- */
5741
- const DELIMITER = "---";
5742
- /**
5743
- * The canonical on-disk body: LF endings, no leading or trailing blank lines. Applied
5744
- * on both read and write so the round trip is a fixed point — without it, the blank
5745
- * line the writer puts after the closing `---` and the file's trailing newline would
5746
- * accumulate into the body on every export→install→export cycle.
5747
- *
5748
- * Leading/trailing blank lines around a doc body are therefore normalized away on the
5749
- * first export; internal blank lines are untouched.
5750
- */
5751
- const normalizeDocBody = (body) => body.replaceAll("\r\n", "\n").replace(/^\n+/, "").replace(/\n+$/, "");
5752
- /** Split `---\n<yaml>\n---\n<body>`. A doc with no frontmatter is all body. */
5753
- const parseFrontmatter = (text, filePath) => {
5754
- const normalized = text.replaceAll("\r\n", "\n");
5755
- if (!normalized.startsWith(`${DELIMITER}\n`)) return {
5756
- data: {},
5757
- body: normalizeDocBody(normalized)
5758
- };
5759
- const end = normalized.indexOf(`\n${DELIMITER}`, 3);
5760
- if (end === -1) throw new Error(`${filePath} opens YAML frontmatter with "---" but never closes it. Add a closing "---" line.`);
5761
- const rawYaml = normalized.slice(4, end);
5762
- const body = normalizeDocBody(normalized.slice(end + 1 + 3));
5763
- let data;
5764
- try {
5765
- data = parse(rawYaml);
5766
- } catch (error) {
5767
- throw new Error(`${filePath} has invalid YAML frontmatter: ${error.message}`);
5768
- }
5769
- if (data === null || data === void 0) return {
5770
- data: {},
5771
- body
5772
- };
5773
- if (typeof data !== "object" || Array.isArray(data)) throw new Error(`${filePath} frontmatter must be a YAML mapping (key: value pairs).`);
5774
- return {
5775
- data,
5776
- body
5777
- };
5778
- };
5779
- /** Serialize a doc to `---\n<frontmatter>\n---\n<body>` with a trailing newline. */
5780
- const serializeDoc = (fields, body) => {
5781
- const data = { name: fields.name };
5782
- if (fields.description) data.description = fields.description;
5783
- if (fields.position !== void 0) data.position = fields.position;
5784
- const yaml = stringify(data, { lineWidth: 0 }).replace(/\n$/, "");
5785
- return `${DELIMITER}\n${yaml}\n${DELIMITER}\n\n${normalizeDocBody(body)}\n`;
5786
- };
5787
- //#endregion
5788
- //#region ../../packages/busabase-package/src/audit.ts
5789
- /**
5790
- * Is this package **fit to publish**?
5791
- *
5792
- * Deliberately a different question from {@link validateTemplate}, and the split is
5793
- * the point rather than an organisational preference:
5794
- *
5795
- * - `validateTemplate` **classifies**. Its verdict changes what install *does* — a
5796
- * Skill node, ownership stamps and a sample merge, versus a plain package install.
5797
- * Four consumers must agree on it or the catalog lies about what an install does,
5798
- * so it stays small and stable.
5799
- * - `auditPackage` / `auditSkill` **advise**. Nothing behaves differently based on
5800
- * their verdict; they exist for the author and the reviewer, and are free to grow.
5801
- *
5802
- * The practical consequence: a declared screenshot that is not on disk belongs here,
5803
- * never in `validateTemplate`. Promoting it there would stop a template installing
5804
- * over a missing image, which is the wrong lever entirely.
5805
- *
5806
- * Nothing here re-checks what `validateTemplate` already decides (declared resources,
5807
- * the sample-row ceiling, name agreement between manifest and manual, an unambiguous
5808
- * primary AirApp). Run both; they compose.
5809
- *
5810
- * Purely static: no install, no network, and none of the package's own code is
5811
- * executed — safe to run in CI over a pull request from someone you do not know.
5812
- */
5813
- /**
5814
- * A materialized workspace id: busabase-core's `id()` is a three-letter prefix, the
5815
- * creation time in base36, then seven random characters.
5816
- *
5817
- * The lookahead requiring a digit is what keeps ordinary words out — `recommendation`
5818
- * has the right prefix and the wrong shape. A real id always carries its timestamp.
5819
- */
5820
- const WORKSPACE_ID = /(?<![0-9a-z])(?:nod|bse|viw|rec|crq|ast|com|fom|shr|fvl|opr|cmt|rev|whd|aud)(?=[0-9a-z]{15}(?![0-9a-z]))(?=[0-9a-z]*\d)[0-9a-z]{15}/;
5821
- const TEXT_EXTENSIONS = /\.(?:js|mjs|cjs|ts|mts|cts|json|ndjson|md|html|css|ya?ml|txt)$|(?:^|\/)\.env(?:\.|$)/;
5822
- /**
5823
- * Files that exist to *forbid* a string necessarily contain it. An AirApp's own
5824
- * `scripts/check.mjs` asserts the obsolete API prefix is absent, and a test fixture
5825
- * spells out the credential it proves gets rejected — scanning those turns a
5826
- * correctly-guarded package into a failure.
5827
- */
5828
- const IS_ASSERTION_CODE = /(?:^|\/)(?:scripts\/check\.mjs|tests?\/|[^/]*\.test\.[cm]?[jt]s)$/;
5829
- /** Lockfiles carry integrity hashes that are not ours to interpret. */
5830
- const NOT_SOURCE = /(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/;
5831
- const relativePaths = (files, root = "") => {
5832
- const prefix = root ? `${root.replace(/\/+$/, "")}/` : "";
5833
- const out = [];
5834
- for (const key of files.keys()) if (!prefix || key.startsWith(prefix)) out.push(key.slice(prefix.length));
5835
- return out;
5836
- };
5837
- const readText = (files, root, relative) => {
5838
- const prefix = root ? `${root.replace(/\/+$/, "")}/` : "";
5839
- return files.get(`${prefix}${relative}`)?.toString("utf8");
5840
- };
5841
- /**
5842
- * Audit the package layer: identity, the assets the catalog promises, and anything
5843
- * that must never be published.
5844
- *
5845
- * `files` is the flat map `readPackageTree` was given, not the tree. That is load-bearing:
5846
- * `assets/screenshots/` holds no nodes and so never reaches the tree, and "the manifest
5847
- * promises a screenshot that does not exist" is exactly a file-level question.
5848
- */
5849
- const auditPackage = (tree, files, options = {}) => {
5850
- const findings = [];
5851
- const error = (rule, message) => findings.push({
5852
- severity: "error",
5853
- rule,
5854
- message
5855
- });
5856
- const warn = (rule, message) => findings.push({
5857
- severity: "warning",
5858
- rule,
5859
- message
5860
- });
5861
- const root = options.root ?? "";
5862
- const paths = relativePaths(files, root);
5863
- const present = new Set(paths);
5864
- if (options.directoryName !== void 0 && options.directoryName !== tree.manifest.name) error("package/identity", `Directory is "${options.directoryName}" but busabase.json declares "${tree.manifest.name}". The directory name is the package's identity, not a label beside it.`);
5865
- for (const screenshot of tree.manifest.template?.screenshots ?? []) if (!present.has(screenshot)) error("package/screenshot-missing", `busabase.json declares screenshot "${screenshot}", which is not in the package. The Template Center card would render a broken image.`);
5866
- for (const relative of paths) {
5867
- const base = relative.slice(relative.lastIndexOf("/") + 1);
5868
- if (/^\.env(?!\.example$)(?:\.|$)/.test(base)) error("package/dotenv", `${relative} is part of the package. Credentials belong in Vault, never in something people install.`);
5869
- if (/^(?:id_rsa|id_ed25519)$/.test(base) || /\.(?:pem|p12|pfx|keystore)$/.test(base)) error("package/key-material", `${relative} looks like key material and must not be published.`);
5870
- if (/(?:^|\/)(?:node_modules|dist|build)\//.test(relative)) warn("package/build-output", `${relative} looks like installed or built output. Ship source; the installer builds nothing.`);
5871
- }
5872
- for (const relative of paths) {
5873
- if (!TEXT_EXTENSIONS.test(relative)) continue;
5874
- if (NOT_SOURCE.test(relative) || IS_ASSERTION_CODE.test(relative)) continue;
5875
- const source = readText(files, root, relative);
5876
- if (source === void 0) continue;
5877
- const found = WORKSPACE_ID.exec(source);
5878
- if (found) error("package/workspace-ids", `${relative} contains "${found[0]}", a materialized workspace id. Node, Base and Space ids are whoever last ran setup's runtime state — publishing them ships one person's workspace layout to everyone.`);
5879
- }
5880
- return findings;
5881
- };
5882
- /**
5883
- * Audit the Skill layer: is the manual one an agent can actually act on?
5884
- *
5885
- * Runs for any directory carrying a root `SKILL.md` — package or not, template or not.
5886
- * It reports nothing at all when there is no manual: a package without one is a
5887
- * perfectly legitimate thing to publish, not a Skill that failed.
5888
- *
5889
- * The honest limit: a checker can tell you the frontmatter parses and the files the
5890
- * manual points at exist. It cannot tell you the manual is *true*. Everything in
5891
- * `SKILL.md` becomes instructions an agent acts on, and no static rule catches a
5892
- * confident, wrong sentence about what a table means.
5893
- */
5894
- const auditSkill = (files, options = {}) => {
5895
- const findings = [];
5896
- const error = (rule, message) => findings.push({
5897
- severity: "error",
5898
- rule,
5899
- message
5900
- });
5901
- const warn = (rule, message) => findings.push({
5902
- severity: "warning",
5903
- rule,
5904
- message
5905
- });
5906
- const root = options.root ?? "";
5907
- const source = readText(files, root, PACKAGE_SKILL_ENTRY);
5908
- if (source === void 0) return findings;
5909
- let body = "";
5910
- try {
5911
- const parsed = parseFrontmatter(source, PACKAGE_SKILL_ENTRY);
5912
- body = parsed.body;
5913
- const frontmatter = SkillFrontmatterSchema$1.safeParse(parsed.data);
5914
- if (!frontmatter.success) error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY} frontmatter is not a valid Skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
5915
- else if (frontmatter.data.description.trim() === "") error("skill/description", `${PACKAGE_SKILL_ENTRY} has no description. An agent chooses whether to reach for a Skill by reading it, so an empty one is not cosmetic — it is a Skill that never gets picked.`);
5916
- } catch (cause) {
5917
- error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY}: ${cause.message}`);
5918
- return findings;
5919
- }
5920
- const todos = (body.match(/^.*\bTODO\b.*$/gm) ?? []).length;
5921
- if (todos > 0) error("skill/todo", `${PACKAGE_SKILL_ENTRY} still carries ${todos} TODO line(s). The export draft leaves them on purpose — an agent acts on what this file says, and a blank the author was meant to fill in is worse than nothing once it ships.`);
5922
- const present = new Set(relativePaths(files, root));
5923
- const sidecar = new RegExp(`\\]\\((\\.?/?(?:${PACKAGE_SIDECARS})/[^)\\s]+)\\)|\`((?:${PACKAGE_SIDECARS})/[^\`\\s]+)\``, "g");
5924
- const seen = /* @__PURE__ */ new Set();
5925
- for (const match of body.matchAll(sidecar)) {
5926
- const target = (match[1] ?? match[2]).replace(/^\.?\//, "");
5927
- if (seen.has(target)) continue;
5928
- seen.add(target);
5929
- if (!present.has(target)) warn("skill/missing-reference", `${PACKAGE_SKILL_ENTRY} points at "${target}", which the package does not ship. An agent told to read it has an instruction it cannot satisfy.`);
5930
- }
5931
- return findings;
5932
- };
5933
- const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
5934
- //#endregion
5935
5748
  //#region ../../packages/busabase-contract/src/contract/auto-merge.ts
5936
5749
  /**
5937
5750
  * `autoMerge` on a destructive-but-reversible operation.
@@ -7160,9 +6973,27 @@ const PACKAGE_MAX_FILE_COUNT = 5e3;
7160
6973
  const PACKAGE_MAX_RECORDS_PER_BASE = 5e4;
7161
6974
  const PackageManifestSchema = z.object({
7162
6975
  format: z.literal(PACKAGE_FORMAT),
7163
- /** Default `--into-folder` name. Not the repo name — a repo can host many packages. */
6976
+ /**
6977
+ * Default `--into-folder` name. Not the repo name — a repo can host many
6978
+ * packages. This is the package's IDENTITY, not a label: it must equal the
6979
+ * on-disk directory name and the SKILL.md frontmatter name (enforced by
6980
+ * `audit.ts`'s "one identity" rule), and it is used as a slug — CLI sort,
6981
+ * `suggestSlug()`, the Dashboard's `/templates/:name` route, and the
6982
+ * install-target folder name all key off it verbatim. Deliberately NOT an
6983
+ * iString: none of those call sites expect a locale-keyed object, and
6984
+ * "the directory name is the package's identity, not a label beside it"
6985
+ * (audit.ts) is exactly the argument against ever making it one. A
6986
+ * human-facing translated title belongs in `description` below, not here.
6987
+ */
7164
6988
  name: z.string().min(1),
7165
- description: z.string().default(""),
6989
+ /**
6990
+ * The one-line blurb shown on a template's catalog card, and (via
6991
+ * `layout-read.ts`) the description on the Folder node created at install
6992
+ * time — Folder/node descriptions are already iString-capable text
6993
+ * columns, so this single field legitimately serves both. Plain string
6994
+ * stays valid forever; nothing that already writes a bare string breaks.
6995
+ */
6996
+ description: iStringSchema.default(""),
7166
6997
  /**
7167
6998
  * Informational / reserved for a future marketplace index. The real version pin
7168
6999
  * is always the git ref in the install URL.
@@ -7181,19 +7012,40 @@ const PackageManifestSchema = z.object({
7181
7012
  */
7182
7013
  template: TemplateManifestSchema.optional()
7183
7014
  });
7015
+ /**
7016
+ * Scenario Agent prompts for the node this sidecar describes — what a person can
7017
+ * ask an agent to DO with it, carried so it survives export → install.
7018
+ *
7019
+ * Same schema the node's own column and `busabase-cli nodes set-agent-prompts`
7020
+ * validate against (50 per node, 80 chars per label, 8 KiB per body per locale),
7021
+ * so a package cannot smuggle in a list the API would reject.
7022
+ *
7023
+ * Optional and absent by default: a package written before this field, or one
7024
+ * whose author wrote no prompts, is unchanged on disk and installs exactly as
7025
+ * before — the nodes then show their node type's default prompts, which is the
7026
+ * behaviour every package had until now.
7027
+ *
7028
+ * Distinct from `busabase.json`'s template-level `agentPrompts`: that one is a
7029
+ * flat list of strings for the Template Center card and the install-time "Ask
7030
+ * agent" action, about the template as a whole. This one is per node, structured,
7031
+ * and becomes that node's prompts in the dialog after install.
7032
+ */
7033
+ const packageNodeAgentPromptsSchema = customAgentPromptsSchema.optional();
7184
7034
  /** `_node.json` — marks a directory as a verbatim-content file-tree node. */
7185
7035
  const PackageFileTreeNodeMetaSchema = z.object({
7186
7036
  type: z.enum(PACKAGE_FILE_TREE_NODE_TYPES),
7187
7037
  name: z.string().min(1),
7188
7038
  description: z.string().default(""),
7189
- position: z.number().int().optional()
7039
+ position: z.number().int().optional(),
7040
+ agentPrompts: packageNodeAgentPromptsSchema
7190
7041
  });
7191
7042
  /** `_folder.json` — optional; also how an empty folder survives git. */
7192
7043
  const PackageFolderMetaSchema = z.object({
7193
7044
  /** Defaults to the humanized slug when absent. */
7194
7045
  name: z.string().optional(),
7195
7046
  description: z.string().default(""),
7196
- position: z.number().int().optional()
7047
+ position: z.number().int().optional(),
7048
+ agentPrompts: packageNodeAgentPromptsSchema
7197
7049
  });
7198
7050
  /** `<filename>.node.json` — optional display metadata for a `file` node. */
7199
7051
  const PackageFileNodeMetaSchema = PackageFolderMetaSchema;
@@ -7218,7 +7070,8 @@ const PackageDocAssetMetaSchema = z.object({
7218
7070
  const PackageDocFrontmatterSchema = z.object({
7219
7071
  name: z.string().min(1),
7220
7072
  description: z.string().default(""),
7221
- position: z.number().int().optional()
7073
+ position: z.number().int().optional(),
7074
+ agentPrompts: packageNodeAgentPromptsSchema
7222
7075
  });
7223
7076
  /**
7224
7077
  * Field options as carried by the package. Mirrors the API's `fieldOptionsSchema`
@@ -7338,7 +7191,8 @@ const PackageBaseSchema = z.object({
7338
7191
  requiredApprovals: z.number()
7339
7192
  }).optional(),
7340
7193
  fields: z.array(PackageBaseFieldSchema).default([]),
7341
- views: z.array(PackageViewSchema).default([])
7194
+ views: z.array(PackageViewSchema).default([]),
7195
+ agentPrompts: packageNodeAgentPromptsSchema
7342
7196
  });
7343
7197
  /**
7344
7198
  * One record per line. `key` is a package-local stable identifier (export uses the
@@ -7354,84 +7208,30 @@ const PackageRecordLineSchema = z.object({
7354
7208
  fields: z.record(z.string(), z.unknown()).default({})
7355
7209
  });
7356
7210
  //#endregion
7357
- //#region ../../packages/busabase-package/src/ignore.ts
7358
- const escapeLiteral = (value) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
7211
+ //#region ../../packages/busabase-package/src/tree.ts
7359
7212
  /**
7360
- * Translate one gitignore-ish pattern into a regex over a POSIX relative path.
7213
+ * The in-memory `busabase-package@1` tree — the shape both `layout-read` (repo dir
7214
+ * → tree) and `layout-write` (space subtree → tree → repo dir) speak, and that
7215
+ * `plan`/`apply` consume. Plus the format's shared validation rules (§6.3).
7216
+ */
7217
+ /**
7218
+ * A node's scenario prompts in a fixed key order — determinism (§6.6).
7361
7219
  *
7362
- * The three globs are handled in one pass so that `**` is recognised before the
7363
- * `*` it starts with — splitting on `*` first would turn `**` into two
7364
- * segment-local wildcards and quietly stop matching across directories.
7365
- */
7366
- const patternToRegex = (pattern, anchored) => {
7367
- let source = "";
7368
- for (let index = 0; index < pattern.length; index += 1) {
7369
- const char = pattern[index];
7370
- if (char === "*") {
7371
- if (pattern[index + 1] === "*") {
7372
- index += 1;
7373
- if (pattern[index + 1] === "/") {
7374
- index += 1;
7375
- source += "(?:.*/)?";
7376
- } else source += ".*";
7377
- } else source += "[^/]*";
7378
- continue;
7379
- }
7380
- if (char === "?") {
7381
- source += "[^/]";
7382
- continue;
7383
- }
7384
- if (char === "/") {
7385
- source += "/";
7386
- continue;
7387
- }
7388
- source += escapeLiteral(char);
7389
- }
7390
- return new RegExp(`${anchored ? "^" : "^(?:.*/)?"}${source}(?:/.*)?$`);
7391
- };
7392
- const parseRule = (line) => {
7393
- let pattern = line.trim();
7394
- if (!pattern || pattern.startsWith("#")) return void 0;
7395
- const negated = pattern.startsWith("!");
7396
- if (negated) pattern = pattern.slice(1);
7397
- const directoryOnly = pattern.endsWith("/");
7398
- if (directoryOnly) pattern = pattern.slice(0, -1);
7399
- const anchored = pattern.startsWith("/");
7400
- if (anchored) pattern = pattern.slice(1);
7401
- if (!pattern) return void 0;
7402
- return {
7403
- regex: patternToRegex(pattern, anchored),
7404
- negated,
7405
- directoryOnly
7406
- };
7407
- };
7408
- const parseIgnoreFile = (contents) => {
7409
- const rules = contents.split(/\r?\n/).map(parseRule).filter((rule) => rule !== void 0);
7410
- return {
7411
- isEmpty: rules.length === 0,
7412
- ignores: (relativePath) => {
7413
- let ignored = false;
7414
- for (const rule of rules) {
7415
- if (rule.directoryOnly && !rule.regex.test(`${relativePath}/`)) continue;
7416
- if (!rule.regex.test(relativePath)) continue;
7417
- ignored = !rule.negated;
7418
- }
7419
- return ignored;
7420
- }
7421
- };
7422
- };
7423
- /** A matcher that installs everything — used when no ignore file is present. */
7424
- const IGNORE_NOTHING = {
7425
- ignores: () => false,
7426
- isEmpty: true
7427
- };
7428
- //#endregion
7429
- //#region ../../packages/busabase-package/src/tree.ts
7430
- /**
7431
- * The in-memory `busabase-package@1` tree — the shape both `layout-read` (repo dir
7432
- * → tree) and `layout-write` (space subtree → tree → repo dir) speak, and that
7433
- * `plan`/`apply` consume. Plus the format's shared validation rules (§6.3).
7220
+ * Written through instead of spread verbatim because the list reaches the writer
7221
+ * from two directions: hand-authored on disk (whatever order the author typed)
7222
+ * and via a zod parse (schema order). Without this, re-exporting an unchanged
7223
+ * package would diff on key order alone.
7224
+ *
7225
+ * `intent` is omitted when absent rather than defaulted to `"change"` here — the
7226
+ * schema owns that default, and writing it out would silently rewrite every
7227
+ * author's file on the first round trip.
7434
7228
  */
7229
+ const serializeAgentPrompts = (prompts) => prompts?.length ? prompts.map((prompt) => ({
7230
+ key: prompt.key,
7231
+ ...prompt.intent ? { intent: prompt.intent } : {},
7232
+ label: prompt.label,
7233
+ body: prompt.body
7234
+ })) : void 0;
7435
7235
  /**
7436
7236
  * Every creatable node type's slug is `/^[a-z0-9-]+$/` across the whole API
7437
7237
  * (`docs.create`, `files.create`, `createFileTreeInputSchema`, `createBaseInputSchema`,
@@ -7548,27 +7348,298 @@ const walkNodes = function* (nodes) {
7548
7348
  };
7549
7349
  const collectBaseNodes = (nodes) => [...walkNodes(nodes)].filter((node) => node.type === "base");
7550
7350
  //#endregion
7551
- //#region ../../packages/busabase-package/src/layout-read.ts
7351
+ //#region ../../packages/busabase-package/src/frontmatter.ts
7352
+ const DELIMITER = "---";
7353
+ /**
7354
+ * The canonical on-disk body: LF endings, no leading or trailing blank lines. Applied
7355
+ * on both read and write so the round trip is a fixed point — without it, the blank
7356
+ * line the writer puts after the closing `---` and the file's trailing newline would
7357
+ * accumulate into the body on every export→install→export cycle.
7358
+ *
7359
+ * Leading/trailing blank lines around a doc body are therefore normalized away on the
7360
+ * first export; internal blank lines are untouched.
7361
+ */
7362
+ const normalizeDocBody = (body) => body.replaceAll("\r\n", "\n").replace(/^\n+/, "").replace(/\n+$/, "");
7363
+ /** Split `---\n<yaml>\n---\n<body>`. A doc with no frontmatter is all body. */
7364
+ const parseFrontmatter = (text, filePath) => {
7365
+ const normalized = text.replaceAll("\r\n", "\n");
7366
+ if (!normalized.startsWith(`${DELIMITER}\n`)) return {
7367
+ data: {},
7368
+ body: normalizeDocBody(normalized)
7369
+ };
7370
+ const end = normalized.indexOf(`\n${DELIMITER}`, 3);
7371
+ if (end === -1) throw new Error(`${filePath} opens YAML frontmatter with "---" but never closes it. Add a closing "---" line.`);
7372
+ const rawYaml = normalized.slice(4, end);
7373
+ const body = normalizeDocBody(normalized.slice(end + 1 + 3));
7374
+ let data;
7375
+ try {
7376
+ data = parse(rawYaml);
7377
+ } catch (error) {
7378
+ throw new Error(`${filePath} has invalid YAML frontmatter: ${error.message}`);
7379
+ }
7380
+ if (data === null || data === void 0) return {
7381
+ data: {},
7382
+ body
7383
+ };
7384
+ if (typeof data !== "object" || Array.isArray(data)) throw new Error(`${filePath} frontmatter must be a YAML mapping (key: value pairs).`);
7385
+ return {
7386
+ data,
7387
+ body
7388
+ };
7389
+ };
7390
+ /** Serialize a doc to `---\n<frontmatter>\n---\n<body>` with a trailing newline. */
7391
+ const serializeDoc = (fields, body) => {
7392
+ const data = { name: fields.name };
7393
+ if (fields.description) data.description = fields.description;
7394
+ if (fields.position !== void 0) data.position = fields.position;
7395
+ const agentPrompts = serializeAgentPrompts(fields.agentPrompts);
7396
+ if (agentPrompts) data.agentPrompts = agentPrompts;
7397
+ const yaml = stringify(data, { lineWidth: 0 }).replace(/\n$/, "");
7398
+ return `${DELIMITER}\n${yaml}\n${DELIMITER}\n\n${normalizeDocBody(body)}\n`;
7399
+ };
7400
+ //#endregion
7401
+ //#region ../../packages/busabase-package/src/audit.ts
7402
+ /**
7403
+ * Is this package **fit to publish**?
7404
+ *
7405
+ * Deliberately a different question from {@link validateTemplate}, and the split is
7406
+ * the point rather than an organisational preference:
7407
+ *
7408
+ * - `validateTemplate` **classifies**. Its verdict changes what install *does* — a
7409
+ * Skill node, ownership stamps and a sample merge, versus a plain package install.
7410
+ * Four consumers must agree on it or the catalog lies about what an install does,
7411
+ * so it stays small and stable.
7412
+ * - `auditPackage` / `auditSkill` **advise**. Nothing behaves differently based on
7413
+ * their verdict; they exist for the author and the reviewer, and are free to grow.
7414
+ *
7415
+ * The practical consequence: a declared screenshot that is not on disk belongs here,
7416
+ * never in `validateTemplate`. Promoting it there would stop a template installing
7417
+ * over a missing image, which is the wrong lever entirely.
7418
+ *
7419
+ * Nothing here re-checks what `validateTemplate` already decides (declared resources,
7420
+ * the sample-row ceiling, name agreement between manifest and manual, an unambiguous
7421
+ * primary AirApp). Run both; they compose.
7422
+ *
7423
+ * Purely static: no install, no network, and none of the package's own code is
7424
+ * executed — safe to run in CI over a pull request from someone you do not know.
7425
+ */
7426
+ /**
7427
+ * A materialized workspace id: busabase-core's `id()` is a three-letter prefix, the
7428
+ * creation time in base36, then seven random characters.
7429
+ *
7430
+ * The lookahead requiring a digit is what keeps ordinary words out — `recommendation`
7431
+ * has the right prefix and the wrong shape. A real id always carries its timestamp.
7432
+ */
7433
+ const WORKSPACE_ID = /(?<![0-9a-z])(?:nod|bse|viw|rec|crq|ast|com|fom|shr|fvl|opr|cmt|rev|whd|aud)(?=[0-9a-z]{15}(?![0-9a-z]))(?=[0-9a-z]*\d)[0-9a-z]{15}/;
7434
+ const TEXT_EXTENSIONS = /\.(?:js|mjs|cjs|ts|mts|cts|json|ndjson|md|html|css|ya?ml|txt)$|(?:^|\/)\.env(?:\.|$)/;
7435
+ /**
7436
+ * Files that exist to *forbid* a string necessarily contain it. An AirApp's own
7437
+ * `scripts/check.mjs` asserts the obsolete API prefix is absent, and a test fixture
7438
+ * spells out the credential it proves gets rejected — scanning those turns a
7439
+ * correctly-guarded package into a failure.
7440
+ */
7441
+ const IS_ASSERTION_CODE = /(?:^|\/)(?:scripts\/check\.mjs|tests?\/|[^/]*\.test\.[cm]?[jt]s)$/;
7442
+ /** Lockfiles carry integrity hashes that are not ours to interpret. */
7443
+ const NOT_SOURCE = /(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/;
7444
+ const relativePaths = (files, root = "") => {
7445
+ const prefix = root ? `${root.replace(/\/+$/, "")}/` : "";
7446
+ const out = [];
7447
+ for (const key of files.keys()) if (!prefix || key.startsWith(prefix)) out.push(key.slice(prefix.length));
7448
+ return out;
7449
+ };
7450
+ const readText = (files, root, relative) => {
7451
+ const prefix = root ? `${root.replace(/\/+$/, "")}/` : "";
7452
+ return files.get(`${prefix}${relative}`)?.toString("utf8");
7453
+ };
7454
+ /**
7455
+ * Audit the package layer: identity, the assets the catalog promises, and anything
7456
+ * that must never be published.
7457
+ *
7458
+ * `files` is the flat map `readPackageTree` was given, not the tree. That is load-bearing:
7459
+ * `assets/screenshots/` holds no nodes and so never reaches the tree, and "the manifest
7460
+ * promises a screenshot that does not exist" is exactly a file-level question.
7461
+ */
7462
+ const auditPackage = (tree, files, options = {}) => {
7463
+ const findings = [];
7464
+ const error = (rule, message) => findings.push({
7465
+ severity: "error",
7466
+ rule,
7467
+ message
7468
+ });
7469
+ const warn = (rule, message) => findings.push({
7470
+ severity: "warning",
7471
+ rule,
7472
+ message
7473
+ });
7474
+ const root = options.root ?? "";
7475
+ const paths = relativePaths(files, root);
7476
+ const present = new Set(paths);
7477
+ if (options.directoryName !== void 0 && options.directoryName !== tree.manifest.name) error("package/identity", `Directory is "${options.directoryName}" but busabase.json declares "${tree.manifest.name}". The directory name is the package's identity, not a label beside it.`);
7478
+ for (const screenshot of tree.manifest.template?.screenshots ?? []) if (!present.has(screenshot)) error("package/screenshot-missing", `busabase.json declares screenshot "${screenshot}", which is not in the package. The Template Center card would render a broken image.`);
7479
+ for (const relative of paths) {
7480
+ const base = relative.slice(relative.lastIndexOf("/") + 1);
7481
+ if (/^\.env(?!\.example$)(?:\.|$)/.test(base)) error("package/dotenv", `${relative} is part of the package. Credentials belong in Vault, never in something people install.`);
7482
+ if (/^(?:id_rsa|id_ed25519)$/.test(base) || /\.(?:pem|p12|pfx|keystore)$/.test(base)) error("package/key-material", `${relative} looks like key material and must not be published.`);
7483
+ if (/(?:^|\/)(?:node_modules|dist|build)\//.test(relative)) warn("package/build-output", `${relative} looks like installed or built output. Ship source; the installer builds nothing.`);
7484
+ }
7485
+ for (const relative of paths) {
7486
+ if (!TEXT_EXTENSIONS.test(relative)) continue;
7487
+ if (NOT_SOURCE.test(relative) || IS_ASSERTION_CODE.test(relative)) continue;
7488
+ const source = readText(files, root, relative);
7489
+ if (source === void 0) continue;
7490
+ const found = WORKSPACE_ID.exec(source);
7491
+ if (found) error("package/workspace-ids", `${relative} contains "${found[0]}", a materialized workspace id. Node, Base and Space ids are whoever last ran setup's runtime state — publishing them ships one person's workspace layout to everyone.`);
7492
+ }
7493
+ if (!tree.rootSkill) warn("template/no-skill-node", `This package carries no ${PACKAGE_SKILL_ENTRY}, so nothing installs a manual beside its resources. An agent working in the installed folder has nothing to read, and the person who installed it has nothing to point one at.`);
7494
+ const promptless = nodesWithoutAgentPrompts(tree.nodes);
7495
+ if (promptless.length > 0) warn("template/node-without-prompts", `${promptless.length} installed node(s) carry no agentPrompts (${promptless.slice(0, 5).join(", ")}${promptless.length > 5 ? ", …" : ""}). They will show their node type's generic prompts, so nothing tells the user what THIS app is for. Write scenario prompts per node — see \`busabase-cli nodes set-agent-prompts\`.`);
7496
+ return findings;
7497
+ };
7552
7498
  /**
7553
- * Layout reader: a package's files (from a repo dir, or an extracted zip held in
7554
- * memory) → the in-memory tree. Used by `install`, and by the round-trip tests.
7499
+ * Node slugs (dotted by folder) with no scenario prompts of their own.
7555
7500
  *
7556
- * Node detection follows §6.3 exactly — the order matters, first match wins:
7557
- * 1. directory with `_node.json` → skill | airapp | drive (contents verbatim)
7558
- * 2. directory with `base.json` → base
7559
- * 3. any other directory → folder (optional `_folder.json`)
7560
- * 4. `*.md` → doc (YAML frontmatter + body)
7561
- * 5. any other file → file node (optional `<name>.node.json`)
7501
+ * Folders are exempt: a folder is a container, and the useful prompts hang off the
7502
+ * things inside it. Everything else is something a person opens and then has to
7503
+ * work out what to ask for.
7504
+ */
7505
+ const nodesWithoutAgentPrompts = (nodes, prefix = "") => {
7506
+ const out = [];
7507
+ for (const node of nodes) {
7508
+ const path = `${prefix}${node.slug}`;
7509
+ if (node.type === "folder") {
7510
+ out.push(...nodesWithoutAgentPrompts(node.children, `${path}/`));
7511
+ continue;
7512
+ }
7513
+ if (!node.agentPrompts?.length) out.push(path);
7514
+ }
7515
+ return out;
7516
+ };
7517
+ /**
7518
+ * Audit the Skill layer: is the manual one an agent can actually act on?
7562
7519
  *
7563
- * Outside `content/`, the optional `assets/` directory carries the bytes for the
7564
- * images the Docs embed — see {@link readDocAssets}. Nothing in there is a node.
7520
+ * Runs for any directory carrying a root `SKILL.md` — package or not, template or not.
7521
+ * It reports nothing at all when there is no manual: a package without one is a
7522
+ * perfectly legitimate thing to publish, not a Skill that failed.
7565
7523
  *
7566
- * Rule 0 sits ahead of all of them but reads a different place: a `SKILL.md` at
7567
- * the package ROOT (beside the manifest, not under `content/`) makes this
7568
- * package a *template*, and is lifted into `tree.rootSkill` — see
7569
- * {@link readRootSkill}. `content/` itself is untouched by that rule, which is
7570
- * what keeps a template byte-identical to a plain package below the root.
7524
+ * The honest limit: a checker can tell you the frontmatter parses and the files the
7525
+ * manual points at exist. It cannot tell you the manual is *true*. Everything in
7526
+ * `SKILL.md` becomes instructions an agent acts on, and no static rule catches a
7527
+ * confident, wrong sentence about what a table means.
7571
7528
  */
7529
+ const auditSkill = (files, options = {}) => {
7530
+ const findings = [];
7531
+ const error = (rule, message) => findings.push({
7532
+ severity: "error",
7533
+ rule,
7534
+ message
7535
+ });
7536
+ const warn = (rule, message) => findings.push({
7537
+ severity: "warning",
7538
+ rule,
7539
+ message
7540
+ });
7541
+ const root = options.root ?? "";
7542
+ const source = readText(files, root, PACKAGE_SKILL_ENTRY);
7543
+ if (source === void 0) return findings;
7544
+ let body = "";
7545
+ try {
7546
+ const parsed = parseFrontmatter(source, PACKAGE_SKILL_ENTRY);
7547
+ body = parsed.body;
7548
+ const frontmatter = SkillFrontmatterSchema$1.safeParse(parsed.data);
7549
+ if (!frontmatter.success) error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY} frontmatter is not a valid Skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
7550
+ else if (frontmatter.data.description.trim() === "") error("skill/description", `${PACKAGE_SKILL_ENTRY} has no description. An agent chooses whether to reach for a Skill by reading it, so an empty one is not cosmetic — it is a Skill that never gets picked.`);
7551
+ } catch (cause) {
7552
+ error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY}: ${cause.message}`);
7553
+ return findings;
7554
+ }
7555
+ const todos = (body.match(/^.*\bTODO\b.*$/gm) ?? []).length;
7556
+ if (todos > 0) error("skill/todo", `${PACKAGE_SKILL_ENTRY} still carries ${todos} TODO line(s). The export draft leaves them on purpose — an agent acts on what this file says, and a blank the author was meant to fill in is worse than nothing once it ships.`);
7557
+ const present = new Set(relativePaths(files, root));
7558
+ const sidecar = new RegExp(`\\]\\((\\.?/?(?:${PACKAGE_SIDECARS})/[^)\\s]+)\\)|\`((?:${PACKAGE_SIDECARS})/[^\`\\s]+)\``, "g");
7559
+ const seen = /* @__PURE__ */ new Set();
7560
+ for (const match of body.matchAll(sidecar)) {
7561
+ const target = (match[1] ?? match[2]).replace(/^\.?\//, "");
7562
+ if (seen.has(target)) continue;
7563
+ seen.add(target);
7564
+ if (!present.has(target)) warn("skill/missing-reference", `${PACKAGE_SKILL_ENTRY} points at "${target}", which the package does not ship. An agent told to read it has an instruction it cannot satisfy.`);
7565
+ }
7566
+ return findings;
7567
+ };
7568
+ const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
7569
+ //#endregion
7570
+ //#region ../../packages/busabase-package/src/ignore.ts
7571
+ const escapeLiteral = (value) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
7572
+ /**
7573
+ * Translate one gitignore-ish pattern into a regex over a POSIX relative path.
7574
+ *
7575
+ * The three globs are handled in one pass so that `**` is recognised before the
7576
+ * `*` it starts with — splitting on `*` first would turn `**` into two
7577
+ * segment-local wildcards and quietly stop matching across directories.
7578
+ */
7579
+ const patternToRegex = (pattern, anchored) => {
7580
+ let source = "";
7581
+ for (let index = 0; index < pattern.length; index += 1) {
7582
+ const char = pattern[index];
7583
+ if (char === "*") {
7584
+ if (pattern[index + 1] === "*") {
7585
+ index += 1;
7586
+ if (pattern[index + 1] === "/") {
7587
+ index += 1;
7588
+ source += "(?:.*/)?";
7589
+ } else source += ".*";
7590
+ } else source += "[^/]*";
7591
+ continue;
7592
+ }
7593
+ if (char === "?") {
7594
+ source += "[^/]";
7595
+ continue;
7596
+ }
7597
+ if (char === "/") {
7598
+ source += "/";
7599
+ continue;
7600
+ }
7601
+ source += escapeLiteral(char);
7602
+ }
7603
+ return new RegExp(`${anchored ? "^" : "^(?:.*/)?"}${source}(?:/.*)?$`);
7604
+ };
7605
+ const parseRule = (line) => {
7606
+ let pattern = line.trim();
7607
+ if (!pattern || pattern.startsWith("#")) return void 0;
7608
+ const negated = pattern.startsWith("!");
7609
+ if (negated) pattern = pattern.slice(1);
7610
+ const directoryOnly = pattern.endsWith("/");
7611
+ if (directoryOnly) pattern = pattern.slice(0, -1);
7612
+ const anchored = pattern.startsWith("/");
7613
+ if (anchored) pattern = pattern.slice(1);
7614
+ if (!pattern) return void 0;
7615
+ return {
7616
+ regex: patternToRegex(pattern, anchored),
7617
+ negated,
7618
+ directoryOnly
7619
+ };
7620
+ };
7621
+ const parseIgnoreFile = (contents) => {
7622
+ const rules = contents.split(/\r?\n/).map(parseRule).filter((rule) => rule !== void 0);
7623
+ return {
7624
+ isEmpty: rules.length === 0,
7625
+ ignores: (relativePath) => {
7626
+ let ignored = false;
7627
+ for (const rule of rules) {
7628
+ if (rule.directoryOnly && !rule.regex.test(`${relativePath}/`)) continue;
7629
+ if (!rule.regex.test(relativePath)) continue;
7630
+ ignored = !rule.negated;
7631
+ }
7632
+ return ignored;
7633
+ }
7634
+ };
7635
+ };
7636
+ /** A matcher that installs everything — used when no ignore file is present. */
7637
+ const IGNORE_NOTHING = {
7638
+ ignores: () => false,
7639
+ isEmpty: true
7640
+ };
7641
+ //#endregion
7642
+ //#region ../../packages/busabase-package/src/layout-read.ts
7572
7643
  const decodeText = (bytes, filePath) => {
7573
7644
  const text = bytes.toString("utf8");
7574
7645
  if (text.includes("�")) throw new Error(`${filePath} is not valid UTF-8 text.`);
@@ -7623,6 +7694,20 @@ const zodParse = (schema, value, filePath) => {
7623
7694
  }
7624
7695
  return result.data;
7625
7696
  };
7697
+ /**
7698
+ * A sidecar's `agentPrompts` as the tree carries it — as a spread, so a node that
7699
+ * has none carries no `agentPrompts` KEY at all rather than one set to `undefined`.
7700
+ *
7701
+ * `[]` and "absent" mean the same thing on disk (the node falls back to its type's
7702
+ * default prompts either way), so an empty list is dropped rather than
7703
+ * round-tripping as a sidecar key that changes nothing.
7704
+ */
7705
+ /** `base.json` minus the node-level key, so the list lives in exactly one place. */
7706
+ const baseWithoutAgentPrompts = (base) => {
7707
+ const { agentPrompts: _agentPrompts, ...rest } = base;
7708
+ return rest;
7709
+ };
7710
+ const agentPromptsField = (prompts) => prompts?.length ? { agentPrompts: prompts } : {};
7626
7711
  const listDir = (files, dir) => {
7627
7712
  const fileNames = /* @__PURE__ */ new Set();
7628
7713
  const dirNames = /* @__PURE__ */ new Set();
@@ -7666,7 +7751,7 @@ const readPackageTree = (files, options = {}) => {
7666
7751
  const manifest = zodParse(PackageManifestSchema, rawManifest, manifestPath);
7667
7752
  const contentDir = `${root}${PACKAGE_CONTENT_DIRNAME}/`;
7668
7753
  const nodes = readNodes(files, contentDir);
7669
- const rootSkill = readRootSkill(files, root, manifest.name, manifest.description);
7754
+ const rootSkill = readRootSkill(files, root, manifest.name, iStringParse(manifest.description, "en"));
7670
7755
  return {
7671
7756
  manifest,
7672
7757
  nodes,
@@ -7795,6 +7880,7 @@ const readDirNode = (files, dir, slug) => {
7795
7880
  name: meta.name,
7796
7881
  description: meta.description,
7797
7882
  position: meta.position,
7883
+ ...agentPromptsField(meta.agentPrompts),
7798
7884
  files: entries
7799
7885
  };
7800
7886
  }
@@ -7808,7 +7894,8 @@ const readDirNode = (files, dir, slug) => {
7808
7894
  name: base.name,
7809
7895
  description: base.description,
7810
7896
  position: base.position,
7811
- base,
7897
+ ...agentPromptsField(base.agentPrompts),
7898
+ base: baseWithoutAgentPrompts(base),
7812
7899
  records
7813
7900
  };
7814
7901
  }
@@ -7816,7 +7903,8 @@ const readDirNode = (files, dir, slug) => {
7816
7903
  const meta = files.has(metaPath) ? zodParse(PackageFolderMetaSchema, parseJsonFile(files, metaPath), metaPath) : {
7817
7904
  name: void 0,
7818
7905
  description: "",
7819
- position: void 0
7906
+ position: void 0,
7907
+ agentPrompts: void 0
7820
7908
  };
7821
7909
  return {
7822
7910
  type: "folder",
@@ -7824,6 +7912,7 @@ const readDirNode = (files, dir, slug) => {
7824
7912
  name: meta.name ?? humanizeSlug(slug),
7825
7913
  description: meta.description,
7826
7914
  position: meta.position,
7915
+ ...agentPromptsField(meta.agentPrompts),
7827
7916
  children: readNodes(files, dir)
7828
7917
  };
7829
7918
  };
@@ -7874,7 +7963,8 @@ const readFileNode = (files, dir, fileName) => {
7874
7963
  const meta = files.has(sidecarPath) ? zodParse(PackageFileNodeMetaSchema, parseJsonFile(files, sidecarPath), sidecarPath) : {
7875
7964
  name: void 0,
7876
7965
  description: "",
7877
- position: void 0
7966
+ position: void 0,
7967
+ agentPrompts: void 0
7878
7968
  };
7879
7969
  return {
7880
7970
  type: "file",
@@ -7882,6 +7972,7 @@ const readFileNode = (files, dir, fileName) => {
7882
7972
  name: meta.name ?? fileName,
7883
7973
  description: meta.description,
7884
7974
  position: meta.position,
7975
+ ...agentPromptsField(meta.agentPrompts),
7885
7976
  fileName,
7886
7977
  mimeType: guessMimeType$1(fileName),
7887
7978
  bytes
@@ -7902,6 +7993,7 @@ const readDocNode = (bytes, dir, fileName) => {
7902
7993
  name: frontmatter.name,
7903
7994
  description: frontmatter.description,
7904
7995
  position: frontmatter.position,
7996
+ ...agentPromptsField(frontmatter.agentPrompts),
7905
7997
  body
7906
7998
  };
7907
7999
  };
@@ -8037,7 +8129,7 @@ const deriveSkillDraft = (tree) => {
8037
8129
  const bases = nodes.filter((node) => node.type === "base");
8038
8130
  const airapps = nodes.filter(isAirApp);
8039
8131
  const name = tree.manifest.name;
8040
- const description = tree.manifest.description || `TODO: one line on what ${name} does.`;
8132
+ const description = iStringParse(tree.manifest.description, "en") || `TODO: one line on what ${name} does.`;
8041
8133
  const yamlString = (value) => JSON.stringify(value);
8042
8134
  const resourceLines = bases.map((node) => ` - ${yamlString(node.slug)}`).join("\n");
8043
8135
  const resourceDocs = bases.map((node) => {
@@ -8519,7 +8611,7 @@ const applyInstall = async (client, plan, options) => {
8519
8611
  parentNodeId: void 0,
8520
8612
  slug: plan.targetFolderSlug,
8521
8613
  name: plan.tree.manifest.name,
8522
- description: plan.tree.manifest.description,
8614
+ description: iStringParse(plan.tree.manifest.description, "en"),
8523
8615
  submittedBy: options.submittedBy,
8524
8616
  metadata: app?.rootMetadata
8525
8617
  });
@@ -8751,6 +8843,37 @@ const stampResource = async (client, app, nodeId, installedSlug, state, extra =
8751
8843
  }
8752
8844
  };
8753
8845
  /**
8846
+ * Write a node's carried scenario prompts onto the node install just created.
8847
+ *
8848
+ * After creation rather than inline, because the prompts live in their own
8849
+ * column with their own route — no create endpoint accepts them. That has one
8850
+ * visible consequence, and it is the reason for the warning below: on the
8851
+ * review-first path a Doc or File node is a PENDING change request with no node
8852
+ * id yet, so there is nothing to write them to. Structure (folders, Bases,
8853
+ * Skills/AirApps/Drives) is always materialized (see {@link createBase}), so the
8854
+ * nodes that carry most prompts are unaffected.
8855
+ *
8856
+ * Never fails the install, for the same reason {@link stampResource} does not:
8857
+ * the resources are already there and useful, and prompts can be re-applied with
8858
+ * `busabase-cli nodes set-agent-prompts` at any time.
8859
+ */
8860
+ const applyAgentPrompts = async (client, nodeId, node, state) => {
8861
+ const agentPrompts = node.agentPrompts;
8862
+ if (!agentPrompts?.length) return;
8863
+ if (!nodeId) {
8864
+ state.warnings.push(`"${node.slug}" installs as a change request, so its ${agentPrompts.length} agent prompt(s) were not applied. After merging it, run \`busabase-cli nodes set-agent-prompts\` for that node, or re-install with --auto-merge.`);
8865
+ return;
8866
+ }
8867
+ try {
8868
+ await client.nodes.updateAgentPrompts({
8869
+ nodeId,
8870
+ agentPrompts
8871
+ });
8872
+ } catch (error) {
8873
+ state.warnings.push(`Could not set agent prompts on "${node.slug}": ${error.message}. The node installed fine; it will show its node type's default prompts.`);
8874
+ }
8875
+ };
8876
+ /**
8754
8877
  * Re-create the package's carried Doc images on THIS host and return the
8755
8878
  * old id → new id map the Doc bodies are rewritten through.
8756
8879
  *
@@ -8784,21 +8907,24 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
8784
8907
  submittedBy: options.submittedBy
8785
8908
  });
8786
8909
  state.created.folders++;
8910
+ await applyAgentPrompts(client, nodeId, node, state);
8787
8911
  await createStructure(client, node.children, nodeId, options, state, bases, fieldIds, docAssetIds, app);
8788
8912
  break;
8789
8913
  }
8790
8914
  case "doc": {
8791
8915
  const warning = reportUnresolvableDocAssets(node.slug, node.body, new Set(docAssetIds.keys()));
8792
8916
  if (warning) state.warnings.push(warning);
8793
- if ((await client.docs.create({
8917
+ const result = await client.docs.create({
8794
8918
  parentNodeId,
8795
8919
  slug: node.slug,
8796
8920
  name: node.name,
8797
8921
  description: node.description,
8798
8922
  body: rewriteDocAssetIds(node.body, docAssetIds),
8799
8923
  autoMerge: options.autoMerge
8800
- })).materialized) state.created.docs++;
8924
+ });
8925
+ if (result.materialized) state.created.docs++;
8801
8926
  else state.pendingChangeRequests++;
8927
+ await applyAgentPrompts(client, result.materialized ? result.node.id : void 0, node, state);
8802
8928
  break;
8803
8929
  }
8804
8930
  case "base":
@@ -8817,15 +8943,17 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
8817
8943
  state.warnings.push(asSkippableUpload(error, node.fileName));
8818
8944
  break;
8819
8945
  }
8820
- if ((await client.files.create({
8946
+ const result = await client.files.create({
8821
8947
  parentNodeId,
8822
8948
  slug: node.slug,
8823
8949
  name: node.name,
8824
8950
  description: node.description,
8825
8951
  assetId: asset.assetId,
8826
8952
  autoMerge: options.autoMerge
8827
- })).materialized) state.created.files++;
8953
+ });
8954
+ if (result.materialized) state.created.files++;
8828
8955
  else state.pendingChangeRequests++;
8956
+ await applyAgentPrompts(client, result.materialized ? result.node.id : void 0, node, state);
8829
8957
  break;
8830
8958
  }
8831
8959
  }
@@ -8877,6 +9005,7 @@ const createBase = async (client, node, parentNodeId, options, state, bases, fie
8877
9005
  });
8878
9006
  indexFields(fieldIds, node.slug, result.fields);
8879
9007
  await stampResource(client, app, result.nodeId, node.slug, state);
9008
+ await applyAgentPrompts(client, result.nodeId, node, state);
8880
9009
  for (const view of node.base.views) {
8881
9010
  if (!(await client.views.changeRequest({
8882
9011
  operation: "create",
@@ -8911,7 +9040,7 @@ const createFileTreeNode = async (client, node, parentNodeId, options, state, ap
8911
9040
  return;
8912
9041
  }
8913
9042
  }))).filter((entry) => entry !== void 0);
8914
- if ((await client.fileTrees.create({
9043
+ const result = await client.fileTrees.create({
8915
9044
  type: node.type,
8916
9045
  parentNodeId,
8917
9046
  slug: node.slug,
@@ -8921,10 +9050,12 @@ const createFileTreeNode = async (client, node, parentNodeId, options, state, ap
8921
9050
  ...app ? { metadata: resourceStamp(app, node.slug, extraMetadata) } : {},
8922
9051
  mergeMode: "replace",
8923
9052
  autoMerge: options.autoMerge
8924
- })).materialized) {
9053
+ });
9054
+ if (result.materialized) {
8925
9055
  state.created.fileTreeNodes++;
8926
9056
  state.created.files += node.files.length;
8927
9057
  } else state.pendingChangeRequests++;
9058
+ await applyAgentPrompts(client, result.materialized ? result.node.id : void 0, node, state);
8928
9059
  };
8929
9060
  /**
8930
9061
  * Thrown when the host can't accept an upload the normal way — see
@@ -9166,15 +9297,6 @@ const approveAndMerge = async (client, changeRequestId) => {
9166
9297
  };
9167
9298
  //#endregion
9168
9299
  //#region ../../packages/busabase-package/src/collect.ts
9169
- /**
9170
- * `export`'s read half: a space subtree → the in-memory package tree, entirely via
9171
- * the public read API. The inverse of `apply`.
9172
- *
9173
- * Rewrites the three id-bearing option keys back to slug references, drops everything
9174
- * the format has no slot for (ids, history, permissions), carries the bytes behind
9175
- * every Doc's inline images (see {@link collectDocAssets}), and enforces the format's
9176
- * self-containment rule: a relation may not leave the package.
9177
- */
9178
9300
  /** Find the subtree to export by node slug or id. */
9179
9301
  const findSourceNode = (nodes, slugOrId) => {
9180
9302
  const stack = [...nodes];
@@ -9191,7 +9313,8 @@ const collectPackageTree = async (client, root, options) => {
9191
9313
  const { skillSource, rest } = partitionTemplateSkill(children);
9192
9314
  const collectOptions = {
9193
9315
  ...options,
9194
- baseResourceKeys: collectBaseResourceKeys(children)
9316
+ baseResourceKeys: collectBaseResourceKeys(children),
9317
+ agentPromptsUnavailable: options.agentPromptsUnavailable ?? { value: false }
9195
9318
  };
9196
9319
  const nodes = await collectNodes(client, rest, collectOptions);
9197
9320
  const tree = {
@@ -9335,12 +9458,36 @@ const collectNodes = async (client, sources, options) => {
9335
9458
  }
9336
9459
  return sortNodes(nodes);
9337
9460
  };
9461
+ /**
9462
+ * This node's scenario Agent prompts, read from the column they live in.
9463
+ *
9464
+ * A separate read per node because that is the only way to get them: they are
9465
+ * deliberately absent from `nodes.list` (50 prompts x 8 KiB per locale is why
9466
+ * they have their own column and their own route).
9467
+ *
9468
+ * Never fatal. A server that predates the route, or a node this key may read but
9469
+ * whose prompts it may not, costs the export those prompts and nothing else —
9470
+ * the package still installs, and its nodes fall back to their type's defaults,
9471
+ * exactly as every package did before the field existed.
9472
+ */
9473
+ const collectAgentPrompts = async (client, source, options) => {
9474
+ if (options.agentPromptsUnavailable?.value) return void 0;
9475
+ try {
9476
+ const result = await client.nodes.getAgentPrompts({ nodeId: source.id });
9477
+ return result.agentPrompts?.length ? result.agentPrompts : void 0;
9478
+ } catch (error) {
9479
+ if (options.agentPromptsUnavailable) options.agentPromptsUnavailable.value = true;
9480
+ options.warn(`Could not read agent prompts for "${source.slug}": ${error.message}. Exported without them for this node and every node after it; the installed nodes will show their node type's default prompts.`);
9481
+ return;
9482
+ }
9483
+ };
9338
9484
  const collectNode = async (client, source, options) => {
9339
9485
  const common = {
9340
9486
  slug: restoredNodeSlug(source),
9341
9487
  name: source.name,
9342
9488
  description: source.description ?? "",
9343
- position: source.position
9489
+ position: source.position,
9490
+ agentPrompts: await collectAgentPrompts(client, source, options)
9344
9491
  };
9345
9492
  switch (source.type) {
9346
9493
  case "folder": return {
@@ -9942,7 +10089,8 @@ const countTree = (tree) => {
9942
10089
  const renderPlan = (plan) => {
9943
10090
  const lines = [];
9944
10091
  lines.push(`Package: ${plan.tree.manifest.name}`);
9945
- if (plan.tree.manifest.description) lines.push(` ${plan.tree.manifest.description}`);
10092
+ const description = iStringParse(plan.tree.manifest.description, "en");
10093
+ if (description) lines.push(` ${description}`);
9946
10094
  lines.push("");
9947
10095
  lines.push(`Target folder: ${plan.targetFolderSlug}`);
9948
10096
  lines.push("");
@@ -10410,11 +10558,12 @@ const serializeView = (view) => compact({
10410
10558
  fieldWidths: view.config.fieldWidths
10411
10559
  })
10412
10560
  });
10413
- const serializeBase = (base, position) => toJsonFile(compact({
10561
+ const serializeBase = (base, position, agentPrompts) => toJsonFile(compact({
10414
10562
  name: base.name,
10415
10563
  description: base.description || void 0,
10416
10564
  position,
10417
10565
  reviewPolicy: base.reviewPolicy,
10566
+ agentPrompts: serializeAgentPrompts(agentPrompts),
10418
10567
  fields: base.fields.map(serializeField),
10419
10568
  views: base.views.map(serializeView)
10420
10569
  }));
@@ -10488,10 +10637,11 @@ const renderNodes = (nodes, dir, files) => {
10488
10637
  const renderNode = (node, dir, files) => {
10489
10638
  switch (node.type) {
10490
10639
  case "folder":
10491
- 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({
10640
+ if (node.children.length === 0 || node.description !== "" || node.position !== void 0 || node.agentPrompts !== void 0 || node.name !== void 0) files.set(`${dir}${node.slug}/${PACKAGE_FOLDER_META_FILENAME}`, toJsonFile(compact({
10492
10641
  name: node.name,
10493
10642
  description: node.description || void 0,
10494
- position: node.position
10643
+ position: node.position,
10644
+ agentPrompts: serializeAgentPrompts(node.agentPrompts)
10495
10645
  })));
10496
10646
  renderNodes(node.children, `${dir}${node.slug}/`, files);
10497
10647
  return;
@@ -10499,11 +10649,12 @@ const renderNode = (node, dir, files) => {
10499
10649
  files.set(`${dir}${node.slug}.md`, Buffer.from(serializeDoc({
10500
10650
  name: node.name,
10501
10651
  description: node.description,
10502
- position: node.position
10652
+ position: node.position,
10653
+ agentPrompts: node.agentPrompts
10503
10654
  }, node.body), "utf8"));
10504
10655
  return;
10505
10656
  case "base": {
10506
- files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position));
10657
+ files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position, node.agentPrompts));
10507
10658
  const records = serializeRecords(node.records);
10508
10659
  if (records.byteLength > 0) files.set(`${dir}${node.slug}/${PACKAGE_RECORDS_FILENAME}`, records);
10509
10660
  return;
@@ -10517,7 +10668,8 @@ const renderNode = (node, dir, files) => {
10517
10668
  type: node.type,
10518
10669
  name: node.name,
10519
10670
  description: node.description || void 0,
10520
- position: node.position
10671
+ position: node.position,
10672
+ agentPrompts: serializeAgentPrompts(node.agentPrompts)
10521
10673
  })));
10522
10674
  for (const entry of [...node.files].sort((a, b) => a.path.localeCompare(b.path, "en"))) {
10523
10675
  assertSafeFilePath(entry.path, `${node.type} "${node.slug}"`);
@@ -10528,10 +10680,11 @@ const renderNode = (node, dir, files) => {
10528
10680
  case "file":
10529
10681
  assertSafeFilePath(node.fileName, `file node "${node.slug}"`);
10530
10682
  files.set(`${dir}${node.fileName}`, node.bytes);
10531
- if (node.name !== node.fileName || node.description !== "" || node.position !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
10683
+ if (node.name !== node.fileName || node.description !== "" || node.position !== void 0 || node.agentPrompts !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
10532
10684
  name: node.name,
10533
10685
  description: node.description || void 0,
10534
- position: node.position
10686
+ position: node.position,
10687
+ agentPrompts: serializeAgentPrompts(node.agentPrompts)
10535
10688
  })));
10536
10689
  return;
10537
10690
  }
@@ -10691,7 +10844,8 @@ const renderPackageChoices = (repoUrl, candidates, wanted) => {
10691
10844
  candidate.primaryAirApp ? "app" : void 0
10692
10845
  ].filter(Boolean).join(" · ");
10693
10846
  lines.push(` ${candidate.name} (${shape})`);
10694
- if (candidate.description) lines.push(` ${candidate.description}`);
10847
+ const description = iStringParse(candidate.description, "en");
10848
+ if (description) lines.push(` ${description}`);
10695
10849
  for (const error of candidate.templateErrors) lines.push(` not a template: ${error}`);
10696
10850
  }
10697
10851
  lines.push("");
@@ -10742,7 +10896,7 @@ const runExport = async (client, nodeSlugOrId, options) => {
10742
10896
  tree.rootSkill = {
10743
10897
  slug: manifest.name,
10744
10898
  name: manifest.name,
10745
- description: manifest.description,
10899
+ description: iStringParse(manifest.description, "en"),
10746
10900
  files: [{
10747
10901
  path: PACKAGE_SKILL_ENTRY,
10748
10902
  bytes: Buffer.from(deriveSkillDraft(tree), "utf8")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "busa-cli",
3
- "version": "0.50.0",
3
+ "version": "0.51.1",
4
4
  "description": "Command-line client for the Busabase OpenAPI REST API. Talks to a local or remote `busabase server`. Short-name alias for busabase-cli.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/busabase/busabase/tree/main/apps/busabase-cli",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@zip.js/zip.js": "^2.8.20",
35
35
  "busabase-package": "workspace:*",
36
- "busabase-sdk": "0.50.0",
36
+ "busabase-sdk": "0.51.1",
37
37
  "commander": "^14.0.0",
38
38
  "qrcode": "^1.5.4",
39
39
  "tar-stream": "^3.1.7",