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 { 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
+ }
package/dist/git.d.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  export declare const CI_BRANCH = "balladeer/connect-ci";
2
2
  export declare function repositoryRoot(cwd: string): Promise<string | undefined>;
3
+ /**
4
+ * The commit at HEAD, and the whole of its message.
5
+ *
6
+ * Both are read here, on the customer's machine, and only the pair of a SHA and
7
+ * a session id ever leaves it. That is the boundary this feature turns on:
8
+ * Balladeer receives commit SHAs from CI and never a commit message, so the
9
+ * trailer has to be read where the text already is.
10
+ *
11
+ * Absent on a repository with no commits, or on a directory that is not a
12
+ * checkout, which is an answer rather than a fault.
13
+ */
14
+ export declare function headCommit(cwd: string): Promise<Readonly<{
15
+ sha: string;
16
+ message: string;
17
+ }> | undefined>;
3
18
  /**
4
19
  * A repository in the middle of a merge or a rebase is not a repository to open
5
20
  * a pull request from. The person is mid-operation, and anything this command
package/dist/git.js CHANGED
@@ -8,6 +8,29 @@ export async function repositoryRoot(cwd) {
8
8
  const answer = await runCommand("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
9
9
  return answer.ok ? answer.stdout.trim() : undefined;
10
10
  }
11
+ /**
12
+ * The commit at HEAD, and the whole of its message.
13
+ *
14
+ * Both are read here, on the customer's machine, and only the pair of a SHA and
15
+ * a session id ever leaves it. That is the boundary this feature turns on:
16
+ * Balladeer receives commit SHAs from CI and never a commit message, so the
17
+ * trailer has to be read where the text already is.
18
+ *
19
+ * Absent on a repository with no commits, or on a directory that is not a
20
+ * checkout, which is an answer rather than a fault.
21
+ */
22
+ export async function headCommit(cwd) {
23
+ const sha = await runCommand("git", ["-C", cwd, "rev-parse", "HEAD"]);
24
+ if (!sha.ok)
25
+ return undefined;
26
+ const trimmed = sha.stdout.trim();
27
+ if (!/^[0-9a-f]{40}$/.test(trimmed))
28
+ return undefined;
29
+ const message = await runCommand("git", ["-C", cwd, "log", "-1", "--pretty=%B", trimmed]);
30
+ if (!message.ok)
31
+ return undefined;
32
+ return { sha: trimmed, message: message.stdout };
33
+ }
11
34
  async function gitDirectory(root) {
12
35
  const answer = await runCommand("git", ["-C", root, "rev-parse", "--absolute-git-dir"]);
13
36
  return answer.ok ? answer.stdout.trim() : undefined;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The earlier Balladeer, still installed, found before this one writes anything.
3
+ *
4
+ * A team that used the July client has a program on their machine that this one
5
+ * knows nothing about: it updates itself from its own server rather than from
6
+ * npm, it registers an MCP server named `balladeer` for the whole machine, and
7
+ * it installs a session hook and a conventions block of its own. This product
8
+ * registers an MCP server under the same name, per repository. Nothing breaks
9
+ * loudly when both are present, which is the problem: the older session hook
10
+ * keeps firing inside the repository somebody enrolled today, one file ends up
11
+ * carrying two conventions blocks, and the person reads the result as this
12
+ * product misbehaving.
13
+ *
14
+ * The older client can take itself off, reversibly, with `balladeer uninstall`
15
+ * followed by the removal command it prints. So this is a warning that stops
16
+ * rather than a refusal that stands: the sentence names the one thing to do,
17
+ * and `--force` is there for whoever has decided to run both anyway.
18
+ */
19
+ export type LegacyInstall = Readonly<{
20
+ /** Which file said so, in the form a person can go and open. */
21
+ where: string;
22
+ /** What was found there, bounded, so a config file cannot become an essay. */
23
+ detail: string;
24
+ }>;
25
+ /**
26
+ * The instruction, in the same words `docs/didero-day-one.md` and
27
+ * `docs/didero-mcp-walkthrough.md` print. A person who hits this in the terminal
28
+ * and a person who read the document ahead of time are told to do the same
29
+ * thing, by the same sentence.
30
+ */
31
+ export declare const UNINSTALL_INSTRUCTION = "If you used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it prints, then continue.";
32
+ /**
33
+ * The older install this machine still carries, or nothing.
34
+ *
35
+ * Two places, because those are the two the older client owns that reach into a
36
+ * session in a repository enrolled today: the machine-wide MCP entry under our
37
+ * own name, and the hook that fires at session start.
38
+ */
39
+ export declare function findLegacyInstall(environment: NodeJS.ProcessEnv, controlPlane: string): LegacyInstall | undefined;
40
+ /** What the person reads, whichever of the two files said so. */
41
+ export declare function legacyMessage(found: LegacyInstall): string;
package/dist/legacy.js ADDED
@@ -0,0 +1,143 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isOurEntry } from "./mcp-config.js";
4
+ /**
5
+ * The instruction, in the same words `docs/didero-day-one.md` and
6
+ * `docs/didero-mcp-walkthrough.md` print. A person who hits this in the terminal
7
+ * and a person who read the document ahead of time are told to do the same
8
+ * thing, by the same sentence.
9
+ */
10
+ export const UNINSTALL_INSTRUCTION = "If you used the earlier Balladeer on this machine: run `balladeer uninstall`, then the removal command it prints, then continue.";
11
+ /** How much of a foreign command line is worth printing back. */
12
+ const DETAIL_LIMIT = 120;
13
+ /**
14
+ * Where the agent host keeps its configuration, read from the environment this
15
+ * command was handed and never from the ambient process.
16
+ *
17
+ * Every command in this package takes its configuration as a parameter, which is
18
+ * what lets a test plant a whole home directory. When the environment names no
19
+ * home there is nothing to read, and a check that cannot read cannot warn: it
20
+ * stays silent rather than guessing at a path.
21
+ */
22
+ function agentHome(environment) {
23
+ const home = environment.HOME?.trim() || environment.USERPROFILE?.trim() || "";
24
+ return home.length > 0 ? home : undefined;
25
+ }
26
+ function readJson(path) {
27
+ try {
28
+ return JSON.parse(readFileSync(path, "utf8"));
29
+ }
30
+ catch {
31
+ // Absent, unreadable, or somebody's hand-edited file mid-save. None of those
32
+ // is evidence of an older install, and none of them is this command's to fix.
33
+ return undefined;
34
+ }
35
+ }
36
+ function bounded(text) {
37
+ const flattened = text.replace(/\s+/g, " ").trim();
38
+ return flattened.length > DETAIL_LIMIT ? `${flattened.slice(0, DETAIL_LIMIT)}...` : flattened;
39
+ }
40
+ /** The command line an MCP entry runs, in the shape a person would recognise. */
41
+ function describeEntry(entry) {
42
+ const record = entry;
43
+ if (record !== null && typeof record?.url === "string")
44
+ return bounded(record.url);
45
+ const command = typeof record?.command === "string" ? record.command : "";
46
+ const args = Array.isArray(record?.args)
47
+ ? record.args.filter((value) => typeof value === "string")
48
+ : [];
49
+ const line = [command, ...args].join(" ").trim();
50
+ return line.length > 0 ? bounded(line) : "a command this copy does not recognise";
51
+ }
52
+ /**
53
+ * Whether a hook's command line is one of ours.
54
+ *
55
+ * This product installs no agent-host hook at all, so in practice every hook
56
+ * naming `balladeer` belongs to the older client. The exception is written down
57
+ * anyway: a team that wired one of our own published or checkout invocations
58
+ * into a hook of their own must not be told their own line is somebody else's
59
+ * program.
60
+ */
61
+ function isOurCommandLine(command) {
62
+ if (/balladeer@(?:latest|\d+\.\d+\.\d+)\b/.test(command))
63
+ return true;
64
+ return /\bnode\b[^\n]*\bpackages[/\\]cli[/\\]dist[/\\]cli\.js\b/.test(command);
65
+ }
66
+ /** Every `command` string anywhere under an agent host's `hooks` setting. */
67
+ function hookCommands(value, found = []) {
68
+ if (Array.isArray(value)) {
69
+ for (const item of value)
70
+ hookCommands(item, found);
71
+ return found;
72
+ }
73
+ if (value === null || typeof value !== "object")
74
+ return found;
75
+ for (const [key, child] of Object.entries(value)) {
76
+ if (key === "command" && typeof child === "string")
77
+ found.push(child);
78
+ else
79
+ hookCommands(child, found);
80
+ }
81
+ return found;
82
+ }
83
+ /**
84
+ * The older Balladeer's own MCP entry, if the agent host still carries one.
85
+ *
86
+ * Only the machine-wide block counts. This product writes its entry into the
87
+ * repository's `.mcp.json`, and a host that also records a per-project entry
88
+ * under `projects` records ours there: reading those as foreign would stop
89
+ * setup on the very install it just performed. Under the machine-wide key, an
90
+ * entry named `balladeer` that is not one of our recognised shapes is the older
91
+ * client, because nothing else has reason to claim that name.
92
+ */
93
+ function legacyMcpEntry(home, controlPlane) {
94
+ const parsed = readJson(join(home, ".claude.json"));
95
+ const servers = parsed?.mcpServers;
96
+ if (servers === null || typeof servers !== "object")
97
+ return undefined;
98
+ const entry = servers.balladeer;
99
+ if (entry === undefined || entry === null)
100
+ return undefined;
101
+ if (isOurEntry(entry, controlPlane))
102
+ return undefined;
103
+ return {
104
+ where: join(home, ".claude.json"),
105
+ detail: `an MCP server named balladeer, registered for the whole machine, running ${describeEntry(entry)}`,
106
+ };
107
+ }
108
+ /** The older Balladeer's session hook, if the agent host still runs one. */
109
+ function legacyHook(home) {
110
+ const parsed = readJson(join(home, ".claude", "settings.json"));
111
+ const hooks = parsed?.hooks;
112
+ if (hooks === undefined)
113
+ return undefined;
114
+ const command = hookCommands(hooks).find((line) => /balladeer/i.test(line) && !isOurCommandLine(line));
115
+ if (command === undefined)
116
+ return undefined;
117
+ return {
118
+ where: join(home, ".claude", "settings.json"),
119
+ detail: `a hook that runs ${bounded(command)}`,
120
+ };
121
+ }
122
+ /**
123
+ * The older install this machine still carries, or nothing.
124
+ *
125
+ * Two places, because those are the two the older client owns that reach into a
126
+ * session in a repository enrolled today: the machine-wide MCP entry under our
127
+ * own name, and the hook that fires at session start.
128
+ */
129
+ export function findLegacyInstall(environment, controlPlane) {
130
+ const home = agentHome(environment);
131
+ if (home === undefined)
132
+ return undefined;
133
+ return legacyMcpEntry(home, controlPlane) ?? legacyHook(home);
134
+ }
135
+ /** What the person reads, whichever of the two files said so. */
136
+ export function legacyMessage(found) {
137
+ return [
138
+ `An earlier Balladeer is still installed on this machine: ${found.where} carries ${found.detail}.`,
139
+ "Both products register an MCP server named balladeer and the earlier one keeps firing its own session hook, so leaving it in place puts two Balladeers in the sessions you open in this repository.",
140
+ UNINSTALL_INSTRUCTION,
141
+ "Nothing was changed. Run this command again with --force to set up anyway.",
142
+ ].join("\n");
143
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Every absolute time this command prints for a person, on the clock they read.
3
+ *
4
+ * Robert approved a pairing code that had already lapsed. The screen said the
5
+ * code stopped being approvable at a time four hours ahead of the one on his
6
+ * wall, and named no zone, so he read a moment that had gone as a moment still
7
+ * to come. `apps/control-plane/lib/local-time.ts` fixed that on the web pages
8
+ * and says why: an unlabelled time is not a shorter time, it is a different one.
9
+ *
10
+ * The terminal had no equivalent, and it prints the same deadlines: the pairing
11
+ * code, the setup session, the invitation, when a map was measured, when a run
12
+ * observed a failure. Every one of them was a UTC instant in a sentence a person
13
+ * reads. So nothing here formats an instant without saying which clock it is on.
14
+ *
15
+ * Unlike a server render, this one always knows: the command runs on the
16
+ * reader's own machine, so there is a single shape rather than the web's pair of
17
+ * them, and it is the shape the web already shows a signed-in viewer, so a
18
+ * person who reads a deadline in the terminal and again on the promise page
19
+ * reads it written the same way both times.
20
+ *
21
+ * It is a copy of the control plane's formatter rather than an import of it.
22
+ * This package is published to npm on its own and must not carry the web
23
+ * application behind it; the rule the two share is the one in the guard tests,
24
+ * which hold both surfaces to the same sentence.
25
+ *
26
+ * Machine-readable output is not this module's business. `--json` steps carry
27
+ * the ISO instant exactly as Balladeer sent it, because the reader there is a
28
+ * program with its own clock and its own zone.
29
+ */
30
+ /** What a stored value that is not a time says, rather than "Invalid Date". */
31
+ export declare const UNREADABLE_TIME = "an unrecorded time";
32
+ /**
33
+ * The zone this machine is set to.
34
+ *
35
+ * Every reach for `Intl` in the command is in this one file, which is what lets
36
+ * the guard test state the rule as a scan rather than as a list of sentences
37
+ * somebody has to remember to extend.
38
+ */
39
+ export declare function machineTimeZone(): string;
40
+ /**
41
+ * One instant, in the reader's own zone, with the zone named beside it.
42
+ *
43
+ * `timeZoneName: "short"` is the load-bearing option. Remove it and every
44
+ * sentence below silently goes back to printing a bare wall clock, which is the
45
+ * bug this module exists for, so the guard test asserts a zone token on the end
46
+ * of the output in every zone it checks.
47
+ *
48
+ * The zone is a parameter so the tests can read one instant on several clocks.
49
+ * No caller passes one: the machine's own zone is the answer for a command that
50
+ * runs on the reader's machine.
51
+ */
52
+ export declare function formatInstant(value: string, timeZone?: string): string;
53
+ /**
54
+ * A sentence Balladeer wrote, with the instants in it moved onto the reader's
55
+ * clock.
56
+ *
57
+ * Some refusals are relayed to the terminal word for word, on purpose: the
58
+ * server's sentence names what to do next, and rewriting it here would lose the
59
+ * part the person has to act on. But the server has no idea where the reader is
60
+ * sitting, so it writes UTC, and "already prepared by Robert Clark on
61
+ * 2026-09-07T18:05:10.698Z" reaches somebody whose clock says 2:05 in the
62
+ * afternoon. This moves the instants and leaves every other word alone, so what
63
+ * an agent is told over the tool call and what a person is told at a terminal
64
+ * stay the same sentence.
65
+ */
66
+ export declare function inReadersZone(text: string, timeZone?: string): string;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Every absolute time this command prints for a person, on the clock they read.
3
+ *
4
+ * Robert approved a pairing code that had already lapsed. The screen said the
5
+ * code stopped being approvable at a time four hours ahead of the one on his
6
+ * wall, and named no zone, so he read a moment that had gone as a moment still
7
+ * to come. `apps/control-plane/lib/local-time.ts` fixed that on the web pages
8
+ * and says why: an unlabelled time is not a shorter time, it is a different one.
9
+ *
10
+ * The terminal had no equivalent, and it prints the same deadlines: the pairing
11
+ * code, the setup session, the invitation, when a map was measured, when a run
12
+ * observed a failure. Every one of them was a UTC instant in a sentence a person
13
+ * reads. So nothing here formats an instant without saying which clock it is on.
14
+ *
15
+ * Unlike a server render, this one always knows: the command runs on the
16
+ * reader's own machine, so there is a single shape rather than the web's pair of
17
+ * them, and it is the shape the web already shows a signed-in viewer, so a
18
+ * person who reads a deadline in the terminal and again on the promise page
19
+ * reads it written the same way both times.
20
+ *
21
+ * It is a copy of the control plane's formatter rather than an import of it.
22
+ * This package is published to npm on its own and must not carry the web
23
+ * application behind it; the rule the two share is the one in the guard tests,
24
+ * which hold both surfaces to the same sentence.
25
+ *
26
+ * Machine-readable output is not this module's business. `--json` steps carry
27
+ * the ISO instant exactly as Balladeer sent it, because the reader there is a
28
+ * program with its own clock and its own zone.
29
+ */
30
+ /** What a stored value that is not a time says, rather than "Invalid Date". */
31
+ export const UNREADABLE_TIME = "an unrecorded time";
32
+ /**
33
+ * The zone this machine is set to.
34
+ *
35
+ * Every reach for `Intl` in the command is in this one file, which is what lets
36
+ * the guard test state the rule as a scan rather than as a list of sentences
37
+ * somebody has to remember to extend.
38
+ */
39
+ export function machineTimeZone() {
40
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
41
+ }
42
+ /**
43
+ * One instant, in the reader's own zone, with the zone named beside it.
44
+ *
45
+ * `timeZoneName: "short"` is the load-bearing option. Remove it and every
46
+ * sentence below silently goes back to printing a bare wall clock, which is the
47
+ * bug this module exists for, so the guard test asserts a zone token on the end
48
+ * of the output in every zone it checks.
49
+ *
50
+ * The zone is a parameter so the tests can read one instant on several clocks.
51
+ * No caller passes one: the machine's own zone is the answer for a command that
52
+ * runs on the reader's machine.
53
+ */
54
+ export function formatInstant(value, timeZone = machineTimeZone()) {
55
+ const when = new Date(value);
56
+ if (Number.isNaN(when.valueOf()))
57
+ return UNREADABLE_TIME;
58
+ return new Intl.DateTimeFormat("en-US", {
59
+ month: "short",
60
+ day: "numeric",
61
+ hour: "numeric",
62
+ minute: "2-digit",
63
+ timeZone,
64
+ timeZoneName: "short",
65
+ }).format(when);
66
+ }
67
+ /** An instant as Balladeer writes one: ISO 8601, UTC, to the millisecond. */
68
+ const ISO_INSTANT = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g;
69
+ /**
70
+ * A sentence Balladeer wrote, with the instants in it moved onto the reader's
71
+ * clock.
72
+ *
73
+ * Some refusals are relayed to the terminal word for word, on purpose: the
74
+ * server's sentence names what to do next, and rewriting it here would lose the
75
+ * part the person has to act on. But the server has no idea where the reader is
76
+ * sitting, so it writes UTC, and "already prepared by Robert Clark on
77
+ * 2026-09-07T18:05:10.698Z" reaches somebody whose clock says 2:05 in the
78
+ * afternoon. This moves the instants and leaves every other word alone, so what
79
+ * an agent is told over the tool call and what a person is told at a terminal
80
+ * stay the same sentence.
81
+ */
82
+ export function inReadersZone(text, timeZone = machineTimeZone()) {
83
+ return text.replace(ISO_INSTANT, (instant) => formatInstant(instant, timeZone));
84
+ }
@@ -75,6 +75,16 @@ export declare function stdioEntry(repositoryId: string, publishedVersion: strin
75
75
  * Balladeer entry is foreign.
76
76
  */
77
77
  export declare function isOurEntry(entry: unknown, controlPlane: string): boolean;
78
+ /**
79
+ * Atomic, and never through a path something could have pre-planted: the
80
+ * temporary file is opened with `wx` under a random name in the same directory,
81
+ * flushed, and renamed over the target.
82
+ *
83
+ * Exported because the Claude desktop configuration is written under the same
84
+ * rule and for a stronger reason: that file belongs to a running application,
85
+ * and a half-written one is a chat client that starts with no servers at all.
86
+ */
87
+ export declare function writeJsonAtomically(path: string, contents: string): void;
78
88
  /**
79
89
  * Merges our entry into the repository's `.mcp.json`, or refuses.
80
90
  *
@@ -112,9 +112,13 @@ function renderBlock(entry) {
112
112
  * Atomic, and never through a path something could have pre-planted: the
113
113
  * temporary file is opened with `wx` under a random name in the same directory,
114
114
  * flushed, and renamed over the target.
115
+ *
116
+ * Exported because the Claude desktop configuration is written under the same
117
+ * rule and for a stronger reason: that file belongs to a running application,
118
+ * and a half-written one is a chat client that starts with no servers at all.
115
119
  */
116
- function writeAtomically(path, contents) {
117
- const temporary = join(dirname(path), `.mcp.${randomBytes(8).toString("hex")}.tmp`);
120
+ export function writeJsonAtomically(path, contents) {
121
+ const temporary = join(dirname(path), `.balladeer.${randomBytes(8).toString("hex")}.tmp`);
118
122
  let descriptor;
119
123
  try {
120
124
  descriptor = openSync(temporary, "wx", 0o644);
@@ -157,7 +161,7 @@ export function mergeMcpConfig(repositoryRoot, entry, controlPlane) {
157
161
  existing = readFileSync(path, "utf8");
158
162
  }
159
163
  catch {
160
- writeAtomically(path, `${JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2)}\n`);
164
+ writeJsonAtomically(path, `${JSON.stringify({ mcpServers: { balladeer: entry } }, null, 2)}\n`);
161
165
  return { kind: "written", changed: true };
162
166
  }
163
167
  let parsed;
@@ -192,7 +196,7 @@ export function mergeMcpConfig(repositoryRoot, entry, controlPlane) {
192
196
  const next = `${JSON.stringify({ ...parsed, mcpServers: servers }, null, 2)}\n`;
193
197
  if (next === existing)
194
198
  return { kind: "written", changed: false };
195
- writeAtomically(path, next);
199
+ writeJsonAtomically(path, next);
196
200
  return { kind: "written", changed: true };
197
201
  }
198
202
  /**