balladeer 0.0.4 → 1.0.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.
Files changed (60) hide show
  1. package/LICENSE +200 -5
  2. package/README.md +154 -68
  3. package/dist/agent.d.ts +126 -0
  4. package/dist/agent.js +209 -0
  5. package/dist/cli.d.ts +34 -0
  6. package/dist/cli.js +392 -0
  7. package/dist/client.d.ts +44 -0
  8. package/dist/client.js +114 -0
  9. package/dist/commands/affected.d.ts +22 -0
  10. package/dist/commands/affected.js +122 -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 +395 -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 +197 -0
  19. package/dist/commands/mcp.d.ts +65 -0
  20. package/dist/commands/mcp.js +202 -0
  21. package/dist/commands/propose.d.ts +59 -0
  22. package/dist/commands/propose.js +262 -0
  23. package/dist/commands/repositories.d.ts +18 -0
  24. package/dist/commands/repositories.js +185 -0
  25. package/dist/commands/setup.d.ts +75 -0
  26. package/dist/commands/setup.js +1471 -0
  27. package/dist/commands/status.d.ts +35 -0
  28. package/dist/commands/status.js +482 -0
  29. package/dist/commands/touch-map.d.ts +42 -0
  30. package/dist/commands/touch-map.js +251 -0
  31. package/dist/commands/whoami.d.ts +8 -0
  32. package/dist/commands/whoami.js +79 -0
  33. package/dist/conventions.d.ts +69 -0
  34. package/dist/conventions.js +175 -0
  35. package/dist/copy.d.ts +148 -0
  36. package/dist/copy.js +459 -0
  37. package/dist/currency.d.ts +31 -0
  38. package/dist/currency.js +72 -0
  39. package/dist/gh.d.ts +80 -0
  40. package/dist/gh.js +188 -0
  41. package/dist/git.d.ts +76 -0
  42. package/dist/git.js +203 -0
  43. package/dist/markers.d.ts +76 -0
  44. package/dist/markers.js +125 -0
  45. package/dist/mcp-config.d.ts +99 -0
  46. package/dist/mcp-config.js +230 -0
  47. package/dist/release.d.ts +55 -0
  48. package/dist/release.js +67 -0
  49. package/dist/repository.d.ts +8 -0
  50. package/dist/repository.js +32 -0
  51. package/dist/seals.d.ts +48 -0
  52. package/dist/seals.js +112 -0
  53. package/dist/store.d.ts +98 -0
  54. package/dist/store.js +225 -0
  55. package/dist/touch-map.d.ts +241 -0
  56. package/dist/touch-map.js +487 -0
  57. package/dist/wire.d.ts +588 -0
  58. package/dist/wire.js +20 -0
  59. package/package.json +19 -10
  60. package/bin/balladeer.js +0 -136
package/dist/agent.js ADDED
@@ -0,0 +1,209 @@
1
+ import { noteServerVersion } from "./currency.js";
2
+ import { commandLine } from "./release.js";
3
+ import {} from "./store.js";
4
+ import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
5
+ const REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
6
+ /**
7
+ * Which stored credential a command uses, or a refusal saying why none.
8
+ *
9
+ * There are exactly two ways to be right about this, and taking whichever entry
10
+ * comes first is neither. Either the caller named a repository, in which case
11
+ * only that repository's credential will do, or it did not, in which case the
12
+ * only defensible answer is the credential belonging to the repository this
13
+ * process was started in. A machine set up for two repositories otherwise binds
14
+ * an agent working in one of them to the other one's promises, silently, and
15
+ * everything that agent then reads and proposes is about the wrong repository.
16
+ *
17
+ * The bearer is never written to stdout or stderr, and an entry whose MCP URL
18
+ * points somewhere other than the control plane it belongs to is refused: a
19
+ * server response must not be able to send this credential to a new host.
20
+ */
21
+ export function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
22
+ const here = agents.filter((agent) => agent.controlPlane === controlPlane);
23
+ if (repositoryId !== undefined) {
24
+ const named = here.find((agent) => agent.repositoryId === repositoryId);
25
+ if (named === undefined) {
26
+ return {
27
+ kind: "refused",
28
+ reason: `No Balladeer connection for repository ${repositoryId} is stored for ${controlPlane}.`,
29
+ missingFor: repositoryId,
30
+ };
31
+ }
32
+ return withSafeEndpoint(named);
33
+ }
34
+ // `unknown/unknown` is what the origin parse answers when it read nothing, so
35
+ // it is the absence of a repository rather than the name of one.
36
+ if (!REPOSITORY_NAME.test(currentRepository) || currentRepository === "unknown/unknown") {
37
+ return {
38
+ kind: "refused",
39
+ reason: "This directory has no GitHub origin remote I could read, so I cannot tell which repository's Balladeer connection to use. Pass --repository <id>.",
40
+ };
41
+ }
42
+ const matches = here.filter((agent) => agent.repository?.toLowerCase() === currentRepository.toLowerCase());
43
+ // Newest wins among the matches, which is the one the last setup wrote.
44
+ const chosen = matches[matches.length - 1];
45
+ if (chosen === undefined) {
46
+ return {
47
+ kind: "refused",
48
+ reason: `No Balladeer connection for ${currentRepository} is stored for ${controlPlane}.`,
49
+ missingFor: currentRepository,
50
+ };
51
+ }
52
+ return withSafeEndpoint(chosen);
53
+ }
54
+ /**
55
+ * What a command says when this machine holds no credential for the repository.
56
+ *
57
+ * A credential belongs to the machine it was issued on. A repository can be
58
+ * connected in a browser, or from a colleague's laptop, and this machine still
59
+ * hold nothing: the bearer is shown once, to whoever was there. Saying
60
+ * "Balladeer refused this workspace, run setup to pair again" in that state sent
61
+ * the founder to re-pair a machine whose pairing was never the problem, and a
62
+ * new setup session would not have helped, because these commands do not run
63
+ * over one.
64
+ *
65
+ * The invocation named is the checkout form, which is the one this copy was run
66
+ * as: nothing here has read the server's answer about publication yet.
67
+ */
68
+ export function noAgentCredentialSentence(repository) {
69
+ return `This machine holds no agent credential for ${repository}. Run \`${commandLine(null, "setup")}\` to connect this machine; a setup session alone cannot propose.`;
70
+ }
71
+ export function withSafeEndpoint(agent) {
72
+ try {
73
+ if (new URL(agent.mcpUrl).origin !== new URL(agent.controlPlane).origin) {
74
+ return {
75
+ kind: "refused",
76
+ reason: "That connection's MCP endpoint is not on the control plane it belongs to, so I sent its credential nowhere.",
77
+ };
78
+ }
79
+ }
80
+ catch {
81
+ return {
82
+ kind: "refused",
83
+ reason: "That connection's MCP endpoint is not a URL, so I did not use it.",
84
+ };
85
+ }
86
+ return { kind: "agent", agent };
87
+ }
88
+ /**
89
+ * The headers every frame this program sends over an agent connection carries,
90
+ * and the one place they are written.
91
+ *
92
+ * `accept` names both media types because the Streamable HTTP transport refuses
93
+ * a POST that does not: a request accepting only JSON is answered 406 by the
94
+ * server before any tool runs. Nothing in this repository noticed, because every
95
+ * test that drove the endpoint wrote its own headers rather than the ones the
96
+ * forwarder sends. The setup probe now sends its one real call through this same
97
+ * constant, so a forwarder that cannot talk to the server is a failed setup step
98
+ * rather than a connection reported as working.
99
+ *
100
+ * `x-balladeer-client` is what lets the server refuse a copy of this program
101
+ * that is too old to talk to it, and answer with the line that updates it.
102
+ */
103
+ export function forwarderHeaders(token) {
104
+ return {
105
+ authorization: `Bearer ${token}`,
106
+ "content-type": "application/json",
107
+ accept: "application/json, text/event-stream",
108
+ [CLIENT_HEADER]: CLIENT_HEADER_VALUE,
109
+ };
110
+ }
111
+ /**
112
+ * One JSON-RPC tool call to the endpoint the forwarder would use, with the
113
+ * headers the forwarder sends and the same refusal of an endpoint on another
114
+ * origin. Nothing here reads the bearer back out or writes it anywhere.
115
+ */
116
+ export async function callAgentTool(agent, name, argumentsValue, timeoutMs = 20_000) {
117
+ const safe = withSafeEndpoint(agent);
118
+ if (safe.kind === "refused")
119
+ return { kind: "endpoint_refused", reason: safe.reason };
120
+ let response;
121
+ try {
122
+ response = await fetch(agent.mcpUrl, {
123
+ method: "POST",
124
+ headers: forwarderHeaders(agent.token),
125
+ body: JSON.stringify({
126
+ jsonrpc: "2.0",
127
+ id: 1,
128
+ method: "tools/call",
129
+ params: { name, arguments: argumentsValue },
130
+ }),
131
+ redirect: "manual",
132
+ signal: AbortSignal.timeout(timeoutMs),
133
+ });
134
+ }
135
+ catch {
136
+ return { kind: "unreachable" };
137
+ }
138
+ noteServerVersion(response.headers);
139
+ if (response.status === 426) {
140
+ return { kind: "client_too_old", update: updateLine(await safeJson(response)) };
141
+ }
142
+ if (response.status === 401 || response.status === 403)
143
+ return { kind: "unauthorized" };
144
+ if (!response.ok)
145
+ return { kind: "http", status: response.status };
146
+ const frame = await safeJson(response);
147
+ const result = resultOf(frame);
148
+ if (result === undefined)
149
+ return { kind: "malformed" };
150
+ return result;
151
+ }
152
+ export async function safeJson(response) {
153
+ try {
154
+ return (await response.json());
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ }
160
+ /**
161
+ * The server's own remedy, read from the 426 body.
162
+ *
163
+ * Never a literal written here. Only the control plane knows whether this
164
+ * program is published, and a remedy naming a specifier the registry cannot
165
+ * serve leaves the person worse off than the refusal did.
166
+ */
167
+ export function updateLine(payload) {
168
+ const update = payload?.update;
169
+ return typeof update === "string" && update.length > 0 && update.length < 200
170
+ ? update
171
+ : "the update line Balladeer prints on its setup page";
172
+ }
173
+ /**
174
+ * The result inside a JSON-RPC frame, read with `unknown` at every step. A shape
175
+ * that does not match is absence rather than a guess.
176
+ */
177
+ function resultOf(payload) {
178
+ if (payload === null || typeof payload !== "object")
179
+ return undefined;
180
+ const result = payload.result;
181
+ if (result === null || typeof result !== "object")
182
+ return undefined;
183
+ const structured = result.structuredContent;
184
+ if (result.isError === true) {
185
+ return { kind: "tool_refusal", text: refusalText(result), structured };
186
+ }
187
+ if (structured === null || typeof structured !== "object")
188
+ return undefined;
189
+ return { kind: "result", structured };
190
+ }
191
+ /** The first text block of a refusing tool result, bounded. */
192
+ function refusalText(result) {
193
+ const content = result.content;
194
+ if (!Array.isArray(content))
195
+ return "Balladeer refused this call.";
196
+ for (const block of content) {
197
+ const text = block?.text;
198
+ if (typeof text === "string" && text.length > 0)
199
+ return text.slice(0, 800);
200
+ }
201
+ return "Balladeer refused this call.";
202
+ }
203
+ /** One field of a tool result, when it is a string. Never a guess. */
204
+ export function structuredString(structured, field) {
205
+ if (structured === null || typeof structured !== "object")
206
+ return undefined;
207
+ const value = structured[field];
208
+ return typeof value === "string" ? value : undefined;
209
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ type Parsed = Readonly<{
3
+ command: string;
4
+ subcommand: string | undefined;
5
+ json: boolean;
6
+ wait: boolean;
7
+ /** Repair the files a previous setup wrote, and do nothing else. */
8
+ refresh: boolean;
9
+ repo: string | undefined;
10
+ file: string | undefined;
11
+ repository: string | undefined;
12
+ /**
13
+ * Every repository named on this run, in order.
14
+ *
15
+ * `setup` is the one command that takes more than one, because adding a
16
+ * repository is the one step it can do for a repository this directory is not
17
+ * a checkout of. Everywhere else a second name is a mistake, and is refused
18
+ * rather than quietly resolved to the last one.
19
+ */
20
+ repositories: readonly string[];
21
+ owner: string | undefined;
22
+ /** `check-seals --install-hook`: write the optional pre-push hook. */
23
+ installHook: boolean;
24
+ /** `check-seals --runner`: where the pinned runner is, when it has moved. */
25
+ runner: string | undefined;
26
+ createWorkspace: string | undefined;
27
+ /** Everything that was not a flag: the addresses `invite` sends to. */
28
+ positional: readonly string[];
29
+ role: string | undefined;
30
+ controlPlane: string;
31
+ }>;
32
+ export declare function parseArguments(argv: readonly string[]): Parsed;
33
+ export declare function main(argv: readonly string[]): Promise<number>;
34
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { runAffected } from "./commands/affected.js";
5
+ import { runCheckSeals } from "./commands/check-seals.js";
6
+ import { runDiscover } from "./commands/discover.js";
7
+ import { runExplain } from "./commands/explain.js";
8
+ import { parseRole, runInvite } from "./commands/invite.js";
9
+ import { runMcp } from "./commands/mcp.js";
10
+ import { runPropose } from "./commands/propose.js";
11
+ import { runRepositories } from "./commands/repositories.js";
12
+ import { CREATE_WORKSPACE_MAX_LENGTH, runSetup } from "./commands/setup.js";
13
+ import { runStatus } from "./commands/status.js";
14
+ import { runTouchMap } from "./commands/touch-map.js";
15
+ import { runWhoami } from "./commands/whoami.js";
16
+ import { updateNotice } from "./currency.js";
17
+ import { StoreError, normalizeControlPlane } from "./store.js";
18
+ import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
19
+ const USAGE = `balladeer ${CLI_VERSION}
20
+
21
+ balladeer setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
22
+ [--create-workspace <name>] [--refresh]
23
+ Pair this session, add this repository, connect this coding agent and CI,
24
+ and report what a person still has to do. Name --repository more than once
25
+ to add other repositories to the same workspace; each of those is added and
26
+ nothing more, because connecting an agent and CI happens in a checkout.
27
+ --create-workspace carries a name to the approval page, where a person
28
+ signs in and creates the workspace themselves; this command never creates
29
+ one.
30
+ --refresh does one thing and talks to nobody: it rewrites this
31
+ repository's balladeer entry in .mcp.json and its Balladeer instructions
32
+ block to the current form, leaves every other entry in that file alone,
33
+ and prints what it changed. Run it when Balladeer says a newer version is
34
+ available.
35
+
36
+ balladeer repositories [--json] [--control-plane <url>]
37
+ List the repositories this machine's GitHub account can see, marking the
38
+ one you are standing in and the ones Balladeer already has.
39
+
40
+ balladeer invite <email>... [--role contributor|viewer|administrator] [--json]
41
+ Invite teammates into this workspace by email, and say per address whether
42
+ the invitation went. Inviting is an administrator's act.
43
+
44
+ balladeer status [<promise id>] [--repo owner/name] [--repository <uuid>] [--json]
45
+ [--control-plane <url>]
46
+ Report this repository over its agent connection, and every repository in
47
+ the workspace when a setup session is still live. Named with a promise id,
48
+ report that one promise instead: whether it is holding, and if it is not,
49
+ the commit the failing run checked, the bounded reason, and the cases in
50
+ the agreed meaning that run was checking. That is the form the line on a
51
+ broken promise tells a person to run first.
52
+
53
+ balladeer propose --file <path> [--repo owner/name] [--repository <uuid>] [--json]
54
+ Propose one promise from a proposal file, over this repository's agent
55
+ connection. A named person still agrees to it.
56
+
57
+ balladeer discover --file <path> [--repo owner/name] [--repository <uuid>]
58
+ [--owner <membership id>] [--json]
59
+ Propose a whole discovered catalog, up to ten promises from one file, each
60
+ owned by whoever paired this machine, and print one link that opens all of
61
+ them. A named person still agrees to every one.
62
+
63
+ balladeer check-seals [--json] [--runner <path>] [--install-hook]
64
+ Say whether what you are about to push would break a promise's seal. It
65
+ prints nothing and exits 0 when it would not, and names the promise, its
66
+ owner, its page and the line that seals it again when it would. It runs
67
+ offline, over the runner this repository is pinned to. --install-hook
68
+ writes .git/hooks/pre-push so every push asks first; nothing installs it
69
+ for you, and deleting that file removes it.
70
+
71
+ balladeer touch-map [--json]
72
+ Run this repository's promise verifiers under coverage and write down
73
+ which files each one actually executed, to .continuity/touch-map.json.
74
+ It runs offline and the map stays on this machine: Balladeer is never
75
+ sent it. Node verifiers only in this release.
76
+
77
+ balladeer affected <paths...> [--json]
78
+ Say which promises the named files touch, out of that map, marking any
79
+ answer whose verifier has changed since the map was built. It reads one
80
+ local file and contacts nothing.
81
+
82
+ balladeer explain [--control-plane <url>]
83
+ Print, word for word, what Balladeer can and cannot see, where to watch
84
+ your promises, and what leaving costs.
85
+
86
+ balladeer whoami [--json] [--control-plane <url>]
87
+ Report the stored setup session's workspace, role, scopes, and expiry.
88
+
89
+ balladeer agent rotate [--control-plane <url>]
90
+ Replace this repository's agent credential. A setup session cannot do this,
91
+ because rotating revokes the connections the repository already has.
92
+
93
+ balladeer mcp [--repository <uuid>] [--control-plane <url>]
94
+ Forward one MCP session over stdio using this repository's agent connection.
95
+
96
+ Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
97
+ claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
98
+ 5 transport or server failure.
99
+ `;
100
+ const SUBCOMMANDS = { agent: ["rotate"] };
101
+ export function parseArguments(argv) {
102
+ const args = [...argv];
103
+ const command = args.shift() ?? "help";
104
+ let subcommand;
105
+ if (SUBCOMMANDS[command] !== undefined) {
106
+ subcommand = args.shift();
107
+ if (subcommand === undefined || !SUBCOMMANDS[command].includes(subcommand)) {
108
+ throw new StoreError("usage", `${command} needs one of: ${SUBCOMMANDS[command].join(", ")}.`);
109
+ }
110
+ }
111
+ let json = false;
112
+ let wait = false;
113
+ let refresh = false;
114
+ let installHook = false;
115
+ let runner;
116
+ let controlPlane;
117
+ let repo;
118
+ let file;
119
+ let owner;
120
+ let createWorkspace;
121
+ let role;
122
+ const repositories = [];
123
+ const positional = [];
124
+ const NEEDS = {
125
+ "--control-plane": "a URL",
126
+ "--repo": "an owner/name",
127
+ "--file": "a path",
128
+ "--repository": "a repository, as owner/name for setup or as an id elsewhere",
129
+ "--owner": "a membership id",
130
+ "--create-workspace": "a workspace name",
131
+ "--role": "contributor, viewer, or administrator",
132
+ "--runner": "a path to the pinned runner's cli.js",
133
+ };
134
+ const value = (flag, inline) => {
135
+ const next = inline ?? args.shift();
136
+ if (!next)
137
+ throw new StoreError("usage", `${flag} needs ${NEEDS[flag] ?? "a value"}.`);
138
+ return next;
139
+ };
140
+ while (args.length > 0) {
141
+ const flag = args.shift();
142
+ if (!flag.startsWith("--")) {
143
+ positional.push(flag);
144
+ continue;
145
+ }
146
+ const [name, ...rest] = flag.split("=");
147
+ const inline = rest.length > 0 ? rest.join("=") : undefined;
148
+ if (name === "--json")
149
+ json = true;
150
+ else if (name === "--install-hook")
151
+ installHook = true;
152
+ else if (name === "--runner")
153
+ runner = value("--runner", inline);
154
+ else if (name === "--wait")
155
+ wait = true;
156
+ else if (name === "--refresh")
157
+ refresh = true;
158
+ else if (name === "--control-plane")
159
+ controlPlane = value("--control-plane", inline);
160
+ else if (name === "--repo")
161
+ repo = value("--repo", inline);
162
+ else if (name === "--file")
163
+ file = value("--file", inline);
164
+ else if (name === "--repository")
165
+ repositories.push(value("--repository", inline));
166
+ else if (name === "--owner")
167
+ owner = value("--owner", inline);
168
+ else if (name === "--role")
169
+ role = value("--role", inline);
170
+ else if (name === "--create-workspace") {
171
+ createWorkspace = value("--create-workspace", inline).trim();
172
+ if (createWorkspace.length === 0 || createWorkspace.length > CREATE_WORKSPACE_MAX_LENGTH) {
173
+ throw new StoreError("usage", `--create-workspace needs a workspace name of 1 to ${CREATE_WORKSPACE_MAX_LENGTH} characters.`);
174
+ }
175
+ }
176
+ else
177
+ throw new StoreError("usage", `Unknown option ${flag}.`);
178
+ }
179
+ // `invite` is the one command that takes a bare word, and the words it takes
180
+ // are email addresses. Anywhere else a bare word is a mistyped flag or a
181
+ // value that lost its flag, and it was refused before this loop learned to
182
+ // collect them, so it is refused here rather than ignored.
183
+ // `affected` is the other one, and the words it takes are paths in the change
184
+ // in front of the person.
185
+ if (positional.length > 0 && command !== "invite" && command !== "affected") {
186
+ throw new StoreError("usage", `${command} takes no bare arguments: ${positional.join(", ")}.`);
187
+ }
188
+ // `--repository` names one repository everywhere but `setup`, where it names
189
+ // as many as a person wants to add. A second one anywhere else is refused
190
+ // rather than resolved to the last: silently acting on one of two repositories
191
+ // somebody named is worse than saying no.
192
+ // `--refresh` repairs the files setup writes, so it belongs to setup and to
193
+ // nothing else. Accepting it silently elsewhere would let somebody run
194
+ // `status --refresh`, see no error, and believe their install was repaired.
195
+ if (refresh && command !== "setup") {
196
+ throw new StoreError("usage", "--refresh belongs to setup: run `balladeer setup --refresh`.");
197
+ }
198
+ if (command !== "setup" && repositories.length > 1) {
199
+ throw new StoreError("usage", `${command} acts on one repository. Name --repository once, or run it again for the other.`);
200
+ }
201
+ const chosen = controlPlane ?? process.env.BALLADEER_CONTROL_PLANE?.trim() ?? DEFAULT_CONTROL_PLANE;
202
+ return {
203
+ command,
204
+ subcommand,
205
+ json,
206
+ wait,
207
+ refresh,
208
+ repo,
209
+ file,
210
+ repository: repositories[0],
211
+ repositories,
212
+ owner,
213
+ installHook,
214
+ runner,
215
+ createWorkspace,
216
+ positional,
217
+ role,
218
+ controlPlane: normalizeControlPlane(chosen),
219
+ };
220
+ }
221
+ export async function main(argv) {
222
+ let parsed;
223
+ try {
224
+ parsed = parseArguments(argv);
225
+ }
226
+ catch (error) {
227
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
228
+ return 4;
229
+ }
230
+ const write = (text) => void process.stdout.write(text);
231
+ const code = await dispatch(parsed, write);
232
+ // Said once, at the end, on stderr: an agent reading `--json` gets its steps
233
+ // on stdout unchanged, and a person reading a terminal gets the one line that
234
+ // says this copy is behind. It is a nag and never a failure, so it does not
235
+ // touch the exit code.
236
+ const notice = updateNotice();
237
+ if (notice !== undefined)
238
+ process.stderr.write(`${notice}\n`);
239
+ return code;
240
+ }
241
+ async function dispatch(parsed, write) {
242
+ switch (parsed.command) {
243
+ case "explain":
244
+ return runExplain(write, parsed.controlPlane);
245
+ case "setup":
246
+ return runSetup({
247
+ controlPlane: parsed.controlPlane,
248
+ json: parsed.json,
249
+ wait: parsed.wait,
250
+ refresh: parsed.refresh,
251
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
252
+ repositories: parsed.repositories,
253
+ ...(parsed.createWorkspace === undefined
254
+ ? {}
255
+ : { createWorkspace: parsed.createWorkspace }),
256
+ environment: process.env,
257
+ cwd: process.cwd(),
258
+ write,
259
+ });
260
+ case "repositories":
261
+ return runRepositories({
262
+ controlPlane: parsed.controlPlane,
263
+ json: parsed.json,
264
+ environment: process.env,
265
+ cwd: process.cwd(),
266
+ write,
267
+ });
268
+ case "invite": {
269
+ const role = parseRole(parsed.role);
270
+ if (role === undefined) {
271
+ process.stderr.write(`--role takes contributor, viewer, or administrator. It was given "${parsed.role}".\n`);
272
+ return 4;
273
+ }
274
+ return runInvite({
275
+ controlPlane: parsed.controlPlane,
276
+ json: parsed.json,
277
+ emails: parsed.positional,
278
+ role,
279
+ environment: process.env,
280
+ write,
281
+ });
282
+ }
283
+ case "check-seals":
284
+ return runCheckSeals({
285
+ json: parsed.json,
286
+ installHook: parsed.installHook,
287
+ ...(parsed.runner === undefined ? {} : { runner: parsed.runner }),
288
+ environment: process.env,
289
+ cwd: process.cwd(),
290
+ write,
291
+ });
292
+ case "touch-map":
293
+ return runTouchMap({
294
+ json: parsed.json,
295
+ environment: process.env,
296
+ cwd: process.cwd(),
297
+ write,
298
+ });
299
+ case "affected":
300
+ return runAffected({
301
+ json: parsed.json,
302
+ paths: parsed.positional,
303
+ cwd: process.cwd(),
304
+ write,
305
+ });
306
+ case "status": {
307
+ // At most one. Two ids on one line is a mistake, and answering for the
308
+ // first of them silently would report a promise nobody asked about.
309
+ if (parsed.positional.length > 1) {
310
+ process.stderr.write("status reads one promise id at a time.\n");
311
+ return 4;
312
+ }
313
+ const promiseId = parsed.positional[0];
314
+ return runStatus({
315
+ controlPlane: parsed.controlPlane,
316
+ json: parsed.json,
317
+ ...(promiseId === undefined ? {} : { promiseId }),
318
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
319
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
320
+ environment: process.env,
321
+ cwd: process.cwd(),
322
+ write,
323
+ });
324
+ }
325
+ case "propose":
326
+ return runPropose({
327
+ controlPlane: parsed.controlPlane,
328
+ json: parsed.json,
329
+ ...(parsed.file === undefined ? {} : { file: parsed.file }),
330
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
331
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
332
+ environment: process.env,
333
+ cwd: process.cwd(),
334
+ write,
335
+ });
336
+ case "discover":
337
+ return runDiscover({
338
+ controlPlane: parsed.controlPlane,
339
+ json: parsed.json,
340
+ ...(parsed.file === undefined ? {} : { file: parsed.file }),
341
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
342
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
343
+ ...(parsed.owner === undefined ? {} : { owner: parsed.owner }),
344
+ environment: process.env,
345
+ cwd: process.cwd(),
346
+ write,
347
+ });
348
+ case "agent":
349
+ // Rotating revokes every live connection for the repository, which is
350
+ // removal. The approval page tells a person their setup session cannot
351
+ // remove anything, so this command says the same rather than trying and
352
+ // relaying a server refusal the person cannot act on.
353
+ 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" +
354
+ `Rotate it in Balladeer under workspace settings at ${parsed.controlPlane}/settings.\n`);
355
+ return 4;
356
+ case "whoami":
357
+ return runWhoami({
358
+ controlPlane: parsed.controlPlane,
359
+ json: parsed.json,
360
+ environment: process.env,
361
+ write,
362
+ });
363
+ case "mcp":
364
+ return runMcp({
365
+ controlPlane: parsed.controlPlane,
366
+ ...(parsed.repository === undefined ? {} : { repositoryId: parsed.repository }),
367
+ environment: process.env,
368
+ cwd: process.cwd(),
369
+ stdin: process.stdin,
370
+ write,
371
+ error: (text) => void process.stderr.write(text),
372
+ });
373
+ case "help":
374
+ case "--help":
375
+ case "-h":
376
+ write(USAGE);
377
+ return 0;
378
+ case "--version":
379
+ case "-v":
380
+ write(`${CLI_VERSION}\n`);
381
+ return 0;
382
+ default:
383
+ process.stderr.write(`Unknown command "${parsed.command}".\n\n${USAGE}`);
384
+ return 4;
385
+ }
386
+ }
387
+ // Only when this file is the program, so a test may import `main` without the
388
+ // import itself running a command.
389
+ const entry = process.argv[1];
390
+ if (entry !== undefined && fileURLToPath(import.meta.url) === resolve(entry)) {
391
+ process.exitCode = await main(process.argv.slice(2));
392
+ }
@@ -0,0 +1,44 @@
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: `https://localhost:8080/candidates/...`
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
+ export type RequestOptions = Readonly<{
31
+ method: "GET" | "POST";
32
+ path: string;
33
+ body?: unknown;
34
+ bearer?: string;
35
+ timeoutMs?: number;
36
+ }>;
37
+ /**
38
+ * The one place this command talks to a network.
39
+ *
40
+ * `redirect: "manual"` so no redirect can carry a bearer to a host the person
41
+ * never paired with, and the 426 handshake is decoded here so every caller gets
42
+ * the same actionable refusal rather than a status code.
43
+ */
44
+ export declare function request<T>(controlPlane: string, options: RequestOptions): Promise<T>;