busa-cli 0.60.0 → 0.71.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.
@@ -24,7 +24,15 @@ try {
24
24
  if (!Array.isArray(delegatedArgv) || delegatedArgv.some((arg) => typeof arg !== "string")) {
25
25
  throw new Error("BUSABASE_CLI_DELEGATED_ARGV must be a JSON string array.");
26
26
  }
27
- process.exit(await runCli(delegatedArgv));
27
+ const delegatedExit = await runCli(delegatedArgv);
28
+ // process.exit() drops whatever stdout has not flushed, and stdout is async when
29
+ // it is a pipe — so a large `--output json` body would arrive truncated at one
30
+ // pipe buffer. Inlined rather than imported from dist: this file is the entry
31
+ // point and must keep working even when dist is mid-build. See src/flush-stdio.ts.
32
+ for (const stream of [process.stdout, process.stderr]) {
33
+ if (stream.writableLength > 0) await new Promise((done) => stream.write("", done));
34
+ }
35
+ process.exit(delegatedExit);
28
36
  }
29
37
  await import(builtCli.href);
30
38
  } catch (error) {
package/dist/cli.js CHANGED
@@ -1,9 +1,45 @@
1
1
  #!/usr/bin/env node
2
- import { i as checkForUpdate, n as pkgVersion, r as runCli } from "./run-bQ1VIBLc.js";
2
+ import { i as checkForUpdate, n as pkgVersion, r as runCli } from "./run-BkaaGFK_.js";
3
+ //#region src/flush-stdio.ts
4
+ /**
5
+ * Wait for stdout/stderr to reach the other side before the process is torn down.
6
+ *
7
+ * `process.exit()` discards whatever is still buffered. That is harmless when stdout
8
+ * is a file or a TTY — Node writes those synchronously — but a PIPE is asynchronous,
9
+ * so exiting immediately after a large `console.log` delivers only what happened to
10
+ * fit in the pipe buffer and drops the rest, with a zero exit code either way.
11
+ *
12
+ * The symptom is brutal to diagnose because it depends on the caller, not the command:
13
+ *
14
+ * busabase-cli skills read-file … --output json > body.json # 41970 bytes
15
+ * busabase-cli skills read-file … --output json | jq .content # 8192 bytes
16
+ *
17
+ * and 8192 is one pipe buffer, so the JSON simply stops mid-string. Anyone piping a
18
+ * large skill or record body into a parser gets a parse error that reads like a server
19
+ * problem. It has been misattributed to stale CLI versions before; the version is
20
+ * irrelevant, the destination is everything.
21
+ *
22
+ * Writing an empty chunk with a callback is the portable way to ask "is everything I
23
+ * queued delivered?" — the callback fires after the stream has flushed what preceded
24
+ * it. A stream that is already drained resolves without queueing anything.
25
+ */
26
+ const flush = (stream) => new Promise((resolve) => {
27
+ if (stream.writableLength === 0) {
28
+ resolve();
29
+ return;
30
+ }
31
+ stream.write("", () => resolve());
32
+ });
33
+ const flushStdio = async () => {
34
+ await flush(process.stdout);
35
+ await flush(process.stderr);
36
+ };
37
+ //#endregion
3
38
  //#region src/cli.ts
4
39
  const exitCode = await runCli(process.argv.slice(2));
5
40
  const notice = await checkForUpdate("busabase-cli", pkgVersion());
6
41
  if (notice) console.error(notice);
42
+ await flushStdio();
7
43
  process.exit(exitCode);
8
44
  //#endregion
9
45
  export {};
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-bQ1VIBLc.js";
1
+ import { a as render, i as checkForUpdate, n as pkgVersion, r as runCli, t as HELP } from "./run-BkaaGFK_.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 };
@@ -1420,22 +1420,34 @@ const formNodeType = {
1420
1420
  label: "Form",
1421
1421
  icon: "form",
1422
1422
  /**
1423
- * `hidden` until a Form can be created from a create surface at all.
1423
+ * No longer `hidden`. The two conditions that hid it are both met:
1424
1424
  *
1425
- * `busabase_forms.target_base_id` is NOT NULL and `form` registers no
1426
- * `node_create` materializer, so a Form built through the generic New-item
1427
- * flow (which only collects name/slug/description) is a node row with no form
1428
- * config behind it — it opens to a dead end, every time, for everyone. The
1429
- * type stays fully `creatable` so `forms.create` (which does take a target
1430
- * Base) and the REST/MCP surface are untouched; it just no longer offers an
1431
- * entry point that cannot succeed. Drop this once the New-item flow asks for
1432
- * the target Base and a materializer writes the config row.
1425
+ * 1. The New-item flow ASKS FOR THE TARGET BASE — `create-node-modal.tsx`
1426
+ * renders a "Writes into" Base picker (plus the field checklist) whenever
1427
+ * `form` is the selected type, and keeps its submit disabled until one is
1428
+ * chosen, the same way it already does for `file` and its asset.
1429
+ * 2. A MATERIALIZER WRITES THE CONFIG ROW — `materializeFormNode`
1430
+ * (busabase-core `domains/form/logic/form-ops.ts`) inserts the
1431
+ * `busabase_forms` row inside the merge transaction, from the
1432
+ * `node_create` operation's `metadata.targetBaseId` / `metadata.formBindings`.
1433
+ * Both create paths run it, so a Form merged after review is configured
1434
+ * exactly like one created immediately.
1435
+ *
1436
+ * A Form that still arrives unconfigured (an API caller that sent only the
1437
+ * generic node fields) is no longer a dead end either: the detail view's
1438
+ * "not set up yet" state now carries a "Connect this form to a Base" action.
1439
+ *
1440
+ * `hidden` is read by THREE create surfaces — web's `create-node-modal.tsx`,
1441
+ * `node-agent-prompts.ts`, and React Native's
1442
+ * `apps/busabase-mobile/.../CreateNodeModal.tsx`. Mobile has no Base picker,
1443
+ * so it names `form` in its own `UNSUPPORTED_TYPES` set (alongside `file`,
1444
+ * which is excluded there for the same "can't collect the required input"
1445
+ * reason) rather than re-creating the dead end on another platform.
1433
1446
  */
1434
1447
  capabilities: {
1435
1448
  hasDetail: true,
1436
1449
  creatable: true,
1437
- publicAccess: "submit",
1438
- hidden: true
1450
+ publicAccess: "submit"
1439
1451
  },
1440
1452
  operations: []
1441
1453
  };
@@ -1710,18 +1722,10 @@ const writeAgentPrompts = async (client, type, result, agentPrompts) => {
1710
1722
  nextStep: "Once the change request merges: busabase-cli nodes set-agent-prompts --node-id <nodeId> --file prompts.json"
1711
1723
  }
1712
1724
  };
1713
- try {
1714
- await client.nodes.updateAgentPrompts({
1715
- nodeId,
1716
- agentPrompts
1717
- });
1718
- } catch (error) {
1719
- if ((isRecord(error) ? error.status : void 0) !== 404) throw error;
1720
- await client.nodes.updateMetadata({
1721
- nodeId,
1722
- metadata: { agentPrompts }
1723
- });
1724
- }
1725
+ await client.nodes.updateAgentPrompts({
1726
+ nodeId,
1727
+ agentPrompts
1728
+ });
1725
1729
  return {
1726
1730
  ...isRecord(result) ? result : { result },
1727
1731
  agentPromptsWrite: {
@@ -6138,6 +6142,7 @@ const fieldTypeSchema = z.enum([
6138
6142
  "html",
6139
6143
  "attachment",
6140
6144
  "relation",
6145
+ "member",
6141
6146
  "number",
6142
6147
  "date",
6143
6148
  "checkbox",
@@ -6244,8 +6249,7 @@ const baseSchema = z.object({
6244
6249
  requiredApprovals: z.number()
6245
6250
  }),
6246
6251
  createdAt: z.string(),
6247
- fields: z.array(baseFieldSchema),
6248
- metadata: z.record(z.string(), z.unknown()).default({})
6252
+ fields: z.array(baseFieldSchema)
6249
6253
  });
6250
6254
  z.object({
6251
6255
  parentNodeId: z.string().optional().describe("Parent node id. Must be a folder or the space root; container-incapable node types (Base, Doc, AirApp, etc.) cannot hold children."),
@@ -6495,6 +6499,25 @@ z.object({
6495
6499
  expiresAt: z.string().nullable(),
6496
6500
  updatedAt: z.string()
6497
6501
  });
6502
+ z.object({
6503
+ nodeId: z.string(),
6504
+ name: z.string(),
6505
+ slug: z.string(),
6506
+ type: z.enum(NODE_TYPES),
6507
+ icon: NodeIconSchema.nullable().default(null),
6508
+ capability: z.enum(["read", "submit"]),
6509
+ hasPassword: z.boolean(),
6510
+ expiresAt: z.string().nullable(),
6511
+ /**
6512
+ * When this share ROW was first created — NOT "public continuously since".
6513
+ * `disableNodeShare` flips `scope` to `"none"` and keeps the row, so a node
6514
+ * that was shared, revoked, and shared again still reports the original
6515
+ * date. There is no column recording when `scope` last became `"public"`,
6516
+ * so the UI says "first shared {time}" rather than claiming an exposure
6517
+ * window the data cannot support. Adding that column is the follow-up.
6518
+ */
6519
+ createdAt: z.string()
6520
+ });
6498
6521
  z.object({
6499
6522
  parentId: z.string().nullable().optional().describe("Node to start from. Omit or null to start from the space root."),
6500
6523
  depth: z.coerce.number().int().min(1).max(5).optional().describe("How many levels beneath the start point to eagerly include (default 2 once either field is set). Capped at 5."),
@@ -6877,6 +6900,36 @@ z.object({
6877
6900
  */
6878
6901
  contentTruncated: z.boolean().default(false)
6879
6902
  });
6903
+ z.discriminatedUnion("event", [
6904
+ z.object({
6905
+ event: z.literal("result_click"),
6906
+ sessionId: z.string().uuid(),
6907
+ surface: z.enum(["quick", "advanced"]),
6908
+ position: z.number().int().min(1).max(100),
6909
+ resultKind: z.enum([
6910
+ "record",
6911
+ "change_request",
6912
+ "base",
6913
+ "file",
6914
+ "node"
6915
+ ])
6916
+ }).strict(),
6917
+ z.object({
6918
+ event: z.literal("quick_to_advanced"),
6919
+ sessionId: z.string().uuid(),
6920
+ surface: z.literal("quick"),
6921
+ resultCount: z.number().int().min(0).max(100)
6922
+ }).strict(),
6923
+ z.object({
6924
+ event: z.literal("results_shown"),
6925
+ sessionId: z.string().uuid(),
6926
+ surface: z.enum(["quick", "advanced"]),
6927
+ resultCount: z.number().int().min(0).max(100),
6928
+ durationMs: z.number().int().min(0).max(12e4),
6929
+ hasMore: z.boolean()
6930
+ }).strict()
6931
+ ]);
6932
+ z.object({ accepted: z.literal(true) });
6880
6933
  z.object({
6881
6934
  kind: z.enum([
6882
6935
  "change_request.created",
@@ -7101,6 +7154,8 @@ const SearchSortSchema = z.enum([
7101
7154
  ]);
7102
7155
  z.object({
7103
7156
  query: z.string().default("").describe("Full-text query. An empty string matches nothing."),
7157
+ mode: z.enum(["quick", "full"]).optional().default("full").describe("Search depth. `quick` skips live file-body scans for typeahead; `full` preserves complete search behavior."),
7158
+ surface: z.enum(["quick", "advanced"]).optional().describe("Optional first-party UI surface for aggregate quality metrics. API/CLI callers should omit it."),
7104
7159
  limit: z.coerce.number().int().min(1).max(100).optional().default(20).describe("Results per page. Capped at 100; note the default is 20, not 50."),
7105
7160
  offset: z.coerce.number().int().min(0).optional().default(0).describe("0-indexed skip count. This endpoint pages by offset, not by cursor."),
7106
7161
  /**
@@ -7197,7 +7252,21 @@ z.object({
7197
7252
  /** Cloud only: the server created this user's first Space during this exact request. */
7198
7253
  createdSpace: z.boolean().optional(),
7199
7254
  /** Cloud only: this auto-created Space still needs its idempotent starter initialization. */
7200
- bootstrapRequired: z.boolean().optional()
7255
+ bootstrapRequired: z.boolean().optional(),
7256
+ /** Cloud only: effective permission ceiling of the credential used for this request. */
7257
+ credentialPermissionLevel: z.enum([
7258
+ "read",
7259
+ "changeRequest",
7260
+ "write",
7261
+ "manage"
7262
+ ]).optional(),
7263
+ /** Cloud only: credential ceiling capped by the selected Space membership role. */
7264
+ effectivePermissionLevel: z.enum([
7265
+ "read",
7266
+ "changeRequest",
7267
+ "write",
7268
+ "manage"
7269
+ ]).optional()
7201
7270
  });
7202
7271
  //#endregion
7203
7272
  //#region ../../packages/busabase-contract/src/domains/base/contract/view-schemas.ts
@@ -11674,7 +11743,9 @@ const workspace = (level) => ({
11674
11743
  */
11675
11744
  const PROCEDURE_PERMISSION_POLICY = {
11676
11745
  "auth.verify": workspace("read"),
11746
+ "spaces.members": workspace("read"),
11677
11747
  search: node("read"),
11748
+ "searchMetrics.report": node("read"),
11678
11749
  grep: node("read"),
11679
11750
  "nodes.list": node("read"),
11680
11751
  "nodes.get": node("read"),
@@ -11747,6 +11818,7 @@ const PROCEDURE_PERMISSION_POLICY = {
11747
11818
  "embedLinks.create": workspace("manage"),
11748
11819
  "embedLinks.list": workspace("manage"),
11749
11820
  "embedLinks.revoke": workspace("manage"),
11821
+ "nodes.share.list": workspace("manage"),
11750
11822
  "nodes.listFavorites": node("read"),
11751
11823
  "auditEvents.list": node("read"),
11752
11824
  "activity.listPaged": node("read"),
@@ -11787,7 +11859,9 @@ const PROCEDURE_PERMISSION_POLICY = {
11787
11859
  "agents.catalog": workspace("manage"),
11788
11860
  "agents.connections.list": workspace("manage"),
11789
11861
  "agents.disconnect": workspace("manage"),
11862
+ "agents.deleteHistory": workspace("manage"),
11790
11863
  "agents.sessions.list": workspace("manage"),
11864
+ "agents.sessions.listPaged": workspace("manage"),
11791
11865
  "agents.sessions.create": workspace("manage"),
11792
11866
  "agents.sessions.prompt": workspace("manage"),
11793
11867
  "agents.sessions.setConfigOption": workspace("manage"),
@@ -12632,10 +12706,22 @@ function buildBootstrapMarkdown(origin, ctx) {
12632
12706
  const ownMachine = isCloud ? "" : " Everything runs and stays on the user's own machine.";
12633
12707
  const step0 = isCloud ? `## Step 0 — Connect to Busabase Cloud
12634
12708
 
12709
+ ### 0a. MCP first
12710
+
12711
+ 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
12712
+ a read-only identity and Space check; reconnect it first if configured but disconnected, and do not
12713
+ assume tool names.
12714
+ ${preselectedSpaceId ? `Target the Dashboard-selected Space \`${preselectedSpaceId}\` in that first check. If access
12715
+ fails, ask the user to switch or reconnect accounts; never choose or create another Space.` : `With no preselected Space, use the only result, ask when there are several, and accept a new
12716
+ personal Space when the identity has no memberships.`}
12717
+ If verification succeeds, skip **0b** and keep MCP for this run. Otherwise use the CLI fallback.
12718
+
12719
+ ### 0b. CLI fallback
12720
+
12635
12721
  ${preselectedSpaceId ? `One move: sign in. The dashboard already chose the Space, so there is nothing to confirm
12636
12722
  afterwards.` : `Two moves: sign in, then confirm the Space you landed in.
12637
12723
 
12638
- ### 0a. Sign in`}
12724
+ #### Sign in`}
12639
12725
 
12640
12726
  Sign-in is two commands, one per conversational turn. The first returns immediately, so the user
12641
12727
  actually receives the link even if your harness only delivers a turn's final message:
@@ -12675,14 +12761,14 @@ Two edge cases fall out of that same rule: an empty *existing* Space has no mark
12675
12761
  connect-only, not a trigger to initialize; and a retry after interrupted initialization still has the
12676
12762
  version-0 marker and safely resumes Step 2.
12677
12763
  ${preselectedSpaceId ? "" : `
12678
- ### 0b. Confirm the target Space
12764
+ ### 0c. Confirm the target Space
12679
12765
 
12680
12766
  Login never asks which Space to use: it takes the server default (the Space the user was most
12681
12767
  recently active in) and lists the rest in \`availableSpaces\`. The user has not seen that choice yet.
12682
12768
 
12683
12769
  Always tell the user which Space was selected (name + id). Then, by \`availableSpaces\` length:
12684
12770
 
12685
- - **One** → nothing to choose. Continue with the \`bootstrapRequired\` branch from 0a.
12771
+ - **One** → nothing to choose. Continue with the \`bootstrapRequired\` branch from 0b.
12686
12772
  - **More than one** → ask once, a lettered choice in the user's language: \`A\` keeps the current
12687
12773
  Space, then one letter per remaining entry. Offer Spaces, never commands — switching is your job,
12688
12774
  so never tell the user to run anything.
@@ -12690,13 +12776,18 @@ Always tell the user which Space was selected (name + id). Then, by \`availableS
12690
12776
  > 📂 You're in **{space.name}** (the Space you used most recently). Keep it, or switch?
12691
12777
  > **A.** Keep **{space.name}** **B.** {otherSpace1.name} **C.** {otherSpace2.name}
12692
12778
 
12693
- On a switch, run both and re-decide the 0a branch from the new \`bootstrapRequired\` — the old one
12779
+ On a switch, run both and re-decide the 0b branch from the new \`bootstrapRequired\` — the old one
12694
12780
  belonged to the previous Space:
12695
12781
 
12696
12782
  \`\`\`bash
12697
12783
  ${BUSABASE_CLI} space use <id>
12698
12784
  ${BUSABASE_CLI} whoami --output json
12699
- \`\`\``}` : `## Step 0 — Connect to Busabase Personal Desktop
12785
+ \`\`\``}
12786
+
12787
+ On MCP, resolve the target Space from the verification result: ${preselectedSpaceId ? `it must match \`${preselectedSpaceId}\`.` : `use one result; ask the user when there are several.`} Then follow \`bootstrapRequired\`: \`false\` goes to Step 3 with no writes; \`true\` goes to
12788
+ Step 1. Before the first write, require write access plus capabilities to inspect, propose, read
12789
+ back, and complete bootstrap. If anything is missing, stop before writing; ask the user to reconnect
12790
+ with more access or approve CLI fallback. Keep the same transport for the rest of the run.` : `## Step 0 — Connect to Busabase Personal Desktop
12700
12791
 
12701
12792
  Everything runs locally — no account, no API key. The user has already confirmed this edition, so
12702
12793
  check whether the
@@ -12831,7 +12922,15 @@ until curl -fsS ${local}/api/v1/bases >/dev/null 2>&1; do sleep 2; done && echo
12831
12922
  const spaceNote = isCloud ? "(Every call carries `x-busabase-space: $BUSABASE_SPACE_ID` — the space picked in Step 0. The API key is user-scoped, so do not omit the header.)" : "(No `spaceId` in local mode.)";
12832
12923
  const step4 = `## Step ${isCloud ? 3 : 4} — The last setup step: install the permanent skills
12833
12924
 
12834
- One step left before everyday use. Install both permanent skills so any agent on this machine can
12925
+ ${isCloud ? `Have the Harness install both skills into its persistent skill store. Prefer its native
12926
+ installer; otherwise run:
12927
+
12928
+ \`\`\`bash
12929
+ npx skills add busabase/skills --skill busabase busabase-app-creator
12930
+ \`\`\`
12931
+
12932
+ MCP remains the transport when it connected successfully; installing skills does not require CLI
12933
+ credentials. Finish only after both skills are available in future sessions.` : `One step left before everyday use. Install both permanent skills so any agent on this machine can
12835
12934
  drive this workspace with **busabase** and create complete workspace apps with
12836
12935
  **busabase-app-creator** every session — no re-pasting this onboarding doc:
12837
12936
 
@@ -12841,7 +12940,8 @@ npx skills add busabase/skills --skill busabase busabase-app-creator
12841
12940
 
12842
12941
  The skills are self-describing. \`busabase\` reads \`~/.busabase/.env\` (set up above) and becomes
12843
12942
  the reference for the everyday loop and full API; \`busabase-app-creator\` builds on it when the
12844
- user wants a complete workspace app. This onboarding doc has now done its job.
12943
+ user wants a complete workspace app.`}
12944
+ This onboarding doc has now done its job.
12845
12945
 
12846
12946
  ## 🎉 You're set up — congratulations!
12847
12947
 
@@ -12858,12 +12958,14 @@ Tell them so, and close the journey with the tracker fully checked — e.g.:
12858
12958
 
12859
12959
  > ✅ Connect · ✅ Initialize (new Spaces only) · ✅ Verify · ✅ Skills
12860
12960
  > 🎉 *You're all set — Busabase is connected, your first workspace is live, and both skills are
12861
- > installed. From here it's everyday use: you ask, I write, and every change keeps a message, an
12961
+ > installed.
12962
+ > From here it's everyday use: you ask, I write, and every change keeps a message, an
12862
12963
  > author and a history you can undo — and I can build a complete workspace app when you need one.*
12863
12964
  ${isCloud ? `
12864
12965
  Finish the congratulations with a clickable Markdown link labeled **Open Busabase Dashboard**.
12865
- ${preselectedSpaceId ? `Step 0 locked the Space ID used below; before replying, verify login confirmed that same Space.` : `Replace \`{space_id}\` with the real Space ID that Step 0 confirmed and saved as
12866
- \`BUSABASE_SPACE_ID\` before replying.`}
12966
+ ${preselectedSpaceId ? `Step 0 locked the Space ID used below; verify whichever path you connected through confirmed
12967
+ that same Space before replying.` : `Replace \`{space_id}\` with the real selected Space ID — whichever path you connected
12968
+ through resolved and confirmed it — before replying.`}
12867
12969
  Never show \`$BUSABASE_SPACE_ID\`, \`{space_id}\`, \`YOUR_SPACE_ID\`, or any other placeholder
12868
12970
  literally to the user. The user's final line must be:
12869
12971
 
@@ -12891,10 +12993,10 @@ You are guiding the user through four milestones. Existing users skip initializa
12891
12993
 
12892
12994
  | # | Milestone | Covered by | Done when |
12893
12995
  | - | --------- | ---------- | --------- |
12894
- | 1 | 🔌 **Connect** | ${isCloud ? "Step 0" : "Step 0 + Step 1"} | ${isCloud ? "device login returns the selected Space" : "the local API responds and the connection is saved"} |
12996
+ | 1 | 🔌 **Connect** | ${isCloud ? "Step 0" : "Step 0 + Step 1"} | ${isCloud ? "a verified MCP integration, or device login, returns the selected Space" : "the local API responds and the connection is saved"} |
12895
12997
  | 2 | 🏗️ **Initialize if required** | ${isCloud ? "Step 1 + Step 2" : "Step 2 + Step 3"} | version-0 Space gets starter data |
12896
12998
  | 3 | ✅ **Verify** | Step ${isCloud ? 2 : 3} | starter records read back and marker becomes version 1 |
12897
- | 4 | 🎓 **Skills installed** | Step ${isCloud ? 3 : 4} | both skills selected by \`npx skills add\`, 🎉 |
12999
+ | 4 | 🎓 **Skills installed** | Step ${isCloud ? 3 : 4} | both skills available in the agent's persistent skill store, 🎉 |
12898
13000
 
12899
13001
  **Conduct rules — these create the step-by-step feel; follow them at every turn:**
12900
13002
 
@@ -12965,7 +13067,15 @@ types (\`auto_number\`, \`created_time\`, \`ai_summary\`, \`ai_tags\`, …).
12965
13067
  This is the everyday write loop, not an exception to it: the user has write access to their own
12966
13068
  new Space, so structure and sample rows land immediately. They already chose this starter — do not
12967
13069
  ask them to approve it, and do not force a review by passing \`autoMerge: false\`. Every write carries system provenance and stable
12968
- identity so retries cannot duplicate data. Briefly show what is being built:
13070
+ identity so retries cannot duplicate data.
13071
+
13072
+ ${isCloud ? `**MCP path:** skip the shell examples and have the Harness run the same sequence: inspect,
13073
+ create missing structure/views, seed with stable idempotency keys, read back, then complete
13074
+ bootstrap. Require merged structure/records and \`materialized: true\` views; otherwise stop for
13075
+ approval and resume only after a canonical View read confirms materialization. The shell examples
13076
+ below are for CLI only.
13077
+
13078
+ ` : ""}Briefly show what is being built:
12969
13079
 
12970
13080
  \`\`\`txt
12971
13081
  📁 CRM
@@ -13090,7 +13200,7 @@ curl -X POST "${api}/api/v1/onboarding/bootstrap-complete"${H}
13090
13200
  \`\`\`
13091
13201
 
13092
13202
  The completion call is idempotent and changes \`agentBootstrapVersion\` from 0 to 1 while preserving
13093
- the rest of the Space onboarding state. Re-run \`whoami\`; \`bootstrapRequired\` must now be false.
13203
+ the rest of the Space onboarding state. ${isCloud ? `Recheck through the active transport; \`bootstrapRequired\` must now be false.` : `Re-run \`whoami\`; \`bootstrapRequired\` must now be false.`}
13094
13204
 
13095
13205
  ### Custom blueprint (any scenario — this is what keeps it general)
13096
13206
 
@@ -13283,6 +13393,107 @@ async function resolveSkillDocument(topic, doc, runtime) {
13283
13393
  source: "server"
13284
13394
  } : bundled(live.reason ?? "the live document could not be read");
13285
13395
  }
13396
+ /**
13397
+ * Read `name`/`description` out of a hosted SKILL.md's YAML frontmatter.
13398
+ *
13399
+ * Deliberately a small hand-rolled reader rather than a YAML dependency: the two fields
13400
+ * it needs are flat scalars, and the shapes that actually occur in the wild are plain,
13401
+ * quoted, and folded (`>-`) — the last of which every long Busabase description uses.
13402
+ * Anything stranger is better rejected loudly here than half-understood.
13403
+ */
13404
+ function parseSkillIdentity(body) {
13405
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(body.trimStart());
13406
+ if (!match) throw new Error("the hosted SKILL.md has no YAML frontmatter");
13407
+ const lines = match[1].split(/\r?\n/);
13408
+ const read = (key) => {
13409
+ const start = lines.findIndex((line) => line.startsWith(`${key}:`));
13410
+ if (start === -1) return void 0;
13411
+ const inline = lines[start].slice(key.length + 1).trim();
13412
+ if (inline === "" || inline === ">" || inline === ">-" || inline === "|" || inline === "|-") {
13413
+ const rest = [];
13414
+ for (const line of lines.slice(start + 1)) {
13415
+ if (!/^\s/.test(line) || line.trim() === "") break;
13416
+ rest.push(line.trim());
13417
+ }
13418
+ return rest.join(" ");
13419
+ }
13420
+ return inline.replace(/^["']|["']$/g, "");
13421
+ };
13422
+ const name = read("name");
13423
+ const description = read("description");
13424
+ if (!name) throw new Error("the hosted SKILL.md frontmatter has no `name`");
13425
+ if (!description) throw new Error("the hosted SKILL.md frontmatter has no `description`; without it no agent would know when to fetch this skill, so a stub for it cannot work");
13426
+ return {
13427
+ name,
13428
+ description
13429
+ };
13430
+ }
13431
+ /**
13432
+ * Render the stub. Two details here are not stylistic:
13433
+ *
13434
+ * `--output json` — the default text output renders the body as a one-line preview
13435
+ * (a 40 KB skill comes back as ~400 bytes ending in an ellipsis), so a stub that
13436
+ * omitted it would hand the agent a truncated procedure and no error.
13437
+ *
13438
+ * `@latest` — `npx` will reuse whatever copy of the CLI it already cached, which may
13439
+ * predate the subcommand entirely.
13440
+ *
13441
+ * The fetch-first wording is equally deliberate. A stub whose fetch fails must stop,
13442
+ * not improvise from the one-line description; that failure mode is the whole reason
13443
+ * the body is centralized in the first place.
13444
+ */
13445
+ function buildPointerStub(input) {
13446
+ const space = input.spaceName ? `${input.spaceName}${input.spaceId ? ` \`${input.spaceId}\`` : ""}` : input.spaceId ?? "—";
13447
+ return `---
13448
+ name: ${input.name}
13449
+ description: ${JSON.stringify(input.description)}
13450
+ ---
13451
+
13452
+ The body of this skill lives in Busabase, in the same folder as the resources it
13453
+ works with, so it moves when they move. **Fetch it before doing anything:**
13454
+
13455
+ \`\`\`bash
13456
+ npx busabase-cli@latest skills read-file --node-id ${input.nodeId} \\
13457
+ --file-path SKILL.md --output json
13458
+ \`\`\`
13459
+
13460
+ \`--output json\` is required — the default text output truncates the body to a
13461
+ one-line preview. \`@latest\` is required too: \`npx\` will otherwise reuse a cached
13462
+ older CLI that may not have this subcommand.
13463
+
13464
+ If the fetch fails — no access, the node moved, the network is down — **stop and say
13465
+ so.** Do not improvise the procedure from the description above; the body is hosted in
13466
+ one place precisely so that every repository runs the same one.
13467
+
13468
+ | What | Where |
13469
+ | --- | --- |
13470
+ | Space | ${space} |
13471
+ | Skill node | \`${input.name}\` — \`${input.nodeId}\` |
13472
+ `;
13473
+ }
13474
+ /**
13475
+ * Write `<dir>/<name>/SKILL.md`. Same no-clobber rule as `installSkillDoc`, for the
13476
+ * same reason: the file already there may be a real skill someone wrote, and replacing
13477
+ * a body with a pointer to a different one is not a refresh, it is a swap.
13478
+ */
13479
+ function linkSkillDoc(input) {
13480
+ const exists = input.exists ?? existsSync;
13481
+ const write = input.write ?? ((path, body) => {
13482
+ mkdirSync(dirname(path), { recursive: true });
13483
+ writeFileSync(path, body, "utf8");
13484
+ });
13485
+ const path = join(input.dir, input.name, "SKILL.md");
13486
+ if (exists(path) && !input.force) return {
13487
+ path,
13488
+ written: false,
13489
+ reason: `${input.name} already exists there; pass --force to replace it`
13490
+ };
13491
+ write(path, content(input.content));
13492
+ return {
13493
+ path,
13494
+ written: true
13495
+ };
13496
+ }
13286
13497
  //#endregion
13287
13498
  //#region src/task-command.ts
13288
13499
  const collect = (value, previous = []) => [...previous, value];
@@ -13868,24 +14079,6 @@ async function putTextCommand(client, opts) {
13868
14079
  });
13869
14080
  }
13870
14081
  /** Wrap a leaf-command handler: resolve config, build the client, render the result. */
13871
- /**
13872
- * Is this a 404 — either "this server has no such route" or "no such node"?
13873
- *
13874
- * Duck-typed rather than an `instanceof ORPCError` check, because this package
13875
- * deliberately depends on no `@orpc/*` package; the SDK builds the error and
13876
- * only its `status` is needed here.
13877
- *
13878
- * Not distinguishing the two 404s is safe for the one thing this gates: a
13879
- * version fallback whose other branch targets the SAME node. An old server
13880
- * answers 404 because the route is missing and the fallback then succeeds; a
13881
- * current server answers 404 because the node does not exist, the fallback
13882
- * asks about the same missing node and 404s too, and the caller still sees a
13883
- * "not found" error. The cost of guessing wrong is one extra request, not a
13884
- * write landing somewhere unintended.
13885
- */
13886
- function isNotFound(error) {
13887
- return typeof error === "object" && error !== null && "status" in error && error.status === 404;
13888
- }
13889
14082
  function runAction(state, handler) {
13890
14083
  return async (_opts, cmd) => {
13891
14084
  const opts = cmd.optsWithGlobals();
@@ -14628,6 +14821,64 @@ Default target when neither is present: ${skillsDirDefaultHint()}`).action((dir,
14628
14821
  console.log(` ${result.path}`);
14629
14822
  console.error(`\n[busabase-cli] this copy is bound to this host. For the canonical pair, run:\n ${SKILL_REGISTRY_HINT}`);
14630
14823
  });
14824
+ addGlobalFlags(skill.command("link")).argument("[dir]", `skills directory; default: ./.agents/skills, else ./.claude/skills`).description("Point this repo at a Skill node hosted in a workspace, without copying it").requiredOption("--node-id <id>", "the Skill node whose body stays canonical").addOption(new Option("--dir <path>", "same as the positional argument")).addOption(new Option("--force", "replace an existing skill of the same name")).addHelpText("after", `
14825
+ \`install\` writes a COPY. \`link\` writes a POINTER: a short SKILL.md holding only the
14826
+ hosted skill's own name and description plus the command to fetch its body, so the
14827
+ body itself never lands on disk here.
14828
+
14829
+ Use it when one skill has to serve several repositories. Vendoring a copy into each
14830
+ one means copies that drift; a pointer means every repository reads the one body, and
14831
+ an edit to it — reviewed once, through a Change Request — is live for all of them at
14832
+ the same moment.
14833
+
14834
+ busabase-cli skill link --node-id nodmtv5rwuofuhaqwl
14835
+
14836
+ Writes <dir>/<the skill's own name>/SKILL.md. The name comes from the hosted
14837
+ frontmatter, not from you, because that name is how an agent resolves "read the
14838
+ crm-visits skill in this folder".
14839
+
14840
+ A pointer names a specific node in a specific space, so: everyone who uses it needs
14841
+ read access to that space, and it must never be published as a template — a template
14842
+ materializes fresh resources in the installer's space, while these ids name yours.
14843
+ Default target when neither is present: ${skillsDirDefaultHint()}`).action(async (dir, _opts, cmd) => {
14844
+ const opts = cmd.optsWithGlobals();
14845
+ const config = resolveConfig(opts);
14846
+ state.config = config;
14847
+ const nodeId = String(opts.nodeId);
14848
+ const file = await createBusabaseClient(config).fileTrees.readFile({
14849
+ nodeId,
14850
+ type: "skill",
14851
+ filePath: "SKILL.md"
14852
+ });
14853
+ const identity = parseSkillIdentity(String(file.content ?? ""));
14854
+ const result = linkSkillDoc({
14855
+ dir: resolveSkillsDir({
14856
+ explicit: dir ?? opts.dir,
14857
+ cwd: process.cwd(),
14858
+ home: homedir(),
14859
+ exists: existsSync
14860
+ }),
14861
+ name: identity.name,
14862
+ content: buildPointerStub({
14863
+ ...identity,
14864
+ nodeId,
14865
+ spaceId: config.spaceId
14866
+ }),
14867
+ force: Boolean(opts.force)
14868
+ });
14869
+ if (!result.written) throw new CliOutcomeError("CONFLICT", `Not linked: ${result.path} — ${result.reason}`);
14870
+ if (config.output === "json") {
14871
+ console.log(render({
14872
+ path: result.path,
14873
+ name: identity.name,
14874
+ nodeId
14875
+ }, "json"));
14876
+ return;
14877
+ }
14878
+ console.log(`Linked the \`${identity.name}\` skill to node ${nodeId} at:`);
14879
+ console.log(` ${result.path}`);
14880
+ console.error("\n[busabase-cli] this is a pointer, not a copy — the body stays in the workspace and is fetched on use. Everyone using it needs read access to that space.");
14881
+ });
14631
14882
  addGlobalFlags(program.command("setup-skill")).description("Alias for `skill setup` — the onboarding document").addOption(modeOption()).addOption(withKeyOption()).addOption(new Option("--offline", "skip the server and print the bundled copy")).action(async (_opts, cmd) => {
14632
14883
  const opts = cmd.optsWithGlobals();
14633
14884
  const config = resolveConfig(opts);
@@ -14911,18 +15162,10 @@ Examples:
14911
15162
  throw new CliOutcomeError("VALIDATION", `Invalid agent prompts in ${filePath}${detail ? ` — ${detail}` : ""}`);
14912
15163
  }
14913
15164
  const nodeId = opts.nodeId;
14914
- try {
14915
- return await client.nodes.updateAgentPrompts({
14916
- nodeId,
14917
- agentPrompts: parsed.data
14918
- });
14919
- } catch (error) {
14920
- if (!isNotFound(error)) throw error;
14921
- return await client.nodes.updateMetadata({
14922
- nodeId,
14923
- metadata: { agentPrompts: parsed.data }
14924
- });
14925
- }
15165
+ return await client.nodes.updateAgentPrompts({
15166
+ nodeId,
15167
+ agentPrompts: parsed.data
15168
+ });
14926
15169
  }));
14927
15170
  const bases = program.command("bases").description("Bases (structured tables)");
14928
15171
  addGlobalFlags(bases.command("list")).description("List Bases in the active space").addOption(new Option("--status <active|archived>", "live Bases (default) or archived ones").choices(["active", "archived"])).action(runAction(state, (client, opts) => client.bases.list({ status: opts.status ?? "active" })));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "busa-cli",
3
- "version": "0.60.0",
3
+ "version": "0.71.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.60.0",
36
+ "busabase-sdk": "0.71.0",
37
37
  "commander": "^14.0.0",
38
38
  "qrcode": "^1.5.4",
39
39
  "tar-stream": "^3.1.7",