balladeer 1.0.8 → 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/agent.d.ts +1 -1
- package/dist/agent.js +12 -0
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +120 -56
- package/dist/commands/discover.js +2 -2
- package/dist/commands/guidance.d.ts +11 -1
- package/dist/commands/guidance.js +24 -11
- package/dist/commands/mcp.js +2 -2
- package/dist/commands/prepare.js +2 -2
- package/dist/commands/propose.js +2 -2
- package/dist/commands/session.js +2 -2
- package/dist/commands/setup.d.ts +16 -0
- package/dist/commands/setup.js +86 -18
- package/dist/commands/status.js +2 -2
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +8 -0
- package/dist/guidance-hook.mjs +99 -46
- package/dist/guidance.js +4 -0
- package/dist/install.d.ts +2 -0
- package/dist/install.js +2 -0
- package/dist/open-browser.d.ts +13 -0
- package/dist/open-browser.js +35 -0
- package/dist/release.d.ts +6 -0
- package/dist/release.js +6 -0
- package/dist/remove-earlier.d.ts +68 -0
- package/dist/remove-earlier.js +378 -0
- package/dist/repository.d.ts +9 -0
- package/dist/repository.js +31 -5
- package/dist/self-update.d.ts +11 -0
- package/dist/self-update.js +59 -0
- package/dist/user-scope.d.ts +23 -7
- package/dist/user-scope.js +72 -18
- package/dist/wire.d.ts +16 -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/user-scope.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { parse } from "@iarna/toml";
|
|
5
|
-
import {
|
|
5
|
+
import { HOOK_SPECIFIER, checkoutEntryPath } from "./release.js";
|
|
6
6
|
import { writeJsonAtomically } from "./mcp-config.js";
|
|
7
7
|
/**
|
|
8
8
|
* Install Balladeer once per laptop.
|
|
@@ -22,21 +22,43 @@ 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
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The command a host runs to reach Balladeer, with no repository named: the
|
|
27
|
+
* directory decides.
|
|
28
|
+
*
|
|
29
|
+
* With a durable command installed, the entries name it by absolute path:
|
|
30
|
+
* no npx, so a session start costs node's startup and nothing else, and no
|
|
31
|
+
* PATH, so a host launched from the dock finds it. A laptop where the durable
|
|
32
|
+
* install was refused gets the `npx` form on the current major, which works
|
|
33
|
+
* everywhere and costs a registry lookup per session. The checkout form
|
|
34
|
+
* exists so this repository's own tests can point a host at source.
|
|
35
|
+
*/
|
|
36
|
+
export function userScopeCommand(published, installed) {
|
|
37
|
+
if (installed !== undefined)
|
|
38
|
+
return { command: installed, args: [] };
|
|
29
39
|
return published
|
|
30
|
-
? { command: "npx", args: ["-y",
|
|
40
|
+
? { command: "npx", args: ["-y", HOOK_SPECIFIER] }
|
|
31
41
|
: { command: "node", args: [checkoutEntryPath()] };
|
|
32
42
|
}
|
|
33
43
|
function ordinaryFile(path) {
|
|
34
44
|
return !existsSync(path) || (lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink());
|
|
35
45
|
}
|
|
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
|
+
}
|
|
36
56
|
/** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
|
|
37
|
-
export function mergeClaudeUserMcp(home, published) {
|
|
57
|
+
export function mergeClaudeUserMcp(home, published, installed) {
|
|
38
58
|
const path = join(home, ".claude.json");
|
|
39
59
|
const host = "claude";
|
|
60
|
+
if (!homeExists(home))
|
|
61
|
+
return { host, path, status: "refused", reason: "home directory missing" };
|
|
40
62
|
if (!ordinaryFile(path))
|
|
41
63
|
return { host, path, status: "refused", reason: "not an ordinary file" };
|
|
42
64
|
let root = {};
|
|
@@ -54,7 +76,7 @@ export function mergeClaudeUserMcp(home, published) {
|
|
|
54
76
|
const servers = root.mcpServers && typeof root.mcpServers === "object" && !Array.isArray(root.mcpServers)
|
|
55
77
|
? root.mcpServers
|
|
56
78
|
: {};
|
|
57
|
-
const { command, args } = userScopeCommand(published);
|
|
79
|
+
const { command, args } = userScopeCommand(published, installed);
|
|
58
80
|
const expected = { type: "stdio", command, args: [...args, "mcp"] };
|
|
59
81
|
const current = servers.balladeer;
|
|
60
82
|
if (current !== undefined && !isOurUserEntry(current)) {
|
|
@@ -84,16 +106,48 @@ export function isOurUserEntry(entry) {
|
|
|
84
106
|
if (record.command === "npx")
|
|
85
107
|
return (args.length === 3 &&
|
|
86
108
|
args[0] === "-y" &&
|
|
87
|
-
/^balladeer@(?:latest|\d+\.\d+\.\d+)$/.test(args[1]) &&
|
|
109
|
+
/^balladeer@(?:latest|1|\d+\.\d+\.\d+)$/.test(args[1]) &&
|
|
88
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.
|
|
89
114
|
if (record.command === "node" || String(record.command).endsWith("/node"))
|
|
90
|
-
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");
|
|
120
|
+
// The durable command by absolute path: ours when it sits where this
|
|
121
|
+
// product's installer puts it and is asked for `mcp` alone. The July client
|
|
122
|
+
// also registered an absolute `balladeer mcp`, from its own home, so the
|
|
123
|
+
// name is not enough; the place is what says whose it is.
|
|
124
|
+
if (typeof record.command === "string" && isAbsolute(record.command))
|
|
125
|
+
return isInstalledCommandPath(record.command) && args.length === 1 && args[0] === "mcp";
|
|
91
126
|
return false;
|
|
92
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Where `ensureInstalled` puts the durable command: Volta's shim directory, an
|
|
130
|
+
* npm global prefix's `bin` with the package beside it under `lib/node_modules`,
|
|
131
|
+
* or this product's own fallback prefix under the person's home.
|
|
132
|
+
*/
|
|
133
|
+
export function isInstalledCommandPath(command) {
|
|
134
|
+
if (basename(command) !== "balladeer")
|
|
135
|
+
return false;
|
|
136
|
+
const normalized = command.replaceAll("\\", "/");
|
|
137
|
+
if (/\/\.volta\/bin\/balladeer$/.test(normalized))
|
|
138
|
+
return true;
|
|
139
|
+
if (/\/\.local\/share\/balladeer\/npm\/bin\/balladeer$/.test(normalized))
|
|
140
|
+
return true;
|
|
141
|
+
const bin = dirname(command);
|
|
142
|
+
return (basename(bin) === "bin" &&
|
|
143
|
+
existsSync(join(dirname(bin), "lib", "node_modules", "balladeer", "package.json")));
|
|
144
|
+
}
|
|
93
145
|
/** `~/.claude/settings.json` carries user-scope hooks. */
|
|
94
|
-
export function mergeClaudeUserHooks(home, published) {
|
|
146
|
+
export function mergeClaudeUserHooks(home, published, installed) {
|
|
95
147
|
const path = join(home, ".claude", "settings.json");
|
|
96
148
|
const host = "claude";
|
|
149
|
+
if (!homeExists(home))
|
|
150
|
+
return { host, path, status: "refused", reason: "home directory missing" };
|
|
97
151
|
if (!ordinaryFile(path))
|
|
98
152
|
return { host, path, status: "refused", reason: "not an ordinary file" };
|
|
99
153
|
let root = {};
|
|
@@ -111,7 +165,7 @@ export function mergeClaudeUserHooks(home, published) {
|
|
|
111
165
|
const hooks = root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
|
|
112
166
|
? { ...root.hooks }
|
|
113
167
|
: {};
|
|
114
|
-
const { command, args } = userScopeCommand(published);
|
|
168
|
+
const { command, args } = userScopeCommand(published, installed);
|
|
115
169
|
const line = [command, ...args, "guidance", "--hook", "claude"].join(" ");
|
|
116
170
|
let changed = false;
|
|
117
171
|
for (const event of CLAUDE_EVENTS) {
|
|
@@ -149,7 +203,7 @@ const CODEX_START = "# balladeer:user:start";
|
|
|
149
203
|
const CODEX_END = "# balladeer:user:end";
|
|
150
204
|
/** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
|
|
151
205
|
* one fenced block this command owns end to end. */
|
|
152
|
-
export function mergeCodexUserConfig(home, published) {
|
|
206
|
+
export function mergeCodexUserConfig(home, published, installed) {
|
|
153
207
|
const path = join(home, ".codex", "config.toml");
|
|
154
208
|
const host = "codex";
|
|
155
209
|
if (!ordinaryFile(path))
|
|
@@ -163,7 +217,7 @@ export function mergeCodexUserConfig(home, published) {
|
|
|
163
217
|
catch {
|
|
164
218
|
return { host, path, status: "refused", reason: "could not read valid TOML" };
|
|
165
219
|
}
|
|
166
|
-
const { command, args } = userScopeCommand(published);
|
|
220
|
+
const { command, args } = userScopeCommand(published, installed);
|
|
167
221
|
const line = [command, ...args, "guidance", "--hook", "codex"].join(" ");
|
|
168
222
|
const block = [
|
|
169
223
|
CODEX_START,
|
|
@@ -225,10 +279,10 @@ export function userHome(environment = process.env) {
|
|
|
225
279
|
export function installUserScope(options) {
|
|
226
280
|
const home = options.home ?? userHome(options.environment);
|
|
227
281
|
const writes = [
|
|
228
|
-
mergeClaudeUserMcp(home, options.published),
|
|
229
|
-
mergeClaudeUserHooks(home, options.published),
|
|
282
|
+
mergeClaudeUserMcp(home, options.published, options.installed),
|
|
283
|
+
mergeClaudeUserHooks(home, options.published, options.installed),
|
|
230
284
|
];
|
|
231
285
|
if (existsSync(join(home, ".codex")))
|
|
232
|
-
writes.push(mergeCodexUserConfig(home, options.published));
|
|
286
|
+
writes.push(mergeCodexUserConfig(home, options.published, options.installed));
|
|
233
287
|
return writes;
|
|
234
288
|
}
|
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[];
|
|
@@ -222,6 +222,19 @@ export type ClaudeDesktopStep = Readonly<{
|
|
|
222
222
|
reason?: string;
|
|
223
223
|
}>;
|
|
224
224
|
export type JsonStep = Readonly<{
|
|
225
|
+
/** The earlier Balladeer found on this machine and what setup did about it. */
|
|
226
|
+
step: "earlier";
|
|
227
|
+
removed: readonly {
|
|
228
|
+
path: string;
|
|
229
|
+
found: string;
|
|
230
|
+
backup?: string;
|
|
231
|
+
}[];
|
|
232
|
+
failed: readonly {
|
|
233
|
+
path: string;
|
|
234
|
+
found: string;
|
|
235
|
+
reason: string;
|
|
236
|
+
}[];
|
|
237
|
+
}> | Readonly<{
|
|
225
238
|
step: "guidance_loader";
|
|
226
239
|
status: "installed_needs_host_trust" | "installed_trust_unverified" | "unavailable" | "not_applicable";
|
|
227
240
|
changed: boolean;
|
|
@@ -332,7 +345,7 @@ export type JsonStep = Readonly<{
|
|
|
332
345
|
reason?: string;
|
|
333
346
|
}> | Readonly<{
|
|
334
347
|
step: "warning";
|
|
335
|
-
code: "enforcement_unavailable";
|
|
348
|
+
code: "enforcement_unavailable" | "not_in_checkout";
|
|
336
349
|
message: string;
|
|
337
350
|
}> | Readonly<{
|
|
338
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
|
-
}
|