balladeer 1.0.1 → 1.0.3

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.
package/README.md CHANGED
@@ -23,8 +23,9 @@ npx -y balladeer@latest setup
23
23
  ```
24
24
 
25
25
  Use `@latest` so a new session picks up the current command. Balladeer tells an older command when
26
- an update is available. Run `balladeer setup --refresh` to update an existing repository connection
27
- and its instructions without replacing other tools' settings.
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. For Codex, keep
28
+ `--client codex` on the refresh command: `npx -y balladeer@latest setup --client codex --refresh`.
28
29
 
29
30
  Run it inside the repository you want to protect. It prints the boundary explanation below, then one
30
31
  step at a time, and it exits within seconds rather than blocking on anything.
@@ -34,10 +35,16 @@ connects this coding agent, records the CI identity and opens the pull request w
34
35
  and hands you the playbook for discovering this repository's own promises. The two things it never
35
36
  does are sign you in and agree to a promise; both of those are yours, in a browser.
36
37
 
38
+ If you are joining an existing workspace, use `npx -y balladeer@latest setup --existing`. It
39
+ completes browser onboarding without adding a repository or changing CI. When this checkout is
40
+ already enrolled it connects this machine and writes its MCP configuration; when it is absent, setup
41
+ leaves the checkout unchanged and Balladeer shows that enforcement tracking is unavailable until an
42
+ administrator connects a repository.
43
+
37
44
  ## Requirements
38
45
 
39
- - Node 22 or newer. The command has no runtime dependencies at all: it uses Node builtins and
40
- nothing else, so installing it pulls nothing else down.
46
+ - Node 22 or newer. The command uses Node builtins and the pinned `@iarna/toml` parser to preserve
47
+ Codex project configuration safely.
41
48
  - `git`, and the GitHub CLI (`gh`) signed in to an account with push access to the repository. Your
42
49
  `gh` is how the command reads the repository's ids and opens the pull request that carries the
43
50
  workflow. Balladeer itself never holds a GitHub credential.
@@ -58,19 +65,20 @@ test output.
58
65
  The verify job in your CI runs your tests with your checkout and has no Balladeer credential. A
59
66
  separate publish job, with no checkout, sends only those outcomes and hashes.
60
67
 
61
- This setup creates two credentials, both of which you control. Approving in the browser lets this
62
- session act as you to add repositories, connect your coding agent, connect CI, and propose promises;
63
- it cannot approve, activate, or remove anything. That session lasts a day, each use buys it another
64
- day, and it is gone seven days after you approved it however much you used it. The coding agent's
65
- own connection has no expiry: it lets the agent read the promises your team has approved, propose
66
- new ones, and propose replacing, retiring, excepting, or reassigning one you already have; every one
67
- of those waits for a named person to decide it. It can also carry out one of those acts for you, and
68
- only this way: you open a Balladeer page in your own browser, read what the act is, sign it off
69
- there, and read back the one-time code that page gives you. That code covers that one act on that
70
- one thing, once, and Balladeer records you as the person who took it and the connection as the
71
- messenger. Without a code you signed, the connection cannot approve, activate, grant, transfer,
72
- retire, or delete anything. You can revoke either one at any time in Balladeer, and the setup
73
- session also ends on its own.
68
+ Browser approval creates a temporary setup session limited by that person's workspace role. A
69
+ contributor can connect this machine's coding agent to an already-enrolled repository and propose
70
+ promises; an administrator can also add repositories, connect CI, and invite teammates. The session
71
+ cannot approve, activate, rotate, revoke, or remove anything. It lasts a day, each use buys it
72
+ another day, and it is gone seven days after approval however much it was used. When setup connects
73
+ an enrolled repository it also issues that machine a coding-agent credential. That connection has no
74
+ expiry: it lets the agent read the promises your team has approved, propose new ones, and propose
75
+ replacing, retiring, excepting, or reassigning one you already have; every one of those waits for a
76
+ named person to decide it. It can also carry out one of those acts for you, and only this way: you
77
+ open a Balladeer page in your own browser, read what the act is, sign it off there, and read back
78
+ the one-time code that page gives you. That code covers that one act on that one thing, once, and
79
+ Balladeer records you as the person who took it and the connection as the messenger. Without a code
80
+ you signed, the connection cannot approve, activate, grant, transfer, retire, or delete anything.
81
+ You can revoke either one at any time in Balladeer, and the setup session also ends on its own.
74
82
 
75
83
  Adding the workflow puts a check on pull requests into your default branch. That check is advisory
76
84
  on Balladeer's side; whether it blocks a merge is your own branch protection.
@@ -93,10 +101,10 @@ with your checkout and no Balladeer credential.
93
101
  It writes, locally and visibly: your credentials to `$XDG_CONFIG_HOME/balladeer/credentials.json`,
94
102
  or `~/.config/balladeer/credentials.json`, with mode 600 inside a directory with mode 700, and it
95
103
  refuses to write them anywhere a commit could pick them up; a `balladeer` entry in the repository's
96
- `.mcp.json`, added beside whatever is already there and never over the top of somebody else's entry;
97
- and a marker-fenced block in the repository's `CLAUDE.md` or `AGENTS.md`, replaced between the
98
- markers on each run and never outside them. A file whose markers are damaged is left alone and
99
- reported rather than appended to.
104
+ `.mcp.json` for Claude, or `.codex/config.toml` with `--client codex`, preserving unrelated entries;
105
+ and a marker-fenced block in `AGENTS.md` for Codex (the existing `CLAUDE.md` or `AGENTS.md` for
106
+ Claude), replaced between the markers on each run and never outside them. A file whose markers are
107
+ damaged is left alone and reported rather than appended to.
100
108
 
101
109
  It changes, on your GitHub repository and through your own `gh`: the repository variables the
102
110
  workflow reads, so the workflow file itself names no host by hand.
@@ -108,22 +116,22 @@ your own branch protection.
108
116
 
109
117
  ## Commands
110
118
 
111
- | Command | What it does |
112
- | ------------------------ | -------------------------------------------------------------------------------- |
113
- | `balladeer setup` | Run all five steps, then report what a person still has to do |
114
- | `balladeer repositories` | List what this machine could add, marking the one you are in and the ones in |
115
- | `balladeer invite` | Invite teammates by email, and say per address whether the email went |
116
- | `balladeer status` | Report this repository over its agent connection, and the workspace |
117
- | `balladeer status <id>` | Report one promise: whether it is holding, and if not, what the run reported |
118
- | `balladeer prepare <id>` | Prepare a promise's one-time qualification setup, and write it where CI reads it |
119
- | `balladeer propose` | Propose one promise from a proposal file, over that connection |
120
- | `balladeer discover` | File a whole catalog, up to ten promises from one file, behind one review link |
121
- | `balladeer touch-map` | Record which files each promise's verifier runs, on this machine only |
122
- | `balladeer affected` | Say which promises the files you name touch, out of that record |
123
- | `balladeer session` | Print the id for this piece of work, and the line to write into the commit |
124
- | `balladeer mcp` | Forward one MCP session over stdio using this repository's connection |
125
- | `balladeer explain` | What Balladeer can and cannot see, where to watch it, and what leaving costs |
126
- | `balladeer whoami` | Report the stored session's workspace, role, scopes, and expiry |
119
+ | Command | What it does |
120
+ | -------------------------------------- | -------------------------------------------------------------------------------- |
121
+ | `npx -y balladeer@latest setup` | Run all five steps, then report what a person still has to do |
122
+ | `npx -y balladeer@latest repositories` | List what this machine could add, marking the one you are in and the ones in |
123
+ | `npx -y balladeer@latest invite` | Invite teammates by email, and say per address whether the email went |
124
+ | `npx -y balladeer@latest status` | Report this repository over its agent connection, and the workspace |
125
+ | `npx -y balladeer@latest status <id>` | Report one promise: whether it is holding, and if not, what the run reported |
126
+ | `npx -y balladeer@latest prepare <id>` | Prepare a promise's one-time qualification setup, and write it where CI reads it |
127
+ | `npx -y balladeer@latest propose` | Propose one promise from a proposal file, over that connection |
128
+ | `npx -y balladeer@latest discover` | File a whole catalog, up to ten promises from one file, behind one review link |
129
+ | `npx -y balladeer@latest touch-map` | Record which files each promise's verifier runs, on this machine only |
130
+ | `npx -y balladeer@latest affected` | Say which promises the files you name touch, out of that record |
131
+ | `npx -y balladeer@latest session` | Print the id for this piece of work, and the line to write into the commit |
132
+ | `npx -y balladeer@latest mcp` | Forward one MCP session over stdio using this repository's connection |
133
+ | `npx -y balladeer@latest explain` | What Balladeer can and cannot see, where to watch it, and what leaving costs |
134
+ | `npx -y balladeer@latest whoami` | Report the stored session's workspace, role, scopes, and expiry |
127
135
 
128
136
  `--json` emits one object per step on stdout and nothing else. `--wait` makes `setup` poll for the
129
137
  approval instead of exiting; without it the command exits and a later run finishes the pairing.
@@ -132,7 +140,7 @@ approval instead of exiting; without it the command exits and a later run finish
132
140
  to be the one you are standing in, because connecting a coding agent writes files into a working
133
141
  tree and connecting CI pushes a branch to a remote. The rest are added to the workspace and nothing
134
142
  more, and the run says so for each of them; somebody runs setup in a checkout of each one to finish
135
- it. `balladeer repositories` is what you read first to decide which ones to name.
143
+ it. `npx -y balladeer@latest repositories` is what you read first to decide which ones to name.
136
144
 
137
145
  `invite` takes one or more addresses and `--role contributor|viewer|administrator`, defaulting to
138
146
  contributor. It goes over the setup session's `workspace:invite` grant, so it works only for a
@@ -161,16 +169,42 @@ A machine that ran the earlier Balladeer still carries it: that client registers
161
169
  this one goes on. Setup finds it before it writes anything and stops with one instruction. If you
162
170
  used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it
163
171
  prints, then continue. That uninstall is reversible, it backs up `~/.balladeer`, and it leaves the
164
- old binary in place. `balladeer setup --force` runs anyway, for whoever has decided to keep both.
172
+ old binary in place. `npx -y balladeer@latest setup --force` runs anyway, for whoever has decided to
173
+ keep both.
165
174
 
166
175
  ## Revoking
167
176
 
168
177
  Both credentials are yours to end. Revoke the setup session or the coding agent's connection at any
169
178
  time in Balladeer; the setup session also ends on its own. Removing the `balladeer` entry from
170
- `.mcp.json` disconnects the agent locally, and deleting the credential store leaves this machine
171
- with nothing of yours on it.
179
+ `.mcp.json` (Claude) or the managed table in `.codex/config.toml` (Codex) disconnects that project
180
+ host locally after its next reload. Archiving the credential store removes this machine's saved
181
+ Balladeer access without changing the server's records.
172
182
 
173
183
  ## Licence
174
184
 
175
185
  Apache License 2.0. The full text ships in this package as `LICENSE`, so you can read the rights you
176
186
  have from the copy on your own machine rather than taking a field in a manifest on trust.
187
+
188
+ ## Choose Codex or reconnect a workspace
189
+
190
+ Run `npx -y balladeer@latest setup --client codex` in the repository to write its
191
+ `.codex/config.toml` and the managed Balladeer block in `AGENTS.md`. This leaves Claude files,
192
+ Claude Desktop, global Codex settings and unrelated MCP entries alone. The default client remains
193
+ Claude; choose it explicitly with `--client claude`.
194
+
195
+ Open and trust the project in Codex if appropriate, then start a fresh session. Project settings are
196
+ ignored in untrusted projects; the setup connection probe does not prove the host loaded its tools.
197
+ Check the actual Balladeer tools in that new session. Refresh only local Codex configuration with
198
+ `setup --client codex --refresh`; no pairing or network request is made.
199
+
200
+ To reconnect a different existing workspace, run:
201
+
202
+ ```sh
203
+ npx -y balladeer@latest setup --client codex --existing --choose-workspace --wait
204
+ ```
205
+
206
+ Choose the workspace and approve pairing in the browser. The current control plane's setup session
207
+ is replaced only after a new pairing starts; all stored agent connections and other control planes
208
+ remain. `--create-workspace "Team name"` likewise opens fresh pairing instead of silently reusing
209
+ the previous workspace. It pre-fills the name; a human still creates the workspace. An interrupted
210
+ command can resume the same pending choice by running it again with the same flags.
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,8 +6,12 @@ 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
+ client: "codex" | "claude" | undefined;
10
+ chooseWorkspace: boolean;
9
11
  /** `setup --force`: set up even though an earlier Balladeer is still installed. */
10
12
  force: boolean;
13
+ /** `setup --existing`: connect only when this checkout is already enrolled. */
14
+ existingOnly: boolean;
11
15
  /**
12
16
  * `setup --claude-desktop` / `--no-claude-desktop`. Undefined is neither
13
17
  * asked for nor refused, which is the ordinary run: connect the chat client
package/dist/cli.js CHANGED
@@ -18,22 +18,37 @@ import { runTouchMap } from "./commands/touch-map.js";
18
18
  import { runWhoami } from "./commands/whoami.js";
19
19
  import { updateNotice } from "./currency.js";
20
20
  import { StoreError, normalizeControlPlane } from "./store.js";
21
- import { CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
21
+ import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
22
22
  const USAGE = `balladeer ${CLI_VERSION}
23
23
 
24
- balladeer setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
25
- [--create-workspace <name>] [--refresh] [--force]
24
+ ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
25
+ [--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
26
+ [--client codex|claude]
27
+ [--existing]
26
28
  [--claude-desktop | --no-claude-desktop]
27
29
  Pair this session, add this repository, connect this coding agent and CI,
28
30
  and report what a person still has to do. Name --repository more than once
29
31
  to add other repositories to the same workspace; each of those is added and
30
32
  nothing more, because connecting an agent and CI happens in a checkout.
33
+ --client codex writes project .codex/config.toml and AGENTS.md.
34
+ Trust the project in Codex and start a fresh session to load its tools.
35
+ --client claude (the default) preserves the Claude setup behavior.
36
+ For --refresh only, a lone managed Codex entry selects Codex automatically.
37
+ If both clients have Balladeer entries, choose --client explicitly.
38
+ --choose-workspace starts fresh browser pairing for this control plane,
39
+ preserving other saved agent connections. Use it with --existing to
40
+ reconnect an existing workspace. --create-workspace also starts fresh
41
+ pairing instead of reusing the previously selected workspace.
42
+
31
43
  --create-workspace carries a name to the approval page, where a person
32
44
  signs in and creates the workspace themselves; this command never creates
33
45
  one.
46
+ --existing is for an invited teammate and never enrolls repositories or changes CI.
47
+ If this repository is already connected, connect this machine; otherwise
48
+ continue in the browser while an administrator connects it.
34
49
  --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
50
+ selected client's project MCP entry and Balladeer instructions block
51
+ (and Claude desktop entry for Claude) to the current form, leaves every
37
52
  other entry in those files alone, and prints what it changed. Run it when
38
53
  Balladeer says a newer version is available.
39
54
  --force sets up even though an earlier Balladeer is still installed on
@@ -45,15 +60,15 @@ const USAGE = `balladeer ${CLI_VERSION}
45
60
  skipping quietly; --no-claude-desktop leaves that file alone entirely.
46
61
  Quit and reopen the app afterwards: it reads its configuration at startup.
47
62
 
48
- balladeer repositories [--json] [--control-plane <url>]
63
+ ${CLI_INVOCATION} repositories [--json] [--control-plane <url>]
49
64
  List the repositories this machine's GitHub account can see, marking the
50
65
  one you are standing in and the ones Balladeer already has.
51
66
 
52
- balladeer invite <email>... [--role contributor|viewer|administrator] [--json]
67
+ ${CLI_INVOCATION} invite <email>... [--role contributor|viewer|administrator] [--json]
53
68
  Invite teammates into this workspace by email, and say per address whether
54
69
  the invitation went. Inviting is an administrator's act.
55
70
 
56
- balladeer status [<promise id>] [--repo owner/name] [--repository <uuid>] [--json]
71
+ ${CLI_INVOCATION} status [<promise id>] [--repo owner/name] [--repository <uuid>] [--json]
57
72
  [--control-plane <url>]
58
73
  Report this repository over its agent connection, and every repository in
59
74
  the workspace when a setup session is still live. Named with a promise id,
@@ -62,11 +77,11 @@ const USAGE = `balladeer ${CLI_VERSION}
62
77
  the agreed meaning that run was checking. That is the form the line on a
63
78
  broken promise tells a person to run first.
64
79
 
65
- balladeer propose --file <path> [--repo owner/name] [--repository <uuid>] [--json]
80
+ ${CLI_INVOCATION} propose --file <path> [--repo owner/name] [--repository <uuid>] [--json]
66
81
  Propose one promise from a proposal file, over this repository's agent
67
82
  connection. A named person still agrees to it.
68
83
 
69
- balladeer prepare <promise id> [--again] [--repo owner/name] [--repository <uuid>]
84
+ ${CLI_INVOCATION} prepare <promise id> [--again] [--repo owner/name] [--repository <uuid>]
70
85
  [--json] [--control-plane <url>]
71
86
  Prepare the one-time qualification setup for a promise whose meaning is
72
87
  agreed and which nothing is checking yet, and write it to
@@ -79,13 +94,13 @@ const USAGE = `balladeer ${CLI_VERSION}
79
94
  prepared it and when. --again prepares a replacement and invalidates that
80
95
  earlier packet: a run publishing its identities afterwards is refused.
81
96
 
82
- balladeer discover --file <path> [--repo owner/name] [--repository <uuid>]
97
+ ${CLI_INVOCATION} discover --file <path> [--repo owner/name] [--repository <uuid>]
83
98
  [--owner <membership id>] [--json]
84
99
  Propose a whole discovered catalog, up to ten promises from one file, each
85
100
  owned by whoever paired this machine, and print one link that opens all of
86
101
  them. A named person still agrees to every one.
87
102
 
88
- balladeer check-seals [--json] [--runner <path>] [--install-hook]
103
+ ${CLI_INVOCATION} check-seals [--json] [--runner <path>] [--install-hook]
89
104
  Say whether what you are about to push would break a promise's seal. It
90
105
  prints nothing and exits 0 when it would not, and names the promise, its
91
106
  owner, its page and the line that seals it again when it would. It runs
@@ -93,18 +108,18 @@ const USAGE = `balladeer ${CLI_VERSION}
93
108
  writes .git/hooks/pre-push so every push asks first; nothing installs it
94
109
  for you, and deleting that file removes it.
95
110
 
96
- balladeer touch-map [--json]
111
+ ${CLI_INVOCATION} touch-map [--json]
97
112
  Run this repository's promise verifiers under coverage and write down
98
113
  which files each one actually executed, to .continuity/touch-map.json.
99
114
  It runs offline and the map stays on this machine: Balladeer is never
100
115
  sent it. Node verifiers only in this release.
101
116
 
102
- balladeer affected <paths...> [--json]
117
+ ${CLI_INVOCATION} affected <paths...> [--json]
103
118
  Say which promises the named files touch, out of that map, marking any
104
119
  answer whose verifier has changed since the map was built. It reads one
105
120
  local file and contacts nothing.
106
121
 
107
- balladeer session [--new] [--record] [--repo owner/name] [--repository <uuid>] [--json]
122
+ ${CLI_INVOCATION} session [--new] [--record] [--repo owner/name] [--repository <uuid>] [--json]
108
123
  [--control-plane <url>]
109
124
  Print the id for this piece of work, and the one line to write into the
110
125
  commit it produces. Pass the id to every promise read you make, so a check
@@ -114,18 +129,18 @@ const USAGE = `balladeer ${CLI_VERSION}
114
129
  commit this session wrote. Your commit message never leaves this machine:
115
130
  only the session id and the commit SHA are sent.
116
131
 
117
- balladeer explain [--control-plane <url>]
132
+ ${CLI_INVOCATION} explain [--control-plane <url>]
118
133
  Print, word for word, what Balladeer can and cannot see, where to watch
119
134
  your promises, and what leaving costs.
120
135
 
121
- balladeer whoami [--json] [--control-plane <url>]
136
+ ${CLI_INVOCATION} whoami [--json] [--control-plane <url>]
122
137
  Report the stored setup session's workspace, role, scopes, and expiry.
123
138
 
124
- balladeer agent rotate [--control-plane <url>]
139
+ ${CLI_INVOCATION} agent rotate [--control-plane <url>]
125
140
  Replace this repository's agent credential. A setup session cannot do this,
126
141
  because rotating revokes the connections the repository already has.
127
142
 
128
- balladeer mcp [--repository <uuid>] [--control-plane <url>]
143
+ ${CLI_INVOCATION} mcp [--repository <uuid>] [--control-plane <url>]
129
144
  Forward one MCP session over stdio using this repository's agent connection.
130
145
 
131
146
  Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
@@ -147,7 +162,10 @@ export function parseArguments(argv) {
147
162
  let json = false;
148
163
  let wait = false;
149
164
  let refresh = false;
165
+ let client;
166
+ let chooseWorkspace = false;
150
167
  let force = false;
168
+ let existingOnly = false;
151
169
  let claudeDesktop;
152
170
  let installHook = false;
153
171
  let fresh = false;
@@ -170,6 +188,7 @@ export function parseArguments(argv) {
170
188
  "--owner": "a membership id",
171
189
  "--create-workspace": "a workspace name",
172
190
  "--role": "contributor, viewer, or administrator",
191
+ "--client": "codex or claude",
173
192
  "--runner": "a path to the pinned runner's cli.js",
174
193
  };
175
194
  const value = (flag, inline) => {
@@ -202,8 +221,18 @@ export function parseArguments(argv) {
202
221
  wait = true;
203
222
  else if (name === "--refresh")
204
223
  refresh = true;
224
+ else if (name === "--choose-workspace")
225
+ chooseWorkspace = true;
226
+ else if (name === "--client") {
227
+ const requested = value("--client", inline);
228
+ if (requested !== "codex" && requested !== "claude")
229
+ throw new StoreError("usage", "--client needs codex or claude.");
230
+ client = requested;
231
+ }
205
232
  else if (name === "--force")
206
233
  force = true;
234
+ else if (name === "--existing")
235
+ existingOnly = true;
207
236
  else if (name === "--claude-desktop")
208
237
  claudeDesktop = true;
209
238
  else if (name === "--no-claude-desktop")
@@ -241,7 +270,7 @@ export function parseArguments(argv) {
241
270
  // belongs to the one command that mints one. Accepted silently elsewhere, it
242
271
  // would read as a general "do it anyway" flag.
243
272
  if (again && command !== "prepare") {
244
- throw new StoreError("usage", "--again belongs to prepare: run `balladeer prepare <promise id> --again`.");
273
+ throw new StoreError("usage", `--again belongs to prepare: run \`${CLI_INVOCATION} prepare <promise id> --again\`.`);
245
274
  }
246
275
  if (positional.length > 0 &&
247
276
  command !== "invite" &&
@@ -257,20 +286,39 @@ export function parseArguments(argv) {
257
286
  // `--refresh` repairs the files setup writes, so it belongs to setup and to
258
287
  // nothing else. Accepting it silently elsewhere would let somebody run
259
288
  // `status --refresh`, see no error, and believe their install was repaired.
289
+ if ((client !== undefined || chooseWorkspace || createWorkspace !== undefined) &&
290
+ command !== "setup") {
291
+ throw new StoreError("usage", "--client, --choose-workspace and --create-workspace belong to setup.");
292
+ }
293
+ if (refresh && (chooseWorkspace || createWorkspace !== undefined)) {
294
+ throw new StoreError("usage", "--refresh only repairs local files; omit it to choose or create a workspace.");
295
+ }
296
+ if (client === "codex" && claudeDesktop === true) {
297
+ throw new StoreError("usage", "--client codex leaves Claude configuration alone; run a separate --client claude setup to connect Claude Desktop.");
298
+ }
260
299
  if (refresh && command !== "setup") {
261
- throw new StoreError("usage", "--refresh belongs to setup: run `balladeer setup --refresh`.");
300
+ throw new StoreError("usage", `--refresh belongs to setup: run \`${CLI_INVOCATION} setup --refresh\`.`);
262
301
  }
263
302
  // `--force` says one thing only: set up alongside an earlier Balladeer. Taken
264
303
  // silently by another command it would read as a general "do it anyway" flag,
265
304
  // which is exactly what nothing else here offers.
266
305
  if (force && command !== "setup") {
267
- throw new StoreError("usage", "--force belongs to setup: run `balladeer setup --force`.");
306
+ throw new StoreError("usage", `--force belongs to setup: run \`${CLI_INVOCATION} setup --force\`.`);
307
+ }
308
+ if (existingOnly && command !== "setup") {
309
+ throw new StoreError("usage", `--existing belongs to setup: run \`${CLI_INVOCATION} setup --existing\`.`);
310
+ }
311
+ if (existingOnly && createWorkspace !== undefined) {
312
+ throw new StoreError("usage", "--existing cannot be combined with --create-workspace: one refuses repository enrollment and the other starts a new workspace.");
313
+ }
314
+ if (existingOnly && repositories.length > 1) {
315
+ throw new StoreError("usage", "--existing connects one checkout and never adds repositories. Leave off --repository, or name only this checkout.");
268
316
  }
269
317
  // Connecting a chat client is something setup does, so the flag belongs to
270
318
  // setup. Accepted silently on `status`, it would let somebody believe they had
271
319
  // connected Claude desktop when nothing had written a line.
272
320
  if (claudeDesktop !== undefined && command !== "setup") {
273
- throw new StoreError("usage", "--claude-desktop belongs to setup: run `balladeer setup --claude-desktop`.");
321
+ throw new StoreError("usage", `--claude-desktop belongs to setup: run \`${CLI_INVOCATION} setup --claude-desktop\`.`);
274
322
  }
275
323
  if (command !== "setup" && repositories.length > 1) {
276
324
  throw new StoreError("usage", `${command} acts on one repository. Name --repository once, or run it again for the other.`);
@@ -282,7 +330,10 @@ export function parseArguments(argv) {
282
330
  json,
283
331
  wait,
284
332
  refresh,
333
+ client,
334
+ chooseWorkspace,
285
335
  force,
336
+ existingOnly,
286
337
  claudeDesktop,
287
338
  repo,
288
339
  file,
@@ -330,7 +381,10 @@ async function dispatch(parsed, write) {
330
381
  json: parsed.json,
331
382
  wait: parsed.wait,
332
383
  refresh: parsed.refresh,
384
+ chooseWorkspace: parsed.chooseWorkspace,
385
+ ...(parsed.client === undefined ? {} : { client: parsed.client }),
333
386
  force: parsed.force,
387
+ existingOnly: parsed.existingOnly,
334
388
  ...(parsed.claudeDesktop === undefined ? {} : { claudeDesktop: parsed.claudeDesktop }),
335
389
  ...(parsed.repo === undefined ? {} : { repo: parsed.repo }),
336
390
  repositories: parsed.repositories,
@@ -423,7 +477,7 @@ async function dispatch(parsed, write) {
423
477
  // two one-time identities and write two files, and reporting the first
424
478
  // silently would leave the second promise unprepared with nobody told.
425
479
  if (parsed.positional.length !== 1) {
426
- process.stderr.write("prepare reads one promise id: balladeer prepare <promise id> [--again].\n");
480
+ process.stderr.write(`prepare reads one promise id: ${CLI_INVOCATION} prepare <promise id> [--again].\n`);
427
481
  return 4;
428
482
  }
429
483
  return runPrepare({
package/dist/client.d.ts CHANGED
@@ -26,7 +26,7 @@ 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
30
  /**
31
31
  * The one place this command decides where a person reads a proposal.
32
32
  *
@@ -37,7 +37,7 @@ export declare function reviewLink(controlPlane: string, path: string): string;
37
37
  * the retired address redirects to it permanently, so a link an older copy of
38
38
  * this command already printed still lands on it.
39
39
  */
40
- export declare function proposalReviewLink(controlPlane: string, proposalId: string): string;
40
+ export declare function proposalReviewLink(controlPlane: string, proposalId: string, workspaceId?: string): string;
41
41
  /** The address the whole waiting inbox is read on. */
42
42
  export declare function proposalInboxLink(controlPlane: string): string;
43
43
  /**
package/dist/client.js CHANGED
@@ -42,8 +42,11 @@ export class RefusalError extends Error {
42
42
  * identifier and this builds the address, which is why there is no parameter
43
43
  * here for a server's own link to arrive through.
44
44
  */
45
- export function reviewLink(controlPlane, path) {
46
- return new URL(path, ensureTrailing(controlPlane)).toString();
45
+ export function reviewLink(controlPlane, path, workspaceId) {
46
+ const url = new URL(path, ensureTrailing(controlPlane));
47
+ if (workspaceId !== undefined)
48
+ url.searchParams.set("workspaceId", workspaceId);
49
+ return url.toString();
47
50
  }
48
51
  /**
49
52
  * The one place this command decides where a person reads a proposal.
@@ -55,8 +58,8 @@ export function reviewLink(controlPlane, path) {
55
58
  * the retired address redirects to it permanently, so a link an older copy of
56
59
  * this command already printed still lands on it.
57
60
  */
58
- export function proposalReviewLink(controlPlane, proposalId) {
59
- return reviewLink(controlPlane, `proposals/${encodeURIComponent(proposalId)}`);
61
+ export function proposalReviewLink(controlPlane, proposalId, workspaceId) {
62
+ return reviewLink(controlPlane, `proposals/${encodeURIComponent(proposalId)}`, workspaceId);
60
63
  }
61
64
  /** The address the whole waiting inbox is read on. */
62
65
  export function proposalInboxLink(controlPlane) {
@@ -0,0 +1,7 @@
1
+ import { type MergeResult, type StdioMcpEntry } from "./mcp-config.js";
2
+ export declare const CODEX_CONFIG_FILE = ".codex/config.toml";
3
+ export declare function readCodexEntry(root: string): unknown;
4
+ /** A client hint only when the complete file and our exact fenced table agree. */
5
+ export declare function hasManagedCodexEntry(root: string, controlPlane: string): boolean;
6
+ /** Only our fenced table is replaced; every unrelated byte and parsed setting survives. */
7
+ export declare function mergeCodexConfig(root: string, entry: StdioMcpEntry, controlPlane: string): MergeResult;