balladeer 1.0.11 → 1.0.13

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.js CHANGED
@@ -19,6 +19,7 @@ import { runStatus } from "./commands/status.js";
19
19
  import { runTouchMap } from "./commands/touch-map.js";
20
20
  import { runWhoami } from "./commands/whoami.js";
21
21
  import { runInstall } from "./install.js";
22
+ import { openInBrowser } from "./open-browser.js";
22
23
  import { PUBLISHED_SPECIFIER } from "./release.js";
23
24
  import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
24
25
  import { updateNotice } from "./currency.js";
@@ -533,10 +534,12 @@ async function dispatch(parsed, write) {
533
534
  wait: parsed.wait,
534
535
  // A person is here when both ends of the terminal are a TTY; an agent
535
536
  // shell has neither and keeps the print-and-return behaviour.
536
- interactive: !parsed.noOpen &&
537
- !parsed.json &&
538
- process.stdout.isTTY === true &&
539
- process.stdin.isTTY === true,
537
+ // A person at the terminal waits ten minutes; an agent's shell, whose
538
+ // tool may cut the command at two, waits ninety seconds. Both open the
539
+ // page, because both run on the person's laptop; --no-open and --json
540
+ // print the link only.
541
+ interactive: process.stdout.isTTY === true && process.stdin.isTTY === true,
542
+ ...(parsed.noOpen ? {} : { openLink: openInBrowser }),
540
543
  refresh: parsed.refresh,
541
544
  chooseWorkspace: parsed.chooseWorkspace,
542
545
  ...(parsed.client === undefined ? {} : { client: parsed.client }),
@@ -9,8 +9,9 @@ export type SetupOptions = Readonly<{
9
9
  * link and return. Robert, 22 September 2026.
10
10
  */
11
11
  interactive?: boolean;
12
- /** Opens a page for the person; injected so tests never reach a browser. */
13
- openLink?: (url: string) => boolean;
12
+ /** Opens a page for the person; injected so tests never reach a browser.
13
+ * `null` is `--no-open`. */
14
+ openLink?: ((url: string) => boolean) | null;
14
15
  repo?: string;
15
16
  createWorkspace?: string;
16
17
  client?: "codex" | "claude";
@@ -12,7 +12,6 @@ import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, isOurEntry, mergeMcpC
12
12
  import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
13
13
  import { selectAgent } from "../agent.js";
14
14
  import { installGuidanceLoader } from "../guidance-install.js";
15
- import { openInBrowser } from "../open-browser.js";
16
15
  import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "../remove-earlier.js";
17
16
  import { userHome } from "../user-scope.js";
18
17
  import { formatInstant } from "../local-time.js";
@@ -22,8 +21,9 @@ import { StoreError, assertStoreWritable, credentialsPath, dropPendingPairing, f
22
21
  import { CLI_VERSION, DELEGATED_SCOPES, } from "../wire.js";
23
22
  import { explainText } from "./explain.js";
24
23
  import { probeAgentConnection } from "./mcp.js";
25
- const REFUSAL_SENTENCE = "It may not agree to what a promise means, agree a change to what a promise means, grant an exception, transfer ownership, supersede, retire, or offboard anything.";
24
+ const REFUSAL_SENTENCE = "It may not agree to what a promise means, agree a change to what a promise means, transfer ownership, supersede, retire, or offboard anything.";
26
25
  const MAX_WAIT_MS = 10 * 60 * 1000;
26
+ const AGENT_WAIT_MS = 90 * 1000;
27
27
  /** The bound the control plane's own name check holds, refused here so a name
28
28
  * nobody could create never costs a pairing code. */
29
29
  export const CREATE_WORKSPACE_MAX_LENGTH = 120;
@@ -152,8 +152,8 @@ function pendingLines(pending, repository, host, waiting, approvalUri, opened) {
152
152
  return [
153
153
  ...opening,
154
154
  opened
155
- ? ` Opened in your browser: ${approvalUri} (if no page appeared, open that link yourself)`
156
- : ` Open: ${approvalUri}`,
155
+ ? ` FOR THE PERSON AT THIS LAPTOP: a browser tab has opened at ${approvalUri}. Click Approve there. If no tab appeared, open that link yourself.`
156
+ : ` FOR THE PERSON AT THIS LAPTOP: open ${approvalUri} in your browser and click Approve.`,
157
157
  ` The page will show this code: ${pending.userCode}`,
158
158
  // A person who belongs to two workspaces approves into whichever one their
159
159
  // browser is signed into, and twice that was not the one they meant: the
@@ -164,7 +164,7 @@ function pendingLines(pending, repository, host, waiting, approvalUri, opened) {
164
164
  ` Sent to Balladeer so far: a random code, the name "${repository}", and the hostname "${host}". Nothing else.`,
165
165
  ` This pairing expires at ${formatInstant(pending.expiresAt)}.`,
166
166
  opened
167
- ? " Approve in the browser; this command waits here and finishes on its own."
167
+ ? " This command waits here for the approval and finishes on its own; if it returns first, run it again to finish."
168
168
  : " Approve in the browser, then run this command again to finish.",
169
169
  "",
170
170
  // Printed on the pairing step and nowhere else, because this is the one
@@ -296,11 +296,23 @@ export async function runSetup(options) {
296
296
  say(options, `Balladeer setup, version ${CLI_VERSION}.\n`);
297
297
  say(options, `Agent client: ${options.client === "codex" ? "Codex" : "Claude"}. Use --client codex or --client claude to choose.`);
298
298
  emit(options, { step: "explain", version: CLI_VERSION });
299
- say(options, explainText(controlPlane));
300
299
  const chosen = chooseRepositories(namedRepositories(options), options.cwd);
301
300
  if (chosen.kind === "mismatch") {
302
301
  return fail(options, "repository_mismatch", chosen.message, 4);
303
302
  }
303
+ // Where this run is, said before anything else. People paste this command
304
+ // into an agent whose shell may be in no repository at all; the run still
305
+ // pairs the laptop, and the first line says what it will and will not do.
306
+ const inCheckout = repositoryHint(options.cwd) !== "unknown/unknown";
307
+ if (!inCheckout && (options.repositories ?? []).length === 0)
308
+ say(options, "This folder isn't a repository checkout. This run pairs this laptop and installs Balladeer for every session; to connect a repository, run this same command inside a checkout of it afterwards.");
309
+ // The boundary explanation is for a person reading a terminal. An agent
310
+ // shell gets one line and `balladeer explain`, so the approval link is the
311
+ // first actionable thing it sees and relays.
312
+ if (options.interactive === true)
313
+ say(options, explainText(controlPlane));
314
+ else
315
+ say(options, "What Balladeer can and cannot see, and what this run does: `balladeer explain`.");
304
316
  // Before the pairing is started, not after: a name the control plane would
305
317
  // refuse must cost nobody a code they then have to abandon, and this run has
306
318
  // sent nothing yet.
@@ -596,15 +608,26 @@ function terminalRefusal(pending, code) {
596
608
  * if none did.
597
609
  */
598
610
  export function openForPerson(options, url) {
599
- if (options.interactive !== true || options.json)
611
+ // An agent's shell is still on the person's laptop, so the page can open
612
+ // there too; only --json and --no-open keep it closed.
613
+ // Only an opener the entry point handed over is used: a test, or any
614
+ // caller that passed none, never reaches a browser and never waits.
615
+ if (options.json || !options.openLink)
600
616
  return false;
601
- return (options.openLink ?? openInBrowser)(url);
617
+ return options.openLink(url);
618
+ }
619
+ /** How long a run waits for the click: a person at a terminal has ten
620
+ * minutes; an agent's shell, whose tool may cut the command at two, gets
621
+ * ninety seconds and the pairing is saved either way. */
622
+ function waitBudgetMs(options) {
623
+ return options.interactive === true ? MAX_WAIT_MS : AGENT_WAIT_MS;
602
624
  }
603
625
  async function waitForApproval(options, credentials, pending, pollIntervalSeconds) {
604
626
  const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
605
627
  const now = options.now ?? (() => new Date());
606
- const deadline = now().getTime() + MAX_WAIT_MS;
607
- say(options, ` Waiting up to ${MAX_WAIT_MS / 60_000} minutes for someone to approve ${pending.userCode} at ${approvalLink(options, pending.verificationUri)}. Press Ctrl+C to stop; the pairing is saved either way, and the code stays approvable until ${formatInstant(pending.expiresAt)}.`);
628
+ const budget = waitBudgetMs(options);
629
+ const deadline = now().getTime() + budget;
630
+ say(options, ` Waiting up to ${budget >= 60_000 ? `${budget / 60_000} minutes` : `${budget / 1000} seconds`} for someone to approve ${pending.userCode} at ${approvalLink(options, pending.verificationUri)}. Press Ctrl+C to stop; the pairing is saved either way, and the code stays approvable until ${formatInstant(pending.expiresAt)}.`);
608
631
  let interval = pollIntervalSeconds;
609
632
  while (now().getTime() < deadline) {
610
633
  await sleep(Math.max(interval, 2) * 1000);
@@ -635,6 +658,15 @@ function refusalSentence(error) {
635
658
  return error instanceof Error ? error.message : String(error);
636
659
  }
637
660
  async function runRemainingSteps(options, credentials, session) {
661
+ // Said to a JSON reader here, after the pairing object, so the step order
662
+ // every caller relies on is unchanged; a person read it as the first line.
663
+ if (repositoryHint(options.cwd) === "unknown/unknown" &&
664
+ (options.repositories ?? []).length === 0)
665
+ emit(options, {
666
+ step: "warning",
667
+ code: "not_in_checkout",
668
+ message: "This folder isn't a repository checkout; this run paired the laptop and installed Balladeer for every session, and connects no repository.",
669
+ });
638
670
  let state;
639
671
  try {
640
672
  state = await request(options.controlPlane, {
@@ -2027,7 +2027,7 @@ var require_toml = __commonJS({
2027
2027
  });
2028
2028
 
2029
2029
  // src/wire.ts
2030
- var CLI_VERSION = "1.0.11";
2030
+ var CLI_VERSION = "1.0.13";
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";
@@ -10,4 +10,4 @@ import { spawn } from "node:child_process";
10
10
  * link staying on screen, never by this command hanging. Nothing here reads
11
11
  * the page or learns what happened in it.
12
12
  */
13
- export declare function openInBrowser(url: string, platform?: NodeJS.Platform, run?: typeof spawn): boolean;
13
+ export declare function openInBrowser(url: string, platform?: NodeJS.Platform, run?: typeof spawn, environment?: NodeJS.ProcessEnv): boolean;
@@ -10,9 +10,14 @@ import { spawn } from "node:child_process";
10
10
  * link staying on screen, never by this command hanging. Nothing here reads
11
11
  * the page or learns what happened in it.
12
12
  */
13
- export function openInBrowser(url, platform = process.platform, run = spawn) {
13
+ export function openInBrowser(url, platform = process.platform, run = spawn, environment = process.env) {
14
14
  if (!/^https:\/\//.test(url))
15
15
  return false;
16
+ // A Linux box with no display (a CI runner, a server over SSH) has nowhere
17
+ // to open a page, and saying it opened one would make the run wait for a
18
+ // click that cannot come.
19
+ if (platform === "linux" && !environment.DISPLAY && !environment.WAYLAND_DISPLAY)
20
+ return false;
16
21
  const [command, args] = platform === "darwin"
17
22
  ? ["open", [url]]
18
23
  : platform === "win32"
@@ -94,8 +94,15 @@ function claudeConnector(home, controlPlane, now) {
94
94
  },
95
95
  ];
96
96
  }
97
+ // This product's own hook runs `guidance --hook claude|codex --event ...`,
98
+ // whether by npx, by a checkout path, or by the installed command's absolute
99
+ // path (1.0.11 and later). The July client's hook ran nothing of the kind, so
100
+ // that shape is the line: a hook that names it is ours and is never "earlier".
101
+ // Found on 22 September 2026: the installed-path form was being removed as
102
+ // the older client on every setup run, leaving the laptop with no hook.
97
103
  const isEarlierHook = (command) => typeof command === "string" &&
98
104
  /balladeer/i.test(command) &&
105
+ !/\bguidance --hook (?:claude|codex)\b/.test(command) &&
99
106
  !/balladeer@(?:latest|\d+\.\d+\.\d+)\b/.test(command) &&
100
107
  !/\bpackages[/\\]cli[/\\]dist[/\\]cli\.js\b/.test(command);
101
108
  function claudeSettings(home, now) {
@@ -15,6 +15,8 @@
15
15
  * shape, never by trust in the name.
16
16
  */
17
17
  export declare const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
18
+ /** Every tool of the server named `balladeer`, in Claude Code's permission rule form. */
19
+ export declare const USER_SCOPE_ALLOW_RULE = "mcp__balladeer__*";
18
20
  export type UserScopeHost = "claude" | "codex";
19
21
  export type UserScopeWrite = Readonly<{
20
22
  host: UserScopeHost;
@@ -22,6 +22,8 @@ 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
+ /** Every tool of the server named `balladeer`, in Claude Code's permission rule form. */
26
+ export const USER_SCOPE_ALLOW_RULE = "mcp__balladeer__*";
25
27
  /**
26
28
  * The command a host runs to reach Balladeer, with no repository named: the
27
29
  * directory decides.
@@ -43,10 +45,22 @@ export function userScopeCommand(published, installed) {
43
45
  function ordinaryFile(path) {
44
46
  return !existsSync(path) || (lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink());
45
47
  }
48
+ /** The home the entries go under has to exist; a home that does not is a
49
+ * refusal in a sentence, never a stack trace. */
50
+ function homeExists(home) {
51
+ try {
52
+ return lstatSync(home).isDirectory();
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
46
58
  /** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
47
59
  export function mergeClaudeUserMcp(home, published, installed) {
48
60
  const path = join(home, ".claude.json");
49
61
  const host = "claude";
62
+ if (!homeExists(home))
63
+ return { host, path, status: "refused", reason: "home directory missing" };
50
64
  if (!ordinaryFile(path))
51
65
  return { host, path, status: "refused", reason: "not an ordinary file" };
52
66
  let root = {};
@@ -96,8 +110,15 @@ export function isOurUserEntry(entry) {
96
110
  args[0] === "-y" &&
97
111
  /^balladeer@(?:latest|1|\d+\.\d+\.\d+)$/.test(args[1]) &&
98
112
  args[2] === "mcp");
113
+ // A checkout of this repository at any path: the entry setup writes when it
114
+ // runs from source, on this laptop or another. Ours to replace, never to
115
+ // refuse; refusing it once left a developer's laptop with no entry at all.
99
116
  if (record.command === "node" || String(record.command).endsWith("/node"))
100
- return args.length === 2 && args[0] === checkoutEntryPath() && args[1] === "mcp";
117
+ return (args.length === 2 &&
118
+ typeof args[0] === "string" &&
119
+ isAbsolute(args[0]) &&
120
+ /[\\/]packages[\\/]cli[\\/]dist[\\/]cli\.js$/.test(args[0]) &&
121
+ args[1] === "mcp");
101
122
  // The durable command by absolute path: ours when it sits where this
102
123
  // product's installer puts it and is asked for `mcp` alone. The July client
103
124
  // also registered an absolute `balladeer mcp`, from its own home, so the
@@ -127,6 +148,8 @@ export function isInstalledCommandPath(command) {
127
148
  export function mergeClaudeUserHooks(home, published, installed) {
128
149
  const path = join(home, ".claude", "settings.json");
129
150
  const host = "claude";
151
+ if (!homeExists(home))
152
+ return { host, path, status: "refused", reason: "home directory missing" };
130
153
  if (!ordinaryFile(path))
131
154
  return { host, path, status: "refused", reason: "not an ordinary file" };
132
155
  let root = {};
@@ -171,6 +194,24 @@ export function mergeClaudeUserHooks(home, published, installed) {
171
194
  hooks[event] = [...rows.filter((row) => !own.includes(row)), expected];
172
195
  changed = true;
173
196
  }
197
+ // Claude Code asks the person before every call to a server's tools unless
198
+ // a rule allows them. Without one, each read the guidance asks for costs a
199
+ // click, and a session that says no once hears no offer at all (measured
200
+ // 23 September 2026). The rule names this server's tools and nothing else;
201
+ // a deny rule the person wrote for them is theirs and is left to win.
202
+ const permissions = root.permissions && typeof root.permissions === "object" && !Array.isArray(root.permissions)
203
+ ? { ...root.permissions }
204
+ : {};
205
+ const allow = permissions.allow === undefined ? [] : permissions.allow;
206
+ if (!Array.isArray(allow))
207
+ return { host, path, status: "refused", reason: "permissions.allow is not a list" };
208
+ const deny = Array.isArray(permissions.deny) ? permissions.deny : [];
209
+ const denied = deny.some((rule) => typeof rule === "string" && /^mcp__balladeer(__|$)/.test(rule));
210
+ if (!denied && !allow.some((rule) => rule === USER_SCOPE_ALLOW_RULE)) {
211
+ permissions.allow = [...allow, USER_SCOPE_ALLOW_RULE];
212
+ root.permissions = permissions;
213
+ changed = true;
214
+ }
174
215
  if (!changed)
175
216
  return { host, path, status: "current" };
176
217
  root.hooks = hooks;
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.11";
8
+ export declare const CLI_VERSION = "1.0.13";
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.11";
11
+ export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.13";
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[];
@@ -345,7 +345,7 @@ export type JsonStep = Readonly<{
345
345
  reason?: string;
346
346
  }> | Readonly<{
347
347
  step: "warning";
348
- code: "enforcement_unavailable";
348
+ code: "enforcement_unavailable" | "not_in_checkout";
349
349
  message: string;
350
350
  }> | Readonly<{
351
351
  step: "promise";
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.11";
8
+ export const CLI_VERSION = "1.0.13";
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.11",
3
+ "version": "1.0.13",
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,
package/dist/legacy.d.ts DELETED
@@ -1,41 +0,0 @@
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 DELETED
@@ -1,143 +0,0 @@
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
- }
package/dist/quiet.d.ts DELETED
@@ -1,25 +0,0 @@
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 DELETED
@@ -1,73 +0,0 @@
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
- }