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/agent.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export type AgentSelection = Readonly<{
|
|
|
41
41
|
* points somewhere other than the control plane it belongs to is refused: a
|
|
42
42
|
* server response must not be able to send this credential to a new host.
|
|
43
43
|
*/
|
|
44
|
-
export declare function selectAgent(agents: readonly StoredAgent[], controlPlane: string, repositoryId: string | undefined, currentRepository: string): AgentSelection;
|
|
44
|
+
export declare function selectAgent(agents: readonly StoredAgent[], controlPlane: string, repositoryId: string | undefined, currentRepository: string | readonly string[]): AgentSelection;
|
|
45
45
|
/**
|
|
46
46
|
* What a command says when this machine holds no credential for the repository.
|
|
47
47
|
*
|
package/dist/agent.js
CHANGED
|
@@ -20,6 +20,18 @@ const REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
|
20
20
|
*/
|
|
21
21
|
export function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
|
|
22
22
|
const here = agents.filter((agent) => agent.controlPlane === controlPlane);
|
|
23
|
+
// Several remotes, as `repositoryHints` lists them: the first with a
|
|
24
|
+
// connection is the folder's repository. One string is the older one-remote
|
|
25
|
+
// form and every caller of it still works.
|
|
26
|
+
if (typeof currentRepository !== "string") {
|
|
27
|
+
const candidates = currentRepository.filter((name) => REPOSITORY_NAME.test(name) && name !== "unknown/unknown");
|
|
28
|
+
for (const name of candidates) {
|
|
29
|
+
const found = selectAgent(agents, controlPlane, repositoryId, name);
|
|
30
|
+
if (found.kind !== "refused")
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
return selectAgent(agents, controlPlane, repositoryId, candidates[0] ?? "unknown/unknown");
|
|
34
|
+
}
|
|
23
35
|
if (repositoryId !== undefined) {
|
|
24
36
|
const named = here.find((agent) => agent.repositoryId === repositoryId ||
|
|
25
37
|
agent.repository?.toLowerCase() === repositoryId.toLowerCase());
|
package/dist/cli.d.ts
CHANGED
|
@@ -4,11 +4,13 @@ type Parsed = Readonly<{
|
|
|
4
4
|
subcommand: string | undefined;
|
|
5
5
|
json: boolean;
|
|
6
6
|
wait: boolean;
|
|
7
|
+
/** `setup --no-open`: print the approval link instead of opening it. */
|
|
8
|
+
noOpen: boolean;
|
|
7
9
|
/** Repair the files a previous setup wrote, and do nothing else. */
|
|
8
10
|
refresh: boolean;
|
|
9
11
|
client: "codex" | "claude" | undefined;
|
|
10
12
|
chooseWorkspace: boolean;
|
|
11
|
-
/** `setup --force`: set up
|
|
13
|
+
/** `setup --force`: set up alongside an earlier Balladeer instead of removing it. */
|
|
12
14
|
force: boolean;
|
|
13
15
|
/** `setup --existing`: connect only when this checkout is already enrolled. */
|
|
14
16
|
existingOnly: boolean;
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { realpathSync } from "node:fs";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -18,17 +19,33 @@ import { runStatus } from "./commands/status.js";
|
|
|
18
19
|
import { runTouchMap } from "./commands/touch-map.js";
|
|
19
20
|
import { runWhoami } from "./commands/whoami.js";
|
|
20
21
|
import { runInstall } from "./install.js";
|
|
22
|
+
import { PUBLISHED_SPECIFIER } from "./release.js";
|
|
23
|
+
import { installUserScope, runningFromCheckout, userHome, } from "./user-scope.js";
|
|
21
24
|
import { updateNotice } from "./currency.js";
|
|
25
|
+
import { describeEarlier, describeRemoval, findEarlier, removeEarlier } from "./remove-earlier.js";
|
|
22
26
|
import { StoreError, normalizeControlPlane } from "./store.js";
|
|
23
27
|
import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
24
28
|
const USAGE = `balladeer ${CLI_VERSION}
|
|
25
29
|
|
|
26
30
|
${CLI_INVOCATION} install [--json]
|
|
27
|
-
Install this exact release as the durable balladeer command,
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
Install this exact release as the durable balladeer command, and register
|
|
32
|
+
Balladeer once for every Claude Code and Codex session on this machine:
|
|
33
|
+
it decides per folder, from the git remote, which connection applies.
|
|
34
|
+
Pairs nothing and changes no workspace, repository, or CI configuration.
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
balladeer update
|
|
37
|
+
Move the durable balladeer command to the latest published release. The
|
|
38
|
+
hooks and MCP entries already run the latest release on their own.
|
|
39
|
+
|
|
40
|
+
balladeer remove-earlier
|
|
41
|
+
Take the earlier Balladeer (the July client) off this machine and out of
|
|
42
|
+
this repository: its MCP server under the same name, its hooks and status
|
|
43
|
+
line, its guidance blocks and managed files (under your home and in this
|
|
44
|
+
repository's CLAUDE.md or AGENTS.md), and its 0.x command. A file git
|
|
45
|
+
does not track is copied beside itself first; a tracked one is for you
|
|
46
|
+
to commit. setup runs this itself when it finds one.
|
|
47
|
+
|
|
48
|
+
${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--no-open] [--control-plane <url>]
|
|
32
49
|
[--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
|
|
33
50
|
[--client codex|claude]
|
|
34
51
|
[--existing]
|
|
@@ -59,9 +76,12 @@ const USAGE = `balladeer ${CLI_VERSION}
|
|
|
59
76
|
other entry in those files alone, and prints what it changed. Installing
|
|
60
77
|
the command may contact npm; refresh never contacts Balladeer. Run it when
|
|
61
78
|
Balladeer says a newer version is available.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
79
|
+
At a terminal with a person at it, the approval page opens in their
|
|
80
|
+
browser and the run waits for the approval; --no-open prints the link
|
|
81
|
+
instead. An agent shell, or --json, always prints the link and returns.
|
|
82
|
+
--force sets up alongside an earlier Balladeer instead of removing it.
|
|
83
|
+
Without it, a run that finds the older client takes it off first, with a
|
|
84
|
+
copy of every file it changes kept beside the file.
|
|
65
85
|
Claude desktop chat is connected too, under a key named for this
|
|
66
86
|
repository, wherever the app is installed. --claude-desktop asks for it by
|
|
67
87
|
name, so a machine with no Claude desktop on it says so instead of
|
|
@@ -158,7 +178,7 @@ const USAGE = `balladeer ${CLI_VERSION}
|
|
|
158
178
|
Exit codes: 0 progress reported truthfully, 2 pairing expired or denied or already
|
|
159
179
|
claimed, 3 this copy is too old for the server, 4 usage or credential store problem,
|
|
160
180
|
5 transport or server failure, 6 an earlier Balladeer is still installed on this
|
|
161
|
-
machine and
|
|
181
|
+
machine and part of it could not be removed.
|
|
162
182
|
`;
|
|
163
183
|
const SUBCOMMANDS = { agent: ["rotate"] };
|
|
164
184
|
export function parseArguments(argv) {
|
|
@@ -175,6 +195,7 @@ export function parseArguments(argv) {
|
|
|
175
195
|
let event;
|
|
176
196
|
let json = false;
|
|
177
197
|
let wait = false;
|
|
198
|
+
let noOpen = false;
|
|
178
199
|
let refresh = false;
|
|
179
200
|
let client;
|
|
180
201
|
let chooseWorkspace = false;
|
|
@@ -247,6 +268,8 @@ export function parseArguments(argv) {
|
|
|
247
268
|
runner = value("--runner", inline);
|
|
248
269
|
else if (name === "--wait")
|
|
249
270
|
wait = true;
|
|
271
|
+
else if (name === "--no-open")
|
|
272
|
+
noOpen = true;
|
|
250
273
|
else if (name === "--refresh")
|
|
251
274
|
refresh = true;
|
|
252
275
|
else if (name === "--choose-workspace")
|
|
@@ -361,6 +384,7 @@ export function parseArguments(argv) {
|
|
|
361
384
|
subcommand,
|
|
362
385
|
json,
|
|
363
386
|
wait,
|
|
387
|
+
noOpen,
|
|
364
388
|
refresh,
|
|
365
389
|
client,
|
|
366
390
|
chooseWorkspace,
|
|
@@ -405,26 +429,114 @@ export async function main(argv) {
|
|
|
405
429
|
process.stderr.write(`${notice}\n`);
|
|
406
430
|
return code;
|
|
407
431
|
}
|
|
432
|
+
/** What `balladeer --version` answers on this PATH, or nothing. */
|
|
433
|
+
function commandVersion(name) {
|
|
434
|
+
const result = spawnSync(name, ["--version"], { encoding: "utf8", env: process.env });
|
|
435
|
+
const text = result.status === 0 ? result.stdout.trim() : "";
|
|
436
|
+
return /^\d+\.\d+\.\d+/.test(text) ? text : undefined;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Once per laptop: the durable command, then the same server and hook
|
|
440
|
+
* registered where every session on this machine reads them, naming that
|
|
441
|
+
* command by path so a session start costs node's startup and no npx. When
|
|
442
|
+
* the durable install is refused, the entries are still written, on the
|
|
443
|
+
* `npx` form of the current major: a blocked PATH must not keep a laptop from
|
|
444
|
+
* getting them. Nothing here needs approving in the coding host; Claude Code
|
|
445
|
+
* runs hooks from a person's own settings as they are.
|
|
446
|
+
*/
|
|
447
|
+
function installEverything(options) {
|
|
448
|
+
const published = !runningFromCheckout();
|
|
449
|
+
let writes;
|
|
450
|
+
const register = (installed) => {
|
|
451
|
+
writes = installUserScope({
|
|
452
|
+
environment: process.env,
|
|
453
|
+
published,
|
|
454
|
+
...(installed === undefined ? {} : { installed }),
|
|
455
|
+
});
|
|
456
|
+
return writes;
|
|
457
|
+
};
|
|
458
|
+
const code = runInstall({
|
|
459
|
+
environment: process.env,
|
|
460
|
+
json: options.json,
|
|
461
|
+
write: options.write,
|
|
462
|
+
allowPartial: options.allowPartial,
|
|
463
|
+
onInstalled: (result) => ({ userScope: register(result.command) }),
|
|
464
|
+
});
|
|
465
|
+
if (writes === undefined) {
|
|
466
|
+
const fallback = register();
|
|
467
|
+
if (options.json)
|
|
468
|
+
options.write(`${JSON.stringify({ step: "user_scope", userScope: fallback })}\n`);
|
|
469
|
+
}
|
|
470
|
+
for (const w of writes ?? [])
|
|
471
|
+
if (!options.json)
|
|
472
|
+
options.write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
|
|
473
|
+
if (!options.json && (writes ?? []).some((w) => w.status === "written"))
|
|
474
|
+
options.write("Balladeer now runs in every Claude Code" +
|
|
475
|
+
((writes ?? []).some((w) => w.host === "codex") ? " and Codex" : "") +
|
|
476
|
+
" 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");
|
|
477
|
+
return (writes ?? []).some((w) => w.status === "refused") ? 4 : code;
|
|
478
|
+
}
|
|
408
479
|
async function dispatch(parsed, write) {
|
|
409
480
|
switch (parsed.command) {
|
|
410
481
|
case "explain":
|
|
411
482
|
return runExplain(write, parsed.controlPlane);
|
|
483
|
+
case "update": {
|
|
484
|
+
// The latest published copy installs itself as the durable command, so
|
|
485
|
+
// this is one child process and its own report. Old and new are read
|
|
486
|
+
// from the command on PATH, before and after.
|
|
487
|
+
const before = commandVersion("balladeer");
|
|
488
|
+
const result = spawnSync("npx", ["-y", PUBLISHED_SPECIFIER, "install"], {
|
|
489
|
+
stdio: "inherit",
|
|
490
|
+
env: process.env,
|
|
491
|
+
});
|
|
492
|
+
if (result.status !== 0) {
|
|
493
|
+
write(`The update did not finish (exit ${result.status ?? "signal"}). Nothing else was changed.\n`);
|
|
494
|
+
return result.status ?? 5;
|
|
495
|
+
}
|
|
496
|
+
const after = commandVersion("balladeer");
|
|
497
|
+
write(before === after
|
|
498
|
+
? `balladeer is already ${after ?? "installed"}.\n`
|
|
499
|
+
: `balladeer ${before ?? "(not on PATH)"} is now ${after ?? "installed"}.\n`);
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
case "remove-earlier": {
|
|
503
|
+
const earlier = findEarlier({
|
|
504
|
+
home: userHome(process.env),
|
|
505
|
+
controlPlane: parsed.controlPlane,
|
|
506
|
+
environment: process.env,
|
|
507
|
+
cwd: process.cwd(),
|
|
508
|
+
});
|
|
509
|
+
if (earlier.length === 0) {
|
|
510
|
+
write("No earlier Balladeer is on this machine.\n");
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
write("An earlier Balladeer is on this machine:\n");
|
|
514
|
+
for (const line of describeEarlier(earlier))
|
|
515
|
+
write(`${line}\n`);
|
|
516
|
+
const report = removeEarlier(earlier);
|
|
517
|
+
for (const line of describeRemoval(report))
|
|
518
|
+
write(`${line}\n`);
|
|
519
|
+
if (parsed.json)
|
|
520
|
+
write(`${JSON.stringify({ step: "earlier", removed: report.removed, failed: report.failed })}\n`);
|
|
521
|
+
return report.failed.length === 0 ? 0 : 6;
|
|
522
|
+
}
|
|
412
523
|
case "install":
|
|
413
|
-
return
|
|
524
|
+
return installEverything({ json: parsed.json, write, allowPartial: false });
|
|
414
525
|
case "setup":
|
|
415
526
|
return runSetup({
|
|
416
527
|
installCommand: () => {
|
|
417
|
-
const result =
|
|
418
|
-
environment: process.env,
|
|
419
|
-
json: parsed.json,
|
|
420
|
-
write,
|
|
421
|
-
allowPartial: true,
|
|
422
|
-
});
|
|
528
|
+
const result = installEverything({ json: parsed.json, write, allowPartial: true });
|
|
423
529
|
return process.platform === "win32" ? 0 : result;
|
|
424
530
|
},
|
|
425
531
|
controlPlane: parsed.controlPlane,
|
|
426
532
|
json: parsed.json,
|
|
427
533
|
wait: parsed.wait,
|
|
534
|
+
// A person is here when both ends of the terminal are a TTY; an agent
|
|
535
|
+
// shell has neither and keeps the print-and-return behaviour.
|
|
536
|
+
interactive: !parsed.noOpen &&
|
|
537
|
+
!parsed.json &&
|
|
538
|
+
process.stdout.isTTY === true &&
|
|
539
|
+
process.stdin.isTTY === true,
|
|
428
540
|
refresh: parsed.refresh,
|
|
429
541
|
chooseWorkspace: parsed.chooseWorkspace,
|
|
430
542
|
...(parsed.client === undefined ? {} : { client: parsed.client }),
|
|
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { callAgentTool, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
|
|
3
3
|
import { batchProposalReviewLink, proposalReviewLink } from "../client.js";
|
|
4
4
|
import { commandLine } from "../release.js";
|
|
5
|
-
import { repositoryHint } from "../repository.js";
|
|
5
|
+
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
6
6
|
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
7
7
|
import { CLI_INVOCATION } from "../wire.js";
|
|
8
8
|
import { readTeachBackFile } from "./propose.js";
|
|
@@ -217,7 +217,7 @@ export async function runDiscover(options) {
|
|
|
217
217
|
catch (error) {
|
|
218
218
|
return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
219
219
|
}
|
|
220
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ??
|
|
220
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
|
|
221
221
|
if (selection.kind === "refused") {
|
|
222
222
|
return fail("no_agent_connection", `${selection.reason} Run \`${commandLine(null, "setup")}\` in this repository first. Nothing was sent.`, 4);
|
|
223
223
|
}
|
|
@@ -1,4 +1,14 @@
|
|
|
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
|
+
*/
|
|
11
|
+
export declare function untrackedLine(remote: string): string;
|
|
2
12
|
export type GuidanceOptions = Readonly<{
|
|
3
13
|
controlPlane: string;
|
|
4
14
|
repositoryId?: string;
|
|
@@ -12,6 +22,8 @@ export type GuidanceOptions = Readonly<{
|
|
|
12
22
|
error?: (text: string) => void;
|
|
13
23
|
fetchImpl?: typeof fetch;
|
|
14
24
|
now?: () => number;
|
|
25
|
+
/** Runs the background update; injected so tests never reach npm. */
|
|
26
|
+
startUpdate?: (command: string, args: readonly string[]) => void;
|
|
15
27
|
}>;
|
|
16
28
|
/** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
|
|
17
29
|
export declare function runGuidance(options: GuidanceOptions): Promise<number>;
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { validateProjectGuidanceScope } from "../guidance-install.js";
|
|
2
2
|
import { GUIDANCE_UNAVAILABLE, loadGuidance, recordGuidanceContext, } from "../guidance.js";
|
|
3
3
|
import { readCredentials } from "../store.js";
|
|
4
|
+
import { selectAgent } from "../agent.js";
|
|
5
|
+
import { repositoryHints } from "../repository.js";
|
|
6
|
+
import { newerVersionPublished } from "../currency.js";
|
|
7
|
+
import { startSelfUpdateIfDue } from "../self-update.js";
|
|
8
|
+
/**
|
|
9
|
+
* What an agent, or `status`, is told about a folder Balladeer is not tracking.
|
|
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
|
+
*/
|
|
17
|
+
export function untrackedLine(remote) {
|
|
18
|
+
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
19
|
+
return (`${here} isn't connected to Balladeer on this laptop, so nothing is tracked from here. ` +
|
|
20
|
+
`To connect it, run \`balladeer setup\` in this folder.`);
|
|
21
|
+
}
|
|
4
22
|
function readInput(stream) {
|
|
5
23
|
return new Promise((resolve, reject) => {
|
|
6
24
|
let bytes = 0;
|
|
@@ -51,8 +69,11 @@ function identifier(input) {
|
|
|
51
69
|
}
|
|
52
70
|
/** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
|
|
53
71
|
export async function runGuidance(options) {
|
|
54
|
-
// A
|
|
55
|
-
|
|
72
|
+
// A project hook names its repository and must match the checkout it sits
|
|
73
|
+
// in. A user-scope hook names none: the folder's remote decides, and a folder
|
|
74
|
+
// with no connection hears that it is untracked rather than nothing at all.
|
|
75
|
+
const projectScoped = options.repositoryId !== undefined;
|
|
76
|
+
if (projectScoped &&
|
|
56
77
|
!validateProjectGuidanceScope({
|
|
57
78
|
cwd: options.cwd,
|
|
58
79
|
hook: options.hook,
|
|
@@ -71,15 +92,34 @@ export async function runGuidance(options) {
|
|
|
71
92
|
throw new Error("invalid_hook_event");
|
|
72
93
|
event = parsedEvent;
|
|
73
94
|
const record = input;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
const credentials = readCredentials(options.environment).agents;
|
|
96
|
+
const agents = projectScoped
|
|
97
|
+
? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
|
|
98
|
+
agent.repositoryId === options.repositoryId)
|
|
99
|
+
: (() => {
|
|
100
|
+
const selection = selectAgent(credentials, options.controlPlane, undefined, repositoryHints(options.cwd));
|
|
101
|
+
return selection.kind === "refused" ? [] : [selection.agent];
|
|
102
|
+
})();
|
|
103
|
+
if (agents.length !== 1 || !agents[0]) {
|
|
104
|
+
if (projectScoped)
|
|
105
|
+
throw new Error("guidance_connection_unavailable");
|
|
106
|
+
// Untracked: nothing is said. `status` in the folder says why.
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
77
109
|
const loaded = await loadGuidance({
|
|
78
110
|
agent: agents[0],
|
|
79
111
|
environment: options.environment,
|
|
80
112
|
timeoutMs: 750,
|
|
81
113
|
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
82
114
|
});
|
|
115
|
+
// Behind? Start the install in the background, once a day at most, and
|
|
116
|
+
// carry on serving this session from the copy that is here.
|
|
117
|
+
if (event === "SessionStart")
|
|
118
|
+
startSelfUpdateIfDue(newerVersionPublished(), {
|
|
119
|
+
environment: options.environment,
|
|
120
|
+
...(options.startUpdate ? { run: options.startUpdate } : {}),
|
|
121
|
+
...(options.now ? { now: options.now } : {}),
|
|
122
|
+
});
|
|
83
123
|
const sessionId = identifier(record.session_id);
|
|
84
124
|
const agentId = identifier(record.agent_id);
|
|
85
125
|
recordGuidanceContext({
|
package/dist/commands/mcp.js
CHANGED
|
@@ -2,7 +2,9 @@ import { createInterface } from "node:readline";
|
|
|
2
2
|
import { installGuidanceLoader } from "../guidance-install.js";
|
|
3
3
|
import { callAgentTool, forwarderHeaders, noAgentCredentialSentence, safeJson, selectAgent, structuredString, updateLine, } from "../agent.js";
|
|
4
4
|
import { noteServerVersion, updateNotice } from "../currency.js";
|
|
5
|
-
import { repositoryHint } from "../repository.js";
|
|
5
|
+
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
6
|
+
import { CLI_VERSION } from "../wire.js";
|
|
7
|
+
import { untrackedLine } from "./guidance.js";
|
|
6
8
|
import { StoreError, readCredentials } from "../store.js";
|
|
7
9
|
// The three commands that use an agent connection select and address it through
|
|
8
10
|
// one module. These are re-exported because this is where the forwarder's
|
|
@@ -127,6 +129,39 @@ function frameId(line) {
|
|
|
127
129
|
}
|
|
128
130
|
return null;
|
|
129
131
|
}
|
|
132
|
+
/** The smallest server a host accepts: initialize, an empty tools list, ping. */
|
|
133
|
+
async function serveUntracked(options, instructions) {
|
|
134
|
+
const lines = createInterface({ input: options.stdin, crlfDelay: Infinity });
|
|
135
|
+
for await (const line of lines) {
|
|
136
|
+
if (line.trim().length === 0)
|
|
137
|
+
continue;
|
|
138
|
+
let request;
|
|
139
|
+
try {
|
|
140
|
+
request = JSON.parse(line);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (request.id === undefined)
|
|
146
|
+
continue; // a notification
|
|
147
|
+
const result = request.method === "initialize"
|
|
148
|
+
? {
|
|
149
|
+
protocolVersion: request.params?.protocolVersion ?? "2025-06-18",
|
|
150
|
+
capabilities: { tools: {} },
|
|
151
|
+
serverInfo: { name: "balladeer", version: CLI_VERSION },
|
|
152
|
+
instructions,
|
|
153
|
+
}
|
|
154
|
+
: request.method === "tools/list"
|
|
155
|
+
? { tools: [] }
|
|
156
|
+
: request.method === "ping"
|
|
157
|
+
? {}
|
|
158
|
+
: undefined;
|
|
159
|
+
options.write(JSON.stringify(result === undefined
|
|
160
|
+
? { jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } }
|
|
161
|
+
: { jsonrpc: "2.0", id: request.id, result }) + "\n");
|
|
162
|
+
}
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
130
165
|
export async function runMcp(options) {
|
|
131
166
|
let credentials;
|
|
132
167
|
try {
|
|
@@ -136,7 +171,7 @@ export async function runMcp(options) {
|
|
|
136
171
|
options.error(`${error instanceof StoreError ? error.message : String(error)}\n`);
|
|
137
172
|
return 4;
|
|
138
173
|
}
|
|
139
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, options.repositoryId,
|
|
174
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repositoryId, repositoryHints(options.cwd));
|
|
140
175
|
if (selection.kind === "refused") {
|
|
141
176
|
// Issuing one in the browser is what this used to suggest here, and it is the
|
|
142
177
|
// move that produces this state: the bearer is shown once, there, and the
|
|
@@ -144,7 +179,14 @@ export async function runMcp(options) {
|
|
|
144
179
|
options.error(selection.missingFor === undefined
|
|
145
180
|
? `${selection.reason} Run setup in this repository, or issue the connection at ${options.controlPlane}/setup.\n`
|
|
146
181
|
: `${noAgentCredentialSentence(selection.missingFor)}\n`);
|
|
147
|
-
|
|
182
|
+
// A project entry named its repository and got it wrong: that is an error
|
|
183
|
+
// the host should show. A user-scope entry named none and simply landed in
|
|
184
|
+
// a folder nobody tracks: that is ordinary, and a server that dies here
|
|
185
|
+
// shows up red in every untracked folder on the laptop. Answer the host
|
|
186
|
+
// with an empty catalog and the one sentence the hook also says.
|
|
187
|
+
if (options.repositoryId !== undefined)
|
|
188
|
+
return 4;
|
|
189
|
+
return serveUntracked(options, untrackedLine(repositoryHint(options.cwd)));
|
|
148
190
|
}
|
|
149
191
|
const { agent } = selection;
|
|
150
192
|
const guidanceInstall = installGuidanceLoader({
|
package/dist/commands/prepare.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
|
3
3
|
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, } from "../agent.js";
|
|
4
4
|
import { inReadersZone } from "../local-time.js";
|
|
5
5
|
import { commandLine } from "../release.js";
|
|
6
|
-
import { repositoryHint } from "../repository.js";
|
|
6
|
+
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
7
7
|
import { StoreError, readCredentials } from "../store.js";
|
|
8
8
|
import {} from "../wire.js";
|
|
9
9
|
const PROMISE_ID = /^prom_[a-z0-9]{8,64}$/;
|
|
@@ -138,7 +138,7 @@ export async function runPrepare(options) {
|
|
|
138
138
|
catch (error) {
|
|
139
139
|
return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
140
140
|
}
|
|
141
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ??
|
|
141
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
|
|
142
142
|
if (selection.kind === "refused") {
|
|
143
143
|
if (selection.missingFor !== undefined) {
|
|
144
144
|
return fail("no_agent_credential_on_this_machine", `${noAgentCredentialSentence(selection.missingFor)} Nothing was prepared.`, 4);
|
package/dist/commands/propose.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, structuredString, } from "../agent.js";
|
|
3
3
|
import { proposalReviewLink } from "../client.js";
|
|
4
4
|
import { commandLine } from "../release.js";
|
|
5
|
-
import { repositoryHint } from "../repository.js";
|
|
5
|
+
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
6
6
|
import { StoreError, readCredentials } from "../store.js";
|
|
7
7
|
import { CLI_INVOCATION } from "../wire.js";
|
|
8
8
|
const MAX_FILE_BYTES = 64 * 1024;
|
|
@@ -215,7 +215,7 @@ export async function runPropose(options) {
|
|
|
215
215
|
catch (error) {
|
|
216
216
|
return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
217
217
|
}
|
|
218
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ??
|
|
218
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
|
|
219
219
|
if (selection.kind === "refused") {
|
|
220
220
|
// The repository being connected somewhere else is not this machine holding
|
|
221
221
|
// a credential, and a proposal runs over the one this machine holds. Said in
|
package/dist/commands/session.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { callAgentTool, noAgentCredentialSentence, reportAgentEnforcementWarning, selectAgent, } from "../agent.js";
|
|
2
2
|
import { headCommit } from "../git.js";
|
|
3
|
-
import { repositoryHint } from "../repository.js";
|
|
3
|
+
import { repositoryHint, repositoryHints } from "../repository.js";
|
|
4
4
|
import { AGENT_SESSION_TRAILER, agentSessionTrailerLine, currentSession, readAgentSessionTrailer, } from "../session.js";
|
|
5
5
|
import { StoreError, readCredentials } from "../store.js";
|
|
6
6
|
import { CLI_INVOCATION } from "../wire.js";
|
|
@@ -43,7 +43,7 @@ export async function runSession(options) {
|
|
|
43
43
|
return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
44
44
|
}
|
|
45
45
|
const here = options.repo ?? repositoryHint(options.cwd);
|
|
46
|
-
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository,
|
|
46
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, options.repo ?? repositoryHints(options.cwd));
|
|
47
47
|
if (selection.kind === "refused") {
|
|
48
48
|
const message = selection.missingFor === undefined
|
|
49
49
|
? selection.reason
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -2,6 +2,15 @@ export type SetupOptions = Readonly<{
|
|
|
2
2
|
controlPlane: string;
|
|
3
3
|
json: boolean;
|
|
4
4
|
wait: boolean;
|
|
5
|
+
/**
|
|
6
|
+
* A person is at this terminal. The pairing link is opened in their browser
|
|
7
|
+
* and the run waits for the approval, so one command carries them through.
|
|
8
|
+
* Off in an agent shell, in `--json`, and under `--no-open`: those print the
|
|
9
|
+
* link and return. Robert, 22 September 2026.
|
|
10
|
+
*/
|
|
11
|
+
interactive?: boolean;
|
|
12
|
+
/** Opens a page for the person; injected so tests never reach a browser. */
|
|
13
|
+
openLink?: (url: string) => boolean;
|
|
5
14
|
repo?: string;
|
|
6
15
|
createWorkspace?: string;
|
|
7
16
|
client?: "codex" | "claude";
|
|
@@ -93,6 +102,12 @@ export declare function chooseRepositories(named: readonly string[], cwd: string
|
|
|
93
102
|
* a command that blocks for minutes and the pairing would be lost with it.
|
|
94
103
|
*/
|
|
95
104
|
export declare function runSetup(options: SetupOptions): Promise<number>;
|
|
105
|
+
/**
|
|
106
|
+
* The link, opened for a person at the terminal and for nobody else. Whether a
|
|
107
|
+
* browser appeared is not known here; the line beside the link says what to do
|
|
108
|
+
* if none did.
|
|
109
|
+
*/
|
|
110
|
+
export declare function openForPerson(options: SetupOptions, url: string): boolean;
|
|
96
111
|
/**
|
|
97
112
|
* Which invocation this repair writes, decided without asking anybody.
|
|
98
113
|
*
|