busa-cli 0.60.0 → 0.70.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-CTgZmlyi.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-CTgZmlyi.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."),
@@ -6877,6 +6881,36 @@ z.object({
6877
6881
  */
6878
6882
  contentTruncated: z.boolean().default(false)
6879
6883
  });
6884
+ z.discriminatedUnion("event", [
6885
+ z.object({
6886
+ event: z.literal("result_click"),
6887
+ sessionId: z.string().uuid(),
6888
+ surface: z.enum(["quick", "advanced"]),
6889
+ position: z.number().int().min(1).max(100),
6890
+ resultKind: z.enum([
6891
+ "record",
6892
+ "change_request",
6893
+ "base",
6894
+ "file",
6895
+ "node"
6896
+ ])
6897
+ }).strict(),
6898
+ z.object({
6899
+ event: z.literal("quick_to_advanced"),
6900
+ sessionId: z.string().uuid(),
6901
+ surface: z.literal("quick"),
6902
+ resultCount: z.number().int().min(0).max(100)
6903
+ }).strict(),
6904
+ z.object({
6905
+ event: z.literal("results_shown"),
6906
+ sessionId: z.string().uuid(),
6907
+ surface: z.enum(["quick", "advanced"]),
6908
+ resultCount: z.number().int().min(0).max(100),
6909
+ durationMs: z.number().int().min(0).max(12e4),
6910
+ hasMore: z.boolean()
6911
+ }).strict()
6912
+ ]);
6913
+ z.object({ accepted: z.literal(true) });
6880
6914
  z.object({
6881
6915
  kind: z.enum([
6882
6916
  "change_request.created",
@@ -7101,6 +7135,8 @@ const SearchSortSchema = z.enum([
7101
7135
  ]);
7102
7136
  z.object({
7103
7137
  query: z.string().default("").describe("Full-text query. An empty string matches nothing."),
7138
+ mode: z.enum(["quick", "full"]).optional().default("full").describe("Search depth. `quick` skips live file-body scans for typeahead; `full` preserves complete search behavior."),
7139
+ surface: z.enum(["quick", "advanced"]).optional().describe("Optional first-party UI surface for aggregate quality metrics. API/CLI callers should omit it."),
7104
7140
  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
7141
  offset: z.coerce.number().int().min(0).optional().default(0).describe("0-indexed skip count. This endpoint pages by offset, not by cursor."),
7106
7142
  /**
@@ -7197,7 +7233,21 @@ z.object({
7197
7233
  /** Cloud only: the server created this user's first Space during this exact request. */
7198
7234
  createdSpace: z.boolean().optional(),
7199
7235
  /** Cloud only: this auto-created Space still needs its idempotent starter initialization. */
7200
- bootstrapRequired: z.boolean().optional()
7236
+ bootstrapRequired: z.boolean().optional(),
7237
+ /** Cloud only: effective permission ceiling of the credential used for this request. */
7238
+ credentialPermissionLevel: z.enum([
7239
+ "read",
7240
+ "changeRequest",
7241
+ "write",
7242
+ "manage"
7243
+ ]).optional(),
7244
+ /** Cloud only: credential ceiling capped by the selected Space membership role. */
7245
+ effectivePermissionLevel: z.enum([
7246
+ "read",
7247
+ "changeRequest",
7248
+ "write",
7249
+ "manage"
7250
+ ]).optional()
7201
7251
  });
7202
7252
  //#endregion
7203
7253
  //#region ../../packages/busabase-contract/src/domains/base/contract/view-schemas.ts
@@ -11674,7 +11724,9 @@ const workspace = (level) => ({
11674
11724
  */
11675
11725
  const PROCEDURE_PERMISSION_POLICY = {
11676
11726
  "auth.verify": workspace("read"),
11727
+ "spaces.members": workspace("read"),
11677
11728
  search: node("read"),
11729
+ "searchMetrics.report": node("read"),
11678
11730
  grep: node("read"),
11679
11731
  "nodes.list": node("read"),
11680
11732
  "nodes.get": node("read"),
@@ -11787,7 +11839,9 @@ const PROCEDURE_PERMISSION_POLICY = {
11787
11839
  "agents.catalog": workspace("manage"),
11788
11840
  "agents.connections.list": workspace("manage"),
11789
11841
  "agents.disconnect": workspace("manage"),
11842
+ "agents.deleteHistory": workspace("manage"),
11790
11843
  "agents.sessions.list": workspace("manage"),
11844
+ "agents.sessions.listPaged": workspace("manage"),
11791
11845
  "agents.sessions.create": workspace("manage"),
11792
11846
  "agents.sessions.prompt": workspace("manage"),
11793
11847
  "agents.sessions.setConfigOption": workspace("manage"),
@@ -12632,10 +12686,22 @@ function buildBootstrapMarkdown(origin, ctx) {
12632
12686
  const ownMachine = isCloud ? "" : " Everything runs and stays on the user's own machine.";
12633
12687
  const step0 = isCloud ? `## Step 0 — Connect to Busabase Cloud
12634
12688
 
12689
+ ### 0a. MCP first
12690
+
12691
+ 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
12692
+ a read-only identity and Space check; reconnect it first if configured but disconnected, and do not
12693
+ assume tool names.
12694
+ ${preselectedSpaceId ? `Target the Dashboard-selected Space \`${preselectedSpaceId}\` in that first check. If access
12695
+ 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
12696
+ personal Space when the identity has no memberships.`}
12697
+ If verification succeeds, skip **0b** and keep MCP for this run. Otherwise use the CLI fallback.
12698
+
12699
+ ### 0b. CLI fallback
12700
+
12635
12701
  ${preselectedSpaceId ? `One move: sign in. The dashboard already chose the Space, so there is nothing to confirm
12636
12702
  afterwards.` : `Two moves: sign in, then confirm the Space you landed in.
12637
12703
 
12638
- ### 0a. Sign in`}
12704
+ #### Sign in`}
12639
12705
 
12640
12706
  Sign-in is two commands, one per conversational turn. The first returns immediately, so the user
12641
12707
  actually receives the link even if your harness only delivers a turn's final message:
@@ -12675,14 +12741,14 @@ Two edge cases fall out of that same rule: an empty *existing* Space has no mark
12675
12741
  connect-only, not a trigger to initialize; and a retry after interrupted initialization still has the
12676
12742
  version-0 marker and safely resumes Step 2.
12677
12743
  ${preselectedSpaceId ? "" : `
12678
- ### 0b. Confirm the target Space
12744
+ ### 0c. Confirm the target Space
12679
12745
 
12680
12746
  Login never asks which Space to use: it takes the server default (the Space the user was most
12681
12747
  recently active in) and lists the rest in \`availableSpaces\`. The user has not seen that choice yet.
12682
12748
 
12683
12749
  Always tell the user which Space was selected (name + id). Then, by \`availableSpaces\` length:
12684
12750
 
12685
- - **One** → nothing to choose. Continue with the \`bootstrapRequired\` branch from 0a.
12751
+ - **One** → nothing to choose. Continue with the \`bootstrapRequired\` branch from 0b.
12686
12752
  - **More than one** → ask once, a lettered choice in the user's language: \`A\` keeps the current
12687
12753
  Space, then one letter per remaining entry. Offer Spaces, never commands — switching is your job,
12688
12754
  so never tell the user to run anything.
@@ -12690,13 +12756,18 @@ Always tell the user which Space was selected (name + id). Then, by \`availableS
12690
12756
  > 📂 You're in **{space.name}** (the Space you used most recently). Keep it, or switch?
12691
12757
  > **A.** Keep **{space.name}** **B.** {otherSpace1.name} **C.** {otherSpace2.name}
12692
12758
 
12693
- On a switch, run both and re-decide the 0a branch from the new \`bootstrapRequired\` — the old one
12759
+ On a switch, run both and re-decide the 0b branch from the new \`bootstrapRequired\` — the old one
12694
12760
  belonged to the previous Space:
12695
12761
 
12696
12762
  \`\`\`bash
12697
12763
  ${BUSABASE_CLI} space use <id>
12698
12764
  ${BUSABASE_CLI} whoami --output json
12699
- \`\`\``}` : `## Step 0 — Connect to Busabase Personal Desktop
12765
+ \`\`\``}
12766
+
12767
+ 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
12768
+ Step 1. Before the first write, require write access plus capabilities to inspect, propose, read
12769
+ back, and complete bootstrap. If anything is missing, stop before writing; ask the user to reconnect
12770
+ 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
12771
 
12701
12772
  Everything runs locally — no account, no API key. The user has already confirmed this edition, so
12702
12773
  check whether the
@@ -12831,7 +12902,15 @@ until curl -fsS ${local}/api/v1/bases >/dev/null 2>&1; do sleep 2; done && echo
12831
12902
  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
12903
  const step4 = `## Step ${isCloud ? 3 : 4} — The last setup step: install the permanent skills
12833
12904
 
12834
- One step left before everyday use. Install both permanent skills so any agent on this machine can
12905
+ ${isCloud ? `Have the Harness install both skills into its persistent skill store. Prefer its native
12906
+ installer; otherwise run:
12907
+
12908
+ \`\`\`bash
12909
+ npx skills add busabase/skills --skill busabase busabase-app-creator
12910
+ \`\`\`
12911
+
12912
+ MCP remains the transport when it connected successfully; installing skills does not require CLI
12913
+ 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
12914
  drive this workspace with **busabase** and create complete workspace apps with
12836
12915
  **busabase-app-creator** every session — no re-pasting this onboarding doc:
12837
12916
 
@@ -12841,7 +12920,8 @@ npx skills add busabase/skills --skill busabase busabase-app-creator
12841
12920
 
12842
12921
  The skills are self-describing. \`busabase\` reads \`~/.busabase/.env\` (set up above) and becomes
12843
12922
  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.
12923
+ user wants a complete workspace app.`}
12924
+ This onboarding doc has now done its job.
12845
12925
 
12846
12926
  ## 🎉 You're set up — congratulations!
12847
12927
 
@@ -12858,12 +12938,14 @@ Tell them so, and close the journey with the tracker fully checked — e.g.:
12858
12938
 
12859
12939
  > ✅ Connect · ✅ Initialize (new Spaces only) · ✅ Verify · ✅ Skills
12860
12940
  > 🎉 *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
12941
+ > installed.
12942
+ > From here it's everyday use: you ask, I write, and every change keeps a message, an
12862
12943
  > author and a history you can undo — and I can build a complete workspace app when you need one.*
12863
12944
  ${isCloud ? `
12864
12945
  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.`}
12946
+ ${preselectedSpaceId ? `Step 0 locked the Space ID used below; verify whichever path you connected through confirmed
12947
+ that same Space before replying.` : `Replace \`{space_id}\` with the real selected Space ID — whichever path you connected
12948
+ through resolved and confirmed it — before replying.`}
12867
12949
  Never show \`$BUSABASE_SPACE_ID\`, \`{space_id}\`, \`YOUR_SPACE_ID\`, or any other placeholder
12868
12950
  literally to the user. The user's final line must be:
12869
12951
 
@@ -12891,10 +12973,10 @@ You are guiding the user through four milestones. Existing users skip initializa
12891
12973
 
12892
12974
  | # | Milestone | Covered by | Done when |
12893
12975
  | - | --------- | ---------- | --------- |
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"} |
12976
+ | 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
12977
  | 2 | 🏗️ **Initialize if required** | ${isCloud ? "Step 1 + Step 2" : "Step 2 + Step 3"} | version-0 Space gets starter data |
12896
12978
  | 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\`, 🎉 |
12979
+ | 4 | 🎓 **Skills installed** | Step ${isCloud ? 3 : 4} | both skills available in the agent's persistent skill store, 🎉 |
12898
12980
 
12899
12981
  **Conduct rules — these create the step-by-step feel; follow them at every turn:**
12900
12982
 
@@ -12965,7 +13047,15 @@ types (\`auto_number\`, \`created_time\`, \`ai_summary\`, \`ai_tags\`, …).
12965
13047
  This is the everyday write loop, not an exception to it: the user has write access to their own
12966
13048
  new Space, so structure and sample rows land immediately. They already chose this starter — do not
12967
13049
  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:
13050
+ identity so retries cannot duplicate data.
13051
+
13052
+ ${isCloud ? `**MCP path:** skip the shell examples and have the Harness run the same sequence: inspect,
13053
+ create missing structure/views, seed with stable idempotency keys, read back, then complete
13054
+ bootstrap. Require merged structure/records and \`materialized: true\` views; otherwise stop for
13055
+ approval and resume only after a canonical View read confirms materialization. The shell examples
13056
+ below are for CLI only.
13057
+
13058
+ ` : ""}Briefly show what is being built:
12969
13059
 
12970
13060
  \`\`\`txt
12971
13061
  📁 CRM
@@ -13090,7 +13180,7 @@ curl -X POST "${api}/api/v1/onboarding/bootstrap-complete"${H}
13090
13180
  \`\`\`
13091
13181
 
13092
13182
  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.
13183
+ 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
13184
 
13095
13185
  ### Custom blueprint (any scenario — this is what keeps it general)
13096
13186
 
@@ -13283,6 +13373,107 @@ async function resolveSkillDocument(topic, doc, runtime) {
13283
13373
  source: "server"
13284
13374
  } : bundled(live.reason ?? "the live document could not be read");
13285
13375
  }
13376
+ /**
13377
+ * Read `name`/`description` out of a hosted SKILL.md's YAML frontmatter.
13378
+ *
13379
+ * Deliberately a small hand-rolled reader rather than a YAML dependency: the two fields
13380
+ * it needs are flat scalars, and the shapes that actually occur in the wild are plain,
13381
+ * quoted, and folded (`>-`) — the last of which every long Busabase description uses.
13382
+ * Anything stranger is better rejected loudly here than half-understood.
13383
+ */
13384
+ function parseSkillIdentity(body) {
13385
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(body.trimStart());
13386
+ if (!match) throw new Error("the hosted SKILL.md has no YAML frontmatter");
13387
+ const lines = match[1].split(/\r?\n/);
13388
+ const read = (key) => {
13389
+ const start = lines.findIndex((line) => line.startsWith(`${key}:`));
13390
+ if (start === -1) return void 0;
13391
+ const inline = lines[start].slice(key.length + 1).trim();
13392
+ if (inline === "" || inline === ">" || inline === ">-" || inline === "|" || inline === "|-") {
13393
+ const rest = [];
13394
+ for (const line of lines.slice(start + 1)) {
13395
+ if (!/^\s/.test(line) || line.trim() === "") break;
13396
+ rest.push(line.trim());
13397
+ }
13398
+ return rest.join(" ");
13399
+ }
13400
+ return inline.replace(/^["']|["']$/g, "");
13401
+ };
13402
+ const name = read("name");
13403
+ const description = read("description");
13404
+ if (!name) throw new Error("the hosted SKILL.md frontmatter has no `name`");
13405
+ 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");
13406
+ return {
13407
+ name,
13408
+ description
13409
+ };
13410
+ }
13411
+ /**
13412
+ * Render the stub. Two details here are not stylistic:
13413
+ *
13414
+ * `--output json` — the default text output renders the body as a one-line preview
13415
+ * (a 40 KB skill comes back as ~400 bytes ending in an ellipsis), so a stub that
13416
+ * omitted it would hand the agent a truncated procedure and no error.
13417
+ *
13418
+ * `@latest` — `npx` will reuse whatever copy of the CLI it already cached, which may
13419
+ * predate the subcommand entirely.
13420
+ *
13421
+ * The fetch-first wording is equally deliberate. A stub whose fetch fails must stop,
13422
+ * not improvise from the one-line description; that failure mode is the whole reason
13423
+ * the body is centralized in the first place.
13424
+ */
13425
+ function buildPointerStub(input) {
13426
+ const space = input.spaceName ? `${input.spaceName}${input.spaceId ? ` \`${input.spaceId}\`` : ""}` : input.spaceId ?? "—";
13427
+ return `---
13428
+ name: ${input.name}
13429
+ description: ${JSON.stringify(input.description)}
13430
+ ---
13431
+
13432
+ The body of this skill lives in Busabase, in the same folder as the resources it
13433
+ works with, so it moves when they move. **Fetch it before doing anything:**
13434
+
13435
+ \`\`\`bash
13436
+ npx busabase-cli@latest skills read-file --node-id ${input.nodeId} \\
13437
+ --file-path SKILL.md --output json
13438
+ \`\`\`
13439
+
13440
+ \`--output json\` is required — the default text output truncates the body to a
13441
+ one-line preview. \`@latest\` is required too: \`npx\` will otherwise reuse a cached
13442
+ older CLI that may not have this subcommand.
13443
+
13444
+ If the fetch fails — no access, the node moved, the network is down — **stop and say
13445
+ so.** Do not improvise the procedure from the description above; the body is hosted in
13446
+ one place precisely so that every repository runs the same one.
13447
+
13448
+ | What | Where |
13449
+ | --- | --- |
13450
+ | Space | ${space} |
13451
+ | Skill node | \`${input.name}\` — \`${input.nodeId}\` |
13452
+ `;
13453
+ }
13454
+ /**
13455
+ * Write `<dir>/<name>/SKILL.md`. Same no-clobber rule as `installSkillDoc`, for the
13456
+ * same reason: the file already there may be a real skill someone wrote, and replacing
13457
+ * a body with a pointer to a different one is not a refresh, it is a swap.
13458
+ */
13459
+ function linkSkillDoc(input) {
13460
+ const exists = input.exists ?? existsSync;
13461
+ const write = input.write ?? ((path, body) => {
13462
+ mkdirSync(dirname(path), { recursive: true });
13463
+ writeFileSync(path, body, "utf8");
13464
+ });
13465
+ const path = join(input.dir, input.name, "SKILL.md");
13466
+ if (exists(path) && !input.force) return {
13467
+ path,
13468
+ written: false,
13469
+ reason: `${input.name} already exists there; pass --force to replace it`
13470
+ };
13471
+ write(path, content(input.content));
13472
+ return {
13473
+ path,
13474
+ written: true
13475
+ };
13476
+ }
13286
13477
  //#endregion
13287
13478
  //#region src/task-command.ts
13288
13479
  const collect = (value, previous = []) => [...previous, value];
@@ -13868,24 +14059,6 @@ async function putTextCommand(client, opts) {
13868
14059
  });
13869
14060
  }
13870
14061
  /** 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
14062
  function runAction(state, handler) {
13890
14063
  return async (_opts, cmd) => {
13891
14064
  const opts = cmd.optsWithGlobals();
@@ -14628,6 +14801,64 @@ Default target when neither is present: ${skillsDirDefaultHint()}`).action((dir,
14628
14801
  console.log(` ${result.path}`);
14629
14802
  console.error(`\n[busabase-cli] this copy is bound to this host. For the canonical pair, run:\n ${SKILL_REGISTRY_HINT}`);
14630
14803
  });
14804
+ 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", `
14805
+ \`install\` writes a COPY. \`link\` writes a POINTER: a short SKILL.md holding only the
14806
+ hosted skill's own name and description plus the command to fetch its body, so the
14807
+ body itself never lands on disk here.
14808
+
14809
+ Use it when one skill has to serve several repositories. Vendoring a copy into each
14810
+ one means copies that drift; a pointer means every repository reads the one body, and
14811
+ an edit to it — reviewed once, through a Change Request — is live for all of them at
14812
+ the same moment.
14813
+
14814
+ busabase-cli skill link --node-id nodmtv5rwuofuhaqwl
14815
+
14816
+ Writes <dir>/<the skill's own name>/SKILL.md. The name comes from the hosted
14817
+ frontmatter, not from you, because that name is how an agent resolves "read the
14818
+ crm-visits skill in this folder".
14819
+
14820
+ A pointer names a specific node in a specific space, so: everyone who uses it needs
14821
+ read access to that space, and it must never be published as a template — a template
14822
+ materializes fresh resources in the installer's space, while these ids name yours.
14823
+ Default target when neither is present: ${skillsDirDefaultHint()}`).action(async (dir, _opts, cmd) => {
14824
+ const opts = cmd.optsWithGlobals();
14825
+ const config = resolveConfig(opts);
14826
+ state.config = config;
14827
+ const nodeId = String(opts.nodeId);
14828
+ const file = await createBusabaseClient(config).fileTrees.readFile({
14829
+ nodeId,
14830
+ type: "skill",
14831
+ filePath: "SKILL.md"
14832
+ });
14833
+ const identity = parseSkillIdentity(String(file.content ?? ""));
14834
+ const result = linkSkillDoc({
14835
+ dir: resolveSkillsDir({
14836
+ explicit: dir ?? opts.dir,
14837
+ cwd: process.cwd(),
14838
+ home: homedir(),
14839
+ exists: existsSync
14840
+ }),
14841
+ name: identity.name,
14842
+ content: buildPointerStub({
14843
+ ...identity,
14844
+ nodeId,
14845
+ spaceId: config.spaceId
14846
+ }),
14847
+ force: Boolean(opts.force)
14848
+ });
14849
+ if (!result.written) throw new CliOutcomeError("CONFLICT", `Not linked: ${result.path} — ${result.reason}`);
14850
+ if (config.output === "json") {
14851
+ console.log(render({
14852
+ path: result.path,
14853
+ name: identity.name,
14854
+ nodeId
14855
+ }, "json"));
14856
+ return;
14857
+ }
14858
+ console.log(`Linked the \`${identity.name}\` skill to node ${nodeId} at:`);
14859
+ console.log(` ${result.path}`);
14860
+ 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.");
14861
+ });
14631
14862
  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
14863
  const opts = cmd.optsWithGlobals();
14633
14864
  const config = resolveConfig(opts);
@@ -14911,18 +15142,10 @@ Examples:
14911
15142
  throw new CliOutcomeError("VALIDATION", `Invalid agent prompts in ${filePath}${detail ? ` — ${detail}` : ""}`);
14912
15143
  }
14913
15144
  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
- }
15145
+ return await client.nodes.updateAgentPrompts({
15146
+ nodeId,
15147
+ agentPrompts: parsed.data
15148
+ });
14926
15149
  }));
14927
15150
  const bases = program.command("bases").description("Bases (structured tables)");
14928
15151
  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.70.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.70.0",
37
37
  "commander": "^14.0.0",
38
38
  "qrcode": "^1.5.4",
39
39
  "tar-stream": "^3.1.7",