balladeer 1.0.11 → 1.0.12
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 +7 -4
- package/dist/commands/setup.d.ts +3 -2
- package/dist/commands/setup.js +41 -9
- package/dist/guidance-hook.mjs +1 -1
- package/dist/open-browser.d.ts +1 -1
- package/dist/open-browser.js +6 -1
- package/dist/user-scope.js +22 -1
- package/dist/wire.d.ts +3 -3
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/dist/legacy.d.ts +0 -41
- package/dist/legacy.js +0 -143
- package/dist/quiet.d.ts +0 -25
- package/dist/quiet.js +0 -73
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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 }),
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|
package/dist/commands/setup.js
CHANGED
|
@@ -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";
|
|
@@ -24,6 +23,7 @@ import { explainText } from "./explain.js";
|
|
|
24
23
|
import { probeAgentConnection } from "./mcp.js";
|
|
25
24
|
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.";
|
|
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
|
-
? `
|
|
156
|
-
: `
|
|
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
|
-
? "
|
|
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
|
-
|
|
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
|
|
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
|
|
607
|
-
|
|
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, {
|
package/dist/guidance-hook.mjs
CHANGED
|
@@ -2027,7 +2027,7 @@ var require_toml = __commonJS({
|
|
|
2027
2027
|
});
|
|
2028
2028
|
|
|
2029
2029
|
// src/wire.ts
|
|
2030
|
-
var CLI_VERSION = "1.0.
|
|
2030
|
+
var CLI_VERSION = "1.0.12";
|
|
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";
|
package/dist/open-browser.d.ts
CHANGED
|
@@ -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;
|
package/dist/open-browser.js
CHANGED
|
@@ -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"
|
package/dist/user-scope.js
CHANGED
|
@@ -43,10 +43,22 @@ export function userScopeCommand(published, installed) {
|
|
|
43
43
|
function ordinaryFile(path) {
|
|
44
44
|
return !existsSync(path) || (lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink());
|
|
45
45
|
}
|
|
46
|
+
/** The home the entries go under has to exist; a home that does not is a
|
|
47
|
+
* refusal in a sentence, never a stack trace. */
|
|
48
|
+
function homeExists(home) {
|
|
49
|
+
try {
|
|
50
|
+
return lstatSync(home).isDirectory();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
46
56
|
/** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
|
|
47
57
|
export function mergeClaudeUserMcp(home, published, installed) {
|
|
48
58
|
const path = join(home, ".claude.json");
|
|
49
59
|
const host = "claude";
|
|
60
|
+
if (!homeExists(home))
|
|
61
|
+
return { host, path, status: "refused", reason: "home directory missing" };
|
|
50
62
|
if (!ordinaryFile(path))
|
|
51
63
|
return { host, path, status: "refused", reason: "not an ordinary file" };
|
|
52
64
|
let root = {};
|
|
@@ -96,8 +108,15 @@ export function isOurUserEntry(entry) {
|
|
|
96
108
|
args[0] === "-y" &&
|
|
97
109
|
/^balladeer@(?:latest|1|\d+\.\d+\.\d+)$/.test(args[1]) &&
|
|
98
110
|
args[2] === "mcp");
|
|
111
|
+
// A checkout of this repository at any path: the entry setup writes when it
|
|
112
|
+
// runs from source, on this laptop or another. Ours to replace, never to
|
|
113
|
+
// refuse; refusing it once left a developer's laptop with no entry at all.
|
|
99
114
|
if (record.command === "node" || String(record.command).endsWith("/node"))
|
|
100
|
-
return args.length === 2 &&
|
|
115
|
+
return (args.length === 2 &&
|
|
116
|
+
typeof args[0] === "string" &&
|
|
117
|
+
isAbsolute(args[0]) &&
|
|
118
|
+
/[\\/]packages[\\/]cli[\\/]dist[\\/]cli\.js$/.test(args[0]) &&
|
|
119
|
+
args[1] === "mcp");
|
|
101
120
|
// The durable command by absolute path: ours when it sits where this
|
|
102
121
|
// product's installer puts it and is asked for `mcp` alone. The July client
|
|
103
122
|
// also registered an absolute `balladeer mcp`, from its own home, so the
|
|
@@ -127,6 +146,8 @@ export function isInstalledCommandPath(command) {
|
|
|
127
146
|
export function mergeClaudeUserHooks(home, published, installed) {
|
|
128
147
|
const path = join(home, ".claude", "settings.json");
|
|
129
148
|
const host = "claude";
|
|
149
|
+
if (!homeExists(home))
|
|
150
|
+
return { host, path, status: "refused", reason: "home directory missing" };
|
|
130
151
|
if (!ordinaryFile(path))
|
|
131
152
|
return { host, path, status: "refused", reason: "not an ordinary file" };
|
|
132
153
|
let root = {};
|
package/dist/wire.d.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export declare const CLI_VERSION = "1.0.
|
|
8
|
+
export declare const CLI_VERSION = "1.0.12";
|
|
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
|
+
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.12";
|
|
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.
|
|
8
|
+
export const CLI_VERSION = "1.0.12";
|
|
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
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
|
-
}
|