balladeer 1.0.7 → 1.0.11

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,71 @@
1
+ /**
2
+ * Install Balladeer once per laptop.
3
+ *
4
+ * Setup used to write two files into one repository checkout, uncommitted: an
5
+ * `.mcp.json` entry and a hook in `.claude/settings.json`. A coding host lists
6
+ * a project server only when that exact folder is open, so every other clone,
7
+ * every worktree and every teammate saw nothing, and `/mcp` on two laptops
8
+ * showed only the legacy server. This registers the same server and the same
9
+ * hook at the host's user scope instead: one entry that runs in every session,
10
+ * and decides at run time, from the folder's git remote, which connection it
11
+ * is for. Nothing to commit and nothing per folder.
12
+ *
13
+ * Every write here is an additive merge that refuses to touch an entry it does
14
+ * not own, exactly as the project writers do. What it owns is recognised by
15
+ * shape, never by trust in the name.
16
+ */
17
+ export declare const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
18
+ export type UserScopeHost = "claude" | "codex";
19
+ export type UserScopeWrite = Readonly<{
20
+ host: UserScopeHost;
21
+ path: string;
22
+ status: "current" | "written" | "refused";
23
+ reason?: string;
24
+ }>;
25
+ /**
26
+ * The command a host runs to reach Balladeer, with no repository named: the
27
+ * directory decides.
28
+ *
29
+ * With a durable command installed, the entries name it by absolute path:
30
+ * no npx, so a session start costs node's startup and nothing else, and no
31
+ * PATH, so a host launched from the dock finds it. A laptop where the durable
32
+ * install was refused gets the `npx` form on the current major, which works
33
+ * everywhere and costs a registry lookup per session. The checkout form
34
+ * exists so this repository's own tests can point a host at source.
35
+ */
36
+ export declare function userScopeCommand(published: boolean, installed?: string): {
37
+ command: string;
38
+ args: string[];
39
+ };
40
+ /** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
41
+ export declare function mergeClaudeUserMcp(home: string, published: boolean, installed?: string): UserScopeWrite;
42
+ /** Ours when it runs `npx` on our published specifier, or `node` on this
43
+ * checkout's entry file, with `mcp` as the subcommand and no repository named:
44
+ * a repository argument at user scope would pin every folder to one connection. */
45
+ export declare function isOurUserEntry(entry: unknown): boolean;
46
+ /**
47
+ * Where `ensureInstalled` puts the durable command: Volta's shim directory, an
48
+ * npm global prefix's `bin` with the package beside it under `lib/node_modules`,
49
+ * or this product's own fallback prefix under the person's home.
50
+ */
51
+ export declare function isInstalledCommandPath(command: string): boolean;
52
+ /** `~/.claude/settings.json` carries user-scope hooks. */
53
+ export declare function mergeClaudeUserHooks(home: string, published: boolean, installed?: string): UserScopeWrite;
54
+ /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
55
+ * one fenced block this command owns end to end. */
56
+ export declare function mergeCodexUserConfig(home: string, published: boolean, installed?: string): UserScopeWrite;
57
+ /** A copy of this command running out of a source checkout registers the
58
+ * checkout; any installed or npx copy registers the published package. */
59
+ export declare function runningFromCheckout(entry?: string): boolean;
60
+ /** The home whose host files get the entries. `BALLADEER_USER_HOME` exists for
61
+ * this repository's own tests, which cannot move HOME under a Volta-managed
62
+ * Node without losing Node. */
63
+ export declare function userHome(environment?: NodeJS.ProcessEnv): string;
64
+ /** Register both hosts. Codex is skipped, not refused, on a laptop without it. */
65
+ export declare function installUserScope(options: {
66
+ environment: NodeJS.ProcessEnv;
67
+ published: boolean;
68
+ home?: string;
69
+ /** The durable command's absolute path, when one is installed. */
70
+ installed?: string;
71
+ }): UserScopeWrite[];
@@ -0,0 +1,267 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { parse } from "@iarna/toml";
5
+ import { HOOK_SPECIFIER, checkoutEntryPath } from "./release.js";
6
+ import { writeJsonAtomically } from "./mcp-config.js";
7
+ /**
8
+ * Install Balladeer once per laptop.
9
+ *
10
+ * Setup used to write two files into one repository checkout, uncommitted: an
11
+ * `.mcp.json` entry and a hook in `.claude/settings.json`. A coding host lists
12
+ * a project server only when that exact folder is open, so every other clone,
13
+ * every worktree and every teammate saw nothing, and `/mcp` on two laptops
14
+ * showed only the legacy server. This registers the same server and the same
15
+ * hook at the host's user scope instead: one entry that runs in every session,
16
+ * and decides at run time, from the folder's git remote, which connection it
17
+ * is for. Nothing to commit and nothing per folder.
18
+ *
19
+ * Every write here is an additive merge that refuses to touch an entry it does
20
+ * not own, exactly as the project writers do. What it owns is recognised by
21
+ * shape, never by trust in the name.
22
+ */
23
+ export const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
24
+ const CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart"];
25
+ /**
26
+ * The command a host runs to reach Balladeer, with no repository named: the
27
+ * directory decides.
28
+ *
29
+ * With a durable command installed, the entries name it by absolute path:
30
+ * no npx, so a session start costs node's startup and nothing else, and no
31
+ * PATH, so a host launched from the dock finds it. A laptop where the durable
32
+ * install was refused gets the `npx` form on the current major, which works
33
+ * everywhere and costs a registry lookup per session. The checkout form
34
+ * exists so this repository's own tests can point a host at source.
35
+ */
36
+ export function userScopeCommand(published, installed) {
37
+ if (installed !== undefined)
38
+ return { command: installed, args: [] };
39
+ return published
40
+ ? { command: "npx", args: ["-y", HOOK_SPECIFIER] }
41
+ : { command: "node", args: [checkoutEntryPath()] };
42
+ }
43
+ function ordinaryFile(path) {
44
+ return !existsSync(path) || (lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink());
45
+ }
46
+ /** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
47
+ export function mergeClaudeUserMcp(home, published, installed) {
48
+ const path = join(home, ".claude.json");
49
+ const host = "claude";
50
+ if (!ordinaryFile(path))
51
+ return { host, path, status: "refused", reason: "not an ordinary file" };
52
+ let root = {};
53
+ if (existsSync(path)) {
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
56
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
57
+ throw new Error();
58
+ root = parsed;
59
+ }
60
+ catch {
61
+ return { host, path, status: "refused", reason: "could not read valid JSON" };
62
+ }
63
+ }
64
+ const servers = root.mcpServers && typeof root.mcpServers === "object" && !Array.isArray(root.mcpServers)
65
+ ? root.mcpServers
66
+ : {};
67
+ const { command, args } = userScopeCommand(published, installed);
68
+ const expected = { type: "stdio", command, args: [...args, "mcp"] };
69
+ const current = servers.balladeer;
70
+ if (current !== undefined && !isOurUserEntry(current)) {
71
+ return {
72
+ host,
73
+ path,
74
+ status: "refused",
75
+ reason: "an entry named balladeer that is not Balladeer's is already there",
76
+ };
77
+ }
78
+ if (current !== undefined && JSON.stringify(current) === JSON.stringify(expected))
79
+ return { host, path, status: "current" };
80
+ root.mcpServers = { ...servers, balladeer: expected };
81
+ writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
82
+ return { host, path, status: "written" };
83
+ }
84
+ /** Ours when it runs `npx` on our published specifier, or `node` on this
85
+ * checkout's entry file, with `mcp` as the subcommand and no repository named:
86
+ * a repository argument at user scope would pin every folder to one connection. */
87
+ export function isOurUserEntry(entry) {
88
+ if (!entry || typeof entry !== "object")
89
+ return false;
90
+ const record = entry;
91
+ if (!Array.isArray(record.args) || record.args.some((a) => typeof a !== "string"))
92
+ return false;
93
+ const args = record.args;
94
+ if (record.command === "npx")
95
+ return (args.length === 3 &&
96
+ args[0] === "-y" &&
97
+ /^balladeer@(?:latest|1|\d+\.\d+\.\d+)$/.test(args[1]) &&
98
+ args[2] === "mcp");
99
+ if (record.command === "node" || String(record.command).endsWith("/node"))
100
+ return args.length === 2 && args[0] === checkoutEntryPath() && args[1] === "mcp";
101
+ // The durable command by absolute path: ours when it sits where this
102
+ // product's installer puts it and is asked for `mcp` alone. The July client
103
+ // also registered an absolute `balladeer mcp`, from its own home, so the
104
+ // name is not enough; the place is what says whose it is.
105
+ if (typeof record.command === "string" && isAbsolute(record.command))
106
+ return isInstalledCommandPath(record.command) && args.length === 1 && args[0] === "mcp";
107
+ return false;
108
+ }
109
+ /**
110
+ * Where `ensureInstalled` puts the durable command: Volta's shim directory, an
111
+ * npm global prefix's `bin` with the package beside it under `lib/node_modules`,
112
+ * or this product's own fallback prefix under the person's home.
113
+ */
114
+ export function isInstalledCommandPath(command) {
115
+ if (basename(command) !== "balladeer")
116
+ return false;
117
+ const normalized = command.replaceAll("\\", "/");
118
+ if (/\/\.volta\/bin\/balladeer$/.test(normalized))
119
+ return true;
120
+ if (/\/\.local\/share\/balladeer\/npm\/bin\/balladeer$/.test(normalized))
121
+ return true;
122
+ const bin = dirname(command);
123
+ return (basename(bin) === "bin" &&
124
+ existsSync(join(dirname(bin), "lib", "node_modules", "balladeer", "package.json")));
125
+ }
126
+ /** `~/.claude/settings.json` carries user-scope hooks. */
127
+ export function mergeClaudeUserHooks(home, published, installed) {
128
+ const path = join(home, ".claude", "settings.json");
129
+ const host = "claude";
130
+ if (!ordinaryFile(path))
131
+ return { host, path, status: "refused", reason: "not an ordinary file" };
132
+ let root = {};
133
+ if (existsSync(path)) {
134
+ try {
135
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
136
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
137
+ throw new Error();
138
+ root = parsed;
139
+ }
140
+ catch {
141
+ return { host, path, status: "refused", reason: "could not read valid JSON" };
142
+ }
143
+ }
144
+ const hooks = root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
145
+ ? { ...root.hooks }
146
+ : {};
147
+ const { command, args } = userScopeCommand(published, installed);
148
+ const line = [command, ...args, "guidance", "--hook", "claude"].join(" ");
149
+ let changed = false;
150
+ for (const event of CLAUDE_EVENTS) {
151
+ const rows = hooks[event] === undefined ? [] : hooks[event];
152
+ if (!Array.isArray(rows))
153
+ return { host, path, status: "refused", reason: `hooks.${event} is not a list` };
154
+ const own = rows.filter((row) => row?.hooks?.some((h) => h.statusMessage === USER_SCOPE_OWNER));
155
+ const expected = {
156
+ hooks: [
157
+ {
158
+ type: "command",
159
+ command: `${line} --event ${event}`,
160
+ // npx resolves from its cache in well under this; the first run on
161
+ // a laptop that has never fetched the package needs the network.
162
+ timeout: 10,
163
+ statusMessage: USER_SCOPE_OWNER,
164
+ },
165
+ ],
166
+ };
167
+ if (own.length > 1)
168
+ return { host, path, status: "refused", reason: `two Balladeer hooks under ${event}` };
169
+ if (own.length === 1 && JSON.stringify(own[0]) === JSON.stringify(expected))
170
+ continue;
171
+ hooks[event] = [...rows.filter((row) => !own.includes(row)), expected];
172
+ changed = true;
173
+ }
174
+ if (!changed)
175
+ return { host, path, status: "current" };
176
+ root.hooks = hooks;
177
+ mkdirSync(dirname(path), { recursive: true });
178
+ writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
179
+ return { host, path, status: "written" };
180
+ }
181
+ const CODEX_START = "# balladeer:user:start";
182
+ const CODEX_END = "# balladeer:user:end";
183
+ /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
184
+ * one fenced block this command owns end to end. */
185
+ export function mergeCodexUserConfig(home, published, installed) {
186
+ const path = join(home, ".codex", "config.toml");
187
+ const host = "codex";
188
+ if (!ordinaryFile(path))
189
+ return { host, path, status: "refused", reason: "not an ordinary file" };
190
+ let previous = "";
191
+ try {
192
+ if (existsSync(path))
193
+ previous = readFileSync(path, "utf8");
194
+ parse(previous);
195
+ }
196
+ catch {
197
+ return { host, path, status: "refused", reason: "could not read valid TOML" };
198
+ }
199
+ const { command, args } = userScopeCommand(published, installed);
200
+ const line = [command, ...args, "guidance", "--hook", "codex"].join(" ");
201
+ const block = [
202
+ CODEX_START,
203
+ "[mcp_servers.balladeer]",
204
+ `command = ${JSON.stringify(command)}`,
205
+ `args = ${JSON.stringify([...args, "mcp"])}`,
206
+ ...CLAUDE_EVENTS.flatMap((event) => [
207
+ `[[hooks.${event}]]`,
208
+ `[[hooks.${event}.hooks]]`,
209
+ 'type = "command"',
210
+ `command = ${JSON.stringify(`${line} --event ${event}`)}`,
211
+ "timeout = 10",
212
+ "additionalContextLimit = 0",
213
+ ]),
214
+ CODEX_END,
215
+ ].join("\n");
216
+ const starts = [...previous.matchAll(/^# balladeer:user:start\r?$/gm)];
217
+ const ends = [...previous.matchAll(/^# balladeer:user:end\r?$/gm)];
218
+ if (starts.length !== ends.length || starts.length > 1)
219
+ return { host, path, status: "refused", reason: "the Balladeer block is malformed" };
220
+ if (starts[0] && ends[0]) {
221
+ if (starts[0].index >= ends[0].index)
222
+ return { host, path, status: "refused", reason: "the Balladeer block is malformed" };
223
+ const old = previous.slice(starts[0].index, ends[0].index + CODEX_END.length);
224
+ if (old === block)
225
+ return { host, path, status: "current" };
226
+ const after = previous.slice(0, starts[0].index) + block + previous.slice(ends[0].index + CODEX_END.length);
227
+ parse(after);
228
+ mkdirSync(dirname(path), { recursive: true });
229
+ writeJsonAtomically(path, after);
230
+ return { host, path, status: "written" };
231
+ }
232
+ if (/^\[mcp_servers\.balladeer\]/m.test(previous))
233
+ return {
234
+ host,
235
+ path,
236
+ status: "refused",
237
+ reason: "an mcp_servers.balladeer table that is not Balladeer's is already there",
238
+ };
239
+ const after = previous + (previous.endsWith("\n") || previous === "" ? "" : "\n") + block + "\n";
240
+ parse(after);
241
+ mkdirSync(dirname(path), { recursive: true });
242
+ writeJsonAtomically(path, after);
243
+ return { host, path, status: "written" };
244
+ }
245
+ /** A copy of this command running out of a source checkout registers the
246
+ * checkout; any installed or npx copy registers the published package. */
247
+ export function runningFromCheckout(entry = process.argv[1] ?? "") {
248
+ const normalized = entry.replaceAll("\\", "/");
249
+ return normalized.includes("/packages/cli/dist/") && !normalized.includes("/node_modules/");
250
+ }
251
+ /** The home whose host files get the entries. `BALLADEER_USER_HOME` exists for
252
+ * this repository's own tests, which cannot move HOME under a Volta-managed
253
+ * Node without losing Node. */
254
+ export function userHome(environment = process.env) {
255
+ return resolve(environment.BALLADEER_USER_HOME?.trim() || environment.HOME?.trim() || homedir());
256
+ }
257
+ /** Register both hosts. Codex is skipped, not refused, on a laptop without it. */
258
+ export function installUserScope(options) {
259
+ const home = options.home ?? userHome(options.environment);
260
+ const writes = [
261
+ mergeClaudeUserMcp(home, options.published, options.installed),
262
+ mergeClaudeUserHooks(home, options.published, options.installed),
263
+ ];
264
+ if (existsSync(join(home, ".codex")))
265
+ writes.push(mergeCodexUserConfig(home, options.published, options.installed));
266
+ return writes;
267
+ }
package/dist/wire.d.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export declare const CLI_VERSION = "1.0.7";
8
+ export declare const CLI_VERSION = "1.0.11";
9
9
  export declare const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export declare const CLIENT_HEADER = "x-balladeer-client";
11
- export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.7";
11
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.11";
12
12
  export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
13
13
  export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
14
14
  export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
@@ -222,6 +222,19 @@ export type ClaudeDesktopStep = Readonly<{
222
222
  reason?: string;
223
223
  }>;
224
224
  export type JsonStep = Readonly<{
225
+ /** The earlier Balladeer found on this machine and what setup did about it. */
226
+ step: "earlier";
227
+ removed: readonly {
228
+ path: string;
229
+ found: string;
230
+ backup?: string;
231
+ }[];
232
+ failed: readonly {
233
+ path: string;
234
+ found: string;
235
+ reason: string;
236
+ }[];
237
+ }> | Readonly<{
225
238
  step: "guidance_loader";
226
239
  status: "installed_needs_host_trust" | "installed_trust_unverified" | "unavailable" | "not_applicable";
227
240
  changed: boolean;
package/dist/wire.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * runtime dependency at all: Node 22 builtins and global fetch, nothing else.
6
6
  * A contract test compares the scope list below against the server's.
7
7
  */
8
- export const CLI_VERSION = "1.0.7";
8
+ export const CLI_VERSION = "1.0.11";
9
9
  export const CLI_INVOCATION = "npx -y balladeer@latest";
10
10
  export const CLIENT_HEADER = "x-balladeer-client";
11
11
  export const CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balladeer",
3
- "version": "1.0.7",
3
+ "version": "1.0.11",
4
4
  "description": "Set up Balladeer from your terminal, or from a coding agent's.",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -22,7 +22,8 @@
22
22
  },
23
23
  "scripts": {
24
24
  "build": "tsc -p tsconfig.json && node build-guidance.mjs",
25
- "typecheck": "tsc -p tsconfig.json --noEmit"
25
+ "typecheck": "tsc -p tsconfig.json --noEmit",
26
+ "prepublishOnly": "pnpm --dir ../.. compat:release"
26
27
  },
27
28
  "devDependencies": {
28
29
  "typescript": "5.9.3",