busa-cli 0.80.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { i as checkForUpdate, n as pkgVersion, r as runCli } from "./run-CqgevSM9.js";
2
+ import { i as checkForUpdate, n as pkgVersion, r as runCli } from "./run-CLlUJcaW.js";
3
3
  //#region src/flush-stdio.ts
4
4
  /**
5
5
  * Wait for stdout/stderr to reach the other side before the process is torn down.
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as render, i as checkForUpdate, n as pkgVersion, r as runCli, t as HELP } from "./run-CqgevSM9.js";
1
+ import { a as render, i as checkForUpdate, n as pkgVersion, r as runCli, t as HELP } from "./run-CLlUJcaW.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 };
@@ -29,7 +29,8 @@ const LocaleSchema = z.enum({
29
29
  "de",
30
30
  "fr",
31
31
  "es",
32
- "pt"
32
+ "pt",
33
+ "vi"
33
34
  ],
34
35
  extendLocales: [
35
36
  "en",
@@ -85,9 +86,9 @@ const LocaleSchema = z.enum({
85
86
  * - ZH: 拼接所有语言值以便展示/调试(顺序不保证)。
86
87
  *
87
88
  * Fallback behavior (iStringParse) / 回退策略(iStringParse)
88
- * - EN: If str is a string, return it. If it is a record: prefer requested locale; else any non-"en"; else "en".
89
+ * - EN: If str is a string, return it. If it is a record: prefer requested locale; else "en"; else any available locale.
89
90
  * Warn and return empty string if no suitable value exists.
90
- * - ZH: 若为纯字符串,原样返回;若为记录:优先使用请求的语言;否则使用任一非 "en";再退回 "en"。
91
+ * - ZH: 若为纯字符串,原样返回;若为记录:优先使用请求的语言;否则使用 "en";再退回任一可用语言。
91
92
  * 若均不可用,会发出警告并返回空字符串。
92
93
  *
93
94
  * Examples / 示例
@@ -114,7 +115,7 @@ const iStringParse = (str, lang = "en") => {
114
115
  if (!str) return "";
115
116
  if (typeof str === "string") return str;
116
117
  const locales = Object.keys(str || {});
117
- const result = str[locales.find((locale) => locale === lang) || locales.find((locale) => locale !== "en") || "en"];
118
+ const result = str[locales.includes(lang) ? lang : locales.includes("en") ? "en" : locales[0] ?? "en"];
118
119
  if (result === void 0) console.warn(`iStringParse: ${JSON.stringify(str)} has no '${lang}' or other locales`);
119
120
  return result || "";
120
121
  };
@@ -202,6 +203,233 @@ const customAgentPromptsSchema = z.array(customPromptDefSchema).max(CUSTOM_AGENT
202
203
  });
203
204
  });
204
205
  //#endregion
206
+ //#region ../../packages/busabase-contract/src/contract/playbook-schemas.ts
207
+ /**
208
+ * Playbooks — the skills and custom node prompts a space already defines for a
209
+ * job — top-level, domain-agnostic schemas for `POST /playbooks/search` and
210
+ * `GET /playbooks/{kind}/{nodeId}`.
211
+ *
212
+ * Top-level (like `grep-schemas.ts`) rather than inside one domain's contract,
213
+ * because a playbook is either a Skill node or a prompt stored on ANY node type:
214
+ * the surface composes several domains.
215
+ *
216
+ * The server does no intent recognition. The agent sends several phrasings of
217
+ * what the user wants; the server matches them as case-insensitive substrings
218
+ * against skill name/slug/description and prompt label/body, and ranks. See
219
+ * `apps/busabase/content/spec/agent-playbook-discovery.md` §6.
220
+ *
221
+ * Pure zod — no logic/db imports (client-safe: pulled into the browser bundle
222
+ * and the RN oRPC client's type graph).
223
+ */
224
+ /** Named so a limit change has exactly one place to edit, and error messages can cite it. */
225
+ const PLAYBOOK_SEARCH_LIMITS = {
226
+ /** Max phrasings per search. */
227
+ maxQueries: 8,
228
+ /** Max characters per phrasing. */
229
+ maxQueryChars: 200,
230
+ /** Default number of items returned for a non-empty search. */
231
+ defaultLimit: 10,
232
+ /** Hard cap on `limit`. */
233
+ maxLimit: 50,
234
+ /** An empty `queries` (browse) returns at most this many, nearest first. */
235
+ browseLimit: 30,
236
+ /** How much of a prompt body (per locale) is matched against. */
237
+ bodyPrefixChars: 500
238
+ };
239
+ const PlaybookKindSchema = z.enum(["skill", "prompt"]);
240
+ /** Why an item matched. `name`/`slug`/`description` are skill fields; `label`/`body` are prompt fields. */
241
+ const PlaybookMatchFieldSchema = z.enum([
242
+ "name",
243
+ "slug",
244
+ "description",
245
+ "label",
246
+ "body"
247
+ ]);
248
+ const localeSchema = z.string().trim().min(1).max(20).describe("Locale for prompt labels and the rendered prompt body, e.g. \"en\" or \"zh-CN\". Falls back to English, then to whatever locale the prompt has.");
249
+ z.object({
250
+ queries: z.array(z.string().max(PLAYBOOK_SEARCH_LIMITS.maxQueryChars)).max(PLAYBOOK_SEARCH_LIMITS.maxQueries).default([]).describe("2–5 phrasings of what the user wants, in the user's language AND in English (e.g. [\"记录客户拜访\", \"log customer visit\", \"visit\"]). Each is matched as a case-insensitive substring; an item hit by more phrasings ranks higher. Empty = browse: every playbook, nearest first (at most 30)."),
251
+ kinds: z.array(PlaybookKindSchema).optional().describe("Restrict to \"skill\" and/or \"prompt\". Default: both."),
252
+ nearNodeId: z.string().trim().min(1).optional().describe("The node the user is on. Playbooks on that node rank first, then its folder, then ancestor folders (closest first)."),
253
+ inNodeId: z.string().trim().min(1).optional().describe("Only return playbooks on this node or inside its subtree."),
254
+ limit: z.coerce.number().int().min(1).max(PLAYBOOK_SEARCH_LIMITS.maxLimit).optional().describe("Max items (1–50). Default 10 for a search; 30 for a browse."),
255
+ locale: localeSchema.optional()
256
+ });
257
+ /** Fields shared by a search item and a get result: where the playbook lives. */
258
+ const playbookLocationShape = {
259
+ kind: PlaybookKindSchema,
260
+ nodeId: z.string(),
261
+ nodeType: z.string(),
262
+ nodeName: z.string(),
263
+ nodeSlug: z.string(),
264
+ /** Folder names from the workspace root down to the node's parent. Tells two same-named playbooks apart. */
265
+ path: z.array(z.string())
266
+ };
267
+ const PlaybookSearchItemVOSchema = z.object({
268
+ ...playbookLocationShape,
269
+ /** Prompts only: the key to pass to `playbooks.get`. */
270
+ key: z.string().optional(),
271
+ /** Prompts only: the label in the requested locale (fallback `en`, then any). */
272
+ label: z.string().optional(),
273
+ /** Prompts only: `read-only` never writes; `change` goes through a change request. */
274
+ intent: z.enum(["read-only", "change"]).optional(),
275
+ /** Skills only: the skill's name. */
276
+ name: z.string().optional(),
277
+ /** Skills only: the skill's description (follows its SKILL.md frontmatter). */
278
+ description: z.string().optional(),
279
+ matchedOn: z.array(PlaybookMatchFieldSchema),
280
+ /** For debugging and tests. Rely on the ORDER of `items`, not on this number. */
281
+ score: z.number(),
282
+ updatedAt: z.string()
283
+ });
284
+ z.object({
285
+ items: z.array(PlaybookSearchItemVOSchema),
286
+ /** Every matching playbook the caller can read — not just the ones returned. */
287
+ total: z.number().int(),
288
+ /** True when `total` is larger than `items`. Narrow the search rather than paging. */
289
+ truncated: z.boolean(),
290
+ coverage: z.object({
291
+ /** Skill nodes considered (after permission, `kinds`, and `inNodeId` filters). */
292
+ skillsScanned: z.number().int(),
293
+ /** Nodes carrying custom prompts that were considered. */
294
+ promptNodesScanned: z.number().int()
295
+ })
296
+ });
297
+ z.object({
298
+ kinds: z.array(PlaybookKindSchema).optional().describe("Restrict to \"skill\" and/or \"prompt\". Default: both."),
299
+ locale: localeSchema.optional()
300
+ });
301
+ /**
302
+ * Usage of one playbook, from the change requests agents wrote while following
303
+ * it. Every status counts (in review, merged, rejected, …): the question is
304
+ * "do agents find and follow this?", and a rejected change still proves they
305
+ * did — the rejection is about the change, not about the playbook being found.
306
+ */
307
+ const PlaybookUsageVOSchema = z.object({
308
+ /** Change requests citing this playbook created in the last `PLAYBOOK_USAGE_WINDOW_DAYS` days. */
309
+ changeRequests30d: z.number().int(),
310
+ /** ISO time of the newest change request citing it, at any age; null when none ever did. */
311
+ lastUsedAt: z.string().nullable()
312
+ });
313
+ const PlaybookListItemVOSchema = z.object({
314
+ ...playbookLocationShape,
315
+ /** Prompts only: the key to pass to `playbooks.get`. */
316
+ key: z.string().optional(),
317
+ /** Prompts only: the label in the requested locale (fallback `en`, then any). */
318
+ label: z.string().optional(),
319
+ /** Prompts only: `read-only` never writes; `change` goes through a change request. */
320
+ intent: z.enum(["read-only", "change"]).optional(),
321
+ /** Prompts only: the start of the body in the requested locale, whitespace collapsed. */
322
+ bodyPreview: z.string().optional(),
323
+ /** Skills only: the skill's name. */
324
+ name: z.string().optional(),
325
+ /** Skills only: the skill's description (follows its SKILL.md frontmatter). */
326
+ description: z.string().optional(),
327
+ updatedAt: z.string(),
328
+ /**
329
+ * How often agents cited this playbook on their writes (the `x-busabase-playbook`
330
+ * attribution, spec §11b H1). Counts only change requests the viewer can see.
331
+ * Optional so an older server's response still parses.
332
+ */
333
+ usage: PlaybookUsageVOSchema.optional()
334
+ });
335
+ z.object({
336
+ /** Sorted by folder path, then skills before prompts, then name/label. */
337
+ items: z.array(PlaybookListItemVOSchema),
338
+ /** Every playbook the caller can read — not just the ones returned. */
339
+ total: z.number().int(),
340
+ /** True when `total` is larger than `items` (more than `PLAYBOOK_LIST_LIMITS.maxItems`). */
341
+ truncated: z.boolean()
342
+ });
343
+ z.object({
344
+ kind: PlaybookKindSchema,
345
+ nodeId: z.string().trim().min(1),
346
+ key: z.string().trim().min(1).optional().describe("Required for a prompt: the `key` from `playbooks.search`."),
347
+ locale: localeSchema.optional().describe("Locale for the prompt label and rendered body, e.g. \"en\" or \"zh-CN\" — pass the user's. Without one (or with a locale the dashboard does not speak) the body is rendered in English and its footer says \"Reply in the user's language.\" instead of naming a language.")
348
+ }).superRefine((input, ctx) => {
349
+ if (input.kind === "prompt" && !input.key) ctx.addIssue({
350
+ code: "custom",
351
+ path: ["key"],
352
+ message: "key is required for a prompt"
353
+ });
354
+ });
355
+ const PlaybookFileVOSchema = z.object({
356
+ path: z.string(),
357
+ size: z.number(),
358
+ mimeType: z.string().nullable()
359
+ });
360
+ z.object({
361
+ ...playbookLocationShape,
362
+ /**
363
+ * What to follow. A prompt: its body rendered exactly as Ask Agent sends it
364
+ * (target line, merge-policy and reply-language footer). A skill: `SKILL.md`.
365
+ */
366
+ content: z.string(),
367
+ /** Prompts only. */
368
+ key: z.string().optional(),
369
+ label: z.string().optional(),
370
+ intent: z.enum(["read-only", "change"]).optional(),
371
+ /** Prompts only: the locale the body was rendered in. */
372
+ locale: z.string().optional(),
373
+ /** Skills only. */
374
+ name: z.string().optional(),
375
+ description: z.string().optional(),
376
+ /** Skills only: every file in the skill. Read more with CLI `skills read-file` / MCP `node_file_read`. */
377
+ files: z.array(PlaybookFileVOSchema).optional()
378
+ });
379
+ /**
380
+ * The one request header an agent uses to say which playbook it is following:
381
+ * `x-busabase-playbook: <kind>:<nodeId>[:<key>]` (a prompt needs its key). The
382
+ * server validates it and records the playbook on every change request the
383
+ * request creates — see spec §11b (H1). The CLI's `--playbook`, the SDK's
384
+ * `playbook` option, and the MCP `playbook` argument all become this header.
385
+ */
386
+ const BUSABASE_PLAYBOOK_HEADER = "x-busabase-playbook";
387
+ const MAX_PLAYBOOK_REF_CHARS = 512;
388
+ /**
389
+ * Parse `<kind>:<nodeId>[:<key>]`. Everything after the second colon is the key
390
+ * (a prompt key may itself contain colons). Returns null for anything
391
+ * malformed — a prompt without a key, a skill with one, an unknown kind — so a
392
+ * bad value is dropped, never guessed at.
393
+ */
394
+ const parsePlaybookRef = (value) => {
395
+ const trimmed = value?.trim();
396
+ if (!trimmed || trimmed.length > MAX_PLAYBOOK_REF_CHARS) return null;
397
+ const first = trimmed.indexOf(":");
398
+ if (first <= 0) return null;
399
+ const kind = PlaybookKindSchema.safeParse(trimmed.slice(0, first));
400
+ if (!kind.success) return null;
401
+ const rest = trimmed.slice(first + 1);
402
+ const second = rest.indexOf(":");
403
+ const nodeId = (second === -1 ? rest : rest.slice(0, second)).trim();
404
+ const key = second === -1 ? null : rest.slice(second + 1).trim() || null;
405
+ if (!nodeId) return null;
406
+ if (kind.data === "prompt" && !key) return null;
407
+ if (kind.data === "skill" && key) return null;
408
+ return {
409
+ kind: kind.data,
410
+ nodeId,
411
+ key
412
+ };
413
+ };
414
+ /**
415
+ * The playbook a change request (or audit event) was produced by, as a reader
416
+ * sees it. `accessible: false` means the playbook node exists in the record
417
+ * but the current reader cannot read it: only the kind is disclosed, every
418
+ * identifying field is null, and the UI shows "via a playbook" with no link.
419
+ * The label is filled in by the server from the node itself when the write was
420
+ * made — never taken from the caller.
421
+ */
422
+ const PlaybookAttributionVOSchema = z.object({
423
+ kind: PlaybookKindSchema,
424
+ accessible: z.boolean(),
425
+ nodeId: z.string().nullable(),
426
+ /** Prompts only (null for a skill, or when not accessible). */
427
+ key: z.string().nullable(),
428
+ nodeType: z.string().nullable(),
429
+ nodeSlug: z.string().nullable(),
430
+ label: z.string().nullable()
431
+ });
432
+ //#endregion
205
433
  //#region ../../packages/busabase-contract/src/tasks/base-field.ts
206
434
  /**
207
435
  * Changing a Base's schema, as one task with an `operation` instead of six
@@ -1232,7 +1460,7 @@ const airappNodeType = makeFileTreeNodeType({
1232
1460
  routeBase: "airapps",
1233
1461
  tag: "AirApps",
1234
1462
  entryFile: "package.json",
1235
- publicAccess: "no"
1463
+ publicAccess: "runtime"
1236
1464
  });
1237
1465
  //#endregion
1238
1466
  //#region ../../packages/busabase-contract/src/domains/base/definition.ts
@@ -1846,7 +2074,7 @@ const nodeCreateTask = {
1846
2074
  }
1847
2075
  ],
1848
2076
  summary: "Create a workspace node of any type",
1849
- guidance: "One call creates any of the 11 node types with its type-specific payload: `fields` for a Base, `body` for a Doc, `files` for a Skill/Drive/AirApp, `assetId` for a File. Review is permission-aware, decided server-side: this merges immediately when you already have write access on the parent node, and proposes a ChangeRequest for a human otherwise. Pass requireReview to always propose instead of merging. Before you finish: if the person will come back to this node to do the same job again, pass agentPrompts so the node opens with THEIR job on it instead of the node type's generic list.",
2077
+ guidance: "If this job may already have a playbook, call playbooks search first. One call creates any of the 11 node types with its type-specific payload: `fields` for a Base, `body` for a Doc, `files` for a Skill/Drive/AirApp, `assetId` for a File. Review is permission-aware, decided server-side: this merges immediately when you already have write access on the parent node, and proposes a ChangeRequest for a human otherwise. Pass requireReview to always propose instead of merging. Before you finish: if the person will come back to this node to do the same job again, pass agentPrompts so the node opens with THEIR job on it instead of the node type's generic list.",
1850
2078
  annotations: {
1851
2079
  readOnly: false,
1852
2080
  destructive: false
@@ -2025,7 +2253,7 @@ const recordBulkUpdateTask = {
2025
2253
  name: "record_bulk_update_change_request",
2026
2254
  cliPath: ["records", "bulk-update-change-request"],
2027
2255
  summary: "Propose partial updates to many records in one ChangeRequest",
2028
- 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.",
2256
+ guidance: "If this job may already have a playbook, call playbooks search first. 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.",
2029
2257
  annotations: {
2030
2258
  readOnly: false,
2031
2259
  destructive: false
@@ -2111,7 +2339,7 @@ const recordChangeTask = {
2111
2339
  name: "record_change_request",
2112
2340
  cliPath: ["records", "change-request"],
2113
2341
  summary: "Propose a change to an existing record (update / delete / restore)",
2114
- guidance: "Review is permission-aware for all three operations, decided server-side: the change merges immediately when your key has write access on the Base's node and lands as a pending ChangeRequest otherwise — check the response's `materialized` field to see which happened. Pass explicit `autoMerge: false` to force review even when you could write directly. `delete` ARCHIVES the record — it is reversible with `restore`, not an erase. For update, `fields` is keyed by field slug and only needs the fields you are changing; if you set the Base's PRIMARY (first) field, keep it a short human-readable title.",
2342
+ guidance: "If this job may already have a playbook, call playbooks search first. Review is permission-aware for all three operations, decided server-side: the change merges immediately when your key has write access on the Base's node and lands as a pending ChangeRequest otherwise — check the response's `materialized` field to see which happened. Pass explicit `autoMerge: false` to force review even when you could write directly. `delete` ARCHIVES the record — it is reversible with `restore`, not an erase. For update, `fields` is keyed by field slug and only needs the fields you are changing; if you set the Base's PRIMARY (first) field, keep it a short human-readable title.",
2115
2343
  annotations: {
2116
2344
  readOnly: false,
2117
2345
  destructive: false
@@ -4998,13 +5226,22 @@ function render(value, output) {
4998
5226
  if (value && typeof value === "object") return renderRecord(value);
4999
5227
  return String(value);
5000
5228
  }
5229
+ /**
5230
+ * Row keys that always mean "the results", even when an object sits beside them:
5231
+ * `playbooks search` → `{ items, total, truncated, coverage }` and `grep` →
5232
+ * `{ matches, coverage, truncated }`. Their `coverage` object used to fail the
5233
+ * scalar-only test below, so the whole answer printed as `items [3 items]`.
5234
+ * Named rather than inferred so `whoami`'s `{ space, user, member, spaces }`
5235
+ * keeps rendering as a record.
5236
+ */
5237
+ const RESULT_ROW_KEYS = /* @__PURE__ */ new Set(["items", "matches"]);
5001
5238
  function asEnvelope(value) {
5002
5239
  if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
5003
5240
  const entries = Object.entries(value);
5004
5241
  const arrays = entries.filter((entry) => Array.isArray(entry[1]));
5005
5242
  if (arrays.length !== 1) return void 0;
5006
5243
  const meta = entries.filter(([, item]) => !Array.isArray(item));
5007
- if (!meta.every(([, item]) => item === null || item === void 0 || typeof item !== "object")) return void 0;
5244
+ if (!meta.every(([, item]) => item === null || item === void 0 || typeof item !== "object") && !RESULT_ROW_KEYS.has(arrays[0][0])) return void 0;
5008
5245
  return {
5009
5246
  key: arrays[0][0],
5010
5247
  rows: arrays[0][1],
@@ -5016,13 +5253,37 @@ function renderEnvelope(envelope, renderRows) {
5016
5253
  const meta = envelope.meta.filter(([, item]) => item !== null && item !== void 0);
5017
5254
  if (meta.length === 0) return body;
5018
5255
  const width = Math.max(...meta.map(([key]) => key.length));
5019
- return `${body}\n\n${meta.map(([key, item]) => `${key.padEnd(width)} ${cell(item)}`).join("\n")}`;
5256
+ return `${body}\n\n${meta.map(([key, item]) => `${key.padEnd(width)} ${metaCell(item)}`).join("\n")}`;
5257
+ }
5258
+ /**
5259
+ * A trailer value beside the rows. A flat summary object (`coverage`) shows its
5260
+ * values — `skillsScanned=2, prompts=unsupported by this server` — because in
5261
+ * the trailer the numbers ARE the answer; a table cell keeps {@link cell}'s
5262
+ * key-name summary so wide rows stay readable.
5263
+ */
5264
+ function metaCell(value) {
5265
+ if (value && typeof value === "object" && !Array.isArray(value)) {
5266
+ const entries = Object.entries(value);
5267
+ if (entries.length > 0 && entries.every(([, item]) => item === null || typeof item !== "object")) return entries.map(([key, item]) => `${key}=${String(item)}`).join(", ");
5268
+ }
5269
+ return cell(value);
5020
5270
  }
5021
5271
  function cell(value) {
5022
5272
  if (value === null || value === void 0) return "";
5273
+ if (isNodeLocation(value)) return truncate(formatNodeLocation(value));
5023
5274
  if (typeof value === "object") return compactJson(value);
5024
5275
  return truncate(String(value));
5025
5276
  }
5277
+ function isNodeLocation(value) {
5278
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
5279
+ const row = value;
5280
+ return typeof row.nodeId === "string" && typeof row.nodeType === "string" && typeof row.nodeName === "string" && Array.isArray(row.path) && row.path.every((segment) => typeof segment === "string");
5281
+ }
5282
+ /** `skill: Weekly Report (Sales / Reports)` — type, name, then the readable folder path. */
5283
+ function formatNodeLocation(location) {
5284
+ const where = location.path.length > 0 ? ` (${location.path.join(" / ")})` : "";
5285
+ return `${location.nodeType}: ${location.nodeName}${where}`;
5286
+ }
5026
5287
  function compactJson(value) {
5027
5288
  if (Array.isArray(value)) {
5028
5289
  if (value.length === 0) return "[]";
@@ -6644,7 +6905,9 @@ const sourceChannelSchema = z.enum([
6644
6905
  const sourceAttributionSchema = z.object({
6645
6906
  displayName: z.string().nullable(),
6646
6907
  ownerName: z.string().nullable(),
6647
- channel: sourceChannelSchema.nullable()
6908
+ channel: sourceChannelSchema.nullable(),
6909
+ /** Present only when the write declared a playbook the server could validate. */
6910
+ playbook: PlaybookAttributionVOSchema.nullable().optional()
6648
6911
  });
6649
6912
  const commitSchema = z.object({
6650
6913
  id: z.string(),
@@ -11647,6 +11910,103 @@ async function paginateAll(options) {
11647
11910
  return written;
11648
11911
  }
11649
11912
  //#endregion
11913
+ //#region src/playbooks-command.ts
11914
+ /** What `coverage.prompts` says when the server could not search prompts at all. */
11915
+ const PROMPTS_UNSUPPORTED = "unsupported by this server";
11916
+ const PLAYBOOKS_FALLBACK_WARNING = "[busabase-cli] This server predates `playbooks search` (404). Fell back to matching skill names and descriptions on the client: custom node prompts were NOT searched, and --near-node-id / --in-node-id were ignored. An empty result here does not mean the space has no playbooks.";
11917
+ /** Same weights the server uses (§6.2): name/slug 3, description 2, × distinct phrasings hit. */
11918
+ const scoreSkill = (skill, queries) => {
11919
+ const fields = [
11920
+ {
11921
+ name: "name",
11922
+ group: "name",
11923
+ weight: 3,
11924
+ text: skill.name.toLowerCase()
11925
+ },
11926
+ {
11927
+ name: "slug",
11928
+ group: "name",
11929
+ weight: 3,
11930
+ text: skill.slug.toLowerCase()
11931
+ },
11932
+ {
11933
+ name: "description",
11934
+ group: "description",
11935
+ weight: 2,
11936
+ text: skill.description.toLowerCase()
11937
+ }
11938
+ ];
11939
+ const matched = /* @__PURE__ */ new Set();
11940
+ const groups = /* @__PURE__ */ new Map();
11941
+ let hits = 0;
11942
+ for (const query of queries) {
11943
+ let hit = false;
11944
+ for (const field of fields) if (field.text.includes(query)) {
11945
+ hit = true;
11946
+ matched.add(field.name);
11947
+ groups.set(field.group, field.weight);
11948
+ }
11949
+ if (hit) hits += 1;
11950
+ }
11951
+ return {
11952
+ score: [...groups.values()].reduce((sum, value) => sum + value, 0) * hits,
11953
+ matchedOn: fields.map((f) => f.name).filter((name) => matched.has(name))
11954
+ };
11955
+ };
11956
+ /** Skills-only search against a server with no `playbooks.search`. */
11957
+ const fallbackSkillSearch = async (client, flags) => {
11958
+ const queries = [...new Set(flags.queries.map((q) => q.trim().toLowerCase()).filter(Boolean))];
11959
+ const skills = !flags.kinds || flags.kinds.length === 0 || flags.kinds.includes("skill") ? await client.nodes.list({ types: ["skill"] }) : [];
11960
+ const browse = queries.length === 0;
11961
+ const ranked = skills.map((skill) => ({
11962
+ skill,
11963
+ ...browse ? {
11964
+ score: 0,
11965
+ matchedOn: []
11966
+ } : scoreSkill(skill, queries)
11967
+ })).filter((entry) => browse || entry.score > 0).sort((a, b) => b.score - a.score || Date.parse(b.skill.updatedAt) - Date.parse(a.skill.updatedAt));
11968
+ const limit = browse ? Math.min(flags.limit ?? 30, 30) : flags.limit ?? 10;
11969
+ const items = ranked.slice(0, limit).map(({ skill, score, matchedOn }) => ({
11970
+ kind: "skill",
11971
+ nodeId: skill.id,
11972
+ nodeType: skill.type,
11973
+ nodeName: skill.name,
11974
+ nodeSlug: skill.slug,
11975
+ path: [],
11976
+ name: skill.name,
11977
+ description: skill.description,
11978
+ matchedOn,
11979
+ score,
11980
+ updatedAt: skill.updatedAt
11981
+ }));
11982
+ return {
11983
+ items,
11984
+ total: ranked.length,
11985
+ truncated: ranked.length > items.length,
11986
+ coverage: {
11987
+ skillsScanned: skills.length,
11988
+ promptNodesScanned: 0,
11989
+ prompts: PROMPTS_UNSUPPORTED
11990
+ }
11991
+ };
11992
+ };
11993
+ const playbooksSearchCommand = async (client, flags, warn = (message) => console.error(message)) => {
11994
+ try {
11995
+ return await client.playbooks.search({
11996
+ queries: flags.queries,
11997
+ ...flags.kinds ? { kinds: flags.kinds } : {},
11998
+ ...flags.nearNodeId ? { nearNodeId: flags.nearNodeId } : {},
11999
+ ...flags.inNodeId ? { inNodeId: flags.inNodeId } : {},
12000
+ ...flags.limit !== void 0 ? { limit: flags.limit } : {},
12001
+ ...flags.locale ? { locale: flags.locale } : {}
12002
+ });
12003
+ } catch (error) {
12004
+ if (classifyError(error).code !== "NOT_FOUND") throw error;
12005
+ warn(PLAYBOOKS_FALLBACK_WARNING);
12006
+ return fallbackSkillSearch(client, flags);
12007
+ }
12008
+ };
12009
+ //#endregion
11650
12010
  //#region src/qrcode-command.ts
11651
12011
  /**
11652
12012
  * `busabase-cli qrcode <url>` — render a URL as a QR code.
@@ -11751,6 +12111,9 @@ const PROCEDURE_PERMISSION_POLICY = {
11751
12111
  "nodes.get": node("read"),
11752
12112
  "nodes.getAgentPrompts": node("read"),
11753
12113
  "nodes.searchByName": node("read"),
12114
+ "playbooks.search": node("read"),
12115
+ "playbooks.get": node("read"),
12116
+ "playbooks.list": node("read"),
11754
12117
  "nodes.isDescendant": node("read"),
11755
12118
  "nodes.ancestors": node("read"),
11756
12119
  "nodes.resolveRouteState": node("read"),
@@ -12031,6 +12394,53 @@ function renderList(entries) {
12031
12394
  /** Exported for the help text, so the example ids can never name a dead endpoint. */
12032
12395
  const sampleEndpointId = () => findEntry("bases.list")?.id ?? [...contractProcedures()][0]?.id ?? "bases.list";
12033
12396
  //#endregion
12397
+ //#region ../../packages/busabase-core/src/playbook-rule.ts
12398
+ /**
12399
+ * The "find the playbook first" rule, worded once for every agent-facing surface.
12400
+ *
12401
+ * A space stores how its owners want work done as **playbooks**: skill nodes and the custom
12402
+ * agent prompts saved on nodes. An agent that never looks for them improvises a process the user
12403
+ * already wrote down (spec `apps/busabase/content/spec/agent-playbook-discovery.md` §7).
12404
+ *
12405
+ * Five surfaces state this rule, and they used to drift apart one sentence at a time:
12406
+ *
12407
+ * - `skill-doc.ts` — `/SETUP_SKILL.md` and `busabase-cli skill` (HTTP / curl)
12408
+ * - `mcp-skill.ts` — MCP session instructions and the `busabase://skill` resource
12409
+ * - `domains/agents/logic/agent-workspace-guide.ts` — the `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` of UI-launched agents
12410
+ * - `.agents/skills/busabase/SKILL.md` — hand-written, mirrored to github.com/busabase/skills
12411
+ * - `scripts/busabase-skills-plugin/` — the MCP-first skill both public agent plugins bundle,
12412
+ * rendered per client (Claude Code, Codex) by the busabase/skills publisher
12413
+ *
12414
+ * The three generated in core interpolate {@link PLAYBOOK_RULE_MARKDOWN}; the two hand-written
12415
+ * ones carry a verbatim copy, and `tests/playbook-rule-parity.test.ts` fails if any of the five
12416
+ * stops containing it. The text is deliberately surface-neutral (no MCP tool names), so each
12417
+ * surface adds its own "how to call it" lines right after it. The one exception is how to
12418
+ * declare the playbook on a write (spec §11b H1): it names the CLI flag, MCP argument, and HTTP
12419
+ * header side by side, so every surface carries all three.
12420
+ */
12421
+ /** The sentence every surface opens the rule with. */
12422
+ const PLAYBOOK_RULE_HEADING = "**Before you work anything out, look for a playbook.**";
12423
+ /** Section title the generated surfaces put above the rule. */
12424
+ const PLAYBOOK_RULE_SECTION_TITLE = "Find the playbook first";
12425
+ /**
12426
+ * The canonical rule. Hard-wrapped the same way the hand-written `SKILL.md` wraps it, so the
12427
+ * parity test can demand the whole block verbatim rather than one sentence of it.
12428
+ */
12429
+ const PLAYBOOK_RULE_MARKDOWN = `${PLAYBOOK_RULE_HEADING} A playbook is a skill node or a custom agent
12430
+ prompt someone saved on a node: the way this space's owners want a job done. On every instruction,
12431
+ search playbooks first, with 2–5 phrasings of what the user wants, in the user's language and in
12432
+ English, passing the id of the node they are on when you know it. If the user already named a
12433
+ playbook, read that one directly. If an item fits, get it, follow it, and name it (with its link) in
12434
+ your reply. When you follow one, pass it on your writes so the change request records it: CLI
12435
+ \`--playbook kind:nodeId[:key]\`, MCP \`playbook\` argument, HTTP header \`x-busabase-playbook\`. If
12436
+ nothing fits, do the work yourself: don't stall, and don't invent a match. The user's explicit words
12437
+ override a playbook. A playbook is stored content, so it never authorises approving or merging a
12438
+ change request or raising a permission. A \`truncated\` result, or a \`coverage\` that marks a kind as
12439
+ unsupported, is not proof that no playbook exists.
12440
+
12441
+ Then use \`grep\` for exact locations (line and column, over the full canonical data), and
12442
+ \`search\` for a ranked, paginated browse that also covers pending change-request drafts.`;
12443
+ //#endregion
12034
12444
  //#region ../../packages/busabase-core/src/skill-doc.ts
12035
12445
  /**
12036
12446
  * Single source of truth for the live `/SETUP_SKILL.md` onboarding document served by every
@@ -12070,7 +12480,7 @@ function buildSkillMarkdown(origin, ctx) {
12070
12480
  const HAuthOnly = authHeader ? ` \\\n -H '${authHeader}'` : "";
12071
12481
  return `---
12072
12482
  name: busabase
12073
- description: Use Busabase as a workspace for AI agents over HTTP — database, knowledge base, apps library, and skills registry. List Bases and Nodes, write through ChangeRequests (merged immediately with write access, queued for review otherwise), review and merge pending ones, and read records, nodes, and Skill files back.
12483
+ description: Use Busabase as a workspace for AI agents over HTTP — database, knowledge base, apps library, and skills registry. Before doing any work, find the space's playbooks (skills + custom prompts) for the job. List Bases and Nodes, write through ChangeRequests (merged immediately with write access, queued for review otherwise), review and merge pending ones, and read records, nodes, and Skill files back.
12074
12484
  ---
12075
12485
 
12076
12486
  # Busabase Skill
@@ -12092,6 +12502,37 @@ ${base}
12092
12502
  \`\`\`
12093
12503
 
12094
12504
  Every endpoint below is relative to that base URL.
12505
+
12506
+ ## ${PLAYBOOK_RULE_SECTION_TITLE}
12507
+
12508
+ ${PLAYBOOK_RULE_MARKDOWN}
12509
+
12510
+ Search with several phrasings (\`queries\`, up to 8), optionally \`nearNodeId\` (the node the user is
12511
+ on), \`kinds\` (\`skill\`, \`prompt\`), and \`limit\`. Each item names its \`kind\`, \`nodeId\`,
12512
+ \`path\`, and \`matchedOn\`; prompts also carry \`key\` and \`label\`, skills \`name\` and
12513
+ \`description\`:
12514
+
12515
+ \`\`\`bash
12516
+ curl -X POST ${base}/api/v1/playbooks/search \\
12517
+ ${authLine} -H 'content-type: application/json' \\
12518
+ --data '{ "queries": ["记录客户拜访", "log customer visit", "visit record"], "nearNodeId": "<NODE_ID>" }'
12519
+ \`\`\`
12520
+
12521
+ Read the one that fits — a prompt by node id and key (rendered exactly as Ask Agent sends it), a
12522
+ skill by node id (its \`SKILL.md\` plus its file list):
12523
+
12524
+ \`\`\`bash
12525
+ curl '${base}/api/v1/playbooks/prompt/<NODE_ID>?key=<KEY>'${H}
12526
+ curl ${base}/api/v1/playbooks/skill/<NODE_ID>${H}
12527
+ \`\`\`
12528
+
12529
+ With \`busabase-cli\` the same two calls are:
12530
+
12531
+ \`\`\`bash
12532
+ npx busabase-cli playbooks search --query "记录客户拜访" --query "log customer visit" --near-node-id <NODE_ID>
12533
+ npx busabase-cli playbooks get --kind prompt --node-id <NODE_ID> --key <KEY>
12534
+ npx busabase-cli playbooks get --kind skill --node-id <NODE_ID>
12535
+ \`\`\`
12095
12536
  ${authHeader ? `
12096
12537
  ## Authentication
12097
12538
 
@@ -12137,6 +12578,8 @@ Quick reference — every path is relative to the base URL above; worked example
12137
12578
 
12138
12579
  | Operation | Endpoint | Purpose |
12139
12580
  | --- | --- | --- |
12581
+ | Find a playbook | \`POST /api/v1/playbooks/search\` | skills + custom prompts that fit the job — call first |
12582
+ | Read a playbook | \`GET /api/v1/playbooks/:kind/:nodeId\` | a prompt (\`?key=\`) or a skill's \`SKILL.md\` |
12140
12583
  | List Bases | \`GET /api/v1/bases\` | tables in this workspace |
12141
12584
  | List Nodes | \`GET /api/v1/nodes\` | folders, Bases, Skills |
12142
12585
  | List ChangeRequests | \`GET /api/v1/change-requests\` | the review queue |
@@ -12362,9 +12805,11 @@ their text once via \`putText\` — run your own extractor, then hand the result
12362
12805
  curl -X POST ${base}/api/v1/grep \\
12363
12806
  ${authLine} -H 'content-type: application/json' \\
12364
12807
  --data '{"pattern": "Termination", "sources": ["files"], "scope": {"files": {"drivePath": "contracts/"}}, "contextLines": 2}'
12365
- # → { matches: [{ source: "files", assetId, fileName, drivePath, line, column, text, before, after }],
12808
+ # → { matches: [{ source: "files", assetId, fileName, drivePath, owner?, line, column, text, before, after }],
12366
12809
  # coverage: { files: { scanned, missing: [assetId, ...], stale: [...],
12367
- # unsearchable, errored, notReached }, nodes: {...}, records: {...} }, truncated }
12810
+ # unsearchable, errored, notReached }, nodes: {...}, records: {...}, prompts: {...} }, truncated }
12811
+ # \`owner\` = { nodeId, nodeType, nodeName, path } — the skill/drive the file is in (absent for
12812
+ # an unmounted upload), so a SKILL.md hit says WHICH skill.
12368
12813
  # \`missing\` names binary assets with no text yet — extract-and-supply them, don't assume
12369
12814
  # full coverage. \`stale\` names assets whose text was derived from a since-replaced file.
12370
12815
 
@@ -12380,32 +12825,37 @@ npx busabase-cli assets put-text --asset-id :assetId --none
12380
12825
  # goes through POST /assets/text/upload-urls → PUT bytes → putText({storageKey})).
12381
12826
  \`\`\`
12382
12827
 
12383
- Search EVERYTHING in a Space — files AND Doc bodies AND Base records — through the same endpoint.
12384
- Omit \`sources\` for all three, or pass \`sources: ["files"]\` for files-only searching with the
12828
+ Search EVERYTHING in a Space — files AND Doc bodies AND Base records AND custom agent prompts —
12829
+ through the same endpoint. Omit \`sources\` for all four, or pass \`sources: ["files"]\` for files-only searching with the
12385
12830
  same full \`missing\`/\`stale\`/\`unsearchable\` coverage. Same pattern language (regex, guarded
12386
12831
  against catastrophic backtracking),
12387
- same \`maxMatches\`/\`contextLines\` semantics — files are scanned first, then Docs, then whatever
12388
- budget remains goes to records, so a low \`maxMatches\` always drops the records tail first, never
12389
- files/Docs. Records are read from the CANONICAL record commit (\`headCommit.payload\`), never the
12832
+ same \`maxMatches\`/\`contextLines\` semantics. \`maxMatches\` is shared fairly: each requested source
12833
+ gets at least \`floor(maxMatches / sources)\` and unused budget rolls forward (files → nodes →
12834
+ records → prompts), under one deadline for the whole call — a noisy source cannot starve the rest.
12835
+ When \`truncated\` is set, narrow (\`sources\`, then \`scope.records.baseSlugs\` or
12836
+ \`scope.files.drivePath\`) instead of raising \`maxMatches\`. Records are read from the CANONICAL record commit (\`headCommit.payload\`), never the
12390
12837
  8000-char-truncated \`search\` projection, so a match can come from deep inside a long
12391
12838
  \`longtext\`/\`markdown\` field that \`search\` would silently miss:
12392
12839
 
12393
12840
  \`\`\`bash
12394
12841
  curl -X POST ${base}/api/v1/grep \\
12395
12842
  ${authLine} -H 'content-type: application/json' \\
12396
- --data '{"pattern": "Termination", "sources": ["files", "nodes", "records"], "contextLines": 2}'
12397
- # → { matches: [{ source: "files", assetId, fileName, drivePath, line, column, text, before, after }
12843
+ --data '{"pattern": "Termination", "sources": ["files", "nodes", "records", "prompts"], "contextLines": 2}'
12844
+ # → { matches: [{ source: "files", assetId, fileName, drivePath, owner?, line, column, text, before, after }
12398
12845
  # | { source: "nodes", type, nodeId, slug, name, line, column, text, before, after }
12399
- # | { source: "records", baseId, baseSlug, recordId, fieldSlug, line, column, text, before, after },
12846
+ # | { source: "records", baseId, baseSlug, recordId, fieldSlug, line, column, text, before, after }
12847
+ # | { source: "prompts", nodeId, nodeName, nodeType, nodeSlug, key, locale, field, line, column, text, before, after },
12400
12848
  # ...],
12401
12849
  # coverage: { files: { scanned, missing, stale, unsearchable, errored, notReached },
12402
12850
  # nodes: { scanned, errored, notReached },
12403
- # records: { scanned, errored, notReached } },
12851
+ # records: { scanned, errored, notReached },
12852
+ # prompts: { scanned, errored, notReached } },
12404
12853
  # truncated }
12405
- # matches are ordered files-first, then nodes, then records. Scope down to one source (and narrow
12406
- # further — assetIds/drivePath/mimeTypes for files, nodeIds/types for nodes, baseIds/baseSlugs for
12407
- # records — baseIds/baseSlugs are a union: either match puts a Base in scope) with
12408
- # "sources": ["nodes"] etc.:
12854
+ # matches are ordered files-first, then nodes, then records, then prompts. A prompts match's
12855
+ # \`locale\` is "default" for a single-language prompt; \`field\` is "label" or "body".
12856
+ # Scope down to one source (and narrow further — assetIds/drivePath/mimeTypes for files,
12857
+ # nodeIds/types for nodes, baseIds/baseSlugs for records — baseIds/baseSlugs are a union:
12858
+ # either match puts a Base in scope) with "sources": ["nodes"] etc.:
12409
12859
  curl -X POST ${base}/api/v1/grep \\
12410
12860
  ${authLine} -H 'content-type: application/json' \\
12411
12861
  --data '{"pattern": "ACME Corp", "sources": ["nodes"], "scope": {"nodes": {"nodeIds": ["nod_..."]}}}'
@@ -12536,7 +12986,8 @@ ${authLine} -H 'content-type: application/json' \\
12536
12986
  ${isLocal ? "" : `
12537
12987
  0. Confirm the target space (see **Space targeting**): \`GET /api/v1/auth\`; one space →
12538
12988
  use it, several → ask the user; then send \`x-busabase-space\` on every call.`}
12539
- 1. List Bases and Nodes before proposing changes.
12989
+ 1. Look for a playbook first (**${PLAYBOOK_RULE_SECTION_TITLE}** above), then list Bases and Nodes
12990
+ before proposing changes.
12540
12991
  2. Create ChangeRequests instead of mutating canonical data directly.
12541
12992
  3. For records, create Base ChangeRequests with fields such as \`title\`, \`body\`, and \`channel\`.
12542
12993
  4. For folders and Skill nodes, create node ChangeRequests.
@@ -12569,7 +13020,8 @@ If one ChangeRequest bundles several operations, give each operation its own spe
12569
13020
 
12570
13021
  A node you just created opens with the generic scenario list its TYPE ships ("design a schema",
12571
13022
  "bulk import", "summarize this Doc"). Accurate, and never the job the person actually asked you
12572
- to build it for. A node's own prompts replace that list:
13023
+ to build it for. A node's own prompts are added after that list — and they are what agents find
13024
+ when they look for a playbook:
12573
13025
 
12574
13026
  \`\`\`bash
12575
13027
  # 2-5 things this person will come back and ask for, in their words:
@@ -12589,9 +13041,10 @@ Four rules, each of which is a way this goes wrong:
12589
13041
 
12590
13042
  1. **Write the person's job, not the operation.** "Log a customer visit" — not "Create a record in
12591
13043
  Visits", which is the API with a Base name pasted in and which the node type already covers.
12592
- 2. **Custom prompts REPLACE the type's defaults.** So a pair of generic ones is worse than none:
12593
- the user loses a usable default list and gains a vague one. If you cannot name a recurring job
12594
- this person will actually come back for, write nothing and keep the defaults.
13044
+ 2. **Custom prompts are APPENDED after the type's built-in scenarios,** which always stay. So a
13045
+ generic one only repeats a default the list already shows, and it becomes a vague playbook
13046
+ agents match on. If you cannot name a recurring job this person will actually come back for,
13047
+ write nothing — the defaults already cover the generic work.
12595
13048
  3. **Only write them when you know the job.** You usually do — the user just told you why they
12596
13049
  wanted this node. A scratch table, a one-off Doc, a folder that only groups things: no prompts.
12597
13050
  4. **\`{target}\` expands to a full sentence** naming the node and space, so give it its own line.
@@ -12709,7 +13162,7 @@ function buildBootstrapMarkdown(origin, ctx) {
12709
13162
 
12710
13163
  ### 0a. MCP first
12711
13164
 
12712
- Ask the Harness to discover and use an existing Busabase MCP integration before CLI. Use Call "list_integration_tools" with "providerKey: busabase-cloud-mcp" to find the Busabase integration, if the tool exists. Verify it with
13165
+ Ask the Harness to discover and use an existing Busabase MCP integration before CLI. Call "list_integration_tools" with "providerKey: busabase-cloud-mcp" to find the Busabase integration, if the tool exists. Verify it with
12713
13166
  a read-only identity and Space check; reconnect it first if configured but disconnected, and do not
12714
13167
  assume tool names.
12715
13168
  ${preselectedSpaceId ? `Target the Dashboard-selected Space \`${preselectedSpaceId}\` in that first check. If access
@@ -13807,7 +14260,7 @@ function configuredOutput() {
13807
14260
  * Pinning the target here — ahead of the first {@link loadDotEnvFile} — means every later
13808
14261
  * read *and write* in this process (including login's token rotation) lands on the same file.
13809
14262
  */
13810
- function resolveConfig(opts) {
14263
+ function resolveConfig(opts, { strict = true } = {}) {
13811
14264
  setActiveCredentialTarget(resolveCredentialTarget({
13812
14265
  profile: opts.profile,
13813
14266
  config: opts.config
@@ -13828,10 +14281,34 @@ function resolveConfig(opts) {
13828
14281
  apiKey: opts.apiKey ?? process.env.BUSABASE_API_KEY ?? file.BUSABASE_API_KEY,
13829
14282
  spaceId: opts.spaceId ?? process.env.BUSABASE_SPACE_ID ?? file.BUSABASE_SPACE_ID,
13830
14283
  sourceChannel: "cli",
14284
+ playbook: resolvePlaybook(opts.playbook, strict),
13831
14285
  output: opts.output ?? configuredOutput() ?? "text",
13832
14286
  fetch: withRetry(fetch, { retries })
13833
14287
  };
13834
14288
  }
14289
+ const PLAYBOOK_FORMAT_HINT = "expected `skill:<nodeId>` or `prompt:<nodeId>:<key>` (copy it from `busabase-cli playbooks search`)";
14290
+ /**
14291
+ * `--playbook` beats `BUSABASE_PLAYBOOK`. The flag is already validated by its
14292
+ * option parser; the env var is checked here so a malformed value fails loudly
14293
+ * instead of the server silently dropping it. `strict: false` is only for the
14294
+ * error path, which must be able to build a config for its message even when
14295
+ * this very check is what failed.
14296
+ */
14297
+ function resolvePlaybook(flag, strict) {
14298
+ if (flag) return flag;
14299
+ const fromEnv = process.env.BUSABASE_PLAYBOOK?.trim();
14300
+ if (!fromEnv) return void 0;
14301
+ if (!parsePlaybookRef(fromEnv)) {
14302
+ if (!strict) return void 0;
14303
+ throw new CliOutcomeError("USAGE", `Invalid BUSABASE_PLAYBOOK "${fromEnv}": ${PLAYBOOK_FORMAT_HINT}.`);
14304
+ }
14305
+ return fromEnv;
14306
+ }
14307
+ const parsePlaybookOption = (value) => {
14308
+ const trimmed = value.trim();
14309
+ if (!parsePlaybookRef(trimmed)) throw new InvalidArgumentError(PLAYBOOK_FORMAT_HINT);
14310
+ return trimmed;
14311
+ };
13835
14312
  /** Enough to ride out a rate limiter or a proxy blip; not enough to hide a real outage. */
13836
14313
  const DEFAULT_RETRIES = 2;
13837
14314
  /**
@@ -13852,14 +14329,15 @@ const GLOBAL_LONG_FLAGS = /* @__PURE__ */ new Set([
13852
14329
  "--all",
13853
14330
  "--max-items",
13854
14331
  "--wait",
13855
- "--wait-timeout"
14332
+ "--wait-timeout",
14333
+ "--playbook"
13856
14334
  ]);
13857
14335
  function addGlobalFlags(cmd) {
13858
14336
  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([
13859
14337
  "text",
13860
14338
  "table",
13861
14339
  "json"
13862
- ])).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);
14340
+ ])).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).option("--playbook <kind:nodeId[:key]>", "the playbook you are following (from `playbooks search`); recorded on the change request (env BUSABASE_PLAYBOOK)", parsePlaybookOption);
13863
14341
  }
13864
14342
  const parseNum = (value) => {
13865
14343
  const parsed = Number(value);
@@ -13975,7 +14453,8 @@ async function rawFetch(config, method, path, body) {
13975
14453
  ...body !== void 0 ? { "content-type": "application/json" } : {},
13976
14454
  ...config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {},
13977
14455
  ...config.spaceId ? { "x-busabase-space": config.spaceId } : {},
13978
- "x-busabase-channel": "cli"
14456
+ "x-busabase-channel": "cli",
14457
+ ...config.playbook ? { [BUSABASE_PLAYBOOK_HEADER]: config.playbook } : {}
13979
14458
  },
13980
14459
  body: body !== void 0 ? JSON.stringify(body) : void 0
13981
14460
  });
@@ -14487,6 +14966,10 @@ function commandsSection(program, showAll = false) {
14487
14966
  return lines.join("\n");
14488
14967
  }
14489
14968
  const HELP_FOOTER = `
14969
+ Any task: first run \`busabase-cli playbooks search --query "…"\` (2–5 phrasings of
14970
+ what the user wants) to find the skill or custom prompt this space already defines
14971
+ for it, then \`playbooks get\` the one that fits and follow it.
14972
+
14490
14973
  Config is read from flags, then env vars, then ~/.busabase/.env (auto-loaded — no
14491
14974
  need to source it). An exported env var overrides the file.
14492
14975
 
@@ -15134,7 +15617,7 @@ Examples:
15134
15617
  message: opts.message,
15135
15618
  submittedBy: opts.submittedBy
15136
15619
  })));
15137
- addGlobalFlags(nodes.command("set-agent-prompts")).description("Set a node's custom scenario prompts, shown in the Agent Prompts dialog instead of the node type's generic ones. Capability prompts (what the node type can actually do) are unaffected.").requiredOption("--node-id <id>", "node id to customize").requiredOption("--file <path>", "JSON file: an array of { key, intent?, label, body } — see the help text below").addHelpText("after", `
15620
+ addGlobalFlags(nodes.command("set-agent-prompts")).description("Set a node's custom scenario prompts. They are ADDED after the node type's built-in scenario prompts in the Agent Prompts dialog — the built-ins are always kept — and agents find them with `playbooks search`. Capability prompts (what the node type can actually do) are unaffected.").requiredOption("--node-id <id>", "node id to customize").requiredOption("--file <path>", "JSON file: an array of { key, intent?, label, body } — see the help text below").addHelpText("after", `
15138
15621
  Each entry: { key: string (unique per node), intent?: "read-only" | "change",
15139
15622
  label: string | { <locale>: string }, body: string | { <locale>: string } }.
15140
15623
  Locale keys (when using the object form): en, zh-CN, zh-TW, ja, ko, de, fr, es,
@@ -15230,13 +15713,28 @@ Examples:
15230
15713
  changeRequestId: opts.changeRequestId,
15231
15714
  reason: opts.reason
15232
15715
  })));
15233
- addGlobalFlags(program.command("search")).description("Full-text search").requiredOption("--query <q>", "search query").option("--limit <n>", "max results", parseNum).option("--offset <n>", "results offset", parseNum).option("--sources <sources...>", "restrict which content to search: \"records\", \"files\", and/or \"names\" (default: all three)").action(runAction(state, (client, opts) => client.search({
15716
+ addGlobalFlags(program.command("search")).description("Full-text search").requiredOption("--query <q>", "search query").option("--limit <n>", "max results", parseNum).option("--offset <n>", "results offset", parseNum).option("--sources <sources...>", "restrict which content to search: \"records\", \"files\", \"names\", and/or \"nodes\" (node content: Doc/HTML/whiteboard/workflow text) (default: all)").action(runAction(state, (client, opts) => client.search({
15234
15717
  query: opts.query,
15235
15718
  limit: opts.limit,
15236
15719
  offset: opts.offset,
15237
15720
  sources: opts.sources
15238
15721
  })));
15239
- addGlobalFlags(program.command("grep")).description("Search files, Docs, and Base records with one pattern (unified grep; use `assets grep` for files-only)").requiredOption("--pattern <regex>", "literal or regex pattern").option("--flags <flags>", "RegExp flags, e.g. \"i\" for case-insensitive").option("--sources <sources...>", "sources to scan: \"files\", \"nodes\", and/or \"records\" (default: all three)").option("--asset-ids <ids...>", "files scope: specific asset ids").option("--drive-path <path>", "files scope: Drive/Skill mounted path prefix").option("--mime-types <types...>", "files scope: MIME types").option("--node-ids <ids...>", "nodes scope: specific node ids").option("--node-types <types...>", "nodes scope: narrow to \"doc\", \"html\", \"whiteboard\" and/or \"workflow\"").option("--base-ids <ids...>", "records scope: specific Base ids").option("--base-slugs <slugs...>", "records scope: specific Base slugs").option("--max-matches <n>", "max matches, shared across sources (default 100, cap 1000)", parsePositiveInt).option("--context-lines <n>", "lines of before/after context (default 0, cap 10)", parseNum).action(runAction(state, (client, opts) => {
15722
+ addGlobalFlags(program.command("grep")).description("Search files, node content, Base records, and custom prompts with one pattern (unified grep; use `assets grep` for files-only)").requiredOption("--pattern <regex>", "literal or regex pattern").option("--flags <flags>", "RegExp flags, e.g. \"i\" for case-insensitive").addOption(new Option("--sources <sources...>", "sources to scan: \"files\", \"nodes\" (Doc/HTML/whiteboard/workflow content), \"records\", and/or \"prompts\" (custom agent prompts, every locale) (default: all four)").choices([
15723
+ "files",
15724
+ "nodes",
15725
+ "records",
15726
+ "prompts"
15727
+ ])).option("--asset-ids <ids...>", "files scope: specific asset ids").option("--drive-path <path>", "files scope: Drive/Skill mounted path prefix").option("--mime-types <types...>", "files scope: MIME types").option("--node-ids <ids...>", "nodes scope: specific node ids").option("--node-types <types...>", "nodes scope: narrow to \"doc\", \"html\", \"whiteboard\" and/or \"workflow\"").option("--base-ids <ids...>", "records scope: specific Base ids").option("--base-slugs <slugs...>", "records scope: specific Base slugs").option("--max-matches <n>", "max matches, shared across sources (default 100, cap 1000)", parsePositiveInt).option("--context-lines <n>", "lines of before/after context (default 0, cap 10)", parseNum).addHelpText("after", `
15728
+ Examples:
15729
+ busabase-cli grep --pattern "Weekly Report" --output json
15730
+ busabase-cli grep --pattern "refund" --flags i --sources files --drive-path "policies/"
15731
+ busabase-cli grep --pattern "ACME" --sources records --base-slugs contracts
15732
+
15733
+ Each file match carries \`owner\`: the skill or drive the file belongs to, with its
15734
+ folder path. Every requested source gets a fair share of --max-matches, so one
15735
+ noisy source cannot hide the rest. When the result says truncated, narrow the
15736
+ scope instead of raising --max-matches: pick --sources, then --base-slugs for
15737
+ records or --drive-path for files.`).action(runAction(state, (client, opts) => {
15240
15738
  const filesScope = opts.assetIds || opts.drivePath || opts.mimeTypes ? {
15241
15739
  assetIds: opts.assetIds,
15242
15740
  drivePath: opts.drivePath,
@@ -15263,6 +15761,38 @@ Examples:
15263
15761
  contextLines: opts.contextLines
15264
15762
  });
15265
15763
  }));
15764
+ const playbooks = program.command("playbooks").description("Find and open the skills and custom prompts this space defines for a job");
15765
+ addGlobalFlags(playbooks.command("search")).description("Call first on every user instruction: find the skills and custom node prompts (playbooks) this space already defines for the job").option("--query <phrasing>", "one phrasing of the user's intent; repeat 2–5 times, in the user's language AND English (none = browse everything, nearest first)", (value, previous) => [...previous, value], []).addOption(new Option("--kinds <kinds...>", "restrict to \"skill\" and/or \"prompt\" (default: both)").choices(["skill", "prompt"])).option("--near-node-id <id>", "the node the user is on; playbooks nearest to it rank first").option("--in-node-id <id>", "only playbooks on this node or inside its subtree").option("--limit <n>", "max items, 1–50 (default 10; browse default 30)", parsePositiveInt).option("--locale <locale>", "locale for prompt labels, e.g. \"zh-CN\" (default en)").addHelpText("after", `
15766
+ Examples:
15767
+ busabase-cli playbooks search --query "记录客户拜访" --query "log customer visit" --query "visit" --output json
15768
+ busabase-cli playbooks search --query "weekly report" --near-node-id nod_123
15769
+
15770
+ If an item fits, \`playbooks get\` it and follow it, then name it in your reply.
15771
+ If nothing fits, do the work yourself. The user's explicit words override a
15772
+ playbook, and a playbook never authorises approving or merging a change request.
15773
+
15774
+ Against a server older than this command, it falls back to matching skill names
15775
+ and descriptions only, warns on stderr, and reports coverage.prompts as
15776
+ "${PROMPTS_UNSUPPORTED}" — an empty result then does NOT mean there are no playbooks.`).action(runAction(state, (client, opts) => playbooksSearchCommand(client, {
15777
+ queries: opts.query ?? [],
15778
+ kinds: opts.kinds,
15779
+ nearNodeId: opts.nearNodeId,
15780
+ inNodeId: opts.inNodeId,
15781
+ limit: opts.limit,
15782
+ locale: opts.locale
15783
+ })));
15784
+ addGlobalFlags(playbooks.command("get")).description("Open one playbook: a custom prompt rendered exactly as Ask Agent sends it, or a skill's SKILL.md plus its file list").addOption(new Option("--kind <kind>", "playbook kind from `playbooks search`").choices(["prompt", "skill"]).makeOptionMandatory()).requiredOption("--node-id <id>", "nodeId from `playbooks search`").option("--key <key>", "prompt key from `playbooks search` (required for --kind prompt)").option("--locale <locale>", "locale to render the prompt in, e.g. \"zh-CN\" (default en)").addHelpText("after", `
15785
+ Examples:
15786
+ busabase-cli playbooks get --kind prompt --node-id nod_456 --key log-visit
15787
+ busabase-cli playbooks get --kind skill --node-id nod_789`).action(runAction(state, (client, opts) => {
15788
+ if (opts.kind === "prompt" && !opts.key) throw new CliOutcomeError("VALIDATION", "--key is required with --kind prompt");
15789
+ return client.playbooks.get({
15790
+ kind: opts.kind,
15791
+ nodeId: opts.nodeId,
15792
+ ...opts.key ? { key: opts.key } : {},
15793
+ ...opts.locale ? { locale: opts.locale } : {}
15794
+ });
15795
+ }));
15266
15796
  const addUploadCommand = (parent, hidden = false) => addGlobalFlags(parent.command("upload", { noHelp: hidden })).description("Upload a file and print an asset ref for record fields").requiredOption("--file <path>", "local file to upload").option("--file-name <name>", "stored file name (default: basename of --file)").option("--mime-type <mime>", "MIME type (default: inferred from extension)").option("--context <value>", "upload context (default record-field)").addHelpText("after", `
15267
15797
  Example:
15268
15798
  busabase-cli assets upload --file ./cover.png --output json
@@ -15543,7 +16073,7 @@ async function runCli(argv) {
15543
16073
  }
15544
16074
  const classified = classifyError(error);
15545
16075
  if (classified.code === "UNAUTHORIZED" || classified.code === "FORBIDDEN") {
15546
- const hintConfig = state.config ?? resolveConfig({});
16076
+ const hintConfig = state.config ?? resolveConfig({}, { strict: false });
15547
16077
  const profile = isMultiProfile() ? activeCredentialTarget().profile : void 0;
15548
16078
  const procedureId = state.procedureId ?? (state.lastRequest ? matchRoute(state.lastRequest.method, state.lastRequest.pathname)?.id : void 0);
15549
16079
  classified.hint = authRecoveryHint(classified.code, hintConfig.baseUrl, profile, {
@@ -15552,7 +16082,7 @@ async function runCli(argv) {
15552
16082
  });
15553
16083
  }
15554
16084
  if (json) console.log(JSON.stringify(errorEnvelope(classified), null, 2));
15555
- console.error(explainError(error, state.config ?? resolveConfig({})));
16085
+ console.error(explainError(error, state.config ?? resolveConfig({}, { strict: false })));
15556
16086
  return classified.exitCode;
15557
16087
  }
15558
16088
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "busa-cli",
3
- "version": "0.80.0",
3
+ "version": "0.90.0",
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.80.0",
36
+ "busabase-sdk": "0.90.0",
37
37
  "commander": "^14.0.0",
38
38
  "qrcode": "^1.5.4",
39
39
  "tar-stream": "^3.1.7",