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/commands/setup.js
CHANGED
|
@@ -12,10 +12,12 @@ import { MCP_CONFIG_FILE, currentEntry, entryRepositoryId, isOurEntry, mergeMcpC
|
|
|
12
12
|
import { DESKTOP_CONFIG_FILE, desktopConfigLocation, desktopServerKey, desktopStdioEntry, mergeDesktopConfig, } from "../desktop-config.js";
|
|
13
13
|
import { selectAgent } from "../agent.js";
|
|
14
14
|
import { installGuidanceLoader } from "../guidance-install.js";
|
|
15
|
-
import {
|
|
15
|
+
import { openInBrowser } from "../open-browser.js";
|
|
16
|
+
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "../remove-earlier.js";
|
|
17
|
+
import { userHome } from "../user-scope.js";
|
|
16
18
|
import { formatInstant } from "../local-time.js";
|
|
17
19
|
import { checkoutEntryPath, commandLine, runningFromRegistryInstall } from "../release.js";
|
|
18
|
-
import { hostHint, repositoryHint } from "../repository.js";
|
|
20
|
+
import { hostHint, repositoryHint, repositoryHints } from "../repository.js";
|
|
19
21
|
import { StoreError, assertStoreWritable, credentialsPath, dropPendingPairing, findPendingPairing, findSession, putAgent, putPendingPairing, putSession, readCredentials, writeCredentials, } from "../store.js";
|
|
20
22
|
import { CLI_VERSION, DELEGATED_SCOPES, } from "../wire.js";
|
|
21
23
|
import { explainText } from "./explain.js";
|
|
@@ -143,13 +145,15 @@ function pairedLines(session) {
|
|
|
143
145
|
* scrolled, or an agent whose context was compacted, is told to approve in the
|
|
144
146
|
* browser, and no other command in this release can produce that URL again.
|
|
145
147
|
*/
|
|
146
|
-
function pendingLines(pending, repository, host, waiting, approvalUri) {
|
|
148
|
+
function pendingLines(pending, repository, host, waiting, approvalUri, opened) {
|
|
147
149
|
const opening = waiting
|
|
148
150
|
? ["Step 1 of 5 Waiting for approval", ` Nobody has approved ${pending.userCode} yet.`]
|
|
149
151
|
: ["Step 1 of 5 Sign in and approve this session"];
|
|
150
152
|
return [
|
|
151
153
|
...opening,
|
|
152
|
-
|
|
154
|
+
opened
|
|
155
|
+
? ` Opened in your browser: ${approvalUri} (if no page appeared, open that link yourself)`
|
|
156
|
+
: ` Open: ${approvalUri}`,
|
|
153
157
|
` The page will show this code: ${pending.userCode}`,
|
|
154
158
|
// A person who belongs to two workspaces approves into whichever one their
|
|
155
159
|
// browser is signed into, and twice that was not the one they meant: the
|
|
@@ -159,7 +163,9 @@ function pendingLines(pending, repository, host, waiting, approvalUri) {
|
|
|
159
163
|
` ${APPROVE_IN_THE_RIGHT_WORKSPACE}`,
|
|
160
164
|
` Sent to Balladeer so far: a random code, the name "${repository}", and the hostname "${host}". Nothing else.`,
|
|
161
165
|
` This pairing expires at ${formatInstant(pending.expiresAt)}.`,
|
|
162
|
-
|
|
166
|
+
opened
|
|
167
|
+
? " Approve in the browser; this command waits here and finishes on its own."
|
|
168
|
+
: " Approve in the browser, then run this command again to finish.",
|
|
163
169
|
"",
|
|
164
170
|
// Printed on the pairing step and nowhere else, because this is the one
|
|
165
171
|
// moment the answer matters: the person is standing in front of a page that
|
|
@@ -259,11 +265,29 @@ export async function runSetup(options) {
|
|
|
259
265
|
// that still carries the July client gets one instruction and this run stops.
|
|
260
266
|
// Refresh is inside the check rather than outside it, because a repair writes
|
|
261
267
|
// the same two files a first setup writes.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
268
|
+
// The earlier Balladeer, still on this machine, is taken off first: both
|
|
269
|
+
// products answer to the name `balladeer`, and while the older entry stands
|
|
270
|
+
// the host shows its server and refuses ours. Every file changed is copied
|
|
271
|
+
// beside itself first and the copies are named. `--force` sets up alongside
|
|
272
|
+
// it instead, for whoever has decided to run both.
|
|
273
|
+
if (options.force !== true) {
|
|
274
|
+
const earlier = findEarlier({
|
|
275
|
+
home: userHome(options.environment),
|
|
276
|
+
controlPlane: options.controlPlane,
|
|
277
|
+
environment: options.environment,
|
|
278
|
+
cwd: options.cwd,
|
|
279
|
+
});
|
|
280
|
+
if (earlier.length > 0) {
|
|
281
|
+
say(options, "An earlier Balladeer is on this machine:");
|
|
282
|
+
for (const line of describeEarlier(earlier))
|
|
283
|
+
say(options, line);
|
|
284
|
+
const report = removeEarlier(earlier);
|
|
285
|
+
for (const line of describeRemoval(report))
|
|
286
|
+
say(options, line);
|
|
287
|
+
emit(options, { step: "earlier", removed: report.removed, failed: report.failed });
|
|
288
|
+
if (report.failed.length > 0)
|
|
289
|
+
return fail(options, "earlier_balladeer_remains", `${report.failed.length} of the earlier Balladeer's entries could not be removed; see above. Fix those by hand, or run this command again with --force to set up alongside it.`, 6);
|
|
290
|
+
}
|
|
267
291
|
}
|
|
268
292
|
if (options.refresh === true)
|
|
269
293
|
return runRefresh(options);
|
|
@@ -371,7 +395,8 @@ export async function runSetup(options) {
|
|
|
371
395
|
return outcome.exitCode;
|
|
372
396
|
// Printed before the wait as well as instead of it: a run that blocks for ten
|
|
373
397
|
// minutes without ever naming the link is the run whose person cannot act.
|
|
374
|
-
|
|
398
|
+
const opened = openForPerson(options, approvalLink(options, pending.verificationUri));
|
|
399
|
+
for (const line of pendingLines(pending, repository, host, true, approvalLink(options, pending.verificationUri), opened))
|
|
375
400
|
say(options, line);
|
|
376
401
|
emit(options, {
|
|
377
402
|
step: "pair",
|
|
@@ -380,7 +405,7 @@ export async function runSetup(options) {
|
|
|
380
405
|
verificationUri: approvalLink(options, pending.verificationUri),
|
|
381
406
|
expiresAt: pending.expiresAt,
|
|
382
407
|
});
|
|
383
|
-
if (!options.wait) {
|
|
408
|
+
if (!options.wait && !opened) {
|
|
384
409
|
pairingOnlyReceipt(options, "Nothing yet: this pairing is still waiting for someone to approve it.");
|
|
385
410
|
return 0;
|
|
386
411
|
}
|
|
@@ -422,7 +447,8 @@ export async function runSetup(options) {
|
|
|
422
447
|
expiresAt: lapsed.expiresAt,
|
|
423
448
|
});
|
|
424
449
|
}
|
|
425
|
-
|
|
450
|
+
const opened = openForPerson(options, approvalLink(options, pending.verificationUri));
|
|
451
|
+
for (const line of pendingLines(pending, repository, host, false, approvalLink(options, pending.verificationUri), opened))
|
|
426
452
|
say(options, line);
|
|
427
453
|
emit(options, {
|
|
428
454
|
step: "pair",
|
|
@@ -431,7 +457,7 @@ export async function runSetup(options) {
|
|
|
431
457
|
verificationUri: approvalLink(options, pending.verificationUri),
|
|
432
458
|
expiresAt: pending.expiresAt,
|
|
433
459
|
});
|
|
434
|
-
if (options.wait) {
|
|
460
|
+
if (options.wait || opened) {
|
|
435
461
|
return waitForApproval(options, credentials, pending, started.pollIntervalSeconds);
|
|
436
462
|
}
|
|
437
463
|
pairingOnlyReceipt(options, "Started a pairing and printed the link. Nobody has approved it yet.");
|
|
@@ -564,6 +590,16 @@ function terminalRefusal(pending, code) {
|
|
|
564
590
|
}
|
|
565
591
|
return undefined;
|
|
566
592
|
}
|
|
593
|
+
/**
|
|
594
|
+
* The link, opened for a person at the terminal and for nobody else. Whether a
|
|
595
|
+
* browser appeared is not known here; the line beside the link says what to do
|
|
596
|
+
* if none did.
|
|
597
|
+
*/
|
|
598
|
+
export function openForPerson(options, url) {
|
|
599
|
+
if (options.interactive !== true || options.json)
|
|
600
|
+
return false;
|
|
601
|
+
return (options.openLink ?? openInBrowser)(url);
|
|
602
|
+
}
|
|
567
603
|
async function waitForApproval(options, credentials, pending, pollIntervalSeconds) {
|
|
568
604
|
const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
569
605
|
const now = options.now ?? (() => new Date());
|
|
@@ -1676,7 +1712,7 @@ async function runRefresh(input) {
|
|
|
1676
1712
|
let storedName;
|
|
1677
1713
|
try {
|
|
1678
1714
|
const credentials = readCredentials(options.environment);
|
|
1679
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, undefined,
|
|
1715
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, undefined, repositoryHints(options.cwd));
|
|
1680
1716
|
if (selection.kind === "agent") {
|
|
1681
1717
|
stored = selection.agent.repositoryId;
|
|
1682
1718
|
storedName = selection.agent.repository;
|
package/dist/commands/status.js
CHANGED
|
@@ -4,7 +4,7 @@ import { repositoryRoot } from "../git.js";
|
|
|
4
4
|
import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
|
|
5
5
|
import { formatInstant } from "../local-time.js";
|
|
6
6
|
import { commandLine } from "../release.js";
|
|
7
|
-
import { repositoryHint } from "../repository.js";
|
|
7
|
+
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
8
8
|
import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
|
|
9
9
|
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
10
10
|
import { CLI_INVOCATION } from "../wire.js";
|
|
@@ -103,7 +103,7 @@ async function reportBalladeer(options) {
|
|
|
103
103
|
return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
104
104
|
}
|
|
105
105
|
const here = options.repo ?? repositoryHint(options.cwd);
|
|
106
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository,
|
|
106
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
|
|
107
107
|
const storedSession = findSession(credentials, options.controlPlane);
|
|
108
108
|
const repositoryScoped = options.repo !== undefined ||
|
|
109
109
|
options.repository !== undefined ||
|
package/dist/currency.d.ts
CHANGED
|
@@ -27,5 +27,8 @@ export declare const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
|
27
27
|
export declare function noteServerVersion(headers: Headers): void;
|
|
28
28
|
/** The one line this run prints about itself, or nothing when it is current. */
|
|
29
29
|
export declare function updateNotice(): string | undefined;
|
|
30
|
+
/** The newer version the server named, when it named one; nothing when this
|
|
31
|
+
* copy is current or no server has answered yet. */
|
|
32
|
+
export declare function newerVersionPublished(): string | undefined;
|
|
30
33
|
/** For tests, and for a process that runs more than one command. */
|
|
31
34
|
export declare function resetUpdateNotice(): void;
|
package/dist/currency.js
CHANGED
|
@@ -24,6 +24,7 @@ export const CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
|
24
24
|
const ADVICE_LIMIT = 300;
|
|
25
25
|
const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
|
|
26
26
|
let notice;
|
|
27
|
+
let newestSeen;
|
|
27
28
|
function older(left, right) {
|
|
28
29
|
const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
|
|
29
30
|
for (let index = 0; index < 3; index += 1) {
|
|
@@ -55,6 +56,7 @@ export function noteServerVersion(headers) {
|
|
|
55
56
|
return;
|
|
56
57
|
if (!older(CLI_VERSION, newest))
|
|
57
58
|
return;
|
|
59
|
+
newestSeen = newest;
|
|
58
60
|
const advice = headers.get(CLI_ADVICE_HEADER);
|
|
59
61
|
const line = advice === null ? "" : bounded(advice);
|
|
60
62
|
notice =
|
|
@@ -66,7 +68,13 @@ export function noteServerVersion(headers) {
|
|
|
66
68
|
export function updateNotice() {
|
|
67
69
|
return notice;
|
|
68
70
|
}
|
|
71
|
+
/** The newer version the server named, when it named one; nothing when this
|
|
72
|
+
* copy is current or no server has answered yet. */
|
|
73
|
+
export function newerVersionPublished() {
|
|
74
|
+
return newestSeen;
|
|
75
|
+
}
|
|
69
76
|
/** For tests, and for a process that runs more than one command. */
|
|
70
77
|
export function resetUpdateNotice() {
|
|
71
78
|
notice = undefined;
|
|
79
|
+
newestSeen = undefined;
|
|
72
80
|
}
|
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.11";
|
|
2031
2031
|
var CLIENT_HEADER = "x-balladeer-client";
|
|
2032
2032
|
var CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
|
|
2033
2033
|
var DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
@@ -2119,6 +2119,37 @@ function readCredentials(environment = process.env) {
|
|
|
2119
2119
|
}
|
|
2120
2120
|
}
|
|
2121
2121
|
|
|
2122
|
+
// src/currency.ts
|
|
2123
|
+
var CLI_NEWEST_HEADER = "x-balladeer-cli-newest";
|
|
2124
|
+
var CLI_ADVICE_HEADER = "x-balladeer-cli-advice";
|
|
2125
|
+
var ADVICE_LIMIT = 300;
|
|
2126
|
+
var VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
|
|
2127
|
+
var notice;
|
|
2128
|
+
var newestSeen;
|
|
2129
|
+
function older(left, right) {
|
|
2130
|
+
const [a, b] = [left.split(".").map(Number), right.split(".").map(Number)];
|
|
2131
|
+
for (let index = 0; index < 3; index += 1) {
|
|
2132
|
+
const difference = a[index] - b[index];
|
|
2133
|
+
if (difference !== 0) return difference < 0;
|
|
2134
|
+
}
|
|
2135
|
+
return false;
|
|
2136
|
+
}
|
|
2137
|
+
function bounded(value) {
|
|
2138
|
+
return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, ADVICE_LIMIT);
|
|
2139
|
+
}
|
|
2140
|
+
function noteServerVersion(headers) {
|
|
2141
|
+
const newest = headers.get(CLI_NEWEST_HEADER)?.trim();
|
|
2142
|
+
if (newest === void 0 || !VERSION.test(newest)) return;
|
|
2143
|
+
if (!older(CLI_VERSION, newest)) return;
|
|
2144
|
+
newestSeen = newest;
|
|
2145
|
+
const advice = headers.get(CLI_ADVICE_HEADER);
|
|
2146
|
+
const line = advice === null ? "" : bounded(advice);
|
|
2147
|
+
notice = line === "" ? `A newer Balladeer is available (${newest}; this is ${CLI_VERSION}). Run npx -y balladeer@latest setup --refresh to update the command and instructions, then restart this session.` : line;
|
|
2148
|
+
}
|
|
2149
|
+
function newerVersionPublished() {
|
|
2150
|
+
return newestSeen;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2122
2153
|
// src/guidance.ts
|
|
2123
2154
|
import { createHash, randomBytes } from "node:crypto";
|
|
2124
2155
|
import {
|
|
@@ -2275,6 +2306,7 @@ async function loadGuidance(input) {
|
|
|
2275
2306
|
...previous ? { "if-none-match": previous.etag } : {}
|
|
2276
2307
|
}
|
|
2277
2308
|
});
|
|
2309
|
+
noteServerVersion(response.headers);
|
|
2278
2310
|
if (response.status === 304) {
|
|
2279
2311
|
if (!previous || response.headers.get("etag") !== previous.etag || response.headers.get("x-balladeer-guidance-revision") !== previous.document.revision) {
|
|
2280
2312
|
throw new Error("invalid_guidance_revalidation");
|
|
@@ -2411,6 +2443,11 @@ import {
|
|
|
2411
2443
|
writeSync as writeSync2
|
|
2412
2444
|
} from "node:fs";
|
|
2413
2445
|
import { basename, dirname as dirname2, isAbsolute, join as join3 } from "node:path";
|
|
2446
|
+
|
|
2447
|
+
// src/release.ts
|
|
2448
|
+
var HOOK_SPECIFIER = "balladeer@1";
|
|
2449
|
+
|
|
2450
|
+
// src/mcp-config.ts
|
|
2414
2451
|
var MCP_CONFIG_FILE = ".mcp.json";
|
|
2415
2452
|
function sameOrigin(left, right) {
|
|
2416
2453
|
try {
|
|
@@ -2865,6 +2902,145 @@ function validateProjectGuidanceScope(options) {
|
|
|
2865
2902
|
}
|
|
2866
2903
|
}
|
|
2867
2904
|
|
|
2905
|
+
// src/agent.ts
|
|
2906
|
+
var REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
2907
|
+
function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
|
|
2908
|
+
const here = agents.filter((agent) => agent.controlPlane === controlPlane);
|
|
2909
|
+
if (typeof currentRepository !== "string") {
|
|
2910
|
+
const candidates = currentRepository.filter(
|
|
2911
|
+
(name) => REPOSITORY_NAME.test(name) && name !== "unknown/unknown"
|
|
2912
|
+
);
|
|
2913
|
+
for (const name of candidates) {
|
|
2914
|
+
const found = selectAgent(agents, controlPlane, repositoryId, name);
|
|
2915
|
+
if (found.kind !== "refused") return found;
|
|
2916
|
+
}
|
|
2917
|
+
return selectAgent(agents, controlPlane, repositoryId, candidates[0] ?? "unknown/unknown");
|
|
2918
|
+
}
|
|
2919
|
+
if (repositoryId !== void 0) {
|
|
2920
|
+
const named = here.find(
|
|
2921
|
+
(agent) => agent.repositoryId === repositoryId || agent.repository?.toLowerCase() === repositoryId.toLowerCase()
|
|
2922
|
+
);
|
|
2923
|
+
if (named === void 0) {
|
|
2924
|
+
return {
|
|
2925
|
+
kind: "refused",
|
|
2926
|
+
reason: `No Balladeer connection for repository ${repositoryId} is stored for ${controlPlane}.`,
|
|
2927
|
+
missingFor: repositoryId
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
return withSafeEndpoint(named);
|
|
2931
|
+
}
|
|
2932
|
+
if (!REPOSITORY_NAME.test(currentRepository) || currentRepository === "unknown/unknown") {
|
|
2933
|
+
return {
|
|
2934
|
+
kind: "refused",
|
|
2935
|
+
reason: "This directory has no GitHub origin remote I could read, so I cannot tell which repository's Balladeer connection to use. Pass --repository <id>."
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2938
|
+
const matches = here.filter(
|
|
2939
|
+
(agent) => agent.repository?.toLowerCase() === currentRepository.toLowerCase()
|
|
2940
|
+
);
|
|
2941
|
+
const chosen = matches[matches.length - 1];
|
|
2942
|
+
if (chosen === void 0) {
|
|
2943
|
+
return {
|
|
2944
|
+
kind: "refused",
|
|
2945
|
+
reason: `No Balladeer connection for ${currentRepository} is stored for ${controlPlane}.`,
|
|
2946
|
+
missingFor: currentRepository
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
return withSafeEndpoint(chosen);
|
|
2950
|
+
}
|
|
2951
|
+
function withSafeEndpoint(agent) {
|
|
2952
|
+
try {
|
|
2953
|
+
if (new URL(agent.mcpUrl).origin !== new URL(agent.controlPlane).origin) {
|
|
2954
|
+
return {
|
|
2955
|
+
kind: "refused",
|
|
2956
|
+
reason: "That connection's MCP endpoint is not on the control plane it belongs to, so I sent its credential nowhere."
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
} catch {
|
|
2960
|
+
return {
|
|
2961
|
+
kind: "refused",
|
|
2962
|
+
reason: "That connection's MCP endpoint is not a URL, so I did not use it."
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2965
|
+
return { kind: "agent", agent };
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// src/repository.ts
|
|
2969
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2970
|
+
var REPOSITORY_HINT_PATTERN = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
2971
|
+
function git(cwd, args) {
|
|
2972
|
+
try {
|
|
2973
|
+
return execFileSync2("git", ["-C", cwd, ...args], {
|
|
2974
|
+
encoding: "utf8",
|
|
2975
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2976
|
+
}).trim();
|
|
2977
|
+
} catch {
|
|
2978
|
+
return void 0;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
function ownerName(remote) {
|
|
2982
|
+
const match = /github\.com[:/]+([A-Za-z0-9._-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(
|
|
2983
|
+
remote
|
|
2984
|
+
);
|
|
2985
|
+
if (!match) return void 0;
|
|
2986
|
+
const hint = `${match[1]}/${match[2]}`;
|
|
2987
|
+
return REPOSITORY_HINT_PATTERN.test(hint) ? hint : void 0;
|
|
2988
|
+
}
|
|
2989
|
+
function repositoryHints(cwd = process.cwd()) {
|
|
2990
|
+
const names = git(cwd, ["remote"])?.split("\n").filter((name) => name.length > 0) ?? [];
|
|
2991
|
+
const ordered = ["origin", ...names.filter((name) => name !== "origin")];
|
|
2992
|
+
const hints = [];
|
|
2993
|
+
for (const name of ordered) {
|
|
2994
|
+
const url = git(cwd, ["remote", "get-url", name]);
|
|
2995
|
+
const hint = url === void 0 ? void 0 : ownerName(url);
|
|
2996
|
+
if (hint !== void 0 && !hints.some((h) => h.toLowerCase() === hint.toLowerCase()))
|
|
2997
|
+
hints.push(hint);
|
|
2998
|
+
}
|
|
2999
|
+
return hints;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// src/self-update.ts
|
|
3003
|
+
import { spawn } from "node:child_process";
|
|
3004
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3005
|
+
import { join as join6 } from "node:path";
|
|
3006
|
+
var STAMP = "self-update.json";
|
|
3007
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
3008
|
+
function stampPath(environment) {
|
|
3009
|
+
return join6(configHome(environment), STAMP);
|
|
3010
|
+
}
|
|
3011
|
+
function lastStarted(environment) {
|
|
3012
|
+
const path = stampPath(environment);
|
|
3013
|
+
if (!existsSync3(path)) return 0;
|
|
3014
|
+
try {
|
|
3015
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
3016
|
+
return typeof parsed.startedAt === "number" ? parsed.startedAt : 0;
|
|
3017
|
+
} catch {
|
|
3018
|
+
return 0;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
function detached(command, args) {
|
|
3022
|
+
const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
|
|
3023
|
+
child.on("error", () => void 0);
|
|
3024
|
+
child.unref();
|
|
3025
|
+
}
|
|
3026
|
+
function startSelfUpdateIfDue(newer, deps) {
|
|
3027
|
+
if (newer === void 0) return false;
|
|
3028
|
+
const now = deps.now ?? Date.now;
|
|
3029
|
+
if (now() - lastStarted(deps.environment) < DAY_MS) return false;
|
|
3030
|
+
try {
|
|
3031
|
+
mkdirSync5(configHome(deps.environment), { recursive: true, mode: 448 });
|
|
3032
|
+
writeFileSync3(
|
|
3033
|
+
stampPath(deps.environment),
|
|
3034
|
+
JSON.stringify({ startedAt: now(), toward: newer }) + "\n",
|
|
3035
|
+
{ mode: 384 }
|
|
3036
|
+
);
|
|
3037
|
+
(deps.run ?? detached)("npx", ["-y", HOOK_SPECIFIER, "install", "--json"]);
|
|
3038
|
+
return true;
|
|
3039
|
+
} catch {
|
|
3040
|
+
return false;
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
|
|
2868
3044
|
// src/commands/guidance.ts
|
|
2869
3045
|
function readInput(stream) {
|
|
2870
3046
|
return new Promise((resolve2, reject) => {
|
|
@@ -2908,7 +3084,8 @@ function identifier(input) {
|
|
|
2908
3084
|
return typeof input === "string" && input.length > 0 && input.length <= 256 ? input : void 0;
|
|
2909
3085
|
}
|
|
2910
3086
|
async function runGuidance(options) {
|
|
2911
|
-
|
|
3087
|
+
const projectScoped = options.repositoryId !== void 0;
|
|
3088
|
+
if (projectScoped && !validateProjectGuidanceScope({
|
|
2912
3089
|
cwd: options.cwd,
|
|
2913
3090
|
hook: options.hook,
|
|
2914
3091
|
repositoryId: options.repositoryId,
|
|
@@ -2929,16 +3106,34 @@ async function runGuidance(options) {
|
|
|
2929
3106
|
throw new Error("invalid_hook_event");
|
|
2930
3107
|
event = parsedEvent;
|
|
2931
3108
|
const record = input;
|
|
2932
|
-
const
|
|
3109
|
+
const credentials = readCredentials(options.environment).agents;
|
|
3110
|
+
const agents = projectScoped ? credentials.filter(
|
|
2933
3111
|
(agent) => agent.controlPlane === options.controlPlane && agent.repositoryId === options.repositoryId
|
|
2934
|
-
)
|
|
2935
|
-
|
|
3112
|
+
) : (() => {
|
|
3113
|
+
const selection = selectAgent(
|
|
3114
|
+
credentials,
|
|
3115
|
+
options.controlPlane,
|
|
3116
|
+
void 0,
|
|
3117
|
+
repositoryHints(options.cwd)
|
|
3118
|
+
);
|
|
3119
|
+
return selection.kind === "refused" ? [] : [selection.agent];
|
|
3120
|
+
})();
|
|
3121
|
+
if (agents.length !== 1 || !agents[0]) {
|
|
3122
|
+
if (projectScoped) throw new Error("guidance_connection_unavailable");
|
|
3123
|
+
return 0;
|
|
3124
|
+
}
|
|
2936
3125
|
const loaded = await loadGuidance({
|
|
2937
3126
|
agent: agents[0],
|
|
2938
3127
|
environment: options.environment,
|
|
2939
3128
|
timeoutMs: 750,
|
|
2940
3129
|
...options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}
|
|
2941
3130
|
});
|
|
3131
|
+
if (event === "SessionStart")
|
|
3132
|
+
startSelfUpdateIfDue(newerVersionPublished(), {
|
|
3133
|
+
environment: options.environment,
|
|
3134
|
+
...options.startUpdate ? { run: options.startUpdate } : {},
|
|
3135
|
+
...options.now ? { now: options.now } : {}
|
|
3136
|
+
});
|
|
2942
3137
|
const sessionId = identifier(record.session_id);
|
|
2943
3138
|
const agentId = identifier(record.agent_id);
|
|
2944
3139
|
recordGuidanceContext(
|
package/dist/guidance.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { noteServerVersion } from "./currency.js";
|
|
1
2
|
import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
|
|
2
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
@@ -182,6 +183,9 @@ export async function loadGuidance(input) {
|
|
|
182
183
|
...(previous ? { "if-none-match": previous.etag } : {}),
|
|
183
184
|
},
|
|
184
185
|
});
|
|
186
|
+
// The server names the newest published release on every answer; the
|
|
187
|
+
// hook is where a laptop learns it is behind, and never where it waits.
|
|
188
|
+
noteServerVersion(response.headers);
|
|
185
189
|
if (response.status === 304) {
|
|
186
190
|
if (!previous ||
|
|
187
191
|
response.headers.get("etag") !== previous.etag ||
|
package/dist/install.d.ts
CHANGED
|
@@ -19,5 +19,9 @@ export declare function runInstall(options: InstallOptions & {
|
|
|
19
19
|
json: boolean;
|
|
20
20
|
write: (text: string) => void;
|
|
21
21
|
allowPartial?: boolean;
|
|
22
|
+
/** Carried into the one JSON object this prints, never as a second line. */
|
|
23
|
+
extra?: Record<string, unknown>;
|
|
24
|
+
/** Told the durable command's path once it is installed, before the report. */
|
|
25
|
+
onInstalled?: (result: InstallResult) => Record<string, unknown> | undefined;
|
|
22
26
|
}): number;
|
|
23
27
|
export {};
|
package/dist/install.js
CHANGED
|
@@ -333,6 +333,7 @@ export function ensureInstalled(options) {
|
|
|
333
333
|
export function runInstall(options) {
|
|
334
334
|
try {
|
|
335
335
|
const result = ensureInstalled(options);
|
|
336
|
+
const more = options.onInstalled?.(result) ?? {};
|
|
336
337
|
const message = result.manualPathRequired
|
|
337
338
|
? `Balladeer is installed at ${result.command}. Add ${dirname(result.command)} to your shell PATH and reopen the terminal/coding agent. Automatic PATH editing is unavailable for this shell; this setup process can use the command now.`
|
|
338
339
|
: result.restartRequired
|
|
@@ -344,6 +345,8 @@ export function runInstall(options) {
|
|
|
344
345
|
...result,
|
|
345
346
|
status: result.manualPathRequired ? "path_pending" : result.status,
|
|
346
347
|
message,
|
|
348
|
+
...(options.extra ?? {}),
|
|
349
|
+
...more,
|
|
347
350
|
}) + "\n"
|
|
348
351
|
: message + "\n");
|
|
349
352
|
return result.manualPathRequired && !options.allowPartial ? 4 : 0;
|
|
@@ -353,7 +356,12 @@ export function runInstall(options) {
|
|
|
353
356
|
? error.message
|
|
354
357
|
: "Installation could not access a required local file or command. Allow the reported installer operation and retry; no pairing has started.";
|
|
355
358
|
options.write(options.json
|
|
356
|
-
? JSON.stringify({
|
|
359
|
+
? JSON.stringify({
|
|
360
|
+
step: "install",
|
|
361
|
+
status: "blocked",
|
|
362
|
+
message,
|
|
363
|
+
...(options.extra ?? {}),
|
|
364
|
+
}) + "\n"
|
|
357
365
|
: message + "\n");
|
|
358
366
|
return 4;
|
|
359
367
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Open a page in the person's default browser, and say whether it was asked.
|
|
4
|
+
*
|
|
5
|
+
* Setup's one link used to be copied by hand from the terminal into a browser,
|
|
6
|
+
* which is the step a person at a keyboard does not want and an agent shell
|
|
7
|
+
* cannot do at all. So this runs only when a person is there (the caller
|
|
8
|
+
* checks the terminal), hands the URL to the operating system's own opener,
|
|
9
|
+
* and does not wait for it: a browser that never appears is reported by the
|
|
10
|
+
* link staying on screen, never by this command hanging. Nothing here reads
|
|
11
|
+
* the page or learns what happened in it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function openInBrowser(url: string, platform?: NodeJS.Platform, run?: typeof spawn): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* Open a page in the person's default browser, and say whether it was asked.
|
|
4
|
+
*
|
|
5
|
+
* Setup's one link used to be copied by hand from the terminal into a browser,
|
|
6
|
+
* which is the step a person at a keyboard does not want and an agent shell
|
|
7
|
+
* cannot do at all. So this runs only when a person is there (the caller
|
|
8
|
+
* checks the terminal), hands the URL to the operating system's own opener,
|
|
9
|
+
* and does not wait for it: a browser that never appears is reported by the
|
|
10
|
+
* link staying on screen, never by this command hanging. Nothing here reads
|
|
11
|
+
* the page or learns what happened in it.
|
|
12
|
+
*/
|
|
13
|
+
export function openInBrowser(url, platform = process.platform, run = spawn) {
|
|
14
|
+
if (!/^https:\/\//.test(url))
|
|
15
|
+
return false;
|
|
16
|
+
const [command, args] = platform === "darwin"
|
|
17
|
+
? ["open", [url]]
|
|
18
|
+
: platform === "win32"
|
|
19
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
20
|
+
: ["xdg-open", [url]];
|
|
21
|
+
try {
|
|
22
|
+
const child = run(command, args, { detached: true, stdio: "ignore" });
|
|
23
|
+
child.on("error", () => undefined);
|
|
24
|
+
child.unref();
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/dist/quiet.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
}
|