balladeer 1.0.13 → 1.0.15
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/checkout-form.d.ts +45 -0
- package/dist/checkout-form.js +223 -0
- package/dist/cli.js +52 -4
- package/dist/commands/guidance.d.ts +2 -10
- package/dist/commands/guidance.js +11 -13
- package/dist/commands/mcp.d.ts +6 -0
- package/dist/commands/mcp.js +53 -0
- package/dist/guidance-hook.mjs +162 -80
- package/dist/guidance.d.ts +2 -0
- package/dist/guidance.js +7 -1
- package/dist/hook-trust.d.ts +31 -0
- package/dist/hook-trust.js +63 -0
- package/dist/self-update.d.ts +29 -2
- package/dist/self-update.js +96 -11
- package/dist/user-scope.d.ts +2 -0
- package/dist/user-scope.js +26 -2
- package/dist/wire.d.ts +6 -2
- package/dist/wire.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taking this product's own older, per-repository form out of a checkout once
|
|
3
|
+
* the user-scope form is in place, so one hook speaks per session.
|
|
4
|
+
*
|
|
5
|
+
* Releases before 1.0.8 registered Balladeer inside each repository: a hook in
|
|
6
|
+
* the checkout's `.claude/settings.json` (statusMessage "Balladeer current
|
|
7
|
+
* guidance (loader 1)"), a `balladeer` server in its `.mcp.json` that names the
|
|
8
|
+
* repository, and for Codex the fenced `# balladeer:guidance` and
|
|
9
|
+
* `# balladeer:mcp` blocks in `.codex/config.toml`. From 1.0.8 the same hook
|
|
10
|
+
* and server live at the host's user scope and run in every checkout. A laptop
|
|
11
|
+
* that moved from one to the other without cleaning up runs both: the session
|
|
12
|
+
* hears the guidance twice, and the server sees two hook forms fire for one
|
|
13
|
+
* person (found on Luke's laptop, 23 September 2026). Every removal here
|
|
14
|
+
* proves ownership from the entry itself; nothing else in those files moves.
|
|
15
|
+
* A file git tracks is edited without a copy (git holds the previous version)
|
|
16
|
+
* and the person is told to commit it; an untracked file is copied beside
|
|
17
|
+
* itself first.
|
|
18
|
+
*/
|
|
19
|
+
export type CheckoutFormItem = Readonly<{
|
|
20
|
+
path: string;
|
|
21
|
+
found: string;
|
|
22
|
+
tracked: boolean;
|
|
23
|
+
remove: () => string | undefined;
|
|
24
|
+
}>;
|
|
25
|
+
export type CheckoutFormReport = Readonly<{
|
|
26
|
+
removed: readonly {
|
|
27
|
+
path: string;
|
|
28
|
+
found: string;
|
|
29
|
+
copy?: string;
|
|
30
|
+
tracked: boolean;
|
|
31
|
+
}[];
|
|
32
|
+
failed: readonly {
|
|
33
|
+
path: string;
|
|
34
|
+
found: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
}[];
|
|
37
|
+
}>;
|
|
38
|
+
/** The older per-repository form in the checkout that holds `cwd`, if any. */
|
|
39
|
+
export declare function findCheckoutForm(input: {
|
|
40
|
+
cwd: string;
|
|
41
|
+
controlPlane: string;
|
|
42
|
+
now?: () => Date;
|
|
43
|
+
}): CheckoutFormItem[];
|
|
44
|
+
export declare function removeCheckoutForm(items: readonly CheckoutFormItem[]): CheckoutFormReport;
|
|
45
|
+
export declare function describeCheckoutFormRemoval(report: CheckoutFormReport): string[];
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, lstatSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isOurEntry, writeJsonAtomically } from "./mcp-config.js";
|
|
5
|
+
const PROJECT_OWNER = "Balladeer current guidance (loader 1)";
|
|
6
|
+
const CODEX_BLOCKS = [
|
|
7
|
+
["# balladeer:guidance:start", "# balladeer:guidance:end"],
|
|
8
|
+
["# balladeer:mcp:start", "# balladeer:mcp:end"],
|
|
9
|
+
];
|
|
10
|
+
function repositoryRoot(cwd) {
|
|
11
|
+
try {
|
|
12
|
+
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16
|
+
timeout: 2_000,
|
|
17
|
+
}).trim();
|
|
18
|
+
return root.length > 0 ? root : undefined;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function tracked(root, path) {
|
|
25
|
+
try {
|
|
26
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", path], {
|
|
27
|
+
cwd: root,
|
|
28
|
+
stdio: "ignore",
|
|
29
|
+
timeout: 2_000,
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function regularFile(path) {
|
|
38
|
+
try {
|
|
39
|
+
return lstatSync(path).isFile();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function readJson(path) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
48
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
49
|
+
? parsed
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function backup(path, now) {
|
|
57
|
+
// A file name, not a sentence: the person reads the copy's path back, and the
|
|
58
|
+
// stamp only has to sort. Same form as the earlier-client backups.
|
|
59
|
+
const stamp = now().toISOString().replaceAll(/[:.]/g, "-");
|
|
60
|
+
const copy = `${path}.before-balladeer-${stamp}`;
|
|
61
|
+
copyFileSync(path, copy);
|
|
62
|
+
return copy;
|
|
63
|
+
}
|
|
64
|
+
const isProjectHook = (hook) => {
|
|
65
|
+
if (hook === null || typeof hook !== "object")
|
|
66
|
+
return false;
|
|
67
|
+
const { command, statusMessage } = hook;
|
|
68
|
+
if (statusMessage === PROJECT_OWNER)
|
|
69
|
+
return true;
|
|
70
|
+
return (typeof command === "string" &&
|
|
71
|
+
/balladeer/i.test(command) &&
|
|
72
|
+
/--hook (?:claude|codex)\b/.test(command) &&
|
|
73
|
+
/--repository\b/.test(command));
|
|
74
|
+
};
|
|
75
|
+
function claudeHooks(root, now) {
|
|
76
|
+
const path = join(root, ".claude", "settings.json");
|
|
77
|
+
if (!regularFile(path))
|
|
78
|
+
return [];
|
|
79
|
+
const settings = readJson(path);
|
|
80
|
+
const hooks = settings?.hooks;
|
|
81
|
+
if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
|
|
82
|
+
return [];
|
|
83
|
+
const events = Object.entries(hooks).filter(([, groups]) => Array.isArray(groups) &&
|
|
84
|
+
groups.some((group) => (group?.hooks ?? []).some((h) => isProjectHook(h))));
|
|
85
|
+
if (events.length === 0)
|
|
86
|
+
return [];
|
|
87
|
+
const isTracked = tracked(root, ".claude/settings.json");
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
path,
|
|
91
|
+
tracked: isTracked,
|
|
92
|
+
found: `per-repository hook${events.length === 1 ? "" : "s"} (${events.map(([event]) => event).join(", ")})`,
|
|
93
|
+
remove: () => {
|
|
94
|
+
const copy = isTracked ? undefined : backup(path, now);
|
|
95
|
+
const current = readJson(path) ?? {};
|
|
96
|
+
const kept = {};
|
|
97
|
+
for (const [event, groups] of Object.entries((current.hooks ?? {}))) {
|
|
98
|
+
if (!Array.isArray(groups)) {
|
|
99
|
+
kept[event] = groups;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const left = groups.filter((group) => !(group?.hooks ?? []).some((h) => isProjectHook(h)));
|
|
103
|
+
if (left.length > 0)
|
|
104
|
+
kept[event] = left;
|
|
105
|
+
}
|
|
106
|
+
writeJsonAtomically(path, JSON.stringify({ ...current, hooks: kept }, null, 2) + "\n");
|
|
107
|
+
return copy;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
function claudeServer(root, controlPlane, now) {
|
|
113
|
+
const path = join(root, ".mcp.json");
|
|
114
|
+
if (!regularFile(path))
|
|
115
|
+
return [];
|
|
116
|
+
const config = readJson(path);
|
|
117
|
+
const servers = config?.mcpServers;
|
|
118
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
119
|
+
return [];
|
|
120
|
+
const entry = servers.balladeer;
|
|
121
|
+
if (entry === undefined || !isOurEntry(entry, controlPlane))
|
|
122
|
+
return [];
|
|
123
|
+
const args = entry.args;
|
|
124
|
+
if (!Array.isArray(args) || !args.includes("--repository"))
|
|
125
|
+
return [];
|
|
126
|
+
const isTracked = tracked(root, ".mcp.json");
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
path,
|
|
130
|
+
tracked: isTracked,
|
|
131
|
+
found: "per-repository server entry",
|
|
132
|
+
remove: () => {
|
|
133
|
+
const copy = isTracked ? undefined : backup(path, now);
|
|
134
|
+
const current = readJson(path) ?? {};
|
|
135
|
+
const remaining = { ...(current.mcpServers ?? {}) };
|
|
136
|
+
delete remaining.balladeer;
|
|
137
|
+
writeJsonAtomically(path, JSON.stringify({ ...current, mcpServers: remaining }, null, 2) + "\n");
|
|
138
|
+
return copy;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
function codexBlocks(root, now) {
|
|
144
|
+
const path = join(root, ".codex", "config.toml");
|
|
145
|
+
if (!regularFile(path))
|
|
146
|
+
return [];
|
|
147
|
+
const text = readFileSync(path, "utf8");
|
|
148
|
+
const present = CODEX_BLOCKS.filter(([start, end]) => text.includes(`${start}\n`) && text.includes(end));
|
|
149
|
+
if (present.length === 0)
|
|
150
|
+
return [];
|
|
151
|
+
const isTracked = tracked(root, ".codex/config.toml");
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
path,
|
|
155
|
+
tracked: isTracked,
|
|
156
|
+
found: `per-repository Codex block${present.length === 1 ? "" : "s"}`,
|
|
157
|
+
remove: () => {
|
|
158
|
+
const copy = isTracked ? undefined : backup(path, now);
|
|
159
|
+
let after = readFileSync(path, "utf8");
|
|
160
|
+
for (const [start, end] of CODEX_BLOCKS) {
|
|
161
|
+
const from = after.indexOf(`${start}\n`);
|
|
162
|
+
const to = after.indexOf(end, from);
|
|
163
|
+
if (from < 0 || to < 0)
|
|
164
|
+
continue;
|
|
165
|
+
const cut = to + end.length + (after[to + end.length] === "\n" ? 1 : 0);
|
|
166
|
+
after = after.slice(0, from) + after.slice(cut);
|
|
167
|
+
}
|
|
168
|
+
writeJsonAtomically(path, after.replace(/\n{3,}/g, "\n\n"));
|
|
169
|
+
return copy;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
/** The older per-repository form in the checkout that holds `cwd`, if any. */
|
|
175
|
+
export function findCheckoutForm(input) {
|
|
176
|
+
const root = repositoryRoot(input.cwd);
|
|
177
|
+
if (root === undefined || !existsSync(root))
|
|
178
|
+
return [];
|
|
179
|
+
const now = input.now ?? (() => new Date());
|
|
180
|
+
return [
|
|
181
|
+
...claudeHooks(root, now),
|
|
182
|
+
...claudeServer(root, input.controlPlane, now),
|
|
183
|
+
...codexBlocks(root, now),
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
export function removeCheckoutForm(items) {
|
|
187
|
+
const removed = [];
|
|
188
|
+
const failed = [];
|
|
189
|
+
for (const item of items) {
|
|
190
|
+
try {
|
|
191
|
+
const copy = item.remove();
|
|
192
|
+
removed.push({
|
|
193
|
+
path: item.path,
|
|
194
|
+
found: item.found,
|
|
195
|
+
tracked: item.tracked,
|
|
196
|
+
...(copy === undefined ? {} : { copy }),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
failed.push({
|
|
201
|
+
path: item.path,
|
|
202
|
+
found: item.found,
|
|
203
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { removed, failed };
|
|
208
|
+
}
|
|
209
|
+
export function describeCheckoutFormRemoval(report) {
|
|
210
|
+
const lines = [];
|
|
211
|
+
for (const entry of report.removed)
|
|
212
|
+
lines.push(`Removed the older ${entry.found} from ${entry.path}` +
|
|
213
|
+
(entry.copy
|
|
214
|
+
? ` (copy at ${entry.copy}).`
|
|
215
|
+
: entry.tracked
|
|
216
|
+
? "; git tracks this file, so commit the change."
|
|
217
|
+
: "."));
|
|
218
|
+
for (const entry of report.failed)
|
|
219
|
+
lines.push(`Could not remove the older ${entry.found} from ${entry.path}: ${entry.reason}`);
|
|
220
|
+
if (report.removed.length > 0)
|
|
221
|
+
lines.push("The user-scope hook now speaks alone in this checkout; it was saying the same thing twice.");
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -21,9 +21,12 @@ import { runWhoami } from "./commands/whoami.js";
|
|
|
21
21
|
import { runInstall } from "./install.js";
|
|
22
22
|
import { openInBrowser } from "./open-browser.js";
|
|
23
23
|
import { PUBLISHED_SPECIFIER } from "./release.js";
|
|
24
|
-
import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
|
|
24
|
+
import { CODEX_HOOK_APPROVAL, installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
|
|
25
25
|
import { updateNotice } from "./currency.js";
|
|
26
26
|
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
|
|
27
|
+
import { recordSelfUpdateState, SELF_UPDATE_ENVIRONMENT } from "./self-update.js";
|
|
28
|
+
import { hooksAwaitingTrust } from "./hook-trust.js";
|
|
29
|
+
import { describeCheckoutFormRemoval, findCheckoutForm, removeCheckoutForm, } from "./checkout-form.js";
|
|
27
30
|
import { StoreError, normalizeControlPlane } from "./store.js";
|
|
28
31
|
import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
29
32
|
const USAGE = `balladeer ${CLI_VERSION}
|
|
@@ -471,11 +474,46 @@ function installEverything(options) {
|
|
|
471
474
|
for (const w of writes ?? [])
|
|
472
475
|
if (!options.json)
|
|
473
476
|
options.write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
|
|
477
|
+
// Codex runs a hook only once the person has trusted it, and trusts it by
|
|
478
|
+
// the hash of its definition, so a hook this command just wrote or changed
|
|
479
|
+
// is skipped until they do. Found 29 September 2026: Codex was the most
|
|
480
|
+
// common host at Didero and not one of its sessions had run the hook.
|
|
481
|
+
const codex = (writes ?? []).find((w) => w.host === "codex" && w.status !== "refused");
|
|
482
|
+
// Said whenever it is true, which is after any install that wrote or changed
|
|
483
|
+
// the hooks and until Codex has run one of them; silent once it has.
|
|
484
|
+
if (codex !== undefined && hooksAwaitingTrust(process.env, "codex")) {
|
|
485
|
+
if (options.json)
|
|
486
|
+
options.write(`${JSON.stringify({ step: "codex_hooks", approval: codex.status === "written" ? "required" : "check", command: "/hooks" })}\n`);
|
|
487
|
+
else
|
|
488
|
+
options.write(`${CODEX_HOOK_APPROVAL}\n`);
|
|
489
|
+
}
|
|
490
|
+
// With the user-scope form in place, the older per-repository form in this
|
|
491
|
+
// checkout only makes the session hear the guidance twice. Out it goes, with
|
|
492
|
+
// a copy beside any file git does not already keep.
|
|
493
|
+
if ((writes ?? []).length > 0 && !(writes ?? []).some((w) => w.status === "refused")) {
|
|
494
|
+
const checkoutForm = findCheckoutForm({
|
|
495
|
+
cwd: process.cwd(),
|
|
496
|
+
controlPlane: options.controlPlane,
|
|
497
|
+
});
|
|
498
|
+
if (checkoutForm.length > 0) {
|
|
499
|
+
const report = removeCheckoutForm(checkoutForm);
|
|
500
|
+
if (options.json)
|
|
501
|
+
options.write(`${JSON.stringify({ step: "checkout_form", removed: report.removed, failed: report.failed })}\n`);
|
|
502
|
+
else
|
|
503
|
+
for (const line of describeCheckoutFormRemoval(report))
|
|
504
|
+
options.write(`${line}\n`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
474
507
|
if (!options.json && (writes ?? []).some((w) => w.status === "written"))
|
|
475
508
|
options.write("Balladeer now runs in every Claude Code" +
|
|
476
509
|
((writes ?? []).some((w) => w.host === "codex") ? " and Codex" : "") +
|
|
477
510
|
" session on this machine. In a folder of a connected repository it works as before; anywhere else it stays quiet, and `balladeer status` there says why.\n");
|
|
478
|
-
|
|
511
|
+
const outcome = (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
|
|
512
|
+
// Started by the session hook's background update: leave how it ended where
|
|
513
|
+
// the next hook fire will find it and tell the server.
|
|
514
|
+
if (process.env[SELF_UPDATE_ENVIRONMENT] === "1")
|
|
515
|
+
recordSelfUpdateState(process.env, outcome === 0 ? `installed:${CLI_VERSION}` : `failed:exit-${outcome}`, Date.now());
|
|
516
|
+
return outcome;
|
|
479
517
|
}
|
|
480
518
|
async function dispatch(parsed, write) {
|
|
481
519
|
switch (parsed.command) {
|
|
@@ -522,11 +560,21 @@ async function dispatch(parsed, write) {
|
|
|
522
560
|
return report.failed.length === 0 ? 0 : 6;
|
|
523
561
|
}
|
|
524
562
|
case "install":
|
|
525
|
-
return installEverything({
|
|
563
|
+
return installEverything({
|
|
564
|
+
json: parsed.json,
|
|
565
|
+
write,
|
|
566
|
+
allowPartial: false,
|
|
567
|
+
controlPlane: parsed.controlPlane,
|
|
568
|
+
});
|
|
526
569
|
case "setup":
|
|
527
570
|
return runSetup({
|
|
528
571
|
installCommand: () => {
|
|
529
|
-
const result = installEverything({
|
|
572
|
+
const result = installEverything({
|
|
573
|
+
json: parsed.json,
|
|
574
|
+
write,
|
|
575
|
+
allowPartial: true,
|
|
576
|
+
controlPlane: parsed.controlPlane,
|
|
577
|
+
});
|
|
530
578
|
return process.platform === "win32" ? 0 : result;
|
|
531
579
|
},
|
|
532
580
|
controlPlane: parsed.controlPlane,
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
import { type GuidanceEvent } from "../guidance.js";
|
|
2
|
-
/**
|
|
3
|
-
* What an agent, or `status`, is told about a folder Balladeer is not tracking.
|
|
4
|
-
*
|
|
5
|
-
* Robert, 22 September 2026: the session hook says nothing in such a folder.
|
|
6
|
-
* A customer with one repository has every other folder on the laptop
|
|
7
|
-
* untracked, and a line in each of those is noise in the wrong place. This
|
|
8
|
-
* sentence stays for the two readers who ask: `balladeer status` in the folder,
|
|
9
|
-
* and an agent that opens the empty catalog and wonders why.
|
|
10
|
-
*/
|
|
2
|
+
/** A repository-specific setup step for explicit status and empty-catalog responses. */
|
|
11
3
|
export declare function untrackedLine(remote: string): string;
|
|
12
4
|
export type GuidanceOptions = Readonly<{
|
|
13
5
|
controlPlane: string;
|
|
@@ -23,7 +15,7 @@ export type GuidanceOptions = Readonly<{
|
|
|
23
15
|
fetchImpl?: typeof fetch;
|
|
24
16
|
now?: () => number;
|
|
25
17
|
/** Runs the background update; injected so tests never reach npm. */
|
|
26
|
-
startUpdate?: (command: string, args: readonly string[]) => void;
|
|
18
|
+
startUpdate?: (command: string, args: readonly string[], environment: NodeJS.ProcessEnv) => boolean | void;
|
|
27
19
|
}>;
|
|
28
20
|
/** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
|
|
29
21
|
export declare function runGuidance(options: GuidanceOptions): Promise<number>;
|
|
@@ -5,15 +5,8 @@ import { selectAgent } from "../agent.js";
|
|
|
5
5
|
import { repositoryHints } from "../repository.js";
|
|
6
6
|
import { newerVersionPublished } from "../currency.js";
|
|
7
7
|
import { startSelfUpdateIfDue } from "../self-update.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
* Robert, 22 September 2026: the session hook says nothing in such a folder.
|
|
12
|
-
* A customer with one repository has every other folder on the laptop
|
|
13
|
-
* untracked, and a line in each of those is noise in the wrong place. This
|
|
14
|
-
* sentence stays for the two readers who ask: `balladeer status` in the folder,
|
|
15
|
-
* and an agent that opens the empty catalog and wonders why.
|
|
16
|
-
*/
|
|
8
|
+
import { noteHookFired } from "../hook-trust.js";
|
|
9
|
+
/** A repository-specific setup step for explicit status and empty-catalog responses. */
|
|
17
10
|
export function untrackedLine(remote) {
|
|
18
11
|
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
19
12
|
return (`${here} isn't connected to Balladeer on this laptop, so nothing is tracked from here. ` +
|
|
@@ -70,8 +63,7 @@ function identifier(input) {
|
|
|
70
63
|
/** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
|
|
71
64
|
export async function runGuidance(options) {
|
|
72
65
|
// A project hook names its repository and must match the checkout it sits
|
|
73
|
-
// in. A user-scope hook names none: the folder's
|
|
74
|
-
// with no connection hears that it is untracked rather than nothing at all.
|
|
66
|
+
// in. A user-scope hook names none: the folder's remotes decide.
|
|
75
67
|
const projectScoped = options.repositoryId !== undefined;
|
|
76
68
|
if (projectScoped &&
|
|
77
69
|
!validateProjectGuidanceScope({
|
|
@@ -93,23 +85,29 @@ export async function runGuidance(options) {
|
|
|
93
85
|
event = parsedEvent;
|
|
94
86
|
const record = input;
|
|
95
87
|
const credentials = readCredentials(options.environment).agents;
|
|
88
|
+
const repositories = projectScoped ? [] : repositoryHints(options.cwd);
|
|
96
89
|
const agents = projectScoped
|
|
97
90
|
? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
|
|
98
91
|
agent.repositoryId === options.repositoryId)
|
|
99
92
|
: (() => {
|
|
100
|
-
const selection = selectAgent(credentials, options.controlPlane, undefined,
|
|
93
|
+
const selection = selectAgent(credentials, options.controlPlane, undefined, repositories);
|
|
101
94
|
return selection.kind === "refused" ? [] : [selection.agent];
|
|
102
95
|
})();
|
|
103
96
|
if (agents.length !== 1 || !agents[0]) {
|
|
104
97
|
if (projectScoped)
|
|
105
98
|
throw new Error("guidance_connection_unavailable");
|
|
106
|
-
//
|
|
99
|
+
// Unconnected folders stay silent. An explicit status request explains setup.
|
|
107
100
|
return 0;
|
|
108
101
|
}
|
|
102
|
+
// A hook that runs has been trusted by its host; hosts that ask for that
|
|
103
|
+
// trust are the ones this matters to.
|
|
104
|
+
if (options.hook === "codex")
|
|
105
|
+
noteHookFired(options.environment, "codex");
|
|
109
106
|
const loaded = await loadGuidance({
|
|
110
107
|
agent: agents[0],
|
|
111
108
|
environment: options.environment,
|
|
112
109
|
timeoutMs: 750,
|
|
110
|
+
hook: options.hook,
|
|
113
111
|
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
114
112
|
});
|
|
115
113
|
// Behind? Start the install in the background, once a day at most, and
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -63,3 +63,9 @@ export declare function staleClientFrame(id: unknown, update: string): string;
|
|
|
63
63
|
*/
|
|
64
64
|
export declare function revokedConnectionFrame(id: unknown, controlPlane: string): string;
|
|
65
65
|
export declare function runMcp(options: McpOptions): Promise<number>;
|
|
66
|
+
/**
|
|
67
|
+
* The tool's answer with one more text block at its end, or nothing when the
|
|
68
|
+
* answer is not a single tool result this can add to: a batch, an error frame,
|
|
69
|
+
* or anything it cannot parse is forwarded exactly as it came.
|
|
70
|
+
*/
|
|
71
|
+
export declare function withNotice(text: string, notice: string): string | undefined;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -5,6 +5,7 @@ import { noteServerVersion, updateNotice } from "../currency.js";
|
|
|
5
5
|
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
6
6
|
import { CLI_VERSION } from "../wire.js";
|
|
7
7
|
import { untrackedLine } from "./guidance.js";
|
|
8
|
+
import { HOOKS_AWAITING_TRUST_NOTICE, hooksAwaitingTrust, trustingHost } from "../hook-trust.js";
|
|
8
9
|
import { StoreError, readCredentials } from "../store.js";
|
|
9
10
|
// The three commands that use an agent connection select and address it through
|
|
10
11
|
// one module. These are re-exported because this is where the forwarder's
|
|
@@ -198,6 +199,11 @@ export async function runMcp(options) {
|
|
|
198
199
|
if (guidanceInstall.status !== "not_applicable")
|
|
199
200
|
options.error(`Balladeer guidance loader: ${guidanceInstall.status}${guidanceInstall.reason ? ` (${guidanceInstall.reason})` : ""}. New project hooks may need host trust; no trust is assumed.\n`);
|
|
200
201
|
let saidUpdate = false;
|
|
202
|
+
// Which host this session belongs to is known from its hello; whether its
|
|
203
|
+
// hooks are waiting for the person's trust is asked at the first tool call,
|
|
204
|
+
// by which time a trusted session-start hook has certainly run.
|
|
205
|
+
let host;
|
|
206
|
+
let saidTrust = false;
|
|
201
207
|
const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
|
|
202
208
|
for await (const line of lines) {
|
|
203
209
|
if (line.trim().length === 0)
|
|
@@ -247,7 +253,54 @@ export async function runMcp(options) {
|
|
|
247
253
|
options.error("Balladeer refused a response frame larger than 4 MiB.\n");
|
|
248
254
|
return 5;
|
|
249
255
|
}
|
|
256
|
+
const request = frameOf(line);
|
|
257
|
+
if (request?.method === "initialize")
|
|
258
|
+
host = trustingHost(request.params?.clientInfo?.name);
|
|
259
|
+
if (!saidTrust &&
|
|
260
|
+
host !== undefined &&
|
|
261
|
+
request?.method === "tools/call" &&
|
|
262
|
+
hooksAwaitingTrust(options.environment, host)) {
|
|
263
|
+
const noted = withNotice(text, HOOKS_AWAITING_TRUST_NOTICE);
|
|
264
|
+
if (noted !== undefined) {
|
|
265
|
+
saidTrust = true;
|
|
266
|
+
options.write(`${noted}\n`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
250
270
|
options.write(`${text.replace(/\n+$/, "")}\n`);
|
|
251
271
|
}
|
|
252
272
|
return 0;
|
|
253
273
|
}
|
|
274
|
+
function frameOf(line) {
|
|
275
|
+
try {
|
|
276
|
+
const parsed = JSON.parse(line);
|
|
277
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
278
|
+
? parsed
|
|
279
|
+
: undefined;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The tool's answer with one more text block at its end, or nothing when the
|
|
287
|
+
* answer is not a single tool result this can add to: a batch, an error frame,
|
|
288
|
+
* or anything it cannot parse is forwarded exactly as it came.
|
|
289
|
+
*/
|
|
290
|
+
export function withNotice(text, notice) {
|
|
291
|
+
try {
|
|
292
|
+
const frame = JSON.parse(text);
|
|
293
|
+
if (frame === null || typeof frame !== "object" || Array.isArray(frame))
|
|
294
|
+
return undefined;
|
|
295
|
+
const content = frame.result?.content;
|
|
296
|
+
if (!Array.isArray(content))
|
|
297
|
+
return undefined;
|
|
298
|
+
return JSON.stringify({
|
|
299
|
+
...frame,
|
|
300
|
+
result: { ...frame.result, content: [...content, { type: "text", text: notice }] },
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
}
|