balladeer 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
4
+ import { inReadersZone } from "../local-time.js";
5
+ import { commandLine } from "../release.js";
6
+ import { repositoryHint } from "../repository.js";
7
+ import { StoreError, readCredentials } from "../store.js";
8
+ import {} from "../wire.js";
9
+ const PROMISE_ID = /^prom_[a-z0-9]{8,64}$/;
10
+ /**
11
+ * The tool's argument, as the person in front of this terminal would type it.
12
+ *
13
+ * `prepare_qualification` takes `again: true`, and its refusal says so, which is
14
+ * right for the agent that called the tool and wrong for the reader of this
15
+ * command: `balladeer prepare <id> again` is a usage error, and the flag is
16
+ * `--again`. The sentence is relayed verbatim otherwise, so this is the one word
17
+ * in it that has two correct spellings depending on who is reading.
18
+ */
19
+ const TOOL_ARGUMENT_FOR_AGAIN = /`again(?::\s*true)?`/g;
20
+ /**
21
+ * A refusal Balladeer wrote, as this terminal has to say it.
22
+ *
23
+ * Two changes and no others. The instants move onto the reader's own clock,
24
+ * because the server writes UTC and "already prepared by Robert Clark on
25
+ * 2026-09-07T18:05:10.698Z" reached somebody whose clock said two in the
26
+ * afternoon. And the argument is named the way this command takes it. Every
27
+ * other word, including what to do next and why, is the server's.
28
+ */
29
+ export function atThisTerminal(text) {
30
+ return inReadersZone(text).replace(TOOL_ARGUMENT_FOR_AGAIN, "`--again`");
31
+ }
32
+ /**
33
+ * The six keys the sealed run reads, and nothing else.
34
+ *
35
+ * The runner rejects an unknown field outright, so a metadata file that carried
36
+ * a helpful comment, a timestamp, or the whole packet would fail the customer's
37
+ * protected run for a reason that had nothing to do with their behavior. This
38
+ * command therefore writes the packet's own `qualificationMetadata` object and
39
+ * refuses to write anything it does not recognise.
40
+ */
41
+ const METADATA_KEYS = [
42
+ "schemaVersion",
43
+ "workspaceLocator",
44
+ "receiptId",
45
+ "revisionId",
46
+ "bindingId",
47
+ "workflowDigest",
48
+ ];
49
+ export function readQualificationMetadata(structured) {
50
+ const packet = structured !== null && typeof structured === "object"
51
+ ? structured.packet
52
+ : undefined;
53
+ const metadata = packet !== null && typeof packet === "object"
54
+ ? packet.qualificationMetadata
55
+ : undefined;
56
+ if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
57
+ return { ok: false, reason: "the answer carried no qualification metadata" };
58
+ }
59
+ const record = metadata;
60
+ const unexpected = Object.keys(record).filter((key) => !METADATA_KEYS.includes(key));
61
+ if (unexpected.length > 0) {
62
+ return {
63
+ ok: false,
64
+ reason: `the metadata carries ${unexpected.join(", ")}, which the sealed run refuses, so nothing was written`,
65
+ };
66
+ }
67
+ const missing = METADATA_KEYS.filter((key) => typeof record[key] !== "string");
68
+ if (missing.length > 0) {
69
+ return { ok: false, reason: `the metadata is missing ${missing.join(", ")}` };
70
+ }
71
+ return {
72
+ ok: true,
73
+ value: Object.fromEntries(METADATA_KEYS.map((key) => [key, record[key]])),
74
+ };
75
+ }
76
+ /**
77
+ * Where the file goes, refusing any path that would leave this repository.
78
+ *
79
+ * The path comes off the server's answer, and the whole point of this command is
80
+ * that it writes a file the person did not type. A server that answered with
81
+ * `../../etc/something` would otherwise have this command write there, so the
82
+ * resolved path has to stay under the directory the command was run in.
83
+ */
84
+ export function metadataDestination(cwd, metadataPath) {
85
+ if (isAbsolute(metadataPath)) {
86
+ return { ok: false, reason: "the path Balladeer answered with is absolute" };
87
+ }
88
+ const root = resolve(cwd);
89
+ const destination = resolve(join(root, metadataPath));
90
+ const inside = relative(root, destination);
91
+ if (inside.startsWith("..") || isAbsolute(inside)) {
92
+ return { ok: false, reason: "the path Balladeer answered with leaves this repository" };
93
+ }
94
+ return { ok: true, path: destination };
95
+ }
96
+ function stringList(structured, field) {
97
+ if (structured === null || typeof structured !== "object")
98
+ return [];
99
+ const value = structured[field];
100
+ return Array.isArray(value)
101
+ ? value.filter((item) => typeof item === "string")
102
+ : [];
103
+ }
104
+ /**
105
+ * Prepares the one-time qualification setup for one promise, and writes the file
106
+ * where the sealed run reads it.
107
+ *
108
+ * It runs over this repository's own agent connection, which is what makes it
109
+ * usable at all: preparing a qualification setup is the agent's job, and the
110
+ * setup session that paired the machine expires. There is no sign-off here and
111
+ * no code to paste. Agreeing the meaning was the person's act; building the
112
+ * check that proves it is this command's caller's.
113
+ *
114
+ * The file is written once. A second run while nobody has published against the
115
+ * first packet is refused, naming who prepared it and when, and `--again` mints
116
+ * a replacement that invalidates the earlier one on the server as well as
117
+ * overwriting the file here.
118
+ */
119
+ export async function runPrepare(options) {
120
+ const emit = (step) => {
121
+ if (options.json)
122
+ options.write(`${JSON.stringify(step)}\n`);
123
+ };
124
+ const fail = (reason, message, exitCode) => {
125
+ if (options.json)
126
+ emit({ step: "error", reason, message, changed: false, exitCode });
127
+ else
128
+ options.write(`${message}\n`);
129
+ return exitCode;
130
+ };
131
+ if (!PROMISE_ID.test(options.promiseId)) {
132
+ return fail("usage", `"${options.promiseId}" is not a promise id. A promise id looks like prom_ followed by letters and digits, and every promise page has a control that copies its own.`, 4);
133
+ }
134
+ let credentials;
135
+ try {
136
+ credentials = readCredentials(options.environment);
137
+ }
138
+ catch (error) {
139
+ return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
140
+ }
141
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHint(options.cwd));
142
+ if (selection.kind === "refused") {
143
+ if (selection.missingFor !== undefined) {
144
+ return fail("no_agent_credential_on_this_machine", `${noAgentCredentialSentence(selection.missingFor)} Nothing was prepared.`, 4);
145
+ }
146
+ return fail("no_agent_connection", `${selection.reason} Run \`${commandLine(null, "setup")}\` in this repository first. Nothing was prepared.`, 4);
147
+ }
148
+ const { agent } = selection;
149
+ const call = await callAgentTool(agent, "prepare_qualification", {
150
+ promiseId: options.promiseId,
151
+ again: options.again,
152
+ });
153
+ switch (call.kind) {
154
+ case "endpoint_refused":
155
+ return fail("agent_endpoint_unsafe", `${call.reason} Nothing was prepared.`, 4);
156
+ case "unreachable":
157
+ return fail("control_plane_unreachable", `Balladeer could not be reached at ${options.controlPlane}. Nothing was prepared.`, 5);
158
+ case "client_too_old":
159
+ return fail("client_too_old", `This copy of the Balladeer command is too old for ${options.controlPlane}. Update it with: ${call.update}`, 3);
160
+ case "unauthorized":
161
+ return fail("agent_connection_revoked", `Balladeer refused this repository's agent connection: it was revoked, or the repository it was bound to is no longer enrolled. Retrying will not help. Run \`${commandLine(null, "setup")}\` here again, or issue a new connection at ${options.controlPlane}/setup. Nothing was prepared.`, 5);
162
+ case "tool_refusal":
163
+ // The server's own sentence, which names what to do next: use the packet
164
+ // that already exists, wait for the owner to agree, or run this again with
165
+ // --again. Rewriting it here would lose the part the person has to read,
166
+ // so `atThisTerminal` changes only the two things the server could not
167
+ // know: which clock the reader is on, and that they type a flag.
168
+ return fail("prepare_refused", `Balladeer refused this: ${atThisTerminal(call.text)}`, 5);
169
+ case "http":
170
+ return fail("prepare_failed", `Balladeer answered ${call.status} to this request. Nothing was prepared.`, 5);
171
+ case "malformed":
172
+ return fail("prepare_failed", "Balladeer could not prepare a qualification setup.", 5);
173
+ case "result":
174
+ break;
175
+ }
176
+ const structured = call.structured;
177
+ const metadataPath = typeof structured.metadataPath === "string" ? structured.metadataPath : "";
178
+ if (metadataPath === "") {
179
+ return fail("prepare_failed", "Balladeer could not prepare a qualification setup.", 5);
180
+ }
181
+ const destination = metadataDestination(options.cwd, metadataPath);
182
+ if (!destination.ok) {
183
+ // Minted on the server and not written here. Said plainly rather than
184
+ // silently: the packet is one-time, so somebody has to know it was spent.
185
+ return fail("metadata_path_unsafe", `Balladeer prepared the qualification setup, but ${destination.reason}, so nothing was written to disk. Nothing else was changed.`, 5);
186
+ }
187
+ const metadata = readQualificationMetadata(call.structured);
188
+ if (!metadata.ok) {
189
+ return fail("metadata_unusable", `Balladeer prepared the qualification setup, but ${metadata.reason}, so nothing was written to disk.`, 5);
190
+ }
191
+ try {
192
+ mkdirSync(dirname(destination.path), { recursive: true });
193
+ writeFileSync(destination.path, `${JSON.stringify(metadata.value, null, 2)}\n`, "utf8");
194
+ }
195
+ catch (error) {
196
+ return fail("metadata_unwritable", `Balladeer prepared the qualification setup, but ${metadataPath} could not be written: ${error instanceof Error ? error.message : String(error)}`, 4);
197
+ }
198
+ const superseded = typeof structured.supersededPackets === "number" ? structured.supersededPackets : 0;
199
+ emit({
200
+ step: "qualification",
201
+ status: "prepared",
202
+ promiseId: options.promiseId,
203
+ metadataPath,
204
+ supersededPackets: superseded,
205
+ });
206
+ if (!options.json) {
207
+ options.write(`${metadataPath}\n`);
208
+ if (superseded > 0) {
209
+ options.write(`The qualification setup prepared earlier is no longer valid: a run that publishes its identities is refused.\n`);
210
+ }
211
+ options.write("Next: build this promise's verifier, seal it, and push to the default branch.\n");
212
+ options.write("Protection starts by itself when that run qualifies. Nobody activates anything, and this file is removed in an ordinary follow-up change once the receipt appears.\n");
213
+ for (const step of stringList(call.structured, "nextSteps"))
214
+ options.write(` ${step}\n`);
215
+ }
216
+ return 0;
217
+ }
@@ -18,6 +18,16 @@ export type TeachBackRequest = Readonly<{
18
18
  proposedOwnerId?: unknown;
19
19
  /** How sure whoever wrote this file was, 0 to 1. Read, never assumed. */
20
20
  confidence: number;
21
+ /**
22
+ * What going wrong looks like, in the words the person used. Required, and
23
+ * refused here as well as by the server: a behavior with no sentence of that
24
+ * shape has no failing case a check could ever catch, and a promise that can
25
+ * never fail is worse than no promise at all.
26
+ */
27
+ wrongOutcome: string;
28
+ /** The one thing least certain, which the review page reads instead of the
29
+ * confidence number. Optional: silence here means nothing was said. */
30
+ leastSure?: unknown;
21
31
  }>;
22
32
  }>;
23
33
  /**
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
3
- import { reviewLink } from "../client.js";
3
+ import { proposalReviewLink } from "../client.js";
4
4
  import { commandLine } from "../release.js";
5
5
  import { repositoryHint } from "../repository.js";
6
6
  import { StoreError, readCredentials } from "../store.js";
@@ -33,6 +33,8 @@ const ALLOWED_TEACH_BACK = [
33
33
  "unresolvedQuestions",
34
34
  "proposedOwnerId",
35
35
  "confidence",
36
+ "wrongOutcome",
37
+ "leastSure",
36
38
  ];
37
39
  function unexpectedKeys(value, allowed) {
38
40
  return Object.keys(value).filter((key) => !allowed.includes(key));
@@ -110,6 +112,20 @@ export function readTeachBackFile(raw) {
110
112
  if (confidence < 0 || confidence > 1) {
111
113
  return { ok: false, reason: "teachBack.confidence must be between 0 and 1" };
112
114
  }
115
+ // The server refuses a proposal that cannot say what going wrong looks like,
116
+ // and it is right to: a behavior with no sentence of that shape has no failing
117
+ // case a check could ever catch. Refusing here as well means the person who
118
+ // wrote the file reads why on their own machine rather than after a round trip.
119
+ const wrongOutcome = teachBack.wrongOutcome;
120
+ if (typeof wrongOutcome !== "string" || wrongOutcome.trim().length === 0) {
121
+ return {
122
+ ok: false,
123
+ reason: 'teachBack.wrongOutcome is missing. Say what going wrong looks like, in the words the person used and as a must-not: "a second payment must not go out". If no sentence of that shape exists, nothing could ever fail this promise, so do not propose it',
124
+ };
125
+ }
126
+ if (teachBack.leastSure !== undefined && typeof teachBack.leastSure !== "string") {
127
+ return { ok: false, reason: "teachBack.leastSure must be one sentence of text" };
128
+ }
113
129
  return {
114
130
  ok: true,
115
131
  value: {
@@ -117,6 +133,8 @@ export function readTeachBackFile(raw) {
117
133
  teachBack: {
118
134
  meaning,
119
135
  confidence,
136
+ wrongOutcome,
137
+ ...(teachBack.leastSure === undefined ? {} : { leastSure: teachBack.leastSure }),
120
138
  ...(teachBack.unresolvedQuestions === undefined
121
139
  ? {}
122
140
  : { unresolvedQuestions: teachBack.unresolvedQuestions }),
@@ -217,6 +235,10 @@ export async function runPropose(options) {
217
235
  ? {}
218
236
  : { proposedOwnerId: parsed.value.teachBack.proposedOwnerId }),
219
237
  confidence: parsed.value.teachBack.confidence,
238
+ wrongOutcome: parsed.value.teachBack.wrongOutcome,
239
+ ...(parsed.value.teachBack.leastSure === undefined
240
+ ? {}
241
+ : { leastSure: parsed.value.teachBack.leastSure }),
220
242
  });
221
243
  switch (call.kind) {
222
244
  case "endpoint_refused":
@@ -246,12 +268,12 @@ export async function runPropose(options) {
246
268
  // proposal, which is what the setup route used to answer as a conflict. It
247
269
  // is reported as one here too: sending a person to agree to a record that
248
270
  // was already decided is worse than saying nothing was recorded.
249
- return fail("candidate_not_pending", `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`, 5);
271
+ return fail("proposal_not_pending", `Balladeer matched an existing proposal, ${candidateId}, which is ${status ?? "in an unreported state"}. Nothing new was recorded.`, 5);
250
272
  }
251
273
  // The review link is built from the address this copy paired with. The tool
252
274
  // answers with an id and no link at all, so there is nothing here a
253
275
  // misconfigured public base URL could redirect.
254
- const review = reviewLink(options.controlPlane, `candidates/${candidateId}`);
276
+ const review = proposalReviewLink(options.controlPlane, candidateId);
255
277
  emit({ step: "promise", status: "proposed", candidateId, reviewUrl: review });
256
278
  if (!options.json) {
257
279
  options.write("Proposed. You will own it unless you named someone else; the owner reads it and clicks Agree, and nothing else can.\n");
@@ -0,0 +1,35 @@
1
+ export type SessionOptions = Readonly<{
2
+ controlPlane: string;
3
+ json: boolean;
4
+ /**
5
+ * Read the session out of the commit at HEAD and tell Balladeer which commit
6
+ * it wrote, instead of printing an id.
7
+ */
8
+ record: boolean;
9
+ /** Start a new session even though one is still current. */
10
+ fresh: boolean;
11
+ /** The repository whose connection to use, named as `owner/name`. */
12
+ repo?: string;
13
+ /** The repository whose connection to use, named by id, as `mcp` takes it. */
14
+ repository?: string;
15
+ environment: NodeJS.ProcessEnv;
16
+ cwd: string;
17
+ now?: string;
18
+ write: (text: string) => void;
19
+ }>;
20
+ /**
21
+ * The id that joins what an agent was told to what it then wrote.
22
+ *
23
+ * Retrieval happens before the commit exists, so nothing ties the two together
24
+ * on its own, and without the tie a red check on Tuesday cannot be read back
25
+ * against what Balladeer had said on Monday. The agent's own working session
26
+ * spans both: it reads the index under this id and writes the same id into the
27
+ * commit it produces.
28
+ *
29
+ * `balladeer session` prints the id and the exact line to write.
30
+ * `balladeer session --record` reads that line back out of the commit at HEAD
31
+ * and tells Balladeer which commit the session wrote. The commit message itself
32
+ * never leaves this machine: only the pair of a session id and a forty-
33
+ * character SHA is sent, and the SHA is a fact CI already reports.
34
+ */
35
+ export declare function runSession(options: SessionOptions): Promise<number>;
@@ -0,0 +1,118 @@
1
+ import { callAgentTool, noAgentCredentialSentence, selectAgent } from "../agent.js";
2
+ import { headCommit } from "../git.js";
3
+ import { repositoryHint } from "../repository.js";
4
+ import { AGENT_SESSION_TRAILER, agentSessionTrailerLine, currentSession, readAgentSessionTrailer, } from "../session.js";
5
+ import { StoreError, readCredentials } from "../store.js";
6
+ import {} from "../wire.js";
7
+ /**
8
+ * The id that joins what an agent was told to what it then wrote.
9
+ *
10
+ * Retrieval happens before the commit exists, so nothing ties the two together
11
+ * on its own, and without the tie a red check on Tuesday cannot be read back
12
+ * against what Balladeer had said on Monday. The agent's own working session
13
+ * spans both: it reads the index under this id and writes the same id into the
14
+ * commit it produces.
15
+ *
16
+ * `balladeer session` prints the id and the exact line to write.
17
+ * `balladeer session --record` reads that line back out of the commit at HEAD
18
+ * and tells Balladeer which commit the session wrote. The commit message itself
19
+ * never leaves this machine: only the pair of a session id and a forty-
20
+ * character SHA is sent, and the SHA is a fact CI already reports.
21
+ */
22
+ export async function runSession(options) {
23
+ const emit = (step) => {
24
+ if (options.json)
25
+ options.write(`${JSON.stringify(step)}\n`);
26
+ };
27
+ const say = (text) => {
28
+ if (!options.json)
29
+ options.write(`${text}\n`);
30
+ };
31
+ const fail = (reason, message, exitCode) => {
32
+ if (options.json)
33
+ emit({ step: "error", reason, message, changed: false, exitCode });
34
+ else
35
+ options.write(`${message}\n`);
36
+ return exitCode;
37
+ };
38
+ let credentials;
39
+ try {
40
+ credentials = readCredentials(options.environment);
41
+ }
42
+ catch (error) {
43
+ return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
44
+ }
45
+ const here = options.repo ?? repositoryHint(options.cwd);
46
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
47
+ if (selection.kind === "refused") {
48
+ const message = selection.missingFor === undefined
49
+ ? selection.reason
50
+ : noAgentCredentialSentence(selection.missingFor);
51
+ return fail("no_agent_credential_on_this_machine", message, 4);
52
+ }
53
+ const agent = selection.agent;
54
+ if (options.record)
55
+ return recordCommit(options, agent, emit, say, fail);
56
+ let session;
57
+ try {
58
+ session = currentSession({
59
+ repository: agent.repositoryId,
60
+ now: options.now ?? new Date().toISOString(),
61
+ ...(options.fresh ? { forceNew: true } : {}),
62
+ environment: options.environment,
63
+ });
64
+ }
65
+ catch (error) {
66
+ return fail(error instanceof StoreError ? error.code : "session_store_unwritable", error instanceof StoreError ? error.message : String(error), 4);
67
+ }
68
+ const trailer = agentSessionTrailerLine(session.sessionId);
69
+ emit({
70
+ step: "session",
71
+ sessionId: session.sessionId,
72
+ startedAt: session.startedAt,
73
+ minted: session.minted,
74
+ trailer,
75
+ changed: session.minted,
76
+ });
77
+ say(session.sessionId);
78
+ say("");
79
+ say(session.minted
80
+ ? "This is a new session. Pass it to every promise read you make in this repository."
81
+ : "This session is already open. Pass it to every promise read you make in this repository, and use --new to start a different one.");
82
+ say(`Then write this line into the commit or pull-request body you produce:`);
83
+ say("");
84
+ say(` ${trailer}`);
85
+ say("");
86
+ say(`and run \`balladeer session --record\` once the commit exists, so a check that goes red on it can be read back against what you were told before you started. Balladeer is sent the session id and the commit SHA, and nothing else.`);
87
+ return 0;
88
+ }
89
+ async function recordCommit(options, agent, emit, say, fail) {
90
+ const head = await headCommit(options.cwd);
91
+ if (head === undefined) {
92
+ return fail("no_commit_here", "There is no commit to record: this directory is not a git checkout, or it has no commits yet.", 4);
93
+ }
94
+ const sessionId = readAgentSessionTrailer(head.message);
95
+ if (sessionId === undefined) {
96
+ // Said in full rather than as a code. The likeliest reader of this line is
97
+ // an agent that forgot the trailer, and the remedy is one line it can add
98
+ // to the commit it just made.
99
+ return fail("no_session_trailer", `The commit at HEAD carries no ${AGENT_SESSION_TRAILER} line this release recognises, so there is nothing to record. Add \`${AGENT_SESSION_TRAILER}: <the id balladeer session prints>\` to the commit message and run this again. A commit carrying two different session ids is refused rather than guessed at.`, 4);
100
+ }
101
+ const call = await callAgentTool(agent, "record_session_commit", {
102
+ session: sessionId,
103
+ commitSha: head.sha,
104
+ });
105
+ if (call.kind !== "result") {
106
+ return fail(call.kind === "tool_refusal" ? "refused" : call.kind, call.kind === "tool_refusal"
107
+ ? call.text
108
+ : `Balladeer could not record this commit (${call.kind}). Nothing was recorded, and running this again after the connection is back is safe: the same pair recorded twice is recorded once.`, 5);
109
+ }
110
+ emit({
111
+ step: "session_commit",
112
+ sessionId,
113
+ commitSha: head.sha,
114
+ changed: true,
115
+ });
116
+ say(`Recorded ${head.sha.slice(0, 12)} as written by this session.`);
117
+ return 0;
118
+ }
@@ -21,6 +21,29 @@ export type SetupOptions = Readonly<{
21
21
  * reach the control plane at that moment.
22
22
  */
23
23
  refresh?: boolean;
24
+ /**
25
+ * Set up even though an earlier Balladeer is still installed on this machine.
26
+ *
27
+ * The check that stops this run is a warning about two programs claiming one
28
+ * name, not a safety property, so somebody who has decided to run both says so
29
+ * and this run carries on exactly as it would have.
30
+ */
31
+ force?: boolean;
32
+ /**
33
+ * Whether to also connect Claude desktop chat for this repository.
34
+ *
35
+ * Three states, because there are three answers and only two of them are a
36
+ * flag. `true` is `--claude-desktop`: connect it, and say out loud when this
37
+ * machine has no such app rather than skipping in silence. `false` is
38
+ * `--no-claude-desktop`: do not touch that file and do not mention it.
39
+ * Undefined is the ordinary run, which connects it where Claude desktop is
40
+ * installed and says nothing at all where it is not. Writing it by default is
41
+ * the same bargain `.mcp.json` already makes: the entry is additive, it
42
+ * carries no credential, it never replaces anybody else's server, and the
43
+ * people this is for write their plan in that chat before they open a coding
44
+ * agent at all.
45
+ */
46
+ claudeDesktop?: boolean;
24
47
  environment: NodeJS.ProcessEnv;
25
48
  cwd: string;
26
49
  write: (text: string) => void;