balladeer 0.0.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +167 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +48 -0
  6. package/dist/cli.js +531 -0
  7. package/dist/client.d.ts +66 -0
  8. package/dist/client.js +142 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +123 -0
  11. package/dist/commands/check-seals.d.ts +37 -0
  12. package/dist/commands/check-seals.js +289 -0
  13. package/dist/commands/discover.d.ts +68 -0
  14. package/dist/commands/discover.js +403 -0
  15. package/dist/commands/explain.d.ts +35 -0
  16. package/dist/commands/explain.js +90 -0
  17. package/dist/commands/invite.d.ts +24 -0
  18. package/dist/commands/invite.js +198 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/prepare.d.ts +74 -0
  22. package/dist/commands/prepare.js +217 -0
  23. package/dist/commands/propose.d.ts +69 -0
  24. package/dist/commands/propose.js +284 -0
  25. package/dist/commands/repositories.d.ts +18 -0
  26. package/dist/commands/repositories.js +185 -0
  27. package/dist/commands/session.d.ts +35 -0
  28. package/dist/commands/session.js +118 -0
  29. package/dist/commands/setup.d.ts +98 -0
  30. package/dist/commands/setup.js +1600 -0
  31. package/dist/commands/status.d.ts +51 -0
  32. package/dist/commands/status.js +542 -0
  33. package/dist/commands/touch-map.d.ts +42 -0
  34. package/dist/commands/touch-map.js +251 -0
  35. package/dist/commands/whoami.d.ts +8 -0
  36. package/dist/commands/whoami.js +80 -0
  37. package/dist/conventions.d.ts +77 -0
  38. package/dist/conventions.js +183 -0
  39. package/dist/copy.d.ts +224 -0
  40. package/dist/copy.js +641 -0
  41. package/dist/currency.d.ts +31 -0
  42. package/dist/currency.js +72 -0
  43. package/dist/desktop-config.d.ts +85 -0
  44. package/dist/desktop-config.js +217 -0
  45. package/dist/gh.d.ts +80 -0
  46. package/dist/gh.js +188 -0
  47. package/dist/git.d.ts +91 -0
  48. package/dist/git.js +226 -0
  49. package/dist/legacy.d.ts +41 -0
  50. package/dist/legacy.js +143 -0
  51. package/dist/local-time.d.ts +66 -0
  52. package/dist/local-time.js +84 -0
  53. package/dist/markers.d.ts +76 -0
  54. package/dist/markers.js +125 -0
  55. package/dist/mcp-config.d.ts +109 -0
  56. package/dist/mcp-config.js +234 -0
  57. package/dist/release.d.ts +55 -0
  58. package/dist/release.js +67 -0
  59. package/dist/repository.d.ts +8 -0
  60. package/dist/repository.js +32 -0
  61. package/dist/seals.d.ts +48 -0
  62. package/dist/seals.js +112 -0
  63. package/dist/session.d.ts +84 -0
  64. package/dist/session.js +135 -0
  65. package/dist/store.d.ts +108 -0
  66. package/dist/store.js +237 -0
  67. package/dist/touch-map.d.ts +241 -0
  68. package/dist/touch-map.js +487 -0
  69. package/dist/wire.d.ts +674 -0
  70. package/dist/wire.js +20 -0
  71. package/package.json +19 -10
  72. package/bin/balladeer.js +0 -161
package/dist/cli.js ADDED
@@ -0,0 +1,531 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { runAffected } from "./commands/affected.js";
6
+ import { runCheckSeals } from "./commands/check-seals.js";
7
+ import { runDiscover } from "./commands/discover.js";
8
+ import { runExplain } from "./commands/explain.js";
9
+ import { parseRole, runInvite } from "./commands/invite.js";
10
+ import { runMcp } from "./commands/mcp.js";
11
+ import { runPrepare } from "./commands/prepare.js";
12
+ import { runPropose } from "./commands/propose.js";
13
+ import { runRepositories } from "./commands/repositories.js";
14
+ import { CREATE_WORKSPACE_MAX_LENGTH, runSetup } from "./commands/setup.js";
15
+ import { runSession } from "./commands/session.js";
16
+ import { runStatus } from "./commands/status.js";
17
+ import { runTouchMap } from "./commands/touch-map.js";
18
+ import { runWhoami } from "./commands/whoami.js";
19
+ import { updateNotice } from "./currency.js";
20
+ import { StoreError, normalizeControlPlane } from "./store.js";
21
+ import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
22
+ const USAGE = `balladeer ${CLI_VERSION}
23
+
24
+ balladeer setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
25
+ [--create-workspace <name>] [--refresh] [--force]
26
+ [--claude-desktop | --no-claude-desktop]
27
+ Pair this session, add this repository, connect this coding agent and CI,
28
+ and report what a person still has to do. Name --repository more than once
29
+ to add other repositories to the same workspace; each of those is added and
30
+ nothing more, because connecting an agent and CI happens in a checkout.
31
+ --create-workspace carries a name to the approval page, where a person
32
+ signs in and creates the workspace themselves; this command never creates
33
+ one.
34
+ --refresh does one thing and talks to nobody: it rewrites this
35
+ repository's balladeer entry in .mcp.json, its Balladeer instructions
36
+ block and its Claude desktop chat entry to the current form, leaves every
37
+ other entry in those files alone, and prints what it changed. Run it when
38
+ Balladeer says a newer version is available.
39
+ --force sets up even though an earlier Balladeer is still installed on
40
+ this machine. Without it, a run that finds the older client's machine-wide
41
+ MCP entry or session hook stops and says to remove it first.
42
+ Claude desktop chat is connected too, under a key named for this
43
+ repository, wherever the app is installed. --claude-desktop asks for it by
44
+ name, so a machine with no Claude desktop on it says so instead of
45
+ skipping quietly; --no-claude-desktop leaves that file alone entirely.
46
+ Quit and reopen the app afterwards: it reads its configuration at startup.
47
+
48
+ balladeer repositories [--json] [--control-plane <url>]
49
+ List the repositories this machine's GitHub account can see, marking the
50
+ one you are standing in and the ones Balladeer already has.
51
+
52
+ balladeer invite <email>... [--role contributor|viewer|administrator] [--json]
53
+ Invite teammates into this workspace by email, and say per address whether
54
+ the invitation went. Inviting is an administrator's act.
55
+
56
+ balladeer status [<promise id>] [--repo owner/name] [--repository <uuid>] [--json]
57
+ [--control-plane <url>]
58
+ Report this repository over its agent connection, and every repository in
59
+ the workspace when a setup session is still live. Named with a promise id,
60
+ report that one promise instead: whether it is holding, and if it is not,
61
+ the commit the failing run checked, the bounded reason, and the cases in
62
+ the agreed meaning that run was checking. That is the form the line on a
63
+ broken promise tells a person to run first.
64
+
65
+ balladeer propose --file <path> [--repo owner/name] [--repository <uuid>] [--json]
66
+ Propose one promise from a proposal file, over this repository's agent
67
+ connection. A named person still agrees to it.
68
+
69
+ balladeer prepare <promise id> [--again] [--repo owner/name] [--repository <uuid>]
70
+ [--json] [--control-plane <url>]
71
+ Prepare the one-time qualification setup for a promise whose meaning is
72
+ agreed and which nothing is checking yet, and write it to
73
+ .continuity/qualification/<promise id>.json, which is where the sealed run
74
+ reads it. No sign-off and no code: agreeing the meaning was the person's
75
+ act, and building the check that proves it is yours. Then build the
76
+ verifier, seal it, and push to the default branch; protection starts by
77
+ itself when that run qualifies. It is one-time, so while nobody has
78
+ published against the packet already prepared this refuses and says who
79
+ prepared it and when. --again prepares a replacement and invalidates that
80
+ earlier packet: a run publishing its identities afterwards is refused.
81
+
82
+ balladeer discover --file <path> [--repo owner/name] [--repository <uuid>]
83
+ [--owner <membership id>] [--json]
84
+ Propose a whole discovered catalog, up to ten promises from one file, each
85
+ owned by whoever paired this machine, and print one link that opens all of
86
+ them. A named person still agrees to every one.
87
+
88
+ balladeer check-seals [--json] [--runner <path>] [--install-hook]
89
+ Say whether what you are about to push would break a promise's seal. It
90
+ prints nothing and exits 0 when it would not, and names the promise, its
91
+ owner, its page and the line that seals it again when it would. It runs
92
+ offline, over the runner this repository is pinned to. --install-hook
93
+ writes .git/hooks/pre-push so every push asks first; nothing installs it
94
+ for you, and deleting that file removes it.
95
+
96
+ balladeer touch-map [--json]
97
+ Run this repository's promise verifiers under coverage and write down
98
+ which files each one actually executed, to .continuity/touch-map.json.
99
+ It runs offline and the map stays on this machine: Balladeer is never
100
+ sent it. Node verifiers only in this release.
101
+
102
+ balladeer affected <paths...> [--json]
103
+ Say which promises the named files touch, out of that map, marking any
104
+ answer whose verifier has changed since the map was built. It reads one
105
+ local file and contacts nothing.
106
+
107
+ balladeer session [--new] [--record] [--repo owner/name] [--repository <uuid>] [--json]
108
+ [--control-plane <url>]
109
+ Print the id for this piece of work, and the one line to write into the
110
+ commit it produces. Pass the id to every promise read you make, so a check
111
+ that goes red later can be read back against what Balladeer told you
112
+ before you started. --new starts a different session; --record reads the
113
+ Balladeer-Session line out of the commit at HEAD and tells Balladeer which
114
+ commit this session wrote. Your commit message never leaves this machine:
115
+ only the session id and the commit SHA are sent.
116
+
117
+ balladeer explain [--control-plane <url>]
118
+ Print, word for word, what Balladeer can and cannot see, where to watch
119
+ your promises, and what leaving costs.
120
+
121
+ balladeer whoami [--json] [--control-plane <url>]
122
+ Report the stored setup session's workspace, role, scopes, and expiry.
123
+
124
+ balladeer agent rotate [--control-plane <url>]
125
+ Replace this repository's agent credential. A setup session cannot do this,
126
+ because rotating revokes the connections the repository already has.
127
+
128
+ balladeer mcp [--repository <uuid>] [--control-plane <url>]
129
+ Forward one MCP session over stdio using this repository's agent connection.
130
+
131
+ Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
132
+ claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
133
+ 5 transport or server failure, 6 an earlier Balladeer is still installed on this
134
+ machine and nothing was changed.
135
+ `;
136
+ const SUBCOMMANDS = { agent: ["rotate"] };
137
+ export function parseArguments(argv) {
138
+ const args = [...argv];
139
+ const command = args.shift() ?? "help";
140
+ let subcommand;
141
+ if (SUBCOMMANDS[command] !== undefined) {
142
+ subcommand = args.shift();
143
+ if (subcommand === undefined || !SUBCOMMANDS[command].includes(subcommand)) {
144
+ throw new StoreError("usage", `${command} needs one of: ${SUBCOMMANDS[command].join(", ")}.`);
145
+ }
146
+ }
147
+ let json = false;
148
+ let wait = false;
149
+ let refresh = false;
150
+ let force = false;
151
+ let claudeDesktop;
152
+ let installHook = false;
153
+ let fresh = false;
154
+ let record = false;
155
+ let again = false;
156
+ let runner;
157
+ let controlPlane;
158
+ let repo;
159
+ let file;
160
+ let owner;
161
+ let createWorkspace;
162
+ let role;
163
+ const repositories = [];
164
+ const positional = [];
165
+ const NEEDS = {
166
+ "--control-plane": "a URL",
167
+ "--repo": "an owner/name",
168
+ "--file": "a path",
169
+ "--repository": "a repository, as owner/name for setup or as an id elsewhere",
170
+ "--owner": "a membership id",
171
+ "--create-workspace": "a workspace name",
172
+ "--role": "contributor, viewer, or administrator",
173
+ "--runner": "a path to the pinned runner's cli.js",
174
+ };
175
+ const value = (flag, inline) => {
176
+ const next = inline ?? args.shift();
177
+ if (!next)
178
+ throw new StoreError("usage", `${flag} needs ${NEEDS[flag] ?? "a value"}.`);
179
+ return next;
180
+ };
181
+ while (args.length > 0) {
182
+ const flag = args.shift();
183
+ if (!flag.startsWith("--")) {
184
+ positional.push(flag);
185
+ continue;
186
+ }
187
+ const [name, ...rest] = flag.split("=");
188
+ const inline = rest.length > 0 ? rest.join("=") : undefined;
189
+ if (name === "--json")
190
+ json = true;
191
+ else if (name === "--again")
192
+ again = true;
193
+ else if (name === "--install-hook")
194
+ installHook = true;
195
+ else if (name === "--new")
196
+ fresh = true;
197
+ else if (name === "--record")
198
+ record = true;
199
+ else if (name === "--runner")
200
+ runner = value("--runner", inline);
201
+ else if (name === "--wait")
202
+ wait = true;
203
+ else if (name === "--refresh")
204
+ refresh = true;
205
+ else if (name === "--force")
206
+ force = true;
207
+ else if (name === "--claude-desktop")
208
+ claudeDesktop = true;
209
+ else if (name === "--no-claude-desktop")
210
+ claudeDesktop = false;
211
+ else if (name === "--control-plane")
212
+ controlPlane = value("--control-plane", inline);
213
+ else if (name === "--repo")
214
+ repo = value("--repo", inline);
215
+ else if (name === "--file")
216
+ file = value("--file", inline);
217
+ else if (name === "--repository")
218
+ repositories.push(value("--repository", inline));
219
+ else if (name === "--owner")
220
+ owner = value("--owner", inline);
221
+ else if (name === "--role")
222
+ role = value("--role", inline);
223
+ else if (name === "--create-workspace") {
224
+ createWorkspace = value("--create-workspace", inline).trim();
225
+ if (createWorkspace.length === 0 || createWorkspace.length > CREATE_WORKSPACE_MAX_LENGTH) {
226
+ throw new StoreError("usage", `--create-workspace needs a workspace name of 1 to ${CREATE_WORKSPACE_MAX_LENGTH} characters.`);
227
+ }
228
+ }
229
+ else
230
+ throw new StoreError("usage", `Unknown option ${flag}.`);
231
+ }
232
+ // `invite` is the one command that takes a bare word, and the words it takes
233
+ // are email addresses. Anywhere else a bare word is a mistyped flag or a
234
+ // value that lost its flag, and it was refused before this loop learned to
235
+ // collect them, so it is refused here rather than ignored.
236
+ // `affected` is the other one, and the words it takes are paths in the change
237
+ // in front of the person. `prepare` and `status` each take exactly one bare
238
+ // word, the promise id a person copied off a promise page, which is the form
239
+ // both of those commands are told to people and to agents in.
240
+ // `--again` invalidates a packet somebody may be halfway through using, so it
241
+ // belongs to the one command that mints one. Accepted silently elsewhere, it
242
+ // would read as a general "do it anyway" flag.
243
+ if (again && command !== "prepare") {
244
+ throw new StoreError("usage", "--again belongs to prepare: run `balladeer prepare <promise id> --again`.");
245
+ }
246
+ if (positional.length > 0 &&
247
+ command !== "invite" &&
248
+ command !== "affected" &&
249
+ command !== "prepare" &&
250
+ command !== "status") {
251
+ throw new StoreError("usage", `${command} takes no bare arguments: ${positional.join(", ")}.`);
252
+ }
253
+ // `--repository` names one repository everywhere but `setup`, where it names
254
+ // as many as a person wants to add. A second one anywhere else is refused
255
+ // rather than resolved to the last: silently acting on one of two repositories
256
+ // somebody named is worse than saying no.
257
+ // `--refresh` repairs the files setup writes, so it belongs to setup and to
258
+ // nothing else. Accepting it silently elsewhere would let somebody run
259
+ // `status --refresh`, see no error, and believe their install was repaired.
260
+ if (refresh && command !== "setup") {
261
+ throw new StoreError("usage", "--refresh belongs to setup: run `balladeer setup --refresh`.");
262
+ }
263
+ // `--force` says one thing only: set up alongside an earlier Balladeer. Taken
264
+ // silently by another command it would read as a general "do it anyway" flag,
265
+ // which is exactly what nothing else here offers.
266
+ if (force && command !== "setup") {
267
+ throw new StoreError("usage", "--force belongs to setup: run `balladeer setup --force`.");
268
+ }
269
+ // Connecting a chat client is something setup does, so the flag belongs to
270
+ // setup. Accepted silently on `status`, it would let somebody believe they had
271
+ // connected Claude desktop when nothing had written a line.
272
+ if (claudeDesktop !== undefined && command !== "setup") {
273
+ throw new StoreError("usage", "--claude-desktop belongs to setup: run `balladeer setup --claude-desktop`.");
274
+ }
275
+ if (command !== "setup" && repositories.length > 1) {
276
+ throw new StoreError("usage", `${command} acts on one repository. Name --repository once, or run it again for the other.`);
277
+ }
278
+ const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
279
+ return {
280
+ command,
281
+ subcommand,
282
+ json,
283
+ wait,
284
+ refresh,
285
+ force,
286
+ claudeDesktop,
287
+ repo,
288
+ file,
289
+ repository: repositories[0],
290
+ repositories,
291
+ owner,
292
+ installHook,
293
+ fresh,
294
+ record,
295
+ again,
296
+ runner,
297
+ createWorkspace,
298
+ positional,
299
+ role,
300
+ controlPlane: normalizeControlPlane(chosen),
301
+ };
302
+ }
303
+ export async function main(argv) {
304
+ let parsed;
305
+ try {
306
+ parsed = parseArguments(argv);
307
+ }
308
+ catch (error) {
309
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
310
+ return 4;
311
+ }
312
+ const write = (text) => void process.stdout.write(text);
313
+ const code = await dispatch(parsed, write);
314
+ // Said once, at the end, on stderr: an agent reading `--json` gets its steps
315
+ // on stdout unchanged, and a person reading a terminal gets the one line that
316
+ // says this copy is behind. It is a nag and never a failure, so it does not
317
+ // touch the exit code.
318
+ const notice = updateNotice();
319
+ if (notice !== undefined)
320
+ process.stderr.write(`${notice}\n`);
321
+ return code;
322
+ }
323
+ async function dispatch(parsed, write) {
324
+ switch (parsed.command) {
325
+ case "explain":
326
+ return runExplain(write, parsed.controlPlane);
327
+ case "setup":
328
+ return runSetup({
329
+ controlPlane: parsed.controlPlane,
330
+ json: parsed.json,
331
+ wait: parsed.wait,
332
+ refresh: parsed.refresh,
333
+ force: parsed.force,
334
+ ...(parsed.claudeDesktop === undefined ? {} : { claudeDesktop: parsed.claudeDesktop }),
335
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
336
+ repositories: parsed.repositories,
337
+ ...(parsed.createWorkspace === undefined
338
+ ? {}
339
+ : { createWorkspace: parsed.createWorkspace }),
340
+ environment: process.env,
341
+ cwd: process.cwd(),
342
+ write,
343
+ });
344
+ case "repositories":
345
+ return runRepositories({
346
+ controlPlane: parsed.controlPlane,
347
+ json: parsed.json,
348
+ environment: process.env,
349
+ cwd: process.cwd(),
350
+ write,
351
+ });
352
+ case "invite": {
353
+ const role = parseRole(parsed.role);
354
+ if (role === undefined) {
355
+ process.stderr.write(`--role takes contributor, viewer, or administrator. It was given "${parsed.role}".\n`);
356
+ return 4;
357
+ }
358
+ return runInvite({
359
+ controlPlane: parsed.controlPlane,
360
+ json: parsed.json,
361
+ emails: parsed.positional,
362
+ role,
363
+ environment: process.env,
364
+ write,
365
+ });
366
+ }
367
+ case "check-seals":
368
+ return runCheckSeals({
369
+ json: parsed.json,
370
+ installHook: parsed.installHook,
371
+ ...(parsed.runner === undefined ? {} : { runner: parsed.runner }),
372
+ environment: process.env,
373
+ cwd: process.cwd(),
374
+ write,
375
+ });
376
+ case "touch-map":
377
+ return runTouchMap({
378
+ json: parsed.json,
379
+ environment: process.env,
380
+ cwd: process.cwd(),
381
+ write,
382
+ });
383
+ case "session":
384
+ return runSession({
385
+ controlPlane: parsed.controlPlane,
386
+ json: parsed.json,
387
+ record: parsed.record,
388
+ fresh: parsed.fresh,
389
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
390
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
391
+ environment: process.env,
392
+ cwd: process.cwd(),
393
+ write,
394
+ });
395
+ case "affected":
396
+ return runAffected({
397
+ json: parsed.json,
398
+ paths: parsed.positional,
399
+ cwd: process.cwd(),
400
+ write,
401
+ });
402
+ case "status": {
403
+ // At most one. Two ids on one line is a mistake, and answering for the
404
+ // first of them silently would report a promise nobody asked about.
405
+ if (parsed.positional.length > 1) {
406
+ process.stderr.write("status reads one promise id at a time.\n");
407
+ return 4;
408
+ }
409
+ const promiseId = parsed.positional[0];
410
+ return runStatus({
411
+ controlPlane: parsed.controlPlane,
412
+ json: parsed.json,
413
+ ...(promiseId === undefined ? {} : { promiseId }),
414
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
415
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
416
+ environment: process.env,
417
+ cwd: process.cwd(),
418
+ write,
419
+ });
420
+ }
421
+ case "prepare": {
422
+ // One id, and one only. Preparing two packets from one line would mint
423
+ // two one-time identities and write two files, and reporting the first
424
+ // silently would leave the second promise unprepared with nobody told.
425
+ if (parsed.positional.length !== 1) {
426
+ process.stderr.write("prepare reads one promise id: balladeer prepare <promise id> [--again].\n");
427
+ return 4;
428
+ }
429
+ return runPrepare({
430
+ controlPlane: parsed.controlPlane,
431
+ json: parsed.json,
432
+ promiseId: parsed.positional[0],
433
+ again: parsed.again,
434
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
435
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
436
+ environment: process.env,
437
+ cwd: process.cwd(),
438
+ write,
439
+ });
440
+ }
441
+ case "propose":
442
+ return runPropose({
443
+ controlPlane: parsed.controlPlane,
444
+ json: parsed.json,
445
+ ...(parsed.file === undefined ? {} : { file: parsed.file }),
446
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
447
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
448
+ environment: process.env,
449
+ cwd: process.cwd(),
450
+ write,
451
+ });
452
+ case "discover":
453
+ return runDiscover({
454
+ controlPlane: parsed.controlPlane,
455
+ json: parsed.json,
456
+ ...(parsed.file === undefined ? {} : { file: parsed.file }),
457
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
458
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
459
+ ...(parsed.owner === undefined ? {} : { owner: parsed.owner }),
460
+ environment: process.env,
461
+ cwd: process.cwd(),
462
+ write,
463
+ });
464
+ case "agent":
465
+ // Rotating revokes every live connection for the repository, which is
466
+ // removal. The approval page tells a person their setup session cannot
467
+ // remove anything, so this command says the same rather than trying and
468
+ // relaying a server refusal the person cannot act on.
469
+ process.stderr.write("A setup session cannot rotate an agent connection: rotating revokes the connections this repository already has, and a setup session may not remove anything.\n" +
470
+ `Rotate it in Balladeer under workspace settings at ${parsed.controlPlane}/settings.\n`);
471
+ return 4;
472
+ case "whoami":
473
+ return runWhoami({
474
+ controlPlane: parsed.controlPlane,
475
+ json: parsed.json,
476
+ environment: process.env,
477
+ write,
478
+ });
479
+ case "mcp":
480
+ return runMcp({
481
+ controlPlane: parsed.controlPlane,
482
+ ...(parsed.repository === undefined ? {} : { repositoryId: parsed.repository }),
483
+ environment: process.env,
484
+ cwd: process.cwd(),
485
+ stdin: process.stdin,
486
+ write,
487
+ error: (text) => void process.stderr.write(text),
488
+ });
489
+ case "help":
490
+ case "--help":
491
+ case "-h":
492
+ write(USAGE);
493
+ return 0;
494
+ case "--version":
495
+ case "-v":
496
+ write(`${CLI_VERSION}\n`);
497
+ return 0;
498
+ default:
499
+ process.stderr.write(`Unknown command "${parsed.command}".\n\n${USAGE}`);
500
+ return 4;
501
+ }
502
+ }
503
+ /**
504
+ * The real file behind a path, following symlinks.
505
+ *
506
+ * `npx` and every global install run this program through a
507
+ * `node_modules/.bin/balladeer` symlink, so `process.argv[1]` is the link and
508
+ * not the file Node loaded. `resolve` does not follow a link, so comparing
509
+ * resolved paths made the guard below false for every customer: the process
510
+ * exited 0 having printed nothing, and only a checkout running
511
+ * `node dist/cli.js` ever saw the program run at all.
512
+ *
513
+ * A path that cannot be read back falls through to the resolved form rather
514
+ * than throwing, because a guard that decides whether the program runs must
515
+ * never be the thing that stops it.
516
+ */
517
+ function programPath(path) {
518
+ const absolute = resolve(path);
519
+ try {
520
+ return realpathSync(absolute);
521
+ }
522
+ catch {
523
+ return absolute;
524
+ }
525
+ }
526
+ // Only when this file is the program, so a test may import `main` without the
527
+ // import itself running a command.
528
+ const entry = process.argv[1];
529
+ if (entry !== undefined && programPath(fileURLToPath(import.meta.url)) === programPath(entry)) {
530
+ process.exitCode = await main(process.argv.slice(2));
531
+ }
@@ -0,0 +1,66 @@
1
+ import { type PairRefusal } from "./wire.js";
2
+ export declare class TransportError extends Error {
3
+ readonly controlPlane: string;
4
+ constructor(controlPlane: string, reason: string);
5
+ }
6
+ export declare class ClientTooOldError extends Error {
7
+ readonly update: string;
8
+ readonly controlPlane: string;
9
+ constructor(controlPlane: string, update: string);
10
+ }
11
+ export declare class RefusalError extends Error {
12
+ readonly code: string;
13
+ readonly status: number;
14
+ constructor(status: number, refusal: PairRefusal);
15
+ }
16
+ /**
17
+ * A link this command prints, always built from the address it paired with.
18
+ *
19
+ * Behind a platform proxy a server can resolve a link against the machine it is
20
+ * running on rather than against the address customers use, and the first
21
+ * production `propose` printed exactly that: a review link on `localhost:8080`
22
+ * as the place to go and agree. A command that prints such a link is worse than
23
+ * one that prints none, because the person tries it.
24
+ *
25
+ * So no link a server sends is ever printed. The proposal path answers with an
26
+ * identifier and this builds the address, which is why there is no parameter
27
+ * here for a server's own link to arrive through.
28
+ */
29
+ export declare function reviewLink(controlPlane: string, path: string): string;
30
+ /**
31
+ * The one place this command decides where a person reads a proposal.
32
+ *
33
+ * Every review address this command prints, in a receipt, in a discovery run,
34
+ * or in its JSON, is built here, so the address is one edit rather than a hunt
35
+ * and a printed link cannot fall behind the page it names. It matches the
36
+ * server's own `proposal-links` helper: the page is `/proposals`, and
37
+ * the retired address redirects to it permanently, so a link an older copy of
38
+ * this command already printed still lands on it.
39
+ */
40
+ export declare function proposalReviewLink(controlPlane: string, proposalId: string): string;
41
+ /** The address the whole waiting inbox is read on. */
42
+ export declare function proposalInboxLink(controlPlane: string): string;
43
+ /**
44
+ * One link that opens exactly the proposals named, and nothing else.
45
+ *
46
+ * The ids travel in the query string because the batch is exactly these
47
+ * promises: a link to the whole inbox would also open whatever was already
48
+ * waiting there, and a filter by repository would open a different set
49
+ * tomorrow.
50
+ */
51
+ export declare function batchProposalReviewLink(controlPlane: string, proposalIds: readonly string[]): string;
52
+ export type RequestOptions = Readonly<{
53
+ method: "GET" | "POST";
54
+ path: string;
55
+ body?: unknown;
56
+ bearer?: string;
57
+ timeoutMs?: number;
58
+ }>;
59
+ /**
60
+ * The one place this command talks to a network.
61
+ *
62
+ * `redirect: "manual"` so no redirect can carry a bearer to a host the person
63
+ * never paired with, and the 426 handshake is decoded here so every caller gets
64
+ * the same actionable refusal rather than a status code.
65
+ */
66
+ export declare function request<T>(controlPlane: string, options: RequestOptions): Promise<T>;