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/dist/copy.js CHANGED
@@ -21,23 +21,35 @@ test output.
21
21
  The verify job in your CI runs your tests with your checkout and has no Balladeer credential. A
22
22
  separate publish job, with no checkout, sends only those outcomes and hashes.
23
23
 
24
- This setup creates two credentials, both of which you control. Approving in the browser lets this
25
- session act as you to add repositories, connect your coding agent, connect CI, and propose promises;
26
- it cannot approve, activate, or remove anything. That session lasts a day, each use buys it another
27
- day, and it is gone seven days after you approved it however much you used it. The coding agent's
28
- own connection has no expiry: it lets the agent read the promises your team has approved, propose
29
- new ones, and propose replacing, retiring, excepting, or reassigning one you already have; every one
30
- of those waits for a named person to decide it. It can also carry out one of those acts for you, and
31
- only this way: you open a Balladeer page in your own browser, read what the act is, sign it off
32
- there, and read back the one-time code that page gives you. That code covers that one act on that
33
- one thing, once, and Balladeer records you as the person who took it and the connection as the
34
- messenger. Without a code you signed, the connection cannot approve, activate, grant, transfer,
35
- retire, or delete anything. You can revoke either one at any time in Balladeer, and the setup
36
- session also ends on its own.
24
+ Browser approval creates a temporary setup session limited by that person's workspace role. A
25
+ contributor can connect this machine's coding agent to an already-enrolled repository and propose
26
+ promises; an administrator can also add repositories, connect CI, and invite teammates. The session
27
+ cannot approve, activate, rotate, revoke, or remove anything. It lasts a day, each use buys it
28
+ another day, and it is gone seven days after approval however much it was used. When setup connects
29
+ an enrolled repository it also issues that machine a coding-agent credential. That connection has no
30
+ expiry: it lets the agent read the promises your team has approved, propose new ones, and propose
31
+ replacing, retiring, excepting, or reassigning one you already have; every one of those waits for a
32
+ named person to decide it. It can also carry out one of those acts for you, and only this way: you
33
+ open a Balladeer page in your own browser, read what the act is, sign it off there, and read back
34
+ the one-time code that page gives you. That code covers that one act on that one thing, once, and
35
+ Balladeer records you as the person who took it and the connection as the messenger. Without a code
36
+ you signed, the connection cannot approve, activate, grant, transfer, retire, or delete anything.
37
+ You can revoke either one at any time in Balladeer, and the setup session also ends on its own.
37
38
 
38
39
  Adding the workflow puts a check on pull requests into your default branch. That check is advisory
39
40
  on Balladeer's side; whether it blocks a merge is your own branch protection.
40
41
  `;
42
+ /**
43
+ * The one thing about approving that the terminal has to say.
44
+ *
45
+ * A person who belongs to two workspaces approves into whichever one their
46
+ * browser is signed into, and it is the browser that decides, not the terminal.
47
+ * Twice that was the wrong one: the repository was enrolled in a workspace
48
+ * nobody meant and an agent connection was minted there. The approval page now
49
+ * names the workspace above the button and offers the switch, and this line is
50
+ * what makes somebody look at it before they click.
51
+ */
52
+ export const APPROVE_IN_THE_RIGHT_WORKSPACE = "Approve it while your browser is in the workspace you mean; the page names it.";
41
53
  /**
42
54
  * How a person gets a workspace to approve into, for an agent reading the
43
55
  * setup instructions.
@@ -55,12 +67,18 @@ on Balladeer's side; whether it blocks a merge is your own branch protection.
55
67
  * and the terminal it is reading cannot come to describe this differently.
56
68
  */
57
69
  export const JOIN_OR_CREATE = `The pairing page joins an existing workspace or makes a new one. Somebody whose team already has
58
- a Balladeer workspace signs in and approves the code from the terminal. Somebody who is the first
59
- person from their team signs in, names a workspace, and clicks Create, and only then is there
60
- anything to approve into.
70
+ a Balladeer workspace accepts their email invitation, then runs \`npx -y balladeer@latest setup --existing\` in
71
+ the checkout they mean to use. That teammate mode never adds a repository or changes CI. After they
72
+ sign in and approve the code, onboarding continues even when the checkout is not in the workspace.
73
+ If it is already enrolled, setup connects this machine's coding agent. If it is absent, the person
74
+ can still use Balladeer in the browser; a red warning explains that enforcement cannot be tracked
75
+ until an administrator connects a repository.
76
+
77
+ Somebody who is the first person from their team signs in, names a workspace, and clicks Create, and
78
+ only then is there anything to approve into.
61
79
 
62
80
  You can save them the typing, and nothing else. Ask what the workspace should be called, then run
63
- \`balladeer setup --create-workspace "<name>"\` with the name they gave you. The pairing link this
81
+ \`npx -y balladeer@latest setup --create-workspace "<name>"\` with the name they gave you. The pairing link this
64
82
  command prints then carries \`?create=<name>\`, which pre-fills that name in the create form on the
65
83
  pairing page. It fills the box and no more than that: the person signs in and clicks Create, and
66
84
  until they do there is no workspace. An agent never creates one, and Balladeer refuses the attempt
@@ -135,7 +153,7 @@ seen everything.`;
135
153
  * plane's `/agent` page carries the same bytes under its own constant, and a
136
154
  * packaging test compares them.
137
155
  */
138
- export const SESSION_BEHAVIORS = `### Two things to offer without being asked
156
+ export const SESSION_BEHAVIORS = `### Three things to offer without being asked
139
157
 
140
158
  At the start of any session in this repository, before you plan anything, ask list_promises for the
141
159
  promises nothing is checking yet that belong to the person you are working with: \`unverified\` true
@@ -159,7 +177,136 @@ true, the numbered cases that must keep working and the ones that must be caught
159
177
  you left open. Then offer to finish the agreement from here, by asking for a sign-off, giving them
160
178
  the page it returns and taking the one-time code that page shows them, so the browser is needed only
161
179
  for signing. Give them the review link in the same message too, because some people would rather
162
- read it there and edit it before they agree.`;
180
+ read it there and edit it before they agree.
181
+ A promise whose meaning is agreed and which nothing is checking yet is one you can finish. When
182
+ somebody gives you a promise id, run \`npx -y balladeer@latest status <promise id>\`, or expand it with get_promise.
183
+ If it comes back agreed with nothing checking it, say so in one line and offer to prepare and build
184
+ its verifier now. Only if they say yes: \`prepare_qualification\` mints the one-time setup, or
185
+ \`npx -y balladeer@latest prepare <promise id>\` mints it and writes it where the sealed run reads it. You need no
186
+ sign-off for that and there is no code to ask anybody for, because agreeing the meaning was their
187
+ act and building the check that proves it is yours. Then build the verifier, seal it, push, and tell
188
+ them protection starts by itself when that run qualifies. The offer is the whole of it: never
189
+ prepare one because nobody said no.`;
190
+ /**
191
+ * The capture rules that do not change: when to say nothing, what a yes is
192
+ * worth, and the two promises Balladeer refuses.
193
+ *
194
+ * This block is written into the customer's own instructions file once, at
195
+ * setup, and is never refreshed afterwards. Changing it means opening a pull
196
+ * request against twelve Didero repositories, so only what will still be true
197
+ * in six months belongs here. Silence during an incident, a no that ends it, and
198
+ * nothing filed without a yes are that kind of rule. The words a question is
199
+ * asked in, the moment an offer is made, and the shape a proposal takes are not:
200
+ * they are read every week against what people actually agreed to, and they ride
201
+ * in `CAPTURE_BEHAVIOR`, which the server sends at the start of every session.
202
+ *
203
+ * Nothing here weakens the explicit-capture rule: the question is a question,
204
+ * and nothing is proposed until the answer is yes.
205
+ *
206
+ * The two refusals are here rather than only in the server because an agent that
207
+ * learns them only by being refused has already spent the person's attention on
208
+ * a proposal that could not be kept. The server refuses both as well, and the
209
+ * sentences are the same ones.
210
+ *
211
+ * Byte for byte the same text as `AGENT_INSTRUCTIONS_CAPTURE_SEAM` in the
212
+ * control plane, and contained byte for byte inside both server carriers. A
213
+ * packaging test holds all three together: the block written into a customer's
214
+ * repository, the instructions the MCP server sends on initialize, and the page
215
+ * an agent fetches cannot come to say different things about when to stay quiet.
216
+ */
217
+ export const CAPTURE_SEAM = `### When to say nothing, and what a yes is worth
218
+
219
+ Most rules are said in passing, in the middle of something else. Somebody saying one has not asked
220
+ you to record anything, so propose nothing and start no interview. When you do ask, it is one line
221
+ appended to the end of the reply you were already going to give, never a message of its own, never
222
+ asked twice about the same rule, and nothing is proposed until they say yes. A no ends it, and that
223
+ rule is not raised again for the rest of the conversation.
224
+
225
+ Silence is per conversation rather than per message. Once a conversation is one of these, you ask
226
+ nothing for the rest of it, however good the rule sounds:
227
+
228
+ - A question, or working out how something already behaves. Nothing is filed and nothing is offered.
229
+ - A refactor. Nobody predicts a promise from a rewrite. Offer the promises this area already carries
230
+ that nothing is checking yet, once, and then wait. Ask nothing about new ones.
231
+ - A change to wording alone. Wording somebody may change again tomorrow is not a rule.
232
+ - An incident, while it is still being fixed. Ask nothing at all until the fix is merged or they say
233
+ it is done, however good tonight's failing case would be.
234
+ - Somebody still weighing options. A decision nobody has made yet is not a rule.
235
+ - An exploration or spike. It ends in nothing or in a plan, and its sentences sound like rules and
236
+ are not.
237
+ - Reading somebody else's change, while you are still reading it. Nothing is offered until they
238
+ give a verdict.
239
+
240
+ The words to ask in, the moment to offer, and the shape a proposal takes are not in this file. The
241
+ Balladeer server sends them at the start of every session, and its copy is the current one: read
242
+ what it sent this session rather than what this file remembers.
243
+
244
+ ### Two promises Balladeer cannot keep
245
+
246
+ A promise about speed needs three things before it is a promise at all: a number, a percentile, and
247
+ where it is measured. "The quote page answers in under one second at p95, measured in production at
248
+ peak load" is one Balladeer can keep. "The quote page has to be fast" is not. Say this, and ask for
249
+ the part that is missing rather than filing it:
250
+
251
+ "I can keep that once it has a number, a percentile, and a place it is measured. Without those it is
252
+ a wish, not a promise."
253
+
254
+ Balladeer refuses one without all three and names which of them is missing. When all three are
255
+ there, write the measurement method into the promise, and tell them plainly that it reads agreed and
256
+ unprotected until a test that actually measures it exists.
257
+
258
+ A promise about how the team works is not something the software does, so no check can ever catch
259
+ it. "We must provide a low-friction capture experience" is one of these. File nothing and say:
260
+
261
+ "That is a promise about how we work, not something the software does that a check can fail.
262
+ Balladeer only keeps promises a check can catch. If a customer would notice something when this
263
+ slips, say that and I will keep that instead."
264
+
265
+ Then take the customer-visible half if they give you one, and file that instead.`;
266
+ /**
267
+ * How an agent describes one promise or proposal to a person, and how the two
268
+ * of them settle what is still open on it.
269
+ *
270
+ * The first proposal a founder was ever asked to settle carried four questions
271
+ * that were all true and none of them answerable: each named a hole in the
272
+ * world, and none said what answering it would decide or what the agent would
273
+ * do if nobody replied. So this says both halves of the same rule. When a
274
+ * person points at something by id, describe it and offer to work through what
275
+ * is open; and when you leave a question open, leave one that can be answered
276
+ * with a yes.
277
+ *
278
+ * Three carriers say it byte for byte: the block setup writes into the
279
+ * customer's own instructions file, the instructions the MCP server sends on
280
+ * initialize, and the page `/agent` serves. A packaging test holds them
281
+ * together.
282
+ */
283
+ export const SETTLING_QUESTIONS = `### When somebody says "tell me about" one
284
+
285
+ An id is how a person points at something here, and every promise page and every proposal page
286
+ carries one. A promise id starts with prom_ and a proposal id starts with cand_: expand a promise
287
+ with get_promise and a proposal with get_proposal, and read neither of them out as a list of fields.
288
+ Say in four or five sentences what it is for, who it is for, when it applies and what must then be
289
+ true. Then say where it stands: a promise is agreed, and either protected or not yet checked by
290
+ anything; a proposal is agreed by nobody and waiting on the person it names.
291
+
292
+ A proposal that still carries open questions is not finished, and settling them is usually why
293
+ somebody asked. Offer to work through them, then take them one at a time in the order they come
294
+ back. For each one, say what it decides in their words rather than in the question's; give your
295
+ recommendation and the reason you hold it, drawn from this repository and from the promise itself;
296
+ and stop there. When they answer, record what they said with resolve_question, in their own words
297
+ where they gave you any, and where their answer changes the promise, follow it with update_proposal,
298
+ add_case or remove_case and tell them what you changed. None of that agrees to anything: the named
299
+ owner agrees, in their own browser or through a sign-off you carry, and the questions they answered
300
+ stay on the proposal in their name.
301
+
302
+ The same rule governs every question you leave open in the first place. A question that names a gap
303
+ and stops is a note, and nobody can answer a note. Say what answering it decides, in the words a
304
+ customer would use, and carry your own best guess with the reason behind it, so that the shortest
305
+ true answer is yes. For example: "Decides: whether a worker that is running but reconciling nothing
306
+ counts as an outage this promise covers. Best guess: yes, because the promise is about somebody
307
+ hearing before a customer does, and a wedged worker is invisible to every check this repository has.
308
+ Say yes, or tell me otherwise." Balladeer refuses a question filed without both halves and says
309
+ which one is missing.`;
163
310
  /**
164
311
  * What a session may spend on reading, in three sentences.
165
312
  *
@@ -204,9 +351,9 @@ breaks the seal. A promise whose seal is broken stops being checked, and it stay
204
351
  named person qualifies it again, which is their afternoon rather than your commit. Nothing in there
205
352
  is ordinary source: keep it out of refactors, formatting runs and dependency upgrades.
206
353
 
207
- Find out which directories are sealed before you plan an edit, not after. balladeer status lists
354
+ Find out which directories are sealed before you plan an edit, not after. npx -y balladeer@latest status lists
208
355
  them, and list_promises names a promise's sealed directory on its row once a verifier is bound to
209
- it. Then, before you push, run balladeer check-seals. It prints nothing and exits zero when your
356
+ it. Then, before you push, run npx -y balladeer@latest check-seals. It prints nothing and exits zero when your
210
357
  change touches no seal, and names the promise, its owner and its page when your change would break
211
358
  one.
212
359
 
@@ -214,8 +361,8 @@ The one reason to edit a sealed file is to repair a verifier that can no longer
214
361
  imports moved, or the language it is written in changed under it. Never edit one to make a failing
215
362
  check pass. A check going red is the promise doing its job, and the repair for that belongs in the
216
363
  behavior it protects. A repair is not finished until the promise is sealed again with the runner
217
- this repository is pinned to. get_promise_setup carries that exact seal command, and balladeer
218
- check-seals prints it beside any promise it names.`;
364
+ this repository is pinned to. get_promise_setup carries that exact seal command, and
365
+ npx -y balladeer@latest check-seals prints it beside any promise it names.`;
219
366
  /**
220
367
  * Which promises a change touches, measured rather than guessed.
221
368
  *
@@ -232,19 +379,57 @@ check-seals prints it beside any promise it names.`;
232
379
  * Three carriers say it, byte for byte, and a packaging test holds them
233
380
  * together.
234
381
  */
235
- export const TOUCH_MAP_GUIDANCE = `Prefer the measurement over the markers. Where this repository has a touch map, \`balladeer affected
382
+ export const TOUCH_MAP_GUIDANCE = `Prefer the measurement over the markers. Where this repository has a touch map, \`npx -y balladeer@latest affected
236
383
  <paths...>\` answers which promises ran the files in front of you, out of what each verifier actually
237
- executed the last time \`balladeer touch-map\` measured it. Both commands run on this machine and
384
+ executed the last time \`npx -y balladeer@latest touch-map\` measured it. Both commands run on this machine and
238
385
  send Balladeer nothing, so you may name any path in the change.
239
386
 
240
387
  An answer marked stale was measured against a verifier that has changed since, so read it as the
241
- last thing anybody measured rather than as fact, and offer to run \`balladeer touch-map\` again.
388
+ last thing anybody measured rather than as fact, and offer to run \`npx -y balladeer@latest touch-map\` again.
242
389
  Where there is no map, where it does not name your paths, or where it reports a promise it could not
243
390
  measure, fall back to the paths on the index rows. The map narrows which promises are worth
244
391
  fetching. It never widens what you may read: the catalog is still never enumerated, and the fetch
245
392
  budget is unchanged.
246
393
 
247
394
  When a retrieval receipt asks why you read a row, the answer for one the map named is \`touch\`.`;
395
+ /**
396
+ * The one paragraph that turns retrieval from a thing we argue about into a
397
+ * thing we measure.
398
+ *
399
+ * Two facts are asked for and neither can be observed from this side. Which
400
+ * rows an agent actually used, because a promise expanded and then ignored is
401
+ * not a row the index earned. And which change the session went on to write,
402
+ * because a retrieval read happens before the commit exists and nothing joins
403
+ * the two afterwards: Balladeer receives commit SHAs from CI and never a commit
404
+ * message, so the agent is the only thing present at both ends.
405
+ *
406
+ * It is written as three instructions rather than as an explanation, because
407
+ * what an agent does with it is three specific acts, and a paragraph about
408
+ * measurement would be read and not acted on.
409
+ *
410
+ * Three carriers say it, byte for byte, and a packaging test holds them
411
+ * together.
412
+ */
413
+ export const SESSION_STAMP = `Run \`npx -y balladeer@latest session\` when you start work here and pass the id it prints to every promise read
414
+ you make, as \`session\`. It is the same id for the whole of one piece of work, and it costs nothing:
415
+ a read without it is answered exactly the same way.
416
+
417
+ If local file permissions refuse that session write, no session stamp was saved. Your saved
418
+ connection is unchanged; this does not prove its current server authorization. Continue
419
+ already-authorized MCP reads, coding and explicitly requested capture without the optional
420
+ \`session\` field. Do not invent an ID, add a session trailer, or record an unsaved stamp. Do not
421
+ broaden filesystem access or move credentials to retry this write. A missing credential or server
422
+ authorization refusal is different: follow that refusal. Capture still needs the person's request
423
+ or accepted offer, and human meaning approval is unchanged.
424
+
425
+ If a session ID was saved, near the end of the work call \`mark_promise_used\` with the ids you actually cited
426
+ or acted on. Naming none is a fine answer. It is the only way anybody finds out which rows were
427
+ worth sending you, because reading a promise and then ignoring it is not the same as using it.
428
+
429
+ If a session ID was saved, write \`Balladeer-Session: <the id>\` into the commit message or pull-request body you produce,
430
+ and run \`npx -y balladeer@latest session --record\` once the commit exists. That is what lets a check that goes red
431
+ next week be read back against what Balladeer told you before you started. Your commit message never
432
+ leaves your machine: only the session id and the commit SHA are sent.`;
248
433
  /**
249
434
  * The conventions block written between markers into this repository's agent
250
435
  * instructions file. Fixed text, compared byte for byte by a test, because a
@@ -267,6 +452,10 @@ ${TOUCH_MAP_GUIDANCE}
267
452
 
268
453
  ${FETCH_BUDGET}
269
454
 
455
+ ${CAPTURE_SEAM}
456
+
457
+ ${SESSION_STAMP}
458
+
270
459
  ### When somebody asks you to protect a behavior
271
460
 
272
461
  Somebody has asked you to protect a behavior when they say what the software must do, or must never
@@ -319,6 +508,8 @@ especially: it is Balladeer's word for a proposal, so it reads as jargon whateve
319
508
 
320
509
  ${SESSION_BEHAVIORS}
321
510
 
511
+ ${SETTLING_QUESTIONS}
512
+
322
513
  ${SEALED_FILES}
323
514
 
324
515
  ### The rest of a promise's life
@@ -442,15 +633,21 @@ For each promise you propose:
442
633
  actually keeps and that you have described it as its source describes it. Nine promises from nine
443
634
  sources are not nine equally certain readings, and the person reading them is entitled to know
444
635
  which one you were least sure of. Never write 1 to get past the check.
636
+ - One wrongOutcome sentence: what going wrong looks like, said as a must-not, in the source's own
637
+ words where it has them. "A second payment must not go out." "The buyer must not see a generic
638
+ error." Where no sentence of that shape can be written, no check with a known-bad control can be
639
+ written either, so leave the promise out rather than filing one nothing could ever fail.
640
+ - One leastSure sentence, optional, naming the single thing you are least sure of and what you did
641
+ about it. The person reads that instead of the number.
445
642
 
446
643
  Write them all into one file, an object with a promises array, each entry shaped exactly as one
447
644
  proposal file:
448
645
 
449
- {"promises": [{"explicitIntent": "...", "teachBack": {"confidence": 0.9, "meaning": {"title": "..."}}}]}
646
+ {"promises": [{"explicitIntent": "...", "teachBack": {"confidence": 0.9, "wrongOutcome": "...", "meaning": {"title": "..."}}}]}
450
647
 
451
648
  An entry carries explicitIntent and teachBack, and inside teachBack only meaning, confidence,
452
- unresolvedQuestions, and proposedOwnerId. Anything else is refused on this machine before the file
453
- leaves it, and one bad entry means not one of them is sent.
649
+ wrongOutcome, leastSure, unresolvedQuestions, and proposedOwnerId. Anything else is refused on this
650
+ machine before the file leaves it, and one bad entry means not one of them is sent.
454
651
 
455
652
  Every promise in the file is proposed as owned by whoever ran setup, and waits for that person. The
456
653
  command prints one link that opens all of them at once. Give the person that link and ask them to
@@ -0,0 +1,85 @@
1
+ import { type StdioMcpEntry } from "./mcp-config.js";
2
+ /** The file the Claude desktop app reads its stdio servers out of, on every platform that has one. */
3
+ export declare const DESKTOP_CONFIG_FILE = "claude_desktop_config.json";
4
+ /**
5
+ * Where that file lives, or why this machine has no such place.
6
+ *
7
+ * Two platforms are documented and both are handled: macOS keeps it under
8
+ * `Library/Application Support/Claude`, Windows under `%APPDATA%\Claude`.
9
+ * Claude desktop is published for those two and no others, so every other
10
+ * platform is told so by name rather than written to on a guess. A person
11
+ * running an unofficial build, or a Windows install whose packaging redirects
12
+ * that directory, points `BALLADEER_CLAUDE_DESKTOP_CONFIG` at the file itself
13
+ * and this writes exactly there.
14
+ */
15
+ export type DesktopLocation = Readonly<{
16
+ kind: "path";
17
+ path: string;
18
+ directory: string;
19
+ }> | Readonly<{
20
+ kind: "unsupported";
21
+ platform: string;
22
+ reason: string;
23
+ }>;
24
+ export declare function desktopConfigLocation(environment?: NodeJS.ProcessEnv, platform?: string): DesktopLocation;
25
+ /**
26
+ * One key per repository, named after the repository.
27
+ *
28
+ * The desktop app has no notion of a project: every server in that file is
29
+ * loaded into every chat. A person who plans work in two repositories therefore
30
+ * has two of our servers running at once, and the only thing that tells them
31
+ * apart in the app's own connector list is the key. `balladeer-owner-name` is
32
+ * that name. A directory whose GitHub origin this command could not read has no
33
+ * name to use, so the key falls back to the repository's own id, which is unique
34
+ * by construction and still tells two entries apart.
35
+ */
36
+ export declare function desktopServerKey(repositoryName: string, repositoryId: string): string;
37
+ /**
38
+ * The entry the desktop app can actually start, which is not the one a coding
39
+ * agent gets.
40
+ *
41
+ * Claude desktop launches a stdio server from the application rather than from a
42
+ * login shell, with a minimal PATH carrying none of the places a developer's
43
+ * node lives: nvm, volta, asdf, Homebrew, fnm. So `node` and `npx` both resolve
44
+ * to nothing there, and what a person gets instead of a server is a line in a
45
+ * log file nobody opens. This names the interpreter running setup by its
46
+ * absolute path and this command's own entry point by its absolute path, so the
47
+ * entry needs no PATH at all.
48
+ *
49
+ * The environment is written for the same reason. The forwarder reads the bearer
50
+ * out of this machine's credential store, and where that store is depends on a
51
+ * variable the desktop app never inherits, so the resolved directory goes into
52
+ * the entry rather than being left to a shell that is not there. It is a path
53
+ * and never a secret: the credential itself stays in the store, mode 600.
54
+ */
55
+ export declare function desktopStdioEntry(repositoryId: string, environment?: NodeJS.ProcessEnv, controlPlane?: string, nodePath?: string, entryPath?: string): StdioMcpEntry;
56
+ export type DesktopMergeResult = Readonly<{
57
+ kind: "written";
58
+ changed: boolean;
59
+ path: string;
60
+ key: string;
61
+ }> | Readonly<{
62
+ kind: "absent";
63
+ directory: string;
64
+ reason: string;
65
+ }> | Readonly<{
66
+ kind: "refused";
67
+ reason: string;
68
+ block: string;
69
+ }>;
70
+ export declare function desktopBlock(key: string, entry: StdioMcpEntry): string;
71
+ /**
72
+ * Merges our entry into the desktop app's configuration, or refuses.
73
+ *
74
+ * Everything already in that file stays in it. Other people's servers are other
75
+ * people's servers, and so is every top-level key beside `mcpServers`, which is
76
+ * where the app keeps settings this command knows nothing about. An unparseable
77
+ * file is never overwritten, and a key of ours holding somebody else's server is
78
+ * never replaced: the block is printed and a person decides.
79
+ *
80
+ * The key is found by what the entry says rather than by what it is called. An
81
+ * entry of ours already bound to this repository is rewritten where it sits,
82
+ * whatever it was named, so a repository renamed on GitHub does not quietly
83
+ * acquire a second server that starts alongside the first.
84
+ */
85
+ export declare function mergeDesktopConfig(location: DesktopLocation, key: string, entry: StdioMcpEntry, repositoryId: string, controlPlane: string): DesktopMergeResult;
@@ -0,0 +1,217 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { entryRepositoryId, isOurEntry, writeJsonAtomically, } from "./mcp-config.js";
4
+ import { checkoutEntryPath } from "./release.js";
5
+ import { configHome } from "./store.js";
6
+ import { DEFAULT_CONTROL_PLANE } from "./wire.js";
7
+ /** The file the Claude desktop app reads its stdio servers out of, on every platform that has one. */
8
+ export const DESKTOP_CONFIG_FILE = "claude_desktop_config.json";
9
+ export function desktopConfigLocation(environment = process.env, platform = process.platform) {
10
+ const override = environment.BALLADEER_CLAUDE_DESKTOP_CONFIG?.trim();
11
+ if (override !== undefined && override.length > 0) {
12
+ // An override that is not absolute would be resolved against whatever
13
+ // directory this command happens to have been started in, which is never
14
+ // the directory the person meant.
15
+ return isAbsolute(override)
16
+ ? { kind: "path", path: override, directory: parentOf(override) }
17
+ : {
18
+ kind: "unsupported",
19
+ platform,
20
+ reason: `BALLADEER_CLAUDE_DESKTOP_CONFIG is set to ${override}, which is not an absolute path, so I did not guess what it meant.`,
21
+ };
22
+ }
23
+ const home = homeOf(environment);
24
+ if (platform === "darwin") {
25
+ if (home === undefined)
26
+ return noHome(platform);
27
+ const directory = join(home, "Library", "Application Support", "Claude");
28
+ return { kind: "path", path: join(directory, DESKTOP_CONFIG_FILE), directory };
29
+ }
30
+ if (platform === "win32") {
31
+ const appData = environment.APPDATA?.trim();
32
+ const roaming = appData !== undefined && appData.length > 0
33
+ ? appData
34
+ : home === undefined
35
+ ? undefined
36
+ : join(home, "AppData", "Roaming");
37
+ if (roaming === undefined)
38
+ return noHome(platform);
39
+ const directory = join(roaming, "Claude");
40
+ return { kind: "path", path: join(directory, DESKTOP_CONFIG_FILE), directory };
41
+ }
42
+ return {
43
+ kind: "unsupported",
44
+ platform,
45
+ reason: `Claude desktop is published for macOS and Windows, and this machine reports ${platform}, so there is no ${DESKTOP_CONFIG_FILE} here to write. If you run an unofficial build, set BALLADEER_CLAUDE_DESKTOP_CONFIG to the absolute path of its ${DESKTOP_CONFIG_FILE} and run this again.`,
46
+ };
47
+ }
48
+ /**
49
+ * The home directory this environment names, and never the one the operating
50
+ * system would name instead.
51
+ *
52
+ * Asking the OS whose home this is would be a second source, and it answers for
53
+ * the account the process runs under rather than for the environment it was
54
+ * handed. A command given a bounded environment on purpose, which is what the
55
+ * tests hand it and what a service account hands it, would reach past that
56
+ * environment and write into a real person's chat client. So an environment that
57
+ * names no home is an answer: this run cannot tell where Claude desktop's
58
+ * configuration is, and it says so rather than guessing.
59
+ */
60
+ function homeOf(environment) {
61
+ for (const value of [environment.HOME, environment.USERPROFILE]) {
62
+ const trimmed = value?.trim();
63
+ if (trimmed !== undefined && trimmed.length > 0)
64
+ return trimmed;
65
+ }
66
+ return undefined;
67
+ }
68
+ function noHome(platform) {
69
+ return {
70
+ kind: "unsupported",
71
+ platform,
72
+ reason: `This process was given no HOME, so I could not tell where Claude desktop keeps ${DESKTOP_CONFIG_FILE} and did not guess. Set BALLADEER_CLAUDE_DESKTOP_CONFIG to its absolute path, or run this from a shell that sets HOME.`,
73
+ };
74
+ }
75
+ function parentOf(path) {
76
+ const at = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
77
+ return at <= 0 ? path : path.slice(0, at);
78
+ }
79
+ /**
80
+ * One key per repository, named after the repository.
81
+ *
82
+ * The desktop app has no notion of a project: every server in that file is
83
+ * loaded into every chat. A person who plans work in two repositories therefore
84
+ * has two of our servers running at once, and the only thing that tells them
85
+ * apart in the app's own connector list is the key. `balladeer-owner-name` is
86
+ * that name. A directory whose GitHub origin this command could not read has no
87
+ * name to use, so the key falls back to the repository's own id, which is unique
88
+ * by construction and still tells two entries apart.
89
+ */
90
+ export function desktopServerKey(repositoryName, repositoryId) {
91
+ const cleaned = repositoryName
92
+ .toLowerCase()
93
+ .replace(/[^a-z0-9]+/g, "-")
94
+ .replace(/^-+|-+$/g, "");
95
+ const usable = cleaned.length > 0 && cleaned !== "unknown-unknown" ? cleaned : repositoryId.toLowerCase();
96
+ return `balladeer-${usable}`.slice(0, 100).replace(/-+$/, "");
97
+ }
98
+ /**
99
+ * The entry the desktop app can actually start, which is not the one a coding
100
+ * agent gets.
101
+ *
102
+ * Claude desktop launches a stdio server from the application rather than from a
103
+ * login shell, with a minimal PATH carrying none of the places a developer's
104
+ * node lives: nvm, volta, asdf, Homebrew, fnm. So `node` and `npx` both resolve
105
+ * to nothing there, and what a person gets instead of a server is a line in a
106
+ * log file nobody opens. This names the interpreter running setup by its
107
+ * absolute path and this command's own entry point by its absolute path, so the
108
+ * entry needs no PATH at all.
109
+ *
110
+ * The environment is written for the same reason. The forwarder reads the bearer
111
+ * out of this machine's credential store, and where that store is depends on a
112
+ * variable the desktop app never inherits, so the resolved directory goes into
113
+ * the entry rather than being left to a shell that is not there. It is a path
114
+ * and never a secret: the credential itself stays in the store, mode 600.
115
+ */
116
+ export function desktopStdioEntry(repositoryId, environment = process.env, controlPlane, nodePath = process.execPath, entryPath = checkoutEntryPath()) {
117
+ const env = { BALLADEER_CONFIG_HOME: configHome(environment) };
118
+ // Only when it is not the default. A deployment nobody named is the one the
119
+ // forwarder already reaches on its own, and writing it down would pin an
120
+ // address that a later release moves.
121
+ if (controlPlane !== undefined && controlPlane !== DEFAULT_CONTROL_PLANE) {
122
+ env.BALLADEER_CONTROL_PLANE = controlPlane;
123
+ }
124
+ return { command: nodePath, args: [entryPath, "mcp", "--repository", repositoryId], env };
125
+ }
126
+ export function desktopBlock(key, entry) {
127
+ return JSON.stringify({ mcpServers: { [key]: entry } }, null, 2);
128
+ }
129
+ /**
130
+ * Merges our entry into the desktop app's configuration, or refuses.
131
+ *
132
+ * Everything already in that file stays in it. Other people's servers are other
133
+ * people's servers, and so is every top-level key beside `mcpServers`, which is
134
+ * where the app keeps settings this command knows nothing about. An unparseable
135
+ * file is never overwritten, and a key of ours holding somebody else's server is
136
+ * never replaced: the block is printed and a person decides.
137
+ *
138
+ * The key is found by what the entry says rather than by what it is called. An
139
+ * entry of ours already bound to this repository is rewritten where it sits,
140
+ * whatever it was named, so a repository renamed on GitHub does not quietly
141
+ * acquire a second server that starts alongside the first.
142
+ */
143
+ export function mergeDesktopConfig(location, key, entry, repositoryId, controlPlane) {
144
+ const block = desktopBlock(key, entry);
145
+ if (location.kind === "unsupported") {
146
+ return { kind: "refused", reason: location.reason, block };
147
+ }
148
+ // The app creates its own directory the first time it runs. Creating one it
149
+ // never made would leave a configuration file behind for an application that
150
+ // is not installed, and the person would read "connected" about a chat client
151
+ // they do not have.
152
+ if (!existsSync(location.directory)) {
153
+ return {
154
+ kind: "absent",
155
+ directory: location.directory,
156
+ reason: `Claude desktop was not found on this machine: ${location.directory} does not exist, so I wrote no ${DESKTOP_CONFIG_FILE}. Install Claude desktop and open it once, then run this again.`,
157
+ };
158
+ }
159
+ let existing = "";
160
+ try {
161
+ existing = readFileSync(location.path, "utf8");
162
+ }
163
+ catch {
164
+ writeJsonAtomically(location.path, `${block}\n`);
165
+ return { kind: "written", changed: true, path: location.path, key };
166
+ }
167
+ let parsed;
168
+ try {
169
+ const value = JSON.parse(existing);
170
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
171
+ throw new Error("not an object");
172
+ }
173
+ parsed = value;
174
+ }
175
+ catch {
176
+ return {
177
+ kind: "refused",
178
+ reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} could not be parsed; I did not change it.`,
179
+ block,
180
+ };
181
+ }
182
+ const raw = parsed.mcpServers;
183
+ if (raw !== undefined && (raw === null || typeof raw !== "object" || Array.isArray(raw))) {
184
+ return {
185
+ kind: "refused",
186
+ reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} has an mcpServers value that is not a set of servers; I did not change it.`,
187
+ block,
188
+ };
189
+ }
190
+ const servers = raw === undefined ? {} : { ...raw };
191
+ const target = ourKeyFor(servers, repositoryId, controlPlane) ?? key;
192
+ const current = servers[target];
193
+ if (current !== undefined && !isOurEntry(current, controlPlane)) {
194
+ return {
195
+ kind: "refused",
196
+ reason: `Claude desktop's ${DESKTOP_CONFIG_FILE} already has an entry named ${target} that is not this workspace's server; I did not change it.`,
197
+ block: desktopBlock(target, entry),
198
+ };
199
+ }
200
+ servers[target] = entry;
201
+ const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
202
+ if (next === existing) {
203
+ return { kind: "written", changed: false, path: location.path, key: target };
204
+ }
205
+ writeJsonAtomically(location.path, next);
206
+ return { kind: "written", changed: true, path: location.path, key: target };
207
+ }
208
+ /** The key already carrying our server for this repository, whatever it is called. */
209
+ function ourKeyFor(servers, repositoryId, controlPlane) {
210
+ for (const [name, value] of Object.entries(servers)) {
211
+ if (!isOurEntry(value, controlPlane))
212
+ continue;
213
+ if (entryRepositoryId(value) === repositoryId)
214
+ return name;
215
+ }
216
+ return undefined;
217
+ }