balladeer 1.0.8 → 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.
@@ -1,8 +1,8 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
4
  import { parse } from "@iarna/toml";
5
- import { PUBLISHED_SPECIFIER, checkoutEntryPath } from "./release.js";
5
+ import { HOOK_SPECIFIER, checkoutEntryPath } from "./release.js";
6
6
  import { writeJsonAtomically } from "./mcp-config.js";
7
7
  /**
8
8
  * Install Balladeer once per laptop.
@@ -22,19 +22,29 @@ import { writeJsonAtomically } from "./mcp-config.js";
22
22
  */
23
23
  export const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
24
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) {
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: [] };
29
39
  return published
30
- ? { command: "npx", args: ["-y", PUBLISHED_SPECIFIER] }
40
+ ? { command: "npx", args: ["-y", HOOK_SPECIFIER] }
31
41
  : { command: "node", args: [checkoutEntryPath()] };
32
42
  }
33
43
  function ordinaryFile(path) {
34
44
  return !existsSync(path) || (lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink());
35
45
  }
36
46
  /** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
37
- export function mergeClaudeUserMcp(home, published) {
47
+ export function mergeClaudeUserMcp(home, published, installed) {
38
48
  const path = join(home, ".claude.json");
39
49
  const host = "claude";
40
50
  if (!ordinaryFile(path))
@@ -54,7 +64,7 @@ export function mergeClaudeUserMcp(home, published) {
54
64
  const servers = root.mcpServers && typeof root.mcpServers === "object" && !Array.isArray(root.mcpServers)
55
65
  ? root.mcpServers
56
66
  : {};
57
- const { command, args } = userScopeCommand(published);
67
+ const { command, args } = userScopeCommand(published, installed);
58
68
  const expected = { type: "stdio", command, args: [...args, "mcp"] };
59
69
  const current = servers.balladeer;
60
70
  if (current !== undefined && !isOurUserEntry(current)) {
@@ -84,14 +94,37 @@ export function isOurUserEntry(entry) {
84
94
  if (record.command === "npx")
85
95
  return (args.length === 3 &&
86
96
  args[0] === "-y" &&
87
- /^balladeer@(?:latest|\d+\.\d+\.\d+)$/.test(args[1]) &&
97
+ /^balladeer@(?:latest|1|\d+\.\d+\.\d+)$/.test(args[1]) &&
88
98
  args[2] === "mcp");
89
99
  if (record.command === "node" || String(record.command).endsWith("/node"))
90
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";
91
107
  return false;
92
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
+ }
93
126
  /** `~/.claude/settings.json` carries user-scope hooks. */
94
- export function mergeClaudeUserHooks(home, published) {
127
+ export function mergeClaudeUserHooks(home, published, installed) {
95
128
  const path = join(home, ".claude", "settings.json");
96
129
  const host = "claude";
97
130
  if (!ordinaryFile(path))
@@ -111,7 +144,7 @@ export function mergeClaudeUserHooks(home, published) {
111
144
  const hooks = root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
112
145
  ? { ...root.hooks }
113
146
  : {};
114
- const { command, args } = userScopeCommand(published);
147
+ const { command, args } = userScopeCommand(published, installed);
115
148
  const line = [command, ...args, "guidance", "--hook", "claude"].join(" ");
116
149
  let changed = false;
117
150
  for (const event of CLAUDE_EVENTS) {
@@ -149,7 +182,7 @@ const CODEX_START = "# balladeer:user:start";
149
182
  const CODEX_END = "# balladeer:user:end";
150
183
  /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
151
184
  * one fenced block this command owns end to end. */
152
- export function mergeCodexUserConfig(home, published) {
185
+ export function mergeCodexUserConfig(home, published, installed) {
153
186
  const path = join(home, ".codex", "config.toml");
154
187
  const host = "codex";
155
188
  if (!ordinaryFile(path))
@@ -163,7 +196,7 @@ export function mergeCodexUserConfig(home, published) {
163
196
  catch {
164
197
  return { host, path, status: "refused", reason: "could not read valid TOML" };
165
198
  }
166
- const { command, args } = userScopeCommand(published);
199
+ const { command, args } = userScopeCommand(published, installed);
167
200
  const line = [command, ...args, "guidance", "--hook", "codex"].join(" ");
168
201
  const block = [
169
202
  CODEX_START,
@@ -225,10 +258,10 @@ export function userHome(environment = process.env) {
225
258
  export function installUserScope(options) {
226
259
  const home = options.home ?? userHome(options.environment);
227
260
  const writes = [
228
- mergeClaudeUserMcp(home, options.published),
229
- mergeClaudeUserHooks(home, options.published),
261
+ mergeClaudeUserMcp(home, options.published, options.installed),
262
+ mergeClaudeUserHooks(home, options.published, options.installed),
230
263
  ];
231
264
  if (existsSync(join(home, ".codex")))
232
- writes.push(mergeCodexUserConfig(home, options.published));
265
+ writes.push(mergeCodexUserConfig(home, options.published, options.installed));
233
266
  return writes;
234
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.8";
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.8";
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.8";
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.8",
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,