balladeer 1.0.0 → 1.0.2

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 (47) hide show
  1. package/README.md +53 -32
  2. package/dist/agent.d.ts +5 -0
  3. package/dist/agent.js +13 -1
  4. package/dist/cli.d.ts +16 -0
  5. package/dist/cli.js +181 -24
  6. package/dist/client.d.ts +24 -2
  7. package/dist/client.js +34 -3
  8. package/dist/commands/affected.js +8 -7
  9. package/dist/commands/check-seals.js +4 -4
  10. package/dist/commands/discover.js +16 -7
  11. package/dist/commands/explain.d.ts +1 -1
  12. package/dist/commands/explain.js +1 -1
  13. package/dist/commands/invite.js +2 -1
  14. package/dist/commands/prepare.d.ts +74 -0
  15. package/dist/commands/prepare.js +218 -0
  16. package/dist/commands/propose.d.ts +10 -0
  17. package/dist/commands/propose.js +29 -6
  18. package/dist/commands/repositories.js +1 -0
  19. package/dist/commands/session.d.ts +35 -0
  20. package/dist/commands/session.js +131 -0
  21. package/dist/commands/setup.d.ts +29 -0
  22. package/dist/commands/setup.js +302 -92
  23. package/dist/commands/status.d.ts +16 -0
  24. package/dist/commands/status.js +106 -23
  25. package/dist/commands/touch-map.js +2 -2
  26. package/dist/commands/whoami.js +2 -1
  27. package/dist/conventions.d.ts +9 -1
  28. package/dist/conventions.js +9 -1
  29. package/dist/copy.d.ts +83 -7
  30. package/dist/copy.js +226 -29
  31. package/dist/desktop-config.d.ts +85 -0
  32. package/dist/desktop-config.js +217 -0
  33. package/dist/git.d.ts +15 -0
  34. package/dist/git.js +23 -0
  35. package/dist/legacy.d.ts +41 -0
  36. package/dist/legacy.js +143 -0
  37. package/dist/local-time.d.ts +66 -0
  38. package/dist/local-time.js +84 -0
  39. package/dist/mcp-config.d.ts +10 -0
  40. package/dist/mcp-config.js +8 -4
  41. package/dist/session.d.ts +84 -0
  42. package/dist/session.js +135 -0
  43. package/dist/store.d.ts +11 -1
  44. package/dist/store.js +18 -6
  45. package/dist/wire.d.ts +95 -4
  46. package/dist/wire.js +2 -1
  47. package/package.json +1 -1
package/README.md CHANGED
@@ -19,11 +19,12 @@ The agent reads the setup instructions, runs the command, and hands you the two
19
19
  do. To run it yourself instead:
20
20
 
21
21
  ```sh
22
- npx balladeer@1.0.0 setup
22
+ npx -y balladeer@latest setup
23
23
  ```
24
24
 
25
- Pin the version, every time. Never use an unpinned or `@latest` form. The bare name resolves to an
26
- unrelated package that installs a different program.
25
+ Use `@latest` so a new session picks up the current command. Balladeer tells an older command when
26
+ an update is available. Run `npx -y balladeer@latest setup --refresh` to update an existing
27
+ repository connection and its instructions without replacing other tools' settings.
27
28
 
28
29
  Run it inside the repository you want to protect. It prints the boundary explanation below, then one
29
30
  step at a time, and it exits within seconds rather than blocking on anything.
@@ -33,6 +34,12 @@ connects this coding agent, records the CI identity and opens the pull request w
33
34
  and hands you the playbook for discovering this repository's own promises. The two things it never
34
35
  does are sign you in and agree to a promise; both of those are yours, in a browser.
35
36
 
37
+ If you are joining an existing workspace, use `npx -y balladeer@latest setup --existing`. It
38
+ completes browser onboarding without adding a repository or changing CI. When this checkout is
39
+ already enrolled it connects this machine and writes its MCP configuration; when it is absent, setup
40
+ leaves the checkout unchanged and Balladeer shows that enforcement tracking is unavailable until an
41
+ administrator connects a repository.
42
+
36
43
  ## Requirements
37
44
 
38
45
  - Node 22 or newer. The command has no runtime dependencies at all: it uses Node builtins and
@@ -57,19 +64,20 @@ test output.
57
64
  The verify job in your CI runs your tests with your checkout and has no Balladeer credential. A
58
65
  separate publish job, with no checkout, sends only those outcomes and hashes.
59
66
 
60
- This setup creates two credentials, both of which you control. Approving in the browser lets this
61
- session act as you to add repositories, connect your coding agent, connect CI, and propose promises;
62
- it cannot approve, activate, or remove anything. That session lasts a day, each use buys it another
63
- day, and it is gone seven days after you approved it however much you used it. The coding agent's
64
- own connection has no expiry: it lets the agent read the promises your team has approved, propose
65
- new ones, and propose replacing, retiring, excepting, or reassigning one you already have; every one
66
- of those waits for a named person to decide it. It can also carry out one of those acts for you, and
67
- only this way: you open a Balladeer page in your own browser, read what the act is, sign it off
68
- there, and read back the one-time code that page gives you. That code covers that one act on that
69
- one thing, once, and Balladeer records you as the person who took it and the connection as the
70
- messenger. Without a code you signed, the connection cannot approve, activate, grant, transfer,
71
- retire, or delete anything. You can revoke either one at any time in Balladeer, and the setup
72
- session also ends on its own.
67
+ Browser approval creates a temporary setup session limited by that person's workspace role. A
68
+ contributor can connect this machine's coding agent to an already-enrolled repository and propose
69
+ promises; an administrator can also add repositories, connect CI, and invite teammates. The session
70
+ cannot approve, activate, rotate, revoke, or remove anything. It lasts a day, each use buys it
71
+ another day, and it is gone seven days after approval however much it was used. When setup connects
72
+ an enrolled repository it also issues that machine a coding-agent credential. That connection has no
73
+ expiry: it lets the agent read the promises your team has approved, propose new ones, and propose
74
+ replacing, retiring, excepting, or reassigning one you already have; every one of those waits for a
75
+ named person to decide it. It can also carry out one of those acts for you, and only this way: you
76
+ open a Balladeer page in your own browser, read what the act is, sign it off there, and read back
77
+ the one-time code that page gives you. That code covers that one act on that one thing, once, and
78
+ Balladeer records you as the person who took it and the connection as the messenger. Without a code
79
+ you signed, the connection cannot approve, activate, grant, transfer, retire, or delete anything.
80
+ You can revoke either one at any time in Balladeer, and the setup session also ends on its own.
73
81
 
74
82
  Adding the workflow puts a check on pull requests into your default branch. That check is advisory
75
83
  on Balladeer's side; whether it blocks a merge is your own branch protection.
@@ -107,20 +115,22 @@ your own branch protection.
107
115
 
108
116
  ## Commands
109
117
 
110
- | Command | What it does |
111
- | ------------------------ | ------------------------------------------------------------------------------ |
112
- | `balladeer setup` | Run all five steps, then report what a person still has to do |
113
- | `balladeer repositories` | List what this machine could add, marking the one you are in and the ones in |
114
- | `balladeer invite` | Invite teammates by email, and say per address whether the email went |
115
- | `balladeer status` | Report this repository over its agent connection, and the workspace |
116
- | `balladeer status <id>` | Report one promise: whether it is holding, and if not, what the run reported |
117
- | `balladeer propose` | Propose one promise from a proposal file, over that connection |
118
- | `balladeer discover` | File a whole catalog, up to ten promises from one file, behind one review link |
119
- | `balladeer touch-map` | Record which files each promise's verifier runs, on this machine only |
120
- | `balladeer affected` | Say which promises the files you name touch, out of that record |
121
- | `balladeer mcp` | Forward one MCP session over stdio using this repository's connection |
122
- | `balladeer explain` | What Balladeer can and cannot see, where to watch it, and what leaving costs |
123
- | `balladeer whoami` | Report the stored session's workspace, role, scopes, and expiry |
118
+ | Command | What it does |
119
+ | -------------------------------------- | -------------------------------------------------------------------------------- |
120
+ | `npx -y balladeer@latest setup` | Run all five steps, then report what a person still has to do |
121
+ | `npx -y balladeer@latest repositories` | List what this machine could add, marking the one you are in and the ones in |
122
+ | `npx -y balladeer@latest invite` | Invite teammates by email, and say per address whether the email went |
123
+ | `npx -y balladeer@latest status` | Report this repository over its agent connection, and the workspace |
124
+ | `npx -y balladeer@latest status <id>` | Report one promise: whether it is holding, and if not, what the run reported |
125
+ | `npx -y balladeer@latest prepare <id>` | Prepare a promise's one-time qualification setup, and write it where CI reads it |
126
+ | `npx -y balladeer@latest propose` | Propose one promise from a proposal file, over that connection |
127
+ | `npx -y balladeer@latest discover` | File a whole catalog, up to ten promises from one file, behind one review link |
128
+ | `npx -y balladeer@latest touch-map` | Record which files each promise's verifier runs, on this machine only |
129
+ | `npx -y balladeer@latest affected` | Say which promises the files you name touch, out of that record |
130
+ | `npx -y balladeer@latest session` | Print the id for this piece of work, and the line to write into the commit |
131
+ | `npx -y balladeer@latest mcp` | Forward one MCP session over stdio using this repository's connection |
132
+ | `npx -y balladeer@latest explain` | What Balladeer can and cannot see, where to watch it, and what leaving costs |
133
+ | `npx -y balladeer@latest whoami` | Report the stored session's workspace, role, scopes, and expiry |
124
134
 
125
135
  `--json` emits one object per step on stdout and nothing else. `--wait` makes `setup` poll for the
126
136
  approval instead of exiting; without it the command exits and a later run finishes the pairing.
@@ -129,7 +139,7 @@ approval instead of exiting; without it the command exits and a later run finish
129
139
  to be the one you are standing in, because connecting a coding agent writes files into a working
130
140
  tree and connecting CI pushes a branch to a remote. The rest are added to the workspace and nothing
131
141
  more, and the run says so for each of them; somebody runs setup in a checkout of each one to finish
132
- it. `balladeer repositories` is what you read first to decide which ones to name.
142
+ it. `npx -y balladeer@latest repositories` is what you read first to decide which ones to name.
133
143
 
134
144
  `invite` takes one or more addresses and `--role contributor|viewer|administrator`, defaulting to
135
145
  contributor. It goes over the setup session's `workspace:invite` grant, so it works only for a
@@ -148,7 +158,18 @@ once they exist, and what stopping costs: your tests are yours, they keep runnin
148
158
  administrator can download everything Balladeer holds at any time.
149
159
 
150
160
  Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already claimed, 3 this
151
- copy is too old for the server, 4 usage or credential store problem, 5 transport or server failure.
161
+ copy is too old for the server, 4 usage or credential store problem, 5 transport or server failure,
162
+ 6 an earlier Balladeer is still installed on this machine and nothing was changed.
163
+
164
+ ## An earlier Balladeer on the same machine
165
+
166
+ A machine that ran the earlier Balladeer still carries it: that client registers an MCP server named
167
+ `balladeer` for the whole machine and installs a session hook of its own, and neither comes off when
168
+ this one goes on. Setup finds it before it writes anything and stops with one instruction. If you
169
+ used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it
170
+ prints, then continue. That uninstall is reversible, it backs up `~/.balladeer`, and it leaves the
171
+ old binary in place. `npx -y balladeer@latest setup --force` runs anyway, for whoever has decided to
172
+ keep both.
152
173
 
153
174
  ## Revoking
154
175
 
package/dist/agent.d.ts CHANGED
@@ -124,3 +124,8 @@ export declare function safeJson(response: Response): Promise<unknown>;
124
124
  export declare function updateLine(payload: unknown): string;
125
125
  /** One field of a tool result, when it is a string. Never a guess. */
126
126
  export declare function structuredString(structured: unknown, field: string): string | undefined;
127
+ /** Render the server's live enforcement limitation once per CLI operation. */
128
+ export declare function reportAgentEnforcementWarning(call: AgentToolCall, output: Readonly<{
129
+ json: boolean;
130
+ write: (text: string) => void;
131
+ }>): void;
package/dist/agent.js CHANGED
@@ -21,7 +21,8 @@ const REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
21
21
  export function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
22
22
  const here = agents.filter((agent) => agent.controlPlane === controlPlane);
23
23
  if (repositoryId !== undefined) {
24
- const named = here.find((agent) => agent.repositoryId === repositoryId);
24
+ const named = here.find((agent) => agent.repositoryId === repositoryId ||
25
+ agent.repository?.toLowerCase() === repositoryId.toLowerCase());
25
26
  if (named === undefined) {
26
27
  return {
27
28
  kind: "refused",
@@ -207,3 +208,14 @@ export function structuredString(structured, field) {
207
208
  const value = structured[field];
208
209
  return typeof value === "string" ? value : undefined;
209
210
  }
211
+ /** Render the server's live enforcement limitation once per CLI operation. */
212
+ export function reportAgentEnforcementWarning(call, output) {
213
+ if (call.kind !== "result" && call.kind !== "tool_refusal")
214
+ return;
215
+ const message = structuredString(call.structured, "enforcementWarning");
216
+ if (message === undefined)
217
+ return;
218
+ output.write(output.json
219
+ ? `${JSON.stringify({ step: "warning", code: "enforcement_unavailable", message })}\n`
220
+ : `WARNING ${message}\n`);
221
+ }
package/dist/cli.d.ts CHANGED
@@ -6,6 +6,16 @@ type Parsed = Readonly<{
6
6
  wait: boolean;
7
7
  /** Repair the files a previous setup wrote, and do nothing else. */
8
8
  refresh: boolean;
9
+ /** `setup --force`: set up even though an earlier Balladeer is still installed. */
10
+ force: boolean;
11
+ /** `setup --existing`: connect only when this checkout is already enrolled. */
12
+ existingOnly: boolean;
13
+ /**
14
+ * `setup --claude-desktop` / `--no-claude-desktop`. Undefined is neither
15
+ * asked for nor refused, which is the ordinary run: connect the chat client
16
+ * where it is installed and say nothing where it is not.
17
+ */
18
+ claudeDesktop: boolean | undefined;
9
19
  repo: string | undefined;
10
20
  file: string | undefined;
11
21
  repository: string | undefined;
@@ -21,6 +31,12 @@ type Parsed = Readonly<{
21
31
  owner: string | undefined;
22
32
  /** `check-seals --install-hook`: write the optional pre-push hook. */
23
33
  installHook: boolean;
34
+ /** `session --new`: start a different session even though one is current. */
35
+ fresh: boolean;
36
+ /** `session --record`: read the trailer out of HEAD and record that commit. */
37
+ record: boolean;
38
+ /** `prepare --again`: mint a replacement packet and invalidate the unspent one. */
39
+ again: boolean;
24
40
  /** `check-seals --runner`: where the pinned runner is, when it has moved. */
25
41
  runner: string | undefined;
26
42
  createWorkspace: string | undefined;
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
2
3
  import { resolve } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { runAffected } from "./commands/affected.js";
@@ -7,19 +8,23 @@ import { runDiscover } from "./commands/discover.js";
7
8
  import { runExplain } from "./commands/explain.js";
8
9
  import { parseRole, runInvite } from "./commands/invite.js";
9
10
  import { runMcp } from "./commands/mcp.js";
11
+ import { runPrepare } from "./commands/prepare.js";
10
12
  import { runPropose } from "./commands/propose.js";
11
13
  import { runRepositories } from "./commands/repositories.js";
12
14
  import { CREATE_WORKSPACE_MAX_LENGTH, runSetup } from "./commands/setup.js";
15
+ import { runSession } from "./commands/session.js";
13
16
  import { runStatus } from "./commands/status.js";
14
17
  import { runTouchMap } from "./commands/touch-map.js";
15
18
  import { runWhoami } from "./commands/whoami.js";
16
19
  import { updateNotice } from "./currency.js";
17
20
  import { StoreError, normalizeControlPlane } from "./store.js";
18
- import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
21
+ import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
19
22
  const USAGE = `balladeer ${CLI_VERSION}
20
23
 
21
- balladeer setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
22
- [--create-workspace <name>] [--refresh]
24
+ ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
25
+ [--create-workspace <name>] [--refresh] [--force]
26
+ [--existing]
27
+ [--claude-desktop | --no-claude-desktop]
23
28
  Pair this session, add this repository, connect this coding agent and CI,
24
29
  and report what a person still has to do. Name --repository more than once
25
30
  to add other repositories to the same workspace; each of those is added and
@@ -27,21 +32,32 @@ const USAGE = `balladeer ${CLI_VERSION}
27
32
  --create-workspace carries a name to the approval page, where a person
28
33
  signs in and creates the workspace themselves; this command never creates
29
34
  one.
35
+ --existing is for an invited teammate and never enrolls repositories or changes CI.
36
+ If this repository is already connected, connect this machine; otherwise
37
+ continue in the browser while an administrator connects it.
30
38
  --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.
39
+ repository's balladeer entry in .mcp.json, its Balladeer instructions
40
+ block and its Claude desktop chat entry to the current form, leaves every
41
+ other entry in those files alone, and prints what it changed. Run it when
42
+ Balladeer says a newer version is available.
43
+ --force sets up even though an earlier Balladeer is still installed on
44
+ this machine. Without it, a run that finds the older client's machine-wide
45
+ MCP entry or session hook stops and says to remove it first.
46
+ Claude desktop chat is connected too, under a key named for this
47
+ repository, wherever the app is installed. --claude-desktop asks for it by
48
+ name, so a machine with no Claude desktop on it says so instead of
49
+ skipping quietly; --no-claude-desktop leaves that file alone entirely.
50
+ Quit and reopen the app afterwards: it reads its configuration at startup.
35
51
 
36
- balladeer repositories [--json] [--control-plane <url>]
52
+ ${CLI_INVOCATION} repositories [--json] [--control-plane <url>]
37
53
  List the repositories this machine's GitHub account can see, marking the
38
54
  one you are standing in and the ones Balladeer already has.
39
55
 
40
- balladeer invite <email>... [--role contributor|viewer|administrator] [--json]
56
+ ${CLI_INVOCATION} invite <email>... [--role contributor|viewer|administrator] [--json]
41
57
  Invite teammates into this workspace by email, and say per address whether
42
58
  the invitation went. Inviting is an administrator's act.
43
59
 
44
- balladeer status [<promise id>] [--repo owner/name] [--repository <uuid>] [--json]
60
+ ${CLI_INVOCATION} status [<promise id>] [--repo owner/name] [--repository <uuid>] [--json]
45
61
  [--control-plane <url>]
46
62
  Report this repository over its agent connection, and every repository in
47
63
  the workspace when a setup session is still live. Named with a promise id,
@@ -50,17 +66,30 @@ const USAGE = `balladeer ${CLI_VERSION}
50
66
  the agreed meaning that run was checking. That is the form the line on a
51
67
  broken promise tells a person to run first.
52
68
 
53
- balladeer propose --file <path> [--repo owner/name] [--repository <uuid>] [--json]
69
+ ${CLI_INVOCATION} propose --file <path> [--repo owner/name] [--repository <uuid>] [--json]
54
70
  Propose one promise from a proposal file, over this repository's agent
55
71
  connection. A named person still agrees to it.
56
72
 
57
- balladeer discover --file <path> [--repo owner/name] [--repository <uuid>]
73
+ ${CLI_INVOCATION} prepare <promise id> [--again] [--repo owner/name] [--repository <uuid>]
74
+ [--json] [--control-plane <url>]
75
+ Prepare the one-time qualification setup for a promise whose meaning is
76
+ agreed and which nothing is checking yet, and write it to
77
+ .continuity/qualification/<promise id>.json, which is where the sealed run
78
+ reads it. No sign-off and no code: agreeing the meaning was the person's
79
+ act, and building the check that proves it is yours. Then build the
80
+ verifier, seal it, and push to the default branch; protection starts by
81
+ itself when that run qualifies. It is one-time, so while nobody has
82
+ published against the packet already prepared this refuses and says who
83
+ prepared it and when. --again prepares a replacement and invalidates that
84
+ earlier packet: a run publishing its identities afterwards is refused.
85
+
86
+ ${CLI_INVOCATION} discover --file <path> [--repo owner/name] [--repository <uuid>]
58
87
  [--owner <membership id>] [--json]
59
88
  Propose a whole discovered catalog, up to ten promises from one file, each
60
89
  owned by whoever paired this machine, and print one link that opens all of
61
90
  them. A named person still agrees to every one.
62
91
 
63
- balladeer check-seals [--json] [--runner <path>] [--install-hook]
92
+ ${CLI_INVOCATION} check-seals [--json] [--runner <path>] [--install-hook]
64
93
  Say whether what you are about to push would break a promise's seal. It
65
94
  prints nothing and exits 0 when it would not, and names the promise, its
66
95
  owner, its page and the line that seals it again when it would. It runs
@@ -68,34 +97,45 @@ const USAGE = `balladeer ${CLI_VERSION}
68
97
  writes .git/hooks/pre-push so every push asks first; nothing installs it
69
98
  for you, and deleting that file removes it.
70
99
 
71
- balladeer touch-map [--json]
100
+ ${CLI_INVOCATION} touch-map [--json]
72
101
  Run this repository's promise verifiers under coverage and write down
73
102
  which files each one actually executed, to .continuity/touch-map.json.
74
103
  It runs offline and the map stays on this machine: Balladeer is never
75
104
  sent it. Node verifiers only in this release.
76
105
 
77
- balladeer affected <paths...> [--json]
106
+ ${CLI_INVOCATION} affected <paths...> [--json]
78
107
  Say which promises the named files touch, out of that map, marking any
79
108
  answer whose verifier has changed since the map was built. It reads one
80
109
  local file and contacts nothing.
81
110
 
82
- balladeer explain [--control-plane <url>]
111
+ ${CLI_INVOCATION} session [--new] [--record] [--repo owner/name] [--repository <uuid>] [--json]
112
+ [--control-plane <url>]
113
+ Print the id for this piece of work, and the one line to write into the
114
+ commit it produces. Pass the id to every promise read you make, so a check
115
+ that goes red later can be read back against what Balladeer told you
116
+ before you started. --new starts a different session; --record reads the
117
+ Balladeer-Session line out of the commit at HEAD and tells Balladeer which
118
+ commit this session wrote. Your commit message never leaves this machine:
119
+ only the session id and the commit SHA are sent.
120
+
121
+ ${CLI_INVOCATION} explain [--control-plane <url>]
83
122
  Print, word for word, what Balladeer can and cannot see, where to watch
84
123
  your promises, and what leaving costs.
85
124
 
86
- balladeer whoami [--json] [--control-plane <url>]
125
+ ${CLI_INVOCATION} whoami [--json] [--control-plane <url>]
87
126
  Report the stored setup session's workspace, role, scopes, and expiry.
88
127
 
89
- balladeer agent rotate [--control-plane <url>]
128
+ ${CLI_INVOCATION} agent rotate [--control-plane <url>]
90
129
  Replace this repository's agent credential. A setup session cannot do this,
91
130
  because rotating revokes the connections the repository already has.
92
131
 
93
- balladeer mcp [--repository <uuid>] [--control-plane <url>]
132
+ ${CLI_INVOCATION} mcp [--repository <uuid>] [--control-plane <url>]
94
133
  Forward one MCP session over stdio using this repository's agent connection.
95
134
 
96
135
  Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
97
136
  claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
98
- 5 transport or server failure.
137
+ 5 transport or server failure, 6 an earlier Balladeer is still installed on this
138
+ machine and nothing was changed.
99
139
  `;
100
140
  const SUBCOMMANDS = { agent: ["rotate"] };
101
141
  export function parseArguments(argv) {
@@ -111,7 +151,13 @@ export function parseArguments(argv) {
111
151
  let json = false;
112
152
  let wait = false;
113
153
  let refresh = false;
154
+ let force = false;
155
+ let existingOnly = false;
156
+ let claudeDesktop;
114
157
  let installHook = false;
158
+ let fresh = false;
159
+ let record = false;
160
+ let again = false;
115
161
  let runner;
116
162
  let controlPlane;
117
163
  let repo;
@@ -147,14 +193,28 @@ export function parseArguments(argv) {
147
193
  const inline = rest.length > 0 ? rest.join("=") : undefined;
148
194
  if (name === "--json")
149
195
  json = true;
196
+ else if (name === "--again")
197
+ again = true;
150
198
  else if (name === "--install-hook")
151
199
  installHook = true;
200
+ else if (name === "--new")
201
+ fresh = true;
202
+ else if (name === "--record")
203
+ record = true;
152
204
  else if (name === "--runner")
153
205
  runner = value("--runner", inline);
154
206
  else if (name === "--wait")
155
207
  wait = true;
156
208
  else if (name === "--refresh")
157
209
  refresh = true;
210
+ else if (name === "--force")
211
+ force = true;
212
+ else if (name === "--existing")
213
+ existingOnly = true;
214
+ else if (name === "--claude-desktop")
215
+ claudeDesktop = true;
216
+ else if (name === "--no-claude-desktop")
217
+ claudeDesktop = false;
158
218
  else if (name === "--control-plane")
159
219
  controlPlane = value("--control-plane", inline);
160
220
  else if (name === "--repo")
@@ -181,8 +241,20 @@ export function parseArguments(argv) {
181
241
  // value that lost its flag, and it was refused before this loop learned to
182
242
  // collect them, so it is refused here rather than ignored.
183
243
  // `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") {
244
+ // in front of the person. `prepare` and `status` each take exactly one bare
245
+ // word, the promise id a person copied off a promise page, which is the form
246
+ // both of those commands are told to people and to agents in.
247
+ // `--again` invalidates a packet somebody may be halfway through using, so it
248
+ // belongs to the one command that mints one. Accepted silently elsewhere, it
249
+ // would read as a general "do it anyway" flag.
250
+ if (again && command !== "prepare") {
251
+ throw new StoreError("usage", `--again belongs to prepare: run \`${CLI_INVOCATION} prepare <promise id> --again\`.`);
252
+ }
253
+ if (positional.length > 0 &&
254
+ command !== "invite" &&
255
+ command !== "affected" &&
256
+ command !== "prepare" &&
257
+ command !== "status") {
186
258
  throw new StoreError("usage", `${command} takes no bare arguments: ${positional.join(", ")}.`);
187
259
  }
188
260
  // `--repository` names one repository everywhere but `setup`, where it names
@@ -193,7 +265,28 @@ export function parseArguments(argv) {
193
265
  // nothing else. Accepting it silently elsewhere would let somebody run
194
266
  // `status --refresh`, see no error, and believe their install was repaired.
195
267
  if (refresh && command !== "setup") {
196
- throw new StoreError("usage", "--refresh belongs to setup: run `balladeer setup --refresh`.");
268
+ throw new StoreError("usage", `--refresh belongs to setup: run \`${CLI_INVOCATION} setup --refresh\`.`);
269
+ }
270
+ // `--force` says one thing only: set up alongside an earlier Balladeer. Taken
271
+ // silently by another command it would read as a general "do it anyway" flag,
272
+ // which is exactly what nothing else here offers.
273
+ if (force && command !== "setup") {
274
+ throw new StoreError("usage", `--force belongs to setup: run \`${CLI_INVOCATION} setup --force\`.`);
275
+ }
276
+ if (existingOnly && command !== "setup") {
277
+ throw new StoreError("usage", `--existing belongs to setup: run \`${CLI_INVOCATION} setup --existing\`.`);
278
+ }
279
+ if (existingOnly && createWorkspace !== undefined) {
280
+ throw new StoreError("usage", "--existing cannot be combined with --create-workspace: one refuses repository enrollment and the other starts a new workspace.");
281
+ }
282
+ if (existingOnly && repositories.length > 1) {
283
+ throw new StoreError("usage", "--existing connects one checkout and never adds repositories. Leave off --repository, or name only this checkout.");
284
+ }
285
+ // Connecting a chat client is something setup does, so the flag belongs to
286
+ // setup. Accepted silently on `status`, it would let somebody believe they had
287
+ // connected Claude desktop when nothing had written a line.
288
+ if (claudeDesktop !== undefined && command !== "setup") {
289
+ throw new StoreError("usage", `--claude-desktop belongs to setup: run \`${CLI_INVOCATION} setup --claude-desktop\`.`);
197
290
  }
198
291
  if (command !== "setup" && repositories.length > 1) {
199
292
  throw new StoreError("usage", `${command} acts on one repository. Name --repository once, or run it again for the other.`);
@@ -205,12 +298,18 @@ export function parseArguments(argv) {
205
298
  json,
206
299
  wait,
207
300
  refresh,
301
+ force,
302
+ existingOnly,
303
+ claudeDesktop,
208
304
  repo,
209
305
  file,
210
306
  repository: repositories[0],
211
307
  repositories,
212
308
  owner,
213
309
  installHook,
310
+ fresh,
311
+ record,
312
+ again,
214
313
  runner,
215
314
  createWorkspace,
216
315
  positional,
@@ -248,6 +347,9 @@ async function dispatch(parsed, write) {
248
347
  json: parsed.json,
249
348
  wait: parsed.wait,
250
349
  refresh: parsed.refresh,
350
+ force: parsed.force,
351
+ existingOnly: parsed.existingOnly,
352
+ ...(parsed.claudeDesktop === undefined ? {} : { claudeDesktop: parsed.claudeDesktop }),
251
353
  ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
252
354
  repositories: parsed.repositories,
253
355
  ...(parsed.createWorkspace === undefined
@@ -296,6 +398,18 @@ async function dispatch(parsed, write) {
296
398
  cwd: process.cwd(),
297
399
  write,
298
400
  });
401
+ case "session":
402
+ return runSession({
403
+ controlPlane: parsed.controlPlane,
404
+ json: parsed.json,
405
+ record: parsed.record,
406
+ fresh: parsed.fresh,
407
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
408
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
409
+ environment: process.env,
410
+ cwd: process.cwd(),
411
+ write,
412
+ });
299
413
  case "affected":
300
414
  return runAffected({
301
415
  json: parsed.json,
@@ -322,6 +436,26 @@ async function dispatch(parsed, write) {
322
436
  write,
323
437
  });
324
438
  }
439
+ case "prepare": {
440
+ // One id, and one only. Preparing two packets from one line would mint
441
+ // two one-time identities and write two files, and reporting the first
442
+ // silently would leave the second promise unprepared with nobody told.
443
+ if (parsed.positional.length !== 1) {
444
+ process.stderr.write(`prepare reads one promise id: ${CLI_INVOCATION} prepare <promise id> [--again].\n`);
445
+ return 4;
446
+ }
447
+ return runPrepare({
448
+ controlPlane: parsed.controlPlane,
449
+ json: parsed.json,
450
+ promiseId: parsed.positional[0],
451
+ again: parsed.again,
452
+ ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
453
+ ...(parsed.repository === undefined ? {} : { repository: parsed.repository }),
454
+ environment: process.env,
455
+ cwd: process.cwd(),
456
+ write,
457
+ });
458
+ }
325
459
  case "propose":
326
460
  return runPropose({
327
461
  controlPlane: parsed.controlPlane,
@@ -384,9 +518,32 @@ async function dispatch(parsed, write) {
384
518
  return 4;
385
519
  }
386
520
  }
521
+ /**
522
+ * The real file behind a path, following symlinks.
523
+ *
524
+ * `npx` and every global install run this program through a
525
+ * `node_modules/.bin/balladeer` symlink, so `process.argv[1]` is the link and
526
+ * not the file Node loaded. `resolve` does not follow a link, so comparing
527
+ * resolved paths made the guard below false for every customer: the process
528
+ * exited 0 having printed nothing, and only a checkout running
529
+ * `node dist/cli.js` ever saw the program run at all.
530
+ *
531
+ * A path that cannot be read back falls through to the resolved form rather
532
+ * than throwing, because a guard that decides whether the program runs must
533
+ * never be the thing that stops it.
534
+ */
535
+ function programPath(path) {
536
+ const absolute = resolve(path);
537
+ try {
538
+ return realpathSync(absolute);
539
+ }
540
+ catch {
541
+ return absolute;
542
+ }
543
+ }
387
544
  // Only when this file is the program, so a test may import `main` without the
388
545
  // import itself running a command.
389
546
  const entry = process.argv[1];
390
- if (entry !== undefined && fileURLToPath(import.meta.url) === resolve(entry)) {
547
+ if (entry !== undefined && programPath(fileURLToPath(import.meta.url)) === programPath(entry)) {
391
548
  process.exitCode = await main(process.argv.slice(2));
392
549
  }
package/dist/client.d.ts CHANGED
@@ -18,7 +18,7 @@ export declare class RefusalError extends Error {
18
18
  *
19
19
  * Behind a platform proxy a server can resolve a link against the machine it is
20
20
  * running on rather than against the address customers use, and the first
21
- * production `propose` printed exactly that: `https://localhost:8080/candidates/...`
21
+ * production `propose` printed exactly that: a review link on `localhost:8080`
22
22
  * as the place to go and agree. A command that prints such a link is worse than
23
23
  * one that prints none, because the person tries it.
24
24
  *
@@ -26,7 +26,29 @@ export declare class RefusalError extends Error {
26
26
  * identifier and this builds the address, which is why there is no parameter
27
27
  * here for a server's own link to arrive through.
28
28
  */
29
- export declare function reviewLink(controlPlane: string, path: string): string;
29
+ export declare function reviewLink(controlPlane: string, path: string, workspaceId?: 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, workspaceId?: 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;
30
52
  export type RequestOptions = Readonly<{
31
53
  method: "GET" | "POST";
32
54
  path: string;