balladeer 1.0.7 → 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.
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +12 -0
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +127 -15
- package/dist/commands/discover.js +2 -2
- package/dist/commands/guidance.d.ts +12 -0
- package/dist/commands/guidance.js +45 -5
- package/dist/commands/mcp.js +45 -3
- 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 +15 -0
- package/dist/commands/setup.js +51 -15
- 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 +200 -5
- package/dist/guidance.js +4 -0
- package/dist/install.d.ts +4 -0
- package/dist/install.js +9 -1
- package/dist/open-browser.d.ts +13 -0
- package/dist/open-browser.js +30 -0
- package/dist/quiet.d.ts +25 -0
- package/dist/quiet.js +73 -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 +71 -0
- package/dist/user-scope.js +267 -0
- package/dist/wire.d.ts +15 -2
- package/dist/wire.js +1 -1
- package/package.json +3 -2
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[];
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { parse } from "@iarna/toml";
|
|
5
|
+
import { isOurEntry, writeJsonAtomically } from "./mcp-config.js";
|
|
6
|
+
import { isOurUserEntry } from "./user-scope.js";
|
|
7
|
+
const BEGIN = "<!-- balladeer:begin -->";
|
|
8
|
+
const END = "<!-- balladeer:end -->";
|
|
9
|
+
const MANAGED_HEADER = /^<!-- managed by Balladeer \(conventions v\d+\)/;
|
|
10
|
+
function readJson(path) {
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
13
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
14
|
+
? parsed
|
|
15
|
+
: undefined;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function backup(path, now) {
|
|
22
|
+
const stamp = now().toISOString().replaceAll(/[:.]/g, "-");
|
|
23
|
+
const copy = `${path}.before-balladeer-${stamp}`;
|
|
24
|
+
copyFileSync(path, copy);
|
|
25
|
+
return copy;
|
|
26
|
+
}
|
|
27
|
+
/** Whether git holds this file's previous version, so no copy is needed and
|
|
28
|
+
* none is left in a working tree the person will commit from. */
|
|
29
|
+
function gitTracked(path) {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync("git", ["-C", dirname(path), "ls-files", "--error-unmatch", "--", path], {
|
|
32
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
33
|
+
timeout: 2000,
|
|
34
|
+
});
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The git top level of a folder, or nothing when it is not inside a repository. */
|
|
42
|
+
export function repositoryRootOf(cwd) {
|
|
43
|
+
try {
|
|
44
|
+
return execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
47
|
+
timeout: 2000,
|
|
48
|
+
}).trim();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The older client's entry: whatever sits under the reserved key that is not
|
|
55
|
+
* one of this product's shapes. Nothing else has reason to claim the name. */
|
|
56
|
+
function isEarlierConnector(entry, controlPlane) {
|
|
57
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
58
|
+
return false;
|
|
59
|
+
return !isOurEntry(entry, controlPlane) && !isOurUserEntry(entry);
|
|
60
|
+
}
|
|
61
|
+
/** The command line an entry runs, or its URL, in the shape a person recognises. */
|
|
62
|
+
function describeEntry(entry) {
|
|
63
|
+
const record = entry;
|
|
64
|
+
if (typeof record.url === "string")
|
|
65
|
+
return record.url;
|
|
66
|
+
const args = Array.isArray(record.args) ? record.args.map(String) : [];
|
|
67
|
+
return [String(record.command ?? ""), ...args].join(" ").trim().slice(0, 120);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The sentence the terminal and the day-one documents share about the earlier
|
|
71
|
+
* client, so a person who read ahead and a person who hits it are told one thing.
|
|
72
|
+
*/
|
|
73
|
+
export 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.";
|
|
74
|
+
function claudeConnector(home, controlPlane, now) {
|
|
75
|
+
const path = join(home, ".claude.json");
|
|
76
|
+
const root = readJson(path);
|
|
77
|
+
const servers = root?.mcpServers;
|
|
78
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
79
|
+
return [];
|
|
80
|
+
const entry = servers.balladeer;
|
|
81
|
+
if (!isEarlierConnector(entry, controlPlane))
|
|
82
|
+
return [];
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
path,
|
|
86
|
+
found: `the earlier MCP server named balladeer (${describeEntry(entry)})`,
|
|
87
|
+
remove: () => {
|
|
88
|
+
const copy = backup(path, now);
|
|
89
|
+
const current = readJson(path) ?? {};
|
|
90
|
+
const { balladeer: _gone, ...rest } = (current.mcpServers ?? {});
|
|
91
|
+
writeJsonAtomically(path, JSON.stringify({ ...current, mcpServers: rest }, null, 2) + "\n");
|
|
92
|
+
return copy;
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
const isEarlierHook = (command) => typeof command === "string" &&
|
|
98
|
+
/balladeer/i.test(command) &&
|
|
99
|
+
!/balladeer@(?:latest|\d+\.\d+\.\d+)\b/.test(command) &&
|
|
100
|
+
!/\bpackages[/\\]cli[/\\]dist[/\\]cli\.js\b/.test(command);
|
|
101
|
+
function claudeSettings(home, now) {
|
|
102
|
+
const path = join(home, ".claude", "settings.json");
|
|
103
|
+
const root = readJson(path);
|
|
104
|
+
if (root === undefined)
|
|
105
|
+
return [];
|
|
106
|
+
const items = [];
|
|
107
|
+
const statusLine = root.statusLine;
|
|
108
|
+
if (statusLine && typeof statusLine.command === "string" && /balladeer/i.test(statusLine.command))
|
|
109
|
+
items.push({
|
|
110
|
+
path,
|
|
111
|
+
found: `the earlier status line (${statusLine.command})`,
|
|
112
|
+
remove: () => {
|
|
113
|
+
const copy = backup(path, now);
|
|
114
|
+
const { statusLine: _gone, ...rest } = readJson(path) ?? {};
|
|
115
|
+
writeJsonAtomically(path, JSON.stringify(rest, null, 2) + "\n");
|
|
116
|
+
return copy;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
const hooks = root.hooks;
|
|
120
|
+
const earlier = [];
|
|
121
|
+
if (hooks && typeof hooks === "object")
|
|
122
|
+
for (const groups of Object.values(hooks))
|
|
123
|
+
if (Array.isArray(groups))
|
|
124
|
+
for (const group of groups)
|
|
125
|
+
for (const hook of (group?.hooks ?? []))
|
|
126
|
+
if (isEarlierHook(hook?.command))
|
|
127
|
+
earlier.push(String(hook.command));
|
|
128
|
+
if (earlier.length > 0)
|
|
129
|
+
items.push({
|
|
130
|
+
path,
|
|
131
|
+
found: `${earlier.length} earlier hook${earlier.length === 1 ? "" : "s"} (${earlier[0]}${earlier.length > 1 ? ", ..." : ""})`,
|
|
132
|
+
remove: () => {
|
|
133
|
+
const copy = backup(path, now);
|
|
134
|
+
const current = readJson(path) ?? {};
|
|
135
|
+
const kept = {};
|
|
136
|
+
for (const [event, groups] of Object.entries((current.hooks ?? {}))) {
|
|
137
|
+
if (!Array.isArray(groups)) {
|
|
138
|
+
kept[event] = groups;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const remaining = groups
|
|
142
|
+
.map((group) => {
|
|
143
|
+
const record = group;
|
|
144
|
+
if (!Array.isArray(record?.hooks))
|
|
145
|
+
return group;
|
|
146
|
+
const hooksLeft = record.hooks.filter((h) => !isEarlierHook(h?.command));
|
|
147
|
+
return hooksLeft.length === record.hooks.length
|
|
148
|
+
? group
|
|
149
|
+
: hooksLeft.length === 0
|
|
150
|
+
? undefined
|
|
151
|
+
: { ...record, hooks: hooksLeft };
|
|
152
|
+
})
|
|
153
|
+
.filter((group) => group !== undefined);
|
|
154
|
+
if (remaining.length > 0)
|
|
155
|
+
kept[event] = remaining;
|
|
156
|
+
}
|
|
157
|
+
writeJsonAtomically(path, JSON.stringify({ ...current, hooks: kept }, null, 2) + "\n");
|
|
158
|
+
return copy;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
return items;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The fenced block the older client wrote into an instructions file: the
|
|
165
|
+
* person's own under their home, or the repository's own CLAUDE.md or
|
|
166
|
+
* AGENTS.md, which a team commits and every clone then serves. A tracked file
|
|
167
|
+
* gets no copy beside it; git holds the previous version, and the line the
|
|
168
|
+
* person reads says to commit the change.
|
|
169
|
+
*/
|
|
170
|
+
function fencedBlock(path, now) {
|
|
171
|
+
if (!existsSync(path))
|
|
172
|
+
return [];
|
|
173
|
+
const text = readFileSync(path, "utf8");
|
|
174
|
+
const begin = text.indexOf(BEGIN);
|
|
175
|
+
const end = text.indexOf(END, begin + BEGIN.length);
|
|
176
|
+
if (begin < 0 || end < 0)
|
|
177
|
+
return [];
|
|
178
|
+
const tracked = gitTracked(path);
|
|
179
|
+
return [
|
|
180
|
+
{
|
|
181
|
+
path,
|
|
182
|
+
found: "the earlier guidance block between its balladeer:begin and balladeer:end markers" +
|
|
183
|
+
(tracked ? " (git tracks this file: commit the change)" : ""),
|
|
184
|
+
remove: () => {
|
|
185
|
+
const copy = tracked ? undefined : backup(path, now);
|
|
186
|
+
const current = readFileSync(path, "utf8");
|
|
187
|
+
const b = current.indexOf(BEGIN);
|
|
188
|
+
const e = current.indexOf(END, b + BEGIN.length);
|
|
189
|
+
if (b < 0 || e < 0)
|
|
190
|
+
return copy;
|
|
191
|
+
const before = current.slice(0, b).replace(/\n+$/, "\n");
|
|
192
|
+
const after = current.slice(e + END.length).replace(/^\n+/, "\n");
|
|
193
|
+
// What is left keeps one blank line where the block stood, and one
|
|
194
|
+
// newline at the end of the file, never a run of them.
|
|
195
|
+
const next = (before + after).replace(/^\n+/, "").replace(/\n{2,}$/, "\n");
|
|
196
|
+
writeJsonAtomically(path, next.trim().length === 0 ? "" : next);
|
|
197
|
+
return copy;
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
/** The managed conventions file, recognised by its own first line. */
|
|
203
|
+
function managedFile(path, now) {
|
|
204
|
+
if (!existsSync(path))
|
|
205
|
+
return [];
|
|
206
|
+
const first = readFileSync(path, "utf8").split("\n")[0] ?? "";
|
|
207
|
+
if (!MANAGED_HEADER.test(first))
|
|
208
|
+
return [];
|
|
209
|
+
const tracked = gitTracked(path);
|
|
210
|
+
return [
|
|
211
|
+
{
|
|
212
|
+
path,
|
|
213
|
+
found: "the earlier client's managed conventions file" +
|
|
214
|
+
(tracked ? " (git tracks this file: commit the change)" : ""),
|
|
215
|
+
remove: () => {
|
|
216
|
+
const copy = tracked ? undefined : backup(path, now);
|
|
217
|
+
unlinkSync(path);
|
|
218
|
+
return copy;
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* What the older client left in the repository the person is standing in: a
|
|
225
|
+
* fence in its CLAUDE.md or AGENTS.md, a managed BALLADEER.md, or its Cursor
|
|
226
|
+
* rule. Committed once, these reach every clone and every teammate, which is
|
|
227
|
+
* why the home directory alone is not enough.
|
|
228
|
+
*/
|
|
229
|
+
export function findEarlierInRepository(root, now = () => new Date()) {
|
|
230
|
+
return [
|
|
231
|
+
...fencedBlock(join(root, "CLAUDE.md"), now),
|
|
232
|
+
...fencedBlock(join(root, "AGENTS.md"), now),
|
|
233
|
+
...fencedBlock(join(root, ".claude", "CLAUDE.md"), now),
|
|
234
|
+
...managedFile(join(root, "BALLADEER.md"), now),
|
|
235
|
+
...managedFile(join(root, ".claude", "BALLADEER.md"), now),
|
|
236
|
+
...managedFile(join(root, ".cursor", "rules", "balladeer.mdc"), now),
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
function codexConfig(home, now) {
|
|
240
|
+
const path = join(home, ".codex", "config.toml");
|
|
241
|
+
if (!existsSync(path))
|
|
242
|
+
return [];
|
|
243
|
+
const text = readFileSync(path, "utf8");
|
|
244
|
+
let parsed;
|
|
245
|
+
try {
|
|
246
|
+
parsed = parse(text);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
const servers = parsed.mcp_servers;
|
|
252
|
+
const entry = servers?.balladeer;
|
|
253
|
+
if (!entry || typeof entry.url !== "string")
|
|
254
|
+
return [];
|
|
255
|
+
// The table is removed as text, from its header to the next table header,
|
|
256
|
+
// so every other line in the file keeps its bytes. Our own block is fenced
|
|
257
|
+
// and never has a url, so it cannot match here.
|
|
258
|
+
const table = /^\[mcp_servers\.balladeer\]\r?\n(?:(?!\[)[^\n]*\r?\n?)*/m;
|
|
259
|
+
if (!table.test(text))
|
|
260
|
+
return [];
|
|
261
|
+
return [
|
|
262
|
+
{
|
|
263
|
+
path,
|
|
264
|
+
found: `the earlier MCP server named balladeer, at ${entry.url}`,
|
|
265
|
+
remove: () => {
|
|
266
|
+
const copy = backup(path, now);
|
|
267
|
+
const next = readFileSync(path, "utf8").replace(table, "");
|
|
268
|
+
parse(next);
|
|
269
|
+
writeJsonAtomically(path, next);
|
|
270
|
+
return copy;
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
}
|
|
275
|
+
function cursorConnector(home, controlPlane, now) {
|
|
276
|
+
const path = join(home, ".cursor", "mcp.json");
|
|
277
|
+
const root = readJson(path);
|
|
278
|
+
const servers = root?.mcpServers;
|
|
279
|
+
const entry = servers?.balladeer;
|
|
280
|
+
if (!isEarlierConnector(entry, controlPlane))
|
|
281
|
+
return [];
|
|
282
|
+
return [
|
|
283
|
+
{
|
|
284
|
+
path,
|
|
285
|
+
found: `the earlier MCP server named balladeer (${describeEntry(entry)})`,
|
|
286
|
+
remove: () => {
|
|
287
|
+
const copy = backup(path, now);
|
|
288
|
+
const current = readJson(path) ?? {};
|
|
289
|
+
const { balladeer: _gone, ...rest } = (current.mcpServers ?? {});
|
|
290
|
+
writeJsonAtomically(path, JSON.stringify({ ...current, mcpServers: rest }, null, 2) + "\n");
|
|
291
|
+
return copy;
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
/** The 0.x binary Volta installed for the older client. This product's own
|
|
297
|
+
* durable command is 1.x and lives under Volta too, so the version decides. */
|
|
298
|
+
function voltaBinary(home, environment) {
|
|
299
|
+
const shim = join(home, ".volta", "bin", "balladeer");
|
|
300
|
+
if (!existsSync(shim))
|
|
301
|
+
return [];
|
|
302
|
+
let version;
|
|
303
|
+
try {
|
|
304
|
+
version = execFileSync(shim, ["--version"], {
|
|
305
|
+
encoding: "utf8",
|
|
306
|
+
env: environment,
|
|
307
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
308
|
+
timeout: 15_000,
|
|
309
|
+
}).trim();
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
if (!/^0\.\d+\.\d+/.test(version))
|
|
315
|
+
return [];
|
|
316
|
+
return [
|
|
317
|
+
{
|
|
318
|
+
path: shim,
|
|
319
|
+
found: `the earlier balladeer command, version ${version}`,
|
|
320
|
+
remove: () => {
|
|
321
|
+
execFileSync(join(home, ".volta", "bin", "volta"), ["uninstall", "balladeer"], {
|
|
322
|
+
env: environment,
|
|
323
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
324
|
+
timeout: 60_000,
|
|
325
|
+
});
|
|
326
|
+
return undefined;
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
export function findEarlier(options) {
|
|
332
|
+
const now = options.now ?? (() => new Date());
|
|
333
|
+
const { home, controlPlane } = options;
|
|
334
|
+
const codexHome = options.environment.CODEX_HOME?.trim() || join(home, ".codex");
|
|
335
|
+
const root = options.cwd === undefined ? undefined : repositoryRootOf(options.cwd);
|
|
336
|
+
return [
|
|
337
|
+
...(root === undefined ? [] : findEarlierInRepository(root, now)),
|
|
338
|
+
...claudeConnector(home, controlPlane, now),
|
|
339
|
+
...claudeSettings(home, now),
|
|
340
|
+
...fencedBlock(join(home, ".claude", "CLAUDE.md"), now),
|
|
341
|
+
...managedFile(join(home, ".claude", "BALLADEER.md"), now),
|
|
342
|
+
...codexConfig(home, now),
|
|
343
|
+
...fencedBlock(join(codexHome, "AGENTS.md"), now),
|
|
344
|
+
...fencedBlock(join(codexHome, "AGENTS.override.md"), now),
|
|
345
|
+
...managedFile(join(codexHome, "BALLADEER.md"), now),
|
|
346
|
+
...cursorConnector(home, controlPlane, now),
|
|
347
|
+
...voltaBinary(home, options.environment),
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
/** Takes everything found off, one item at a time, and says what happened to each. */
|
|
351
|
+
export function removeEarlier(items) {
|
|
352
|
+
const removed = [];
|
|
353
|
+
const failed = [];
|
|
354
|
+
for (const item of items) {
|
|
355
|
+
try {
|
|
356
|
+
const copy = item.remove();
|
|
357
|
+
removed.push({ path: item.path, found: item.found, ...(copy ? { backup: copy } : {}) });
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
failed.push({
|
|
361
|
+
path: item.path,
|
|
362
|
+
found: item.found,
|
|
363
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return { removed, failed };
|
|
368
|
+
}
|
|
369
|
+
/** What a person reads, before and after. */
|
|
370
|
+
export function describeEarlier(items) {
|
|
371
|
+
return items.map((item) => ` ${item.path}: ${item.found}`);
|
|
372
|
+
}
|
|
373
|
+
export function describeRemoval(report) {
|
|
374
|
+
const lines = report.removed.map((item) => ` Removed ${item.found} from ${item.path}${item.backup ? ` (copy kept at ${item.backup})` : ""}.`);
|
|
375
|
+
for (const item of report.failed)
|
|
376
|
+
lines.push(` Could not remove ${item.found} from ${item.path}: ${item.reason}`);
|
|
377
|
+
return lines;
|
|
378
|
+
}
|
package/dist/repository.d.ts
CHANGED
|
@@ -5,4 +5,13 @@
|
|
|
5
5
|
* written to read like Balladeer's own assertion.
|
|
6
6
|
*/
|
|
7
7
|
export declare function repositoryHint(cwd?: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Every GitHub repository this folder's remotes point at, `origin` first and
|
|
10
|
+
* the rest in git's order, without repeats. A person working from a personal
|
|
11
|
+
* fork has `origin` on the fork and `upstream` on the repository the team
|
|
12
|
+
* connected; the session in that folder is about the team's repository, so
|
|
13
|
+
* matching stops at the first remote that has a connection rather than at
|
|
14
|
+
* `origin`. An empty list is a folder with no GitHub remote at all.
|
|
15
|
+
*/
|
|
16
|
+
export declare function repositoryHints(cwd?: string): readonly string[];
|
|
8
17
|
export declare function hostHint(): string;
|
package/dist/repository.js
CHANGED
|
@@ -9,21 +9,47 @@ const HOST_HINT_PATTERN = /^[A-Za-z0-9.-]{1,63}$/;
|
|
|
9
9
|
* written to read like Balladeer's own assertion.
|
|
10
10
|
*/
|
|
11
11
|
export function repositoryHint(cwd = process.cwd()) {
|
|
12
|
-
|
|
12
|
+
return repositoryHints(cwd)[0] ?? "unknown/unknown";
|
|
13
|
+
}
|
|
14
|
+
function git(cwd, args) {
|
|
13
15
|
try {
|
|
14
|
-
|
|
16
|
+
return execFileSync("git", ["-C", cwd, ...args], {
|
|
15
17
|
encoding: "utf8",
|
|
16
18
|
stdio: ["ignore", "pipe", "ignore"],
|
|
17
19
|
}).trim();
|
|
18
20
|
}
|
|
19
21
|
catch {
|
|
20
|
-
return
|
|
22
|
+
return undefined;
|
|
21
23
|
}
|
|
24
|
+
}
|
|
25
|
+
function ownerName(remote) {
|
|
22
26
|
const match = /github\.com[:/]+([A-Za-z0-9._-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(remote);
|
|
23
27
|
if (!match)
|
|
24
|
-
return
|
|
28
|
+
return undefined;
|
|
25
29
|
const hint = `${match[1]}/${match[2]}`;
|
|
26
|
-
return REPOSITORY_HINT_PATTERN.test(hint) ? hint :
|
|
30
|
+
return REPOSITORY_HINT_PATTERN.test(hint) ? hint : undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every GitHub repository this folder's remotes point at, `origin` first and
|
|
34
|
+
* the rest in git's order, without repeats. A person working from a personal
|
|
35
|
+
* fork has `origin` on the fork and `upstream` on the repository the team
|
|
36
|
+
* connected; the session in that folder is about the team's repository, so
|
|
37
|
+
* matching stops at the first remote that has a connection rather than at
|
|
38
|
+
* `origin`. An empty list is a folder with no GitHub remote at all.
|
|
39
|
+
*/
|
|
40
|
+
export function repositoryHints(cwd = process.cwd()) {
|
|
41
|
+
const names = git(cwd, ["remote"])
|
|
42
|
+
?.split("\n")
|
|
43
|
+
.filter((name) => name.length > 0) ?? [];
|
|
44
|
+
const ordered = ["origin", ...names.filter((name) => name !== "origin")];
|
|
45
|
+
const hints = [];
|
|
46
|
+
for (const name of ordered) {
|
|
47
|
+
const url = git(cwd, ["remote", "get-url", name]);
|
|
48
|
+
const hint = url === undefined ? undefined : ownerName(url);
|
|
49
|
+
if (hint !== undefined && !hints.some((h) => h.toLowerCase() === hint.toLowerCase()))
|
|
50
|
+
hints.push(hint);
|
|
51
|
+
}
|
|
52
|
+
return hints;
|
|
27
53
|
}
|
|
28
54
|
export function hostHint() {
|
|
29
55
|
const raw = hostname().split(".")[0] ?? "";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type SelfUpdateDeps = Readonly<{
|
|
2
|
+
environment: NodeJS.ProcessEnv;
|
|
3
|
+
now?: () => number;
|
|
4
|
+
run?: (command: string, args: readonly string[]) => void;
|
|
5
|
+
}>;
|
|
6
|
+
/**
|
|
7
|
+
* Start the update when the server named a newer release and none was
|
|
8
|
+
* started today. Answers whether one was started, for the caller's own
|
|
9
|
+
* record; the session hears nothing either way.
|
|
10
|
+
*/
|
|
11
|
+
export declare function startSelfUpdateIfDue(newer: string | undefined, deps: SelfUpdateDeps): boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { HOOK_SPECIFIER } from "./release.js";
|
|
5
|
+
import { configHome } from "./store.js";
|
|
6
|
+
/**
|
|
7
|
+
* Bringing the durable command current without the session ever waiting.
|
|
8
|
+
*
|
|
9
|
+
* Robert, 22 September 2026: a session start has to be imperceptible, and an
|
|
10
|
+
* update must never hold the agent. So the hook, having served this session
|
|
11
|
+
* from the copy it has, only starts the install: a detached child on the
|
|
12
|
+
* current major, output discarded, at most once a day per laptop whatever it
|
|
13
|
+
* reports. The next session runs whatever that child installed. A child that
|
|
14
|
+
* fails changes nothing and is tried again the next day; `balladeer update`
|
|
15
|
+
* is the manual path. Nothing here reads the session or writes to it.
|
|
16
|
+
*/
|
|
17
|
+
const STAMP = "self-update.json";
|
|
18
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
function stampPath(environment) {
|
|
20
|
+
return join(configHome(environment), STAMP);
|
|
21
|
+
}
|
|
22
|
+
function lastStarted(environment) {
|
|
23
|
+
const path = stampPath(environment);
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
return 0;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function detached(command, args) {
|
|
35
|
+
const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
|
|
36
|
+
child.on("error", () => undefined);
|
|
37
|
+
child.unref();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Start the update when the server named a newer release and none was
|
|
41
|
+
* started today. Answers whether one was started, for the caller's own
|
|
42
|
+
* record; the session hears nothing either way.
|
|
43
|
+
*/
|
|
44
|
+
export function startSelfUpdateIfDue(newer, deps) {
|
|
45
|
+
if (newer === undefined)
|
|
46
|
+
return false;
|
|
47
|
+
const now = deps.now ?? Date.now;
|
|
48
|
+
if (now() - lastStarted(deps.environment) < DAY_MS)
|
|
49
|
+
return false;
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(configHome(deps.environment), { recursive: true, mode: 0o700 });
|
|
52
|
+
writeFileSync(stampPath(deps.environment), JSON.stringify({ startedAt: now(), toward: newer }) + "\n", { mode: 0o600 });
|
|
53
|
+
(deps.run ?? detached)("npx", ["-y", HOOK_SPECIFIER, "install", "--json"]);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|