balladeer 1.0.6 → 1.0.8

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,234 @@
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { parse } from "@iarna/toml";
5
+ import { PUBLISHED_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
+ /** The command a host runs to reach Balladeer, with no repository named: the
26
+ * directory decides. The published form is what a laptop gets; the checkout
27
+ * form exists so this repository's own tests can point a host at source. */
28
+ export function userScopeCommand(published) {
29
+ return published
30
+ ? { command: "npx", args: ["-y", PUBLISHED_SPECIFIER] }
31
+ : { command: "node", args: [checkoutEntryPath()] };
32
+ }
33
+ function ordinaryFile(path) {
34
+ return !existsSync(path) || (lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink());
35
+ }
36
+ /** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
37
+ export function mergeClaudeUserMcp(home, published) {
38
+ const path = join(home, ".claude.json");
39
+ const host = "claude";
40
+ if (!ordinaryFile(path))
41
+ return { host, path, status: "refused", reason: "not an ordinary file" };
42
+ let root = {};
43
+ if (existsSync(path)) {
44
+ try {
45
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
46
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
47
+ throw new Error();
48
+ root = parsed;
49
+ }
50
+ catch {
51
+ return { host, path, status: "refused", reason: "could not read valid JSON" };
52
+ }
53
+ }
54
+ const servers = root.mcpServers && typeof root.mcpServers === "object" && !Array.isArray(root.mcpServers)
55
+ ? root.mcpServers
56
+ : {};
57
+ const { command, args } = userScopeCommand(published);
58
+ const expected = { type: "stdio", command, args: [...args, "mcp"] };
59
+ const current = servers.balladeer;
60
+ if (current !== undefined && !isOurUserEntry(current)) {
61
+ return {
62
+ host,
63
+ path,
64
+ status: "refused",
65
+ reason: "an entry named balladeer that is not Balladeer's is already there",
66
+ };
67
+ }
68
+ if (current !== undefined && JSON.stringify(current) === JSON.stringify(expected))
69
+ return { host, path, status: "current" };
70
+ root.mcpServers = { ...servers, balladeer: expected };
71
+ writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
72
+ return { host, path, status: "written" };
73
+ }
74
+ /** Ours when it runs `npx` on our published specifier, or `node` on this
75
+ * checkout's entry file, with `mcp` as the subcommand and no repository named:
76
+ * a repository argument at user scope would pin every folder to one connection. */
77
+ export function isOurUserEntry(entry) {
78
+ if (!entry || typeof entry !== "object")
79
+ return false;
80
+ const record = entry;
81
+ if (!Array.isArray(record.args) || record.args.some((a) => typeof a !== "string"))
82
+ return false;
83
+ const args = record.args;
84
+ if (record.command === "npx")
85
+ return (args.length === 3 &&
86
+ args[0] === "-y" &&
87
+ /^balladeer@(?:latest|\d+\.\d+\.\d+)$/.test(args[1]) &&
88
+ args[2] === "mcp");
89
+ if (record.command === "node" || String(record.command).endsWith("/node"))
90
+ return args.length === 2 && args[0] === checkoutEntryPath() && args[1] === "mcp";
91
+ return false;
92
+ }
93
+ /** `~/.claude/settings.json` carries user-scope hooks. */
94
+ export function mergeClaudeUserHooks(home, published) {
95
+ const path = join(home, ".claude", "settings.json");
96
+ const host = "claude";
97
+ if (!ordinaryFile(path))
98
+ return { host, path, status: "refused", reason: "not an ordinary file" };
99
+ let root = {};
100
+ if (existsSync(path)) {
101
+ try {
102
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
103
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
104
+ throw new Error();
105
+ root = parsed;
106
+ }
107
+ catch {
108
+ return { host, path, status: "refused", reason: "could not read valid JSON" };
109
+ }
110
+ }
111
+ const hooks = root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
112
+ ? { ...root.hooks }
113
+ : {};
114
+ const { command, args } = userScopeCommand(published);
115
+ const line = [command, ...args, "guidance", "--hook", "claude"].join(" ");
116
+ let changed = false;
117
+ for (const event of CLAUDE_EVENTS) {
118
+ const rows = hooks[event] === undefined ? [] : hooks[event];
119
+ if (!Array.isArray(rows))
120
+ return { host, path, status: "refused", reason: `hooks.${event} is not a list` };
121
+ const own = rows.filter((row) => row?.hooks?.some((h) => h.statusMessage === USER_SCOPE_OWNER));
122
+ const expected = {
123
+ hooks: [
124
+ {
125
+ type: "command",
126
+ command: `${line} --event ${event}`,
127
+ // npx resolves from its cache in well under this; the first run on
128
+ // a laptop that has never fetched the package needs the network.
129
+ timeout: 10,
130
+ statusMessage: USER_SCOPE_OWNER,
131
+ },
132
+ ],
133
+ };
134
+ if (own.length > 1)
135
+ return { host, path, status: "refused", reason: `two Balladeer hooks under ${event}` };
136
+ if (own.length === 1 && JSON.stringify(own[0]) === JSON.stringify(expected))
137
+ continue;
138
+ hooks[event] = [...rows.filter((row) => !own.includes(row)), expected];
139
+ changed = true;
140
+ }
141
+ if (!changed)
142
+ return { host, path, status: "current" };
143
+ root.hooks = hooks;
144
+ mkdirSync(dirname(path), { recursive: true });
145
+ writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
146
+ return { host, path, status: "written" };
147
+ }
148
+ const CODEX_START = "# balladeer:user:start";
149
+ const CODEX_END = "# balladeer:user:end";
150
+ /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
151
+ * one fenced block this command owns end to end. */
152
+ export function mergeCodexUserConfig(home, published) {
153
+ const path = join(home, ".codex", "config.toml");
154
+ const host = "codex";
155
+ if (!ordinaryFile(path))
156
+ return { host, path, status: "refused", reason: "not an ordinary file" };
157
+ let previous = "";
158
+ try {
159
+ if (existsSync(path))
160
+ previous = readFileSync(path, "utf8");
161
+ parse(previous);
162
+ }
163
+ catch {
164
+ return { host, path, status: "refused", reason: "could not read valid TOML" };
165
+ }
166
+ const { command, args } = userScopeCommand(published);
167
+ const line = [command, ...args, "guidance", "--hook", "codex"].join(" ");
168
+ const block = [
169
+ CODEX_START,
170
+ "[mcp_servers.balladeer]",
171
+ `command = ${JSON.stringify(command)}`,
172
+ `args = ${JSON.stringify([...args, "mcp"])}`,
173
+ ...CLAUDE_EVENTS.flatMap((event) => [
174
+ `[[hooks.${event}]]`,
175
+ `[[hooks.${event}.hooks]]`,
176
+ 'type = "command"',
177
+ `command = ${JSON.stringify(`${line} --event ${event}`)}`,
178
+ "timeout = 10",
179
+ "additionalContextLimit = 0",
180
+ ]),
181
+ CODEX_END,
182
+ ].join("\n");
183
+ const starts = [...previous.matchAll(/^# balladeer:user:start\r?$/gm)];
184
+ const ends = [...previous.matchAll(/^# balladeer:user:end\r?$/gm)];
185
+ if (starts.length !== ends.length || starts.length > 1)
186
+ return { host, path, status: "refused", reason: "the Balladeer block is malformed" };
187
+ if (starts[0] && ends[0]) {
188
+ if (starts[0].index >= ends[0].index)
189
+ return { host, path, status: "refused", reason: "the Balladeer block is malformed" };
190
+ const old = previous.slice(starts[0].index, ends[0].index + CODEX_END.length);
191
+ if (old === block)
192
+ return { host, path, status: "current" };
193
+ const after = previous.slice(0, starts[0].index) + block + previous.slice(ends[0].index + CODEX_END.length);
194
+ parse(after);
195
+ mkdirSync(dirname(path), { recursive: true });
196
+ writeJsonAtomically(path, after);
197
+ return { host, path, status: "written" };
198
+ }
199
+ if (/^\[mcp_servers\.balladeer\]/m.test(previous))
200
+ return {
201
+ host,
202
+ path,
203
+ status: "refused",
204
+ reason: "an mcp_servers.balladeer table that is not Balladeer's is already there",
205
+ };
206
+ const after = previous + (previous.endsWith("\n") || previous === "" ? "" : "\n") + block + "\n";
207
+ parse(after);
208
+ mkdirSync(dirname(path), { recursive: true });
209
+ writeJsonAtomically(path, after);
210
+ return { host, path, status: "written" };
211
+ }
212
+ /** A copy of this command running out of a source checkout registers the
213
+ * checkout; any installed or npx copy registers the published package. */
214
+ export function runningFromCheckout(entry = process.argv[1] ?? "") {
215
+ const normalized = entry.replaceAll("\\", "/");
216
+ return normalized.includes("/packages/cli/dist/") && !normalized.includes("/node_modules/");
217
+ }
218
+ /** The home whose host files get the entries. `BALLADEER_USER_HOME` exists for
219
+ * this repository's own tests, which cannot move HOME under a Volta-managed
220
+ * Node without losing Node. */
221
+ export function userHome(environment = process.env) {
222
+ return resolve(environment.BALLADEER_USER_HOME?.trim() || environment.HOME?.trim() || homedir());
223
+ }
224
+ /** Register both hosts. Codex is skipped, not refused, on a laptop without it. */
225
+ export function installUserScope(options) {
226
+ const home = options.home ?? userHome(options.environment);
227
+ const writes = [
228
+ mergeClaudeUserMcp(home, options.published),
229
+ mergeClaudeUserHooks(home, options.published),
230
+ ];
231
+ if (existsSync(join(home, ".codex")))
232
+ writes.push(mergeCodexUserConfig(home, options.published));
233
+ return writes;
234
+ }
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.6";
8
+ export declare const CLI_VERSION = "1.0.8";
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.6";
11
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.8";
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,12 @@ export type ClaudeDesktopStep = Readonly<{
222
222
  reason?: string;
223
223
  }>;
224
224
  export type JsonStep = Readonly<{
225
+ step: "guidance_loader";
226
+ status: "installed_needs_host_trust" | "installed_trust_unverified" | "unavailable" | "not_applicable";
227
+ changed: boolean;
228
+ hosts: readonly ("codex" | "claude")[];
229
+ reason?: string;
230
+ }> | Readonly<{
225
231
  step: "explain";
226
232
  version: string;
227
233
  }> | Readonly<{
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.6";
8
+ export const CLI_VERSION = "1.0.8";
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.6",
3
+ "version": "1.0.8",
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,
@@ -21,11 +21,13 @@
21
21
  "node": ">=22.0.0"
22
22
  },
23
23
  "scripts": {
24
- "build": "tsc -p tsconfig.json",
25
- "typecheck": "tsc -p tsconfig.json --noEmit"
24
+ "build": "tsc -p tsconfig.json && node build-guidance.mjs",
25
+ "typecheck": "tsc -p tsconfig.json --noEmit",
26
+ "prepublishOnly": "pnpm --dir ../.. compat:release"
26
27
  },
27
28
  "devDependencies": {
28
- "typescript": "5.9.3"
29
+ "typescript": "5.9.3",
30
+ "esbuild": "0.25.12"
29
31
  },
30
32
  "dependencies": {
31
33
  "@iarna/toml": "2.2.5"