balladeer 1.0.7 → 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.
package/dist/cli.d.ts CHANGED
@@ -19,6 +19,7 @@ type Parsed = Readonly<{
19
19
  */
20
20
  claudeDesktop: boolean | undefined;
21
21
  repo: string | undefined;
22
+ repoScope: boolean;
22
23
  file: string | undefined;
23
24
  repository: string | undefined;
24
25
  /**
package/dist/cli.js CHANGED
@@ -18,15 +18,24 @@ import { runStatus } from "./commands/status.js";
18
18
  import { runTouchMap } from "./commands/touch-map.js";
19
19
  import { runWhoami } from "./commands/whoami.js";
20
20
  import { runInstall } from "./install.js";
21
+ import { installUserScope, runningFromCheckout } from "./user-scope.js";
22
+ import { setQuiet } from "./quiet.js";
21
23
  import { updateNotice } from "./currency.js";
22
24
  import { StoreError, normalizeControlPlane } from "./store.js";
23
25
  import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
24
26
  const USAGE = `balladeer ${CLI_VERSION}
25
27
 
26
28
  ${CLI_INVOCATION} install [--json]
27
- Install this exact release as the durable balladeer command, without pairing
28
- or changing any workspace, repository, or CI configuration. Use the latest
29
- npx bootstrap to upgrade an older global command.
29
+ Install this exact release as the durable balladeer command, and register
30
+ Balladeer once for every Claude Code and Codex session on this machine:
31
+ it decides per folder, from the git remote, which connection applies.
32
+ Pairs nothing and changes no workspace, repository, or CI configuration.
33
+
34
+ ${CLI_INVOCATION} quiet [--repo]
35
+ ${CLI_INVOCATION} track [--repo]
36
+ In a folder Balladeer does not track, each session hears that once. quiet
37
+ stops it for this folder (or --repo, for every checkout of this
38
+ repository); track undoes it.
30
39
 
31
40
  ${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
32
41
  [--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
@@ -188,6 +197,7 @@ export function parseArguments(argv) {
188
197
  let runner;
189
198
  let controlPlane;
190
199
  let repo;
200
+ let repoScope = false;
191
201
  let file;
192
202
  let owner;
193
203
  let createWorkspace;
@@ -267,6 +277,8 @@ export function parseArguments(argv) {
267
277
  claudeDesktop = false;
268
278
  else if (name === "--control-plane")
269
279
  controlPlane = value("--control-plane", inline);
280
+ else if (name === "--repo" && (command === "quiet" || command === "track"))
281
+ repoScope = true;
270
282
  else if (name === "--repo")
271
283
  repo = value("--repo", inline);
272
284
  else if (name === "--file")
@@ -368,6 +380,7 @@ export function parseArguments(argv) {
368
380
  existingOnly,
369
381
  claudeDesktop,
370
382
  repo,
383
+ repoScope,
371
384
  file,
372
385
  repository: repositories[0],
373
386
  repositories,
@@ -409,8 +422,46 @@ async function dispatch(parsed, write) {
409
422
  switch (parsed.command) {
410
423
  case "explain":
411
424
  return runExplain(write, parsed.controlPlane);
412
- case "install":
413
- return runInstall({ environment: process.env, json: parsed.json, write });
425
+ case "install": {
426
+ // Once per laptop: the same server and hook, registered where every
427
+ // session on this machine reads them, deciding per folder at run time.
428
+ // Registered first and independently: the entries run through npx and
429
+ // need no durable command, so a blocked PATH must not keep a laptop
430
+ // from getting them.
431
+ const writes = installUserScope({
432
+ environment: process.env,
433
+ published: !runningFromCheckout(),
434
+ });
435
+ for (const w of writes)
436
+ if (!parsed.json)
437
+ write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
438
+ if (!parsed.json && writes.some((w) => w.status === "written"))
439
+ write("Balladeer now runs in every Claude Code" +
440
+ (writes.some((w) => w.host === "codex") ? " and Codex" : "") +
441
+ " session on this machine. In a folder of a connected repository it works as before; anywhere else it says once that the folder isn't tracked. Approve the new hook when your coding host asks.\n");
442
+ const code = runInstall({
443
+ environment: process.env,
444
+ json: parsed.json,
445
+ write,
446
+ extra: { userScope: writes },
447
+ });
448
+ return writes.some((w) => w.status === "refused") ? 4 : code;
449
+ }
450
+ case "quiet":
451
+ case "track": {
452
+ const scope = parsed.repoScope ? "repo" : "folder";
453
+ try {
454
+ const { key, changed } = setQuiet(process.cwd(), scope, parsed.command === "quiet", process.env);
455
+ write(parsed.command === "quiet"
456
+ ? `${changed ? "Balladeer will stay quiet" : "Balladeer was already quiet"} in ${scope === "repo" ? `every checkout of ${key}` : key}. Run \`balladeer track${scope === "repo" ? " --repo" : ""}\` here to undo.\n`
457
+ : `${changed ? "Balladeer will speak again" : "Balladeer was not quiet"} in ${scope === "repo" ? `checkouts of ${key}` : key}.\n`);
458
+ return 0;
459
+ }
460
+ catch (error) {
461
+ write(`${error instanceof Error ? error.message : String(error)}\n`);
462
+ return 4;
463
+ }
464
+ }
414
465
  case "setup":
415
466
  return runSetup({
416
467
  installCommand: () => {
@@ -1,4 +1,6 @@
1
1
  import { type GuidanceEvent } from "../guidance.js";
2
+ /** What a session hears, once, in a folder Balladeer is not tracking. */
3
+ export declare function untrackedLine(remote: string): string;
2
4
  export type GuidanceOptions = Readonly<{
3
5
  controlPlane: string;
4
6
  repositoryId?: string;
@@ -1,6 +1,17 @@
1
1
  import { validateProjectGuidanceScope } from "../guidance-install.js";
2
2
  import { GUIDANCE_UNAVAILABLE, loadGuidance, recordGuidanceContext, } from "../guidance.js";
3
3
  import { readCredentials } from "../store.js";
4
+ import { selectAgent } from "../agent.js";
5
+ import { repositoryHint } from "../repository.js";
6
+ import { isQuiet } from "../quiet.js";
7
+ import { PUBLISHED_SPECIFIER } from "../release.js";
8
+ /** What a session hears, once, in a folder Balladeer is not tracking. */
9
+ export function untrackedLine(remote) {
10
+ const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
11
+ return (`${here} isn't tracked by Balladeer, so promises can't be tracked from here. ` +
12
+ `To track it, run \`npx -y ${PUBLISHED_SPECIFIER} setup\` in this folder. ` +
13
+ `To stop this message here, run \`npx -y ${PUBLISHED_SPECIFIER} quiet\` (or \`quiet --repo\` for the whole repository).`);
14
+ }
4
15
  function readInput(stream) {
5
16
  return new Promise((resolve, reject) => {
6
17
  let bytes = 0;
@@ -51,8 +62,11 @@ function identifier(input) {
51
62
  }
52
63
  /** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
53
64
  export async function runGuidance(options) {
54
- // A globally invoked hook cannot use --repository to reach another project's connection.
55
- if (!options.repositoryId ||
65
+ // A project hook names its repository and must match the checkout it sits
66
+ // in. A user-scope hook names none: the folder's remote decides, and a folder
67
+ // with no connection hears that it is untracked rather than nothing at all.
68
+ const projectScoped = options.repositoryId !== undefined;
69
+ if (projectScoped &&
56
70
  !validateProjectGuidanceScope({
57
71
  cwd: options.cwd,
58
72
  hook: options.hook,
@@ -71,9 +85,22 @@ export async function runGuidance(options) {
71
85
  throw new Error("invalid_hook_event");
72
86
  event = parsedEvent;
73
87
  const record = input;
74
- const agents = readCredentials(options.environment).agents.filter((agent) => agent.controlPlane === options.controlPlane && agent.repositoryId === options.repositoryId);
75
- if (agents.length !== 1 || !agents[0])
76
- throw new Error("guidance_connection_unavailable");
88
+ const credentials = readCredentials(options.environment).agents;
89
+ const agents = projectScoped
90
+ ? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
91
+ agent.repositoryId === options.repositoryId)
92
+ : (() => {
93
+ const selection = selectAgent(credentials, options.controlPlane, undefined, repositoryHint(options.cwd));
94
+ return selection.kind === "refused" ? [] : [selection.agent];
95
+ })();
96
+ if (agents.length !== 1 || !agents[0]) {
97
+ if (projectScoped)
98
+ throw new Error("guidance_connection_unavailable");
99
+ // Untracked. Said once, at session start, and never when silenced.
100
+ if (event === "SessionStart" && !isQuiet(options.cwd, options.environment))
101
+ emit(untrackedLine(repositoryHint(options.cwd)));
102
+ return 0;
103
+ }
77
104
  const loaded = await loadGuidance({
78
105
  agent: agents[0],
79
106
  environment: options.environment,
@@ -3,6 +3,8 @@ import { installGuidanceLoader } from "../guidance-install.js";
3
3
  import { callAgentTool, forwarderHeaders, noAgentCredentialSentence, safeJson, selectAgent, structuredString, updateLine, } from "../agent.js";
4
4
  import { noteServerVersion, updateNotice } from "../currency.js";
5
5
  import { repositoryHint } from "../repository.js";
6
+ import { CLI_VERSION } from "../wire.js";
7
+ import { untrackedLine } from "./guidance.js";
6
8
  import { StoreError, readCredentials } from "../store.js";
7
9
  // The three commands that use an agent connection select and address it through
8
10
  // one module. These are re-exported because this is where the forwarder's
@@ -127,6 +129,39 @@ function frameId(line) {
127
129
  }
128
130
  return null;
129
131
  }
132
+ /** The smallest server a host accepts: initialize, an empty tools list, ping. */
133
+ async function serveUntracked(options, instructions) {
134
+ const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
135
+ for await (const line of lines) {
136
+ if (line.trim().length === 0)
137
+ continue;
138
+ let request;
139
+ try {
140
+ request = JSON.parse(line);
141
+ }
142
+ catch {
143
+ continue;
144
+ }
145
+ if (request.id === undefined)
146
+ continue; // a notification
147
+ const result = request.method === "initialize"
148
+ ? {
149
+ protocolVersion: request.params?.protocolVersion ?? "2025-06-18",
150
+ capabilities: { tools: {} },
151
+ serverInfo: { name: "balladeer", version: CLI_VERSION },
152
+ instructions,
153
+ }
154
+ : request.method === "tools/list"
155
+ ? { tools: [] }
156
+ : request.method === "ping"
157
+ ? {}
158
+ : undefined;
159
+ options.write(JSON.stringify(result === undefined
160
+ ? { jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }
161
+ : { jsonrpc: "2.0", id: request.id, result }) + "\n");
162
+ }
163
+ return 0;
164
+ }
130
165
  export async function runMcp(options) {
131
166
  let credentials;
132
167
  try {
@@ -144,7 +179,14 @@ export async function runMcp(options) {
144
179
  options.error(selection.missingFor === undefined
145
180
  ? `${selection.reason} Run setup in this repository, or issue the connection at ${options.controlPlane}/setup.\n`
146
181
  : `${noAgentCredentialSentence(selection.missingFor)}\n`);
147
- return 4;
182
+ // A project entry named its repository and got it wrong: that is an error
183
+ // the host should show. A user-scope entry named none and simply landed in
184
+ // a folder nobody tracks: that is ordinary, and a server that dies here
185
+ // shows up red in every untracked folder on the laptop. Answer the host
186
+ // with an empty catalog and the one sentence the hook also says.
187
+ if (options.repositoryId !== undefined)
188
+ return 4;
189
+ return serveUntracked(options, untrackedLine(repositoryHint(options.cwd)));
148
190
  }
149
191
  const { agent } = selection;
150
192
  const guidanceInstall = installGuidanceLoader({
@@ -2027,7 +2027,7 @@ var require_toml = __commonJS({
2027
2027
  });
2028
2028
 
2029
2029
  // src/wire.ts
2030
- var CLI_VERSION = "1.0.7";
2030
+ var CLI_VERSION = "1.0.8";
2031
2031
  var CLIENT_HEADER = "x-balladeer-client";
2032
2032
  var CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
2033
2033
  var DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
@@ -2411,6 +2411,11 @@ import {
2411
2411
  writeSync as writeSync2
2412
2412
  } from "node:fs";
2413
2413
  import { basename, dirname as dirname2, isAbsolute, join as join3 } from "node:path";
2414
+
2415
+ // src/release.ts
2416
+ var PUBLISHED_SPECIFIER = "balladeer@latest";
2417
+
2418
+ // src/mcp-config.ts
2414
2419
  var MCP_CONFIG_FILE = ".mcp.json";
2415
2420
  function sameOrigin(left, right) {
2416
2421
  try {
@@ -2865,7 +2870,129 @@ function validateProjectGuidanceScope(options) {
2865
2870
  }
2866
2871
  }
2867
2872
 
2873
+ // src/agent.ts
2874
+ var REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
2875
+ function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
2876
+ const here = agents.filter((agent) => agent.controlPlane === controlPlane);
2877
+ if (repositoryId !== void 0) {
2878
+ const named = here.find(
2879
+ (agent) => agent.repositoryId === repositoryId || agent.repository?.toLowerCase() === repositoryId.toLowerCase()
2880
+ );
2881
+ if (named === void 0) {
2882
+ return {
2883
+ kind: "refused",
2884
+ reason: `No Balladeer connection for repository ${repositoryId} is stored for ${controlPlane}.`,
2885
+ missingFor: repositoryId
2886
+ };
2887
+ }
2888
+ return withSafeEndpoint(named);
2889
+ }
2890
+ if (!REPOSITORY_NAME.test(currentRepository) || currentRepository === "unknown/unknown") {
2891
+ return {
2892
+ kind: "refused",
2893
+ reason: "This directory has no GitHub origin remote I could read, so I cannot tell which repository's Balladeer connection to use. Pass --repository <id>."
2894
+ };
2895
+ }
2896
+ const matches = here.filter(
2897
+ (agent) => agent.repository?.toLowerCase() === currentRepository.toLowerCase()
2898
+ );
2899
+ const chosen = matches[matches.length - 1];
2900
+ if (chosen === void 0) {
2901
+ return {
2902
+ kind: "refused",
2903
+ reason: `No Balladeer connection for ${currentRepository} is stored for ${controlPlane}.`,
2904
+ missingFor: currentRepository
2905
+ };
2906
+ }
2907
+ return withSafeEndpoint(chosen);
2908
+ }
2909
+ function withSafeEndpoint(agent) {
2910
+ try {
2911
+ if (new URL(agent.mcpUrl).origin !== new URL(agent.controlPlane).origin) {
2912
+ return {
2913
+ kind: "refused",
2914
+ reason: "That connection's MCP endpoint is not on the control plane it belongs to, so I sent its credential nowhere."
2915
+ };
2916
+ }
2917
+ } catch {
2918
+ return {
2919
+ kind: "refused",
2920
+ reason: "That connection's MCP endpoint is not a URL, so I did not use it."
2921
+ };
2922
+ }
2923
+ return { kind: "agent", agent };
2924
+ }
2925
+
2926
+ // src/repository.ts
2927
+ import { execFileSync as execFileSync2 } from "node:child_process";
2928
+ var REPOSITORY_HINT_PATTERN = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
2929
+ function repositoryHint(cwd = process.cwd()) {
2930
+ let remote;
2931
+ try {
2932
+ remote = execFileSync2("git", ["-C", cwd, "remote", "get-url", "origin"], {
2933
+ encoding: "utf8",
2934
+ stdio: ["ignore", "pipe", "ignore"]
2935
+ }).trim();
2936
+ } catch {
2937
+ return "unknown/unknown";
2938
+ }
2939
+ const match = /github\.com[:/]+([A-Za-z0-9._-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(
2940
+ remote
2941
+ );
2942
+ if (!match) return "unknown/unknown";
2943
+ const hint = `${match[1]}/${match[2]}`;
2944
+ return REPOSITORY_HINT_PATTERN.test(hint) ? hint : "unknown/unknown";
2945
+ }
2946
+
2947
+ // src/quiet.ts
2948
+ import { execFileSync as execFileSync3 } from "node:child_process";
2949
+ import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync6, realpathSync } from "node:fs";
2950
+ import { join as join6 } from "node:path";
2951
+ var EMPTY2 = { schemaVersion: 1, folders: [], remotes: [] };
2952
+ function quietPath(environment = process.env) {
2953
+ return join6(configHome(environment), "quiet.json");
2954
+ }
2955
+ function readQuiet(environment = process.env) {
2956
+ const path = quietPath(environment);
2957
+ if (!existsSync3(path)) return EMPTY2;
2958
+ try {
2959
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
2960
+ if (parsed.schemaVersion !== 1) return EMPTY2;
2961
+ return {
2962
+ schemaVersion: 1,
2963
+ folders: Array.isArray(parsed.folders) ? parsed.folders.filter(isString) : [],
2964
+ remotes: Array.isArray(parsed.remotes) ? parsed.remotes.filter(isString) : []
2965
+ };
2966
+ } catch {
2967
+ return EMPTY2;
2968
+ }
2969
+ }
2970
+ var isString = (value) => typeof value === "string";
2971
+ function folderKey(cwd) {
2972
+ try {
2973
+ return realpathSync(
2974
+ execFileSync3("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
2975
+ encoding: "utf8",
2976
+ stdio: ["ignore", "pipe", "ignore"],
2977
+ timeout: 1e3
2978
+ }).trim()
2979
+ );
2980
+ } catch {
2981
+ return realpathSync(cwd);
2982
+ }
2983
+ }
2984
+ function isQuiet(cwd, environment = process.env) {
2985
+ const list = readQuiet(environment);
2986
+ const folder = folderKey(cwd);
2987
+ const remote = repositoryHint(cwd);
2988
+ return list.folders.includes(folder) || remote !== "unknown/unknown" && list.remotes.some((r) => r.toLowerCase() === remote.toLowerCase());
2989
+ }
2990
+
2868
2991
  // src/commands/guidance.ts
2992
+ function untrackedLine(remote) {
2993
+ const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
2994
+ return `${here} isn't tracked by Balladeer, so promises can't be tracked from here. To track it, run \`npx -y ${PUBLISHED_SPECIFIER} setup\` in this folder. To stop this message here, run \`npx -y ${PUBLISHED_SPECIFIER} quiet\` (or \`quiet --repo\` for the whole repository).`;
2995
+ }
2869
2996
  function readInput(stream) {
2870
2997
  return new Promise((resolve2, reject) => {
2871
2998
  let bytes = 0;
@@ -2908,7 +3035,8 @@ function identifier(input) {
2908
3035
  return typeof input === "string" && input.length > 0 && input.length <= 256 ? input : void 0;
2909
3036
  }
2910
3037
  async function runGuidance(options) {
2911
- if (!options.repositoryId || !validateProjectGuidanceScope({
3038
+ const projectScoped = options.repositoryId !== void 0;
3039
+ if (projectScoped && !validateProjectGuidanceScope({
2912
3040
  cwd: options.cwd,
2913
3041
  hook: options.hook,
2914
3042
  repositoryId: options.repositoryId,
@@ -2929,10 +3057,24 @@ async function runGuidance(options) {
2929
3057
  throw new Error("invalid_hook_event");
2930
3058
  event = parsedEvent;
2931
3059
  const record = input;
2932
- const agents = readCredentials(options.environment).agents.filter(
3060
+ const credentials = readCredentials(options.environment).agents;
3061
+ const agents = projectScoped ? credentials.filter(
2933
3062
  (agent) => agent.controlPlane === options.controlPlane && agent.repositoryId === options.repositoryId
2934
- );
2935
- if (agents.length !== 1 || !agents[0]) throw new Error("guidance_connection_unavailable");
3063
+ ) : (() => {
3064
+ const selection = selectAgent(
3065
+ credentials,
3066
+ options.controlPlane,
3067
+ void 0,
3068
+ repositoryHint(options.cwd)
3069
+ );
3070
+ return selection.kind === "refused" ? [] : [selection.agent];
3071
+ })();
3072
+ if (agents.length !== 1 || !agents[0]) {
3073
+ if (projectScoped) throw new Error("guidance_connection_unavailable");
3074
+ if (event === "SessionStart" && !isQuiet(options.cwd, options.environment))
3075
+ emit(untrackedLine(repositoryHint(options.cwd)));
3076
+ return 0;
3077
+ }
2936
3078
  const loaded = await loadGuidance({
2937
3079
  agent: agents[0],
2938
3080
  environment: options.environment,
package/dist/install.d.ts CHANGED
@@ -19,5 +19,7 @@ export declare function runInstall(options: InstallOptions & {
19
19
  json: boolean;
20
20
  write: (text: string) => void;
21
21
  allowPartial?: boolean;
22
+ /** Carried into the one JSON object this prints, never as a second line. */
23
+ extra?: Record<string, unknown>;
22
24
  }): number;
23
25
  export {};
package/dist/install.js CHANGED
@@ -344,6 +344,7 @@ export function runInstall(options) {
344
344
  ...result,
345
345
  status: result.manualPathRequired ? "path_pending" : result.status,
346
346
  message,
347
+ ...(options.extra ?? {}),
347
348
  }) + "\n"
348
349
  : message + "\n");
349
350
  return result.manualPathRequired && !options.allowPartial ? 4 : 0;
@@ -353,7 +354,12 @@ export function runInstall(options) {
353
354
  ? error.message
354
355
  : "Installation could not access a required local file or command. Allow the reported installer operation and retry; no pairing has started.";
355
356
  options.write(options.json
356
- ? JSON.stringify({ step: "install", status: "blocked", message }) + "\n"
357
+ ? JSON.stringify({
358
+ step: "install",
359
+ status: "blocked",
360
+ message,
361
+ ...(options.extra ?? {}),
362
+ }) + "\n"
357
363
  : message + "\n");
358
364
  return 4;
359
365
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where a person said "not here".
3
+ *
4
+ * With Balladeer registered once per laptop, every session in every folder
5
+ * asks whether this folder is tracked. In one that is not, the hook says so
6
+ * once and names the command that would track it; a person who does not want
7
+ * to hear that again in a scratch clone silences the folder, and one who does
8
+ * not want it for a whole product silences the remote. The default is the
9
+ * folder, because "not here" usually means this checkout and not the repository.
10
+ */
11
+ export type QuietList = Readonly<{
12
+ schemaVersion: 1;
13
+ folders: string[];
14
+ remotes: string[];
15
+ }>;
16
+ export declare function quietPath(environment?: NodeJS.ProcessEnv): string;
17
+ export declare function readQuiet(environment?: NodeJS.ProcessEnv): QuietList;
18
+ /** The folder a session is in, as git names it, so a worktree and a clone are
19
+ * each their own folder while the remote stays one thing. */
20
+ export declare function folderKey(cwd: string): string;
21
+ export declare function isQuiet(cwd: string, environment?: NodeJS.ProcessEnv): boolean;
22
+ export declare function setQuiet(cwd: string, scope: "folder" | "repo", quiet: boolean, environment?: NodeJS.ProcessEnv): {
23
+ key: string;
24
+ changed: boolean;
25
+ };
package/dist/quiet.js ADDED
@@ -0,0 +1,73 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { writeJsonAtomically } from "./mcp-config.js";
5
+ import { repositoryHint } from "./repository.js";
6
+ import { configHome } from "./store.js";
7
+ const EMPTY = { schemaVersion: 1, folders: [], remotes: [] };
8
+ export function quietPath(environment = process.env) {
9
+ return join(configHome(environment), "quiet.json");
10
+ }
11
+ export function readQuiet(environment = process.env) {
12
+ const path = quietPath(environment);
13
+ if (!existsSync(path))
14
+ return EMPTY;
15
+ try {
16
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
17
+ if (parsed.schemaVersion !== 1)
18
+ return EMPTY;
19
+ return {
20
+ schemaVersion: 1,
21
+ folders: Array.isArray(parsed.folders) ? parsed.folders.filter(isString) : [],
22
+ remotes: Array.isArray(parsed.remotes) ? parsed.remotes.filter(isString) : [],
23
+ };
24
+ }
25
+ catch {
26
+ return EMPTY;
27
+ }
28
+ }
29
+ const isString = (value) => typeof value === "string";
30
+ function writeQuiet(list, environment) {
31
+ mkdirSync(configHome(environment), { recursive: true, mode: 0o700 });
32
+ writeJsonAtomically(quietPath(environment), JSON.stringify(list, null, 2) + "\n");
33
+ }
34
+ /** The folder a session is in, as git names it, so a worktree and a clone are
35
+ * each their own folder while the remote stays one thing. */
36
+ export function folderKey(cwd) {
37
+ try {
38
+ return realpathSync(execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "pipe", "ignore"],
41
+ timeout: 1000,
42
+ }).trim());
43
+ }
44
+ catch {
45
+ return realpathSync(cwd);
46
+ }
47
+ }
48
+ export function isQuiet(cwd, environment = process.env) {
49
+ const list = readQuiet(environment);
50
+ const folder = folderKey(cwd);
51
+ const remote = repositoryHint(cwd);
52
+ return (list.folders.includes(folder) ||
53
+ (remote !== "unknown/unknown" &&
54
+ list.remotes.some((r) => r.toLowerCase() === remote.toLowerCase())));
55
+ }
56
+ export function setQuiet(cwd, scope, quiet, environment = process.env) {
57
+ const list = readQuiet(environment);
58
+ const key = scope === "folder" ? folderKey(cwd) : repositoryHint(cwd);
59
+ if (scope === "repo" && key === "unknown/unknown")
60
+ throw new Error("This folder has no GitHub origin remote, so there is no repository to name.");
61
+ const field = scope === "folder" ? "folders" : "remotes";
62
+ const has = list[field].some((x) => x.toLowerCase() === key.toLowerCase());
63
+ if (has === quiet)
64
+ return { key, changed: false };
65
+ const next = {
66
+ ...list,
67
+ [field]: quiet
68
+ ? [...list[field], key]
69
+ : list[field].filter((x) => x.toLowerCase() !== key.toLowerCase()),
70
+ };
71
+ writeQuiet(next, environment);
72
+ return { key, changed: true };
73
+ }
@@ -0,0 +1,55 @@
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
+ /** 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 declare function userScopeCommand(published: boolean): {
29
+ command: string;
30
+ args: string[];
31
+ };
32
+ /** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
33
+ export declare function mergeClaudeUserMcp(home: string, published: boolean): UserScopeWrite;
34
+ /** Ours when it runs `npx` on our published specifier, or `node` on this
35
+ * checkout's entry file, with `mcp` as the subcommand and no repository named:
36
+ * a repository argument at user scope would pin every folder to one connection. */
37
+ export declare function isOurUserEntry(entry: unknown): boolean;
38
+ /** `~/.claude/settings.json` carries user-scope hooks. */
39
+ export declare function mergeClaudeUserHooks(home: string, published: boolean): UserScopeWrite;
40
+ /** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
41
+ * one fenced block this command owns end to end. */
42
+ export declare function mergeCodexUserConfig(home: string, published: boolean): UserScopeWrite;
43
+ /** A copy of this command running out of a source checkout registers the
44
+ * checkout; any installed or npx copy registers the published package. */
45
+ export declare function runningFromCheckout(entry?: string): boolean;
46
+ /** The home whose host files get the entries. `BALLADEER_USER_HOME` exists for
47
+ * this repository's own tests, which cannot move HOME under a Volta-managed
48
+ * Node without losing Node. */
49
+ export declare function userHome(environment?: NodeJS.ProcessEnv): string;
50
+ /** Register both hosts. Codex is skipped, not refused, on a laptop without it. */
51
+ export declare function installUserScope(options: {
52
+ environment: NodeJS.ProcessEnv;
53
+ published: boolean;
54
+ home?: string;
55
+ }): UserScopeWrite[];
@@ -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.7";
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.7";
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[];
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.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.7",
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,
@@ -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",