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.
@@ -12,10 +12,12 @@ 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 { findLegacyInstall, legacyMessage } from "../legacy.js";
15
+ import { openInBrowser } from "../open-browser.js";
16
+ import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "../remove-earlier.js";
17
+ import { userHome } from "../user-scope.js";
16
18
  import { formatInstant } from "../local-time.js";
17
19
  import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
18
- import { hostHint, repositoryHint } from "../repository.js";
20
+ import { hostHint, repositoryHint, repositoryHints } from "../repository.js";
19
21
  import { StoreError, assertStoreWritable, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
20
22
  import { CLI_VERSION, DELEGATED_SCOPES, } from "../wire.js";
21
23
  import { explainText } from "./explain.js";
@@ -143,13 +145,15 @@ function pairedLines(session) {
143
145
  * scrolled, or an agent whose context was compacted, is told to approve in the
144
146
  * browser, and no other command in this release can produce that URL again.
145
147
  */
146
- function pendingLines(pending, repository, host, waiting, approvalUri) {
148
+ function pendingLines(pending, repository, host, waiting, approvalUri, opened) {
147
149
  const opening = waiting
148
150
  ? ["Step 1 of 5 Waiting for approval", ` Nobody has approved ${pending.userCode} yet.`]
149
151
  : ["Step 1 of 5 Sign in and approve this session"];
150
152
  return [
151
153
  ...opening,
152
- ` Open: ${approvalUri}`,
154
+ opened
155
+ ? ` Opened in your browser: ${approvalUri} (if no page appeared, open that link yourself)`
156
+ : ` Open: ${approvalUri}`,
153
157
  ` The page will show this code: ${pending.userCode}`,
154
158
  // A person who belongs to two workspaces approves into whichever one their
155
159
  // browser is signed into, and twice that was not the one they meant: the
@@ -159,7 +163,9 @@ function pendingLines(pending, repository, host, waiting, approvalUri) {
159
163
  ` ${APPROVE_IN_THE_RIGHT_WORKSPACE}`,
160
164
  ` Sent to Balladeer so far: a random code, the name "${repository}", and the hostname "${host}". Nothing else.`,
161
165
  ` This pairing expires at ${formatInstant(pending.expiresAt)}.`,
162
- " Approve in the browser, then run this command again to finish.",
166
+ opened
167
+ ? " Approve in the browser; this command waits here and finishes on its own."
168
+ : " Approve in the browser, then run this command again to finish.",
163
169
  "",
164
170
  // Printed on the pairing step and nowhere else, because this is the one
165
171
  // moment the answer matters: the person is standing in front of a page that
@@ -259,11 +265,29 @@ export async function runSetup(options) {
259
265
  // that still carries the July client gets one instruction and this run stops.
260
266
  // Refresh is inside the check rather than outside it, because a repair writes
261
267
  // the same two files a first setup writes.
262
- const legacy = options.force === true
263
- ? undefined
264
- : findLegacyInstall(options.environment, options.controlPlane);
265
- if (legacy !== undefined) {
266
- return fail(options, "legacy_balladeer_present", legacyMessage(legacy), 6);
268
+ // The earlier Balladeer, still on this machine, is taken off first: both
269
+ // products answer to the name `balladeer`, and while the older entry stands
270
+ // the host shows its server and refuses ours. Every file changed is copied
271
+ // beside itself first and the copies are named. `--force` sets up alongside
272
+ // it instead, for whoever has decided to run both.
273
+ if (options.force !== true) {
274
+ const earlier = findEarlier({
275
+ home: userHome(options.environment),
276
+ controlPlane: options.controlPlane,
277
+ environment: options.environment,
278
+ cwd: options.cwd,
279
+ });
280
+ if (earlier.length > 0) {
281
+ say(options, "An earlier Balladeer is on this machine:");
282
+ for (const line of describeEarlier(earlier))
283
+ say(options, line);
284
+ const report = removeEarlier(earlier);
285
+ for (const line of describeRemoval(report))
286
+ say(options, line);
287
+ emit(options, { step: "earlier", removed: report.removed, failed: report.failed });
288
+ if (report.failed.length > 0)
289
+ return fail(options, "earlier_balladeer_remains", `${report.failed.length} of the earlier Balladeer's entries could not be removed; see above. Fix those by hand, or run this command again with --force to set up alongside it.`, 6);
290
+ }
267
291
  }
268
292
  if (options.refresh === true)
269
293
  return runRefresh(options);
@@ -371,7 +395,8 @@ export async function runSetup(options) {
371
395
  return outcome.exitCode;
372
396
  // Printed before the wait as well as instead of it: a run that blocks for ten
373
397
  // minutes without ever naming the link is the run whose person cannot act.
374
- for (const line of pendingLines(pending, repository, host, true, approvalLink(options, pending.verificationUri)))
398
+ const opened = openForPerson(options, approvalLink(options, pending.verificationUri));
399
+ for (const line of pendingLines(pending, repository, host, true, approvalLink(options, pending.verificationUri), opened))
375
400
  say(options, line);
376
401
  emit(options, {
377
402
  step: "pair",
@@ -380,7 +405,7 @@ export async function runSetup(options) {
380
405
  verificationUri: approvalLink(options, pending.verificationUri),
381
406
  expiresAt: pending.expiresAt,
382
407
  });
383
- if (!options.wait) {
408
+ if (!options.wait && !opened) {
384
409
  pairingOnlyReceipt(options, "Nothing yet: this pairing is still waiting for someone to approve it.");
385
410
  return 0;
386
411
  }
@@ -422,7 +447,8 @@ export async function runSetup(options) {
422
447
  expiresAt: lapsed.expiresAt,
423
448
  });
424
449
  }
425
- for (const line of pendingLines(pending, repository, host, false, approvalLink(options, pending.verificationUri)))
450
+ const opened = openForPerson(options, approvalLink(options, pending.verificationUri));
451
+ for (const line of pendingLines(pending, repository, host, false, approvalLink(options, pending.verificationUri), opened))
426
452
  say(options, line);
427
453
  emit(options, {
428
454
  step: "pair",
@@ -431,7 +457,7 @@ export async function runSetup(options) {
431
457
  verificationUri: approvalLink(options, pending.verificationUri),
432
458
  expiresAt: pending.expiresAt,
433
459
  });
434
- if (options.wait) {
460
+ if (options.wait || opened) {
435
461
  return waitForApproval(options, credentials, pending, started.pollIntervalSeconds);
436
462
  }
437
463
  pairingOnlyReceipt(options, "Started a pairing and printed the link. Nobody has approved it yet.");
@@ -564,6 +590,16 @@ function terminalRefusal(pending, code) {
564
590
  }
565
591
  return undefined;
566
592
  }
593
+ /**
594
+ * The link, opened for a person at the terminal and for nobody else. Whether a
595
+ * browser appeared is not known here; the line beside the link says what to do
596
+ * if none did.
597
+ */
598
+ export function openForPerson(options, url) {
599
+ if (options.interactive !== true || options.json)
600
+ return false;
601
+ return (options.openLink ?? openInBrowser)(url);
602
+ }
567
603
  async function waitForApproval(options, credentials, pending, pollIntervalSeconds) {
568
604
  const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
569
605
  const now = options.now ?? (() => new Date());
@@ -1676,7 +1712,7 @@ async function runRefresh(input) {
1676
1712
  let storedName;
1677
1713
  try {
1678
1714
  const credentials = readCredentials(options.environment);
1679
- const selection = selectAgent(credentials.agents, options.controlPlane, undefined, repositoryHint(options.cwd));
1715
+ const selection = selectAgent(credentials.agents, options.controlPlane, undefined, repositoryHints(options.cwd));
1680
1716
  if (selection.kind === "agent") {
1681
1717
  stored = selection.agent.repositoryId;
1682
1718
  storedName = selection.agent.repository;
@@ -4,7 +4,7 @@ import { repositoryRoot } from "../git.js";
4
4
  import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
5
5
  import { formatInstant } from "../local-time.js";
6
6
  import { commandLine } from "../release.js";
7
- import { repositoryHint } from "../repository.js";
7
+ import { repositoryHint, repositoryHints } from "../repository.js";
8
8
  import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
9
9
  import { StoreError, findSession, readCredentials } from "../store.js";
10
10
  import { CLI_INVOCATION } from "../wire.js";
@@ -103,7 +103,7 @@ async function reportBalladeer(options) {
103
103
  return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
104
104
  }
105
105
  const here = options.repo ?? repositoryHint(options.cwd);
106
- const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
106
+ const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
107
107
  const storedSession = findSession(credentials, options.controlPlane);
108
108
  const repositoryScoped = options.repo !== undefined ||
109
109
  options.repository !== undefined ||
@@ -27,5 +27,8 @@ export declare const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
27
27
  export declare function noteServerVersion(headers: Headers): void;
28
28
  /** The one line this run prints about itself, or nothing when it is current. */
29
29
  export declare function updateNotice(): string | undefined;
30
+ /** The newer version the server named, when it named one; nothing when this
31
+ * copy is current or no server has answered yet. */
32
+ export declare function newerVersionPublished(): string | undefined;
30
33
  /** For tests, and for a process that runs more than one command. */
31
34
  export declare function resetUpdateNotice(): void;
package/dist/currency.js CHANGED
@@ -24,6 +24,7 @@ export const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
24
24
  const ADVICE_LIMIT = 300;
25
25
  const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
26
26
  let notice;
27
+ let newestSeen;
27
28
  function older(left, right) {
28
29
  const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
29
30
  for (let index = 0; index < 3; index += 1) {
@@ -55,6 +56,7 @@ export function noteServerVersion(headers) {
55
56
  return;
56
57
  if (!older(CLI_VERSION, newest))
57
58
  return;
59
+ newestSeen = newest;
58
60
  const advice = headers.get(CLI_ADVICE_HEADER);
59
61
  const line = advice === null ? "" : bounded(advice);
60
62
  notice =
@@ -66,7 +68,13 @@ export function noteServerVersion(headers) {
66
68
  export function updateNotice() {
67
69
  return notice;
68
70
  }
71
+ /** The newer version the server named, when it named one; nothing when this
72
+ * copy is current or no server has answered yet. */
73
+ export function newerVersionPublished() {
74
+ return newestSeen;
75
+ }
69
76
  /** For tests, and for a process that runs more than one command. */
70
77
  export function resetUpdateNotice() {
71
78
  notice = undefined;
79
+ newestSeen = undefined;
72
80
  }
@@ -2027,7 +2027,7 @@ var require_toml = __commonJS({
2027
2027
  });
2028
2028
 
2029
2029
  // src/wire.ts
2030
- var CLI_VERSION = "1.0.8";
2030
+ var CLI_VERSION = "1.0.11";
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";
@@ -2119,6 +2119,37 @@ function readCredentials(environment = process.env) {
2119
2119
  }
2120
2120
  }
2121
2121
 
2122
+ // src/currency.ts
2123
+ var CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
2124
+ var CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
2125
+ var ADVICE_LIMIT = 300;
2126
+ var VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
2127
+ var notice;
2128
+ var newestSeen;
2129
+ function older(left, right) {
2130
+ const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
2131
+ for (let index = 0; index < 3; index += 1) {
2132
+ const difference = a[index] - b[index];
2133
+ if (difference !== 0) return difference < 0;
2134
+ }
2135
+ return false;
2136
+ }
2137
+ function bounded(value) {
2138
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, ADVICE_LIMIT);
2139
+ }
2140
+ function noteServerVersion(headers) {
2141
+ const newest = headers.get(CLI_NEWEST_HEADER)?.trim();
2142
+ if (newest === void 0 || !VERSION.test(newest)) return;
2143
+ if (!older(CLI_VERSION, newest)) return;
2144
+ newestSeen = newest;
2145
+ const advice = headers.get(CLI_ADVICE_HEADER);
2146
+ const line = advice === null ? "" : bounded(advice);
2147
+ notice = line === "" ? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run npx -y balladeer@latest setup --refresh to update the command and instructions, then restart this session.` : line;
2148
+ }
2149
+ function newerVersionPublished() {
2150
+ return newestSeen;
2151
+ }
2152
+
2122
2153
  // src/guidance.ts
2123
2154
  import { createHash, randomBytes } from "node:crypto";
2124
2155
  import {
@@ -2275,6 +2306,7 @@ async function loadGuidance(input) {
2275
2306
  ...previous ? { "if-none-match": previous.etag } : {}
2276
2307
  }
2277
2308
  });
2309
+ noteServerVersion(response.headers);
2278
2310
  if (response.status === 304) {
2279
2311
  if (!previous || response.headers.get("etag") !== previous.etag || response.headers.get("x-balladeer-guidance-revision") !== previous.document.revision) {
2280
2312
  throw new Error("invalid_guidance_revalidation");
@@ -2413,7 +2445,7 @@ import {
2413
2445
  import { basename, dirname as dirname2, isAbsolute, join as join3 } from "node:path";
2414
2446
 
2415
2447
  // src/release.ts
2416
- var PUBLISHED_SPECIFIER = "balladeer@latest";
2448
+ var HOOK_SPECIFIER = "balladeer@1";
2417
2449
 
2418
2450
  // src/mcp-config.ts
2419
2451
  var MCP_CONFIG_FILE = ".mcp.json";
@@ -2874,6 +2906,16 @@ function validateProjectGuidanceScope(options) {
2874
2906
  var REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
2875
2907
  function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
2876
2908
  const here = agents.filter((agent) => agent.controlPlane === controlPlane);
2909
+ if (typeof currentRepository !== "string") {
2910
+ const candidates = currentRepository.filter(
2911
+ (name) => REPOSITORY_NAME.test(name) && name !== "unknown/unknown"
2912
+ );
2913
+ for (const name of candidates) {
2914
+ const found = selectAgent(agents, controlPlane, repositoryId, name);
2915
+ if (found.kind !== "refused") return found;
2916
+ }
2917
+ return selectAgent(agents, controlPlane, repositoryId, candidates[0] ?? "unknown/unknown");
2918
+ }
2877
2919
  if (repositoryId !== void 0) {
2878
2920
  const named = here.find(
2879
2921
  (agent) => agent.repositoryId === repositoryId || agent.repository?.toLowerCase() === repositoryId.toLowerCase()
@@ -2926,73 +2968,80 @@ function withSafeEndpoint(agent) {
2926
2968
  // src/repository.ts
2927
2969
  import { execFileSync as execFileSync2 } from "node:child_process";
2928
2970
  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;
2971
+ function git(cwd, args) {
2931
2972
  try {
2932
- remote = execFileSync2("git", ["-C", cwd, "remote", "get-url", "origin"], {
2973
+ return execFileSync2("git", ["-C", cwd, ...args], {
2933
2974
  encoding: "utf8",
2934
2975
  stdio: ["ignore", "pipe", "ignore"]
2935
2976
  }).trim();
2936
2977
  } catch {
2937
- return "unknown/unknown";
2978
+ return void 0;
2938
2979
  }
2980
+ }
2981
+ function ownerName(remote) {
2939
2982
  const match = /github\.com[:/]+([A-Za-z0-9._-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(
2940
2983
  remote
2941
2984
  );
2942
- if (!match) return "unknown/unknown";
2985
+ if (!match) return void 0;
2943
2986
  const hint = `${match[1]}/${match[2]}`;
2944
- return REPOSITORY_HINT_PATTERN.test(hint) ? hint : "unknown/unknown";
2987
+ return REPOSITORY_HINT_PATTERN.test(hint) ? hint : void 0;
2988
+ }
2989
+ function repositoryHints(cwd = process.cwd()) {
2990
+ const names = git(cwd, ["remote"])?.split("\n").filter((name) => name.length > 0) ?? [];
2991
+ const ordered = ["origin", ...names.filter((name) => name !== "origin")];
2992
+ const hints = [];
2993
+ for (const name of ordered) {
2994
+ const url = git(cwd, ["remote", "get-url", name]);
2995
+ const hint = url === void 0 ? void 0 : ownerName(url);
2996
+ if (hint !== void 0 && !hints.some((h) => h.toLowerCase() === hint.toLowerCase()))
2997
+ hints.push(hint);
2998
+ }
2999
+ return hints;
2945
3000
  }
2946
3001
 
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";
3002
+ // src/self-update.ts
3003
+ import { spawn } from "node:child_process";
3004
+ import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
2950
3005
  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");
3006
+ var STAMP = "self-update.json";
3007
+ var DAY_MS = 24 * 60 * 60 * 1e3;
3008
+ function stampPath(environment) {
3009
+ return join6(configHome(environment), STAMP);
2954
3010
  }
2955
- function readQuiet(environment = process.env) {
2956
- const path = quietPath(environment);
2957
- if (!existsSync3(path)) return EMPTY2;
3011
+ function lastStarted(environment) {
3012
+ const path = stampPath(environment);
3013
+ if (!existsSync3(path)) return 0;
2958
3014
  try {
2959
3015
  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
- };
3016
+ return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
2966
3017
  } catch {
2967
- return EMPTY2;
3018
+ return 0;
2968
3019
  }
2969
3020
  }
2970
- var isString = (value) => typeof value === "string";
2971
- function folderKey(cwd) {
3021
+ function detached(command, args) {
3022
+ const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
3023
+ child.on("error", () => void 0);
3024
+ child.unref();
3025
+ }
3026
+ function startSelfUpdateIfDue(newer, deps) {
3027
+ if (newer === void 0) return false;
3028
+ const now = deps.now ?? Date.now;
3029
+ if (now() - lastStarted(deps.environment) < DAY_MS) return false;
2972
3030
  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()
3031
+ mkdirSync5(configHome(deps.environment), { recursive: true, mode: 448 });
3032
+ writeFileSync3(
3033
+ stampPath(deps.environment),
3034
+ JSON.stringify({ startedAt: now(), toward: newer }) + "\n",
3035
+ { mode: 384 }
2979
3036
  );
3037
+ (deps.run ?? detached)("npx", ["-y", HOOK_SPECIFIER, "install", "--json"]);
3038
+ return true;
2980
3039
  } catch {
2981
- return realpathSync(cwd);
3040
+ return false;
2982
3041
  }
2983
3042
  }
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
3043
 
2991
3044
  // 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
- }
2996
3045
  function readInput(stream) {
2997
3046
  return new Promise((resolve2, reject) => {
2998
3047
  let bytes = 0;
@@ -3065,14 +3114,12 @@ async function runGuidance(options) {
3065
3114
  credentials,
3066
3115
  options.controlPlane,
3067
3116
  void 0,
3068
- repositoryHint(options.cwd)
3117
+ repositoryHints(options.cwd)
3069
3118
  );
3070
3119
  return selection.kind === "refused" ? [] : [selection.agent];
3071
3120
  })();
3072
3121
  if (agents.length !== 1 || !agents[0]) {
3073
3122
  if (projectScoped) throw new Error("guidance_connection_unavailable");
3074
- if (event === "SessionStart" && !isQuiet(options.cwd, options.environment))
3075
- emit(untrackedLine(repositoryHint(options.cwd)));
3076
3123
  return 0;
3077
3124
  }
3078
3125
  const loaded = await loadGuidance({
@@ -3081,6 +3128,12 @@ async function runGuidance(options) {
3081
3128
  timeoutMs: 750,
3082
3129
  ...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
3083
3130
  });
3131
+ if (event === "SessionStart")
3132
+ startSelfUpdateIfDue(newerVersionPublished(), {
3133
+ environment: options.environment,
3134
+ ...options.startUpdate ? { run: options.startUpdate } : {},
3135
+ ...options.now ? { now: options.now } : {}
3136
+ });
3084
3137
  const sessionId = identifier(record.session_id);
3085
3138
  const agentId = identifier(record.agent_id);
3086
3139
  recordGuidanceContext(
package/dist/guidance.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { noteServerVersion } from "./currency.js";
1
2
  import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
2
3
  import { createHash, randomBytes } from "node:crypto";
3
4
  import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
@@ -182,6 +183,9 @@ export async function loadGuidance(input) {
182
183
  ...(previous ? { "if-none-match": previous.etag } : {}),
183
184
  },
184
185
  });
186
+ // The server names the newest published release on every answer; the
187
+ // hook is where a laptop learns it is behind, and never where it waits.
188
+ noteServerVersion(response.headers);
185
189
  if (response.status === 304) {
186
190
  if (!previous ||
187
191
  response.headers.get("etag") !== previous.etag ||
package/dist/install.d.ts CHANGED
@@ -21,5 +21,7 @@ export declare function runInstall(options: InstallOptions & {
21
21
  allowPartial?: boolean;
22
22
  /** Carried into the one JSON object this prints, never as a second line. */
23
23
  extra?: Record<string, unknown>;
24
+ /** Told the durable command's path once it is installed, before the report. */
25
+ onInstalled?: (result: InstallResult) => Record<string, unknown> | undefined;
24
26
  }): number;
25
27
  export {};
package/dist/install.js CHANGED
@@ -333,6 +333,7 @@ export function ensureInstalled(options) {
333
333
  export function runInstall(options) {
334
334
  try {
335
335
  const result = ensureInstalled(options);
336
+ const more = options.onInstalled?.(result) ?? {};
336
337
  const message = result.manualPathRequired
337
338
  ? `Balladeer is installed at ${result.command}. Add ${dirname(result.command)} to your shell PATH and reopen the terminal/coding agent. Automatic PATH editing is unavailable for this shell; this setup process can use the command now.`
338
339
  : result.restartRequired
@@ -345,6 +346,7 @@ export function runInstall(options) {
345
346
  status: result.manualPathRequired ? "path_pending" : result.status,
346
347
  message,
347
348
  ...(options.extra ?? {}),
349
+ ...more,
348
350
  }) + "\n"
349
351
  : message + "\n");
350
352
  return result.manualPathRequired && !options.allowPartial ? 4 : 0;
@@ -0,0 +1,13 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Open a page in the person's default browser, and say whether it was asked.
4
+ *
5
+ * Setup's one link used to be copied by hand from the terminal into a browser,
6
+ * which is the step a person at a keyboard does not want and an agent shell
7
+ * cannot do at all. So this runs only when a person is there (the caller
8
+ * checks the terminal), hands the URL to the operating system's own opener,
9
+ * and does not wait for it: a browser that never appears is reported by the
10
+ * link staying on screen, never by this command hanging. Nothing here reads
11
+ * the page or learns what happened in it.
12
+ */
13
+ export declare function openInBrowser(url: string, platform?: NodeJS.Platform, run?: typeof spawn): boolean;
@@ -0,0 +1,30 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Open a page in the person's default browser, and say whether it was asked.
4
+ *
5
+ * Setup's one link used to be copied by hand from the terminal into a browser,
6
+ * which is the step a person at a keyboard does not want and an agent shell
7
+ * cannot do at all. So this runs only when a person is there (the caller
8
+ * checks the terminal), hands the URL to the operating system's own opener,
9
+ * and does not wait for it: a browser that never appears is reported by the
10
+ * link staying on screen, never by this command hanging. Nothing here reads
11
+ * the page or learns what happened in it.
12
+ */
13
+ export function openInBrowser(url, platform = process.platform, run = spawn) {
14
+ if (!/^https:\/\//.test(url))
15
+ return false;
16
+ const [command, args] = platform === "darwin"
17
+ ? ["open", [url]]
18
+ : platform === "win32"
19
+ ? ["cmd", ["/c", "start", "", url]]
20
+ : ["xdg-open", [url]];
21
+ try {
22
+ const child = run(command, args, { detached: true, stdio: "ignore" });
23
+ child.on("error", () => undefined);
24
+ child.unref();
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
package/dist/release.d.ts CHANGED
@@ -8,6 +8,12 @@
8
8
  export declare const CHECKOUT_COMMAND = "node packages/cli/dist/cli.js";
9
9
  /** The specifier every published surface names, which is a tag and never a pin. */
10
10
  export declare const PUBLISHED_SPECIFIER = "balladeer@latest";
11
+ /**
12
+ * What a hook or MCP entry runs when no durable command is installed: the
13
+ * current major, so a 2.x is a choice a person makes and never a surprise at
14
+ * session start. The typed first command stays `@latest`.
15
+ */
16
+ export declare const HOOK_SPECIFIER = "balladeer@1";
11
17
  /**
12
18
  * How to invoke this command, with no subcommand attached.
13
19
  *
package/dist/release.js CHANGED
@@ -10,6 +10,12 @@ import { fileURLToPath } from "node:url";
10
10
  export const CHECKOUT_COMMAND = "node packages/cli/dist/cli.js";
11
11
  /** The specifier every published surface names, which is a tag and never a pin. */
12
12
  export const PUBLISHED_SPECIFIER = "balladeer@latest";
13
+ /**
14
+ * What a hook or MCP entry runs when no durable command is installed: the
15
+ * current major, so a 2.x is a choice a person makes and never a surprise at
16
+ * session start. The typed first command stays `@latest`.
17
+ */
18
+ export const HOOK_SPECIFIER = "balladeer@1";
13
19
  /**
14
20
  * How to invoke this command, with no subcommand attached.
15
21
  *
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Taking the earlier Balladeer off a laptop, so one server answers to the name.
3
+ *
4
+ * The July client registered a hosted MCP connector under the same name this
5
+ * product uses, `balladeer`, at the host's user scope; it installed a capture
6
+ * hook and a status line, wrote a fenced guidance block into the person's own
7
+ * CLAUDE.md and Codex AGENTS.md, dropped a managed BALLADEER.md beside them, and
8
+ * put a 0.x `balladeer` binary on PATH through Volta. Left in place, `/mcp`
9
+ * shows the old server with its 22 tools and this product's user-scope install
10
+ * refuses to write over a `balladeer` entry that is not its own, so the person
11
+ * sees the old Balladeer and never the new one.
12
+ *
13
+ * Every removal here proves ownership from the entry itself, the way the older
14
+ * client's own uninstall did: an HTTP connector under the reserved key, a hook
15
+ * that runs its capture script, a status line that names balladeer, the fence
16
+ * markers it wrote, the managed-by header. Anything else in those files is not
17
+ * touched. Each file is copied beside itself before it is changed, and the
18
+ * copy's path is reported, so the whole thing reverses by hand in a minute.
19
+ * The old binary is removed only through Volta, which is how it was installed,
20
+ * and only when the shim reports a version before 1.0.0.
21
+ */
22
+ export type EarlierItem = Readonly<{
23
+ /** The file, in the form a person can go and open. */
24
+ path: string;
25
+ /** What was found there, bounded. */
26
+ found: string;
27
+ /** Takes it off. Returns the backup path when a file was changed. */
28
+ remove: () => string | undefined;
29
+ }>;
30
+ /** The git top level of a folder, or nothing when it is not inside a repository. */
31
+ export declare function repositoryRootOf(cwd: string): string | undefined;
32
+ /**
33
+ * The sentence the terminal and the day-one documents share about the earlier
34
+ * client, so a person who read ahead and a person who hits it are told one thing.
35
+ */
36
+ export declare const EARLIER_INSTRUCTION = "If you used the earlier Balladeer on this machine, setup finds it, copies each file it changes beside itself, and removes the old server, hooks and guidance blocks (under your home and in this repository's own CLAUDE.md or AGENTS.md, where you commit the change) before pairing; `balladeer remove-earlier` does the same on its own.";
37
+ /**
38
+ * What the older client left in the repository the person is standing in: a
39
+ * fence in its CLAUDE.md or AGENTS.md, a managed BALLADEER.md, or its Cursor
40
+ * rule. Committed once, these reach every clone and every teammate, which is
41
+ * why the home directory alone is not enough.
42
+ */
43
+ export declare function findEarlierInRepository(root: string, now?: () => Date): EarlierItem[];
44
+ export declare function findEarlier(options: {
45
+ home: string;
46
+ controlPlane: string;
47
+ environment: NodeJS.ProcessEnv;
48
+ /** The folder the command runs in; its repository is searched too. */
49
+ cwd?: string;
50
+ now?: () => Date;
51
+ }): EarlierItem[];
52
+ export type RemovalReport = Readonly<{
53
+ removed: readonly {
54
+ path: string;
55
+ found: string;
56
+ backup?: string;
57
+ }[];
58
+ failed: readonly {
59
+ path: string;
60
+ found: string;
61
+ reason: string;
62
+ }[];
63
+ }>;
64
+ /** Takes everything found off, one item at a time, and says what happened to each. */
65
+ export declare function removeEarlier(items: readonly EarlierItem[]): RemovalReport;
66
+ /** What a person reads, before and after. */
67
+ export declare function describeEarlier(items: readonly EarlierItem[]): string[];
68
+ export declare function describeRemoval(report: RemovalReport): string[];