balladeer 1.0.7 → 1.0.8
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/cli.d.ts +1 -0
- package/dist/cli.js +56 -5
- package/dist/commands/guidance.d.ts +2 -0
- package/dist/commands/guidance.js +32 -5
- package/dist/commands/mcp.js +43 -1
- package/dist/guidance-hook.mjs +147 -5
- package/dist/install.d.ts +2 -0
- package/dist/install.js +7 -1
- package/dist/quiet.d.ts +25 -0
- package/dist/quiet.js +73 -0
- package/dist/user-scope.d.ts +55 -0
- package/dist/user-scope.js +234 -0
- package/dist/wire.d.ts +2 -2
- package/dist/wire.js +1 -1
- package/package.json +3 -2
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -18,15 +18,24 @@ import { runStatus } from "./commands/status.js";
|
|
|
18
18
|
import { runTouchMap } from "./commands/touch-map.js";
|
|
19
19
|
import { runWhoami } from "./commands/whoami.js";
|
|
20
20
|
import { runInstall } from "./install.js";
|
|
21
|
+
import { installUserScope, runningFromCheckout } from "./user-scope.js";
|
|
22
|
+
import { setQuiet } from "./quiet.js";
|
|
21
23
|
import { updateNotice } from "./currency.js";
|
|
22
24
|
import { StoreError, normalizeControlPlane } from "./store.js";
|
|
23
25
|
import { CLI_INVOCATION, CLI_VERSION, DEFAULT_CONTROL_PLANE } from "./wire.js";
|
|
24
26
|
const USAGE = `balladeer ${CLI_VERSION}
|
|
25
27
|
|
|
26
28
|
${CLI_INVOCATION} install [--json]
|
|
27
|
-
Install this exact release as the durable balladeer command,
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
Install this exact release as the durable balladeer command, and register
|
|
30
|
+
Balladeer once for every Claude Code and Codex session on this machine:
|
|
31
|
+
it decides per folder, from the git remote, which connection applies.
|
|
32
|
+
Pairs nothing and changes no workspace, repository, or CI configuration.
|
|
33
|
+
|
|
34
|
+
${CLI_INVOCATION} quiet [--repo]
|
|
35
|
+
${CLI_INVOCATION} track [--repo]
|
|
36
|
+
In a folder Balladeer does not track, each session hears that once. quiet
|
|
37
|
+
stops it for this folder (or --repo, for every checkout of this
|
|
38
|
+
repository); track undoes it.
|
|
30
39
|
|
|
31
40
|
${CLI_INVOCATION} setup [--repository owner/name]... [--json] [--wait] [--control-plane <url>]
|
|
32
41
|
[--create-workspace <name>] [--choose-workspace] [--refresh] [--force]
|
|
@@ -188,6 +197,7 @@ export function parseArguments(argv) {
|
|
|
188
197
|
let runner;
|
|
189
198
|
let controlPlane;
|
|
190
199
|
let repo;
|
|
200
|
+
let repoScope = false;
|
|
191
201
|
let file;
|
|
192
202
|
let owner;
|
|
193
203
|
let createWorkspace;
|
|
@@ -267,6 +277,8 @@ export function parseArguments(argv) {
|
|
|
267
277
|
claudeDesktop = false;
|
|
268
278
|
else if (name === "--control-plane")
|
|
269
279
|
controlPlane = value("--control-plane", inline);
|
|
280
|
+
else if (name === "--repo" && (command === "quiet" || command === "track"))
|
|
281
|
+
repoScope = true;
|
|
270
282
|
else if (name === "--repo")
|
|
271
283
|
repo = value("--repo", inline);
|
|
272
284
|
else if (name === "--file")
|
|
@@ -368,6 +380,7 @@ export function parseArguments(argv) {
|
|
|
368
380
|
existingOnly,
|
|
369
381
|
claudeDesktop,
|
|
370
382
|
repo,
|
|
383
|
+
repoScope,
|
|
371
384
|
file,
|
|
372
385
|
repository: repositories[0],
|
|
373
386
|
repositories,
|
|
@@ -409,8 +422,46 @@ async function dispatch(parsed, write) {
|
|
|
409
422
|
switch (parsed.command) {
|
|
410
423
|
case "explain":
|
|
411
424
|
return runExplain(write, parsed.controlPlane);
|
|
412
|
-
case "install":
|
|
413
|
-
|
|
425
|
+
case "install": {
|
|
426
|
+
// Once per laptop: the same server and hook, registered where every
|
|
427
|
+
// session on this machine reads them, deciding per folder at run time.
|
|
428
|
+
// Registered first and independently: the entries run through npx and
|
|
429
|
+
// need no durable command, so a blocked PATH must not keep a laptop
|
|
430
|
+
// from getting them.
|
|
431
|
+
const writes = installUserScope({
|
|
432
|
+
environment: process.env,
|
|
433
|
+
published: !runningFromCheckout(),
|
|
434
|
+
});
|
|
435
|
+
for (const w of writes)
|
|
436
|
+
if (!parsed.json)
|
|
437
|
+
write(`${w.status === "refused" ? "Not changed" : w.status === "written" ? "Registered" : "Already current"}: ${w.path}${w.reason ? ` (${w.reason})` : ""}\n`);
|
|
438
|
+
if (!parsed.json && writes.some((w) => w.status === "written"))
|
|
439
|
+
write("Balladeer now runs in every Claude Code" +
|
|
440
|
+
(writes.some((w) => w.host === "codex") ? " and Codex" : "") +
|
|
441
|
+
" session on this machine. In a folder of a connected repository it works as before; anywhere else it says once that the folder isn't tracked. Approve the new hook when your coding host asks.\n");
|
|
442
|
+
const code = runInstall({
|
|
443
|
+
environment: process.env,
|
|
444
|
+
json: parsed.json,
|
|
445
|
+
write,
|
|
446
|
+
extra: { userScope: writes },
|
|
447
|
+
});
|
|
448
|
+
return writes.some((w) => w.status === "refused") ? 4 : code;
|
|
449
|
+
}
|
|
450
|
+
case "quiet":
|
|
451
|
+
case "track": {
|
|
452
|
+
const scope = parsed.repoScope ? "repo" : "folder";
|
|
453
|
+
try {
|
|
454
|
+
const { key, changed } = setQuiet(process.cwd(), scope, parsed.command === "quiet", process.env);
|
|
455
|
+
write(parsed.command === "quiet"
|
|
456
|
+
? `${changed ? "Balladeer will stay quiet" : "Balladeer was already quiet"} in ${scope === "repo" ? `every checkout of ${key}` : key}. Run \`balladeer track${scope === "repo" ? " --repo" : ""}\` here to undo.\n`
|
|
457
|
+
: `${changed ? "Balladeer will speak again" : "Balladeer was not quiet"} in ${scope === "repo" ? `checkouts of ${key}` : key}.\n`);
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
462
|
+
return 4;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
414
465
|
case "setup":
|
|
415
466
|
return runSetup({
|
|
416
467
|
installCommand: () => {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type GuidanceEvent } from "../guidance.js";
|
|
2
|
+
/** What a session hears, once, in a folder Balladeer is not tracking. */
|
|
3
|
+
export declare function untrackedLine(remote: string): string;
|
|
2
4
|
export type GuidanceOptions = Readonly<{
|
|
3
5
|
controlPlane: string;
|
|
4
6
|
repositoryId?: string;
|
|
@@ -1,6 +1,17 @@
|
|
|
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 { repositoryHint } from "../repository.js";
|
|
6
|
+
import { isQuiet } from "../quiet.js";
|
|
7
|
+
import { PUBLISHED_SPECIFIER } from "../release.js";
|
|
8
|
+
/** What a session hears, once, in a folder Balladeer is not tracking. */
|
|
9
|
+
export function untrackedLine(remote) {
|
|
10
|
+
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
11
|
+
return (`${here} isn't tracked by Balladeer, so promises can't be tracked from here. ` +
|
|
12
|
+
`To track it, run \`npx -y ${PUBLISHED_SPECIFIER} setup\` in this folder. ` +
|
|
13
|
+
`To stop this message here, run \`npx -y ${PUBLISHED_SPECIFIER} quiet\` (or \`quiet --repo\` for the whole repository).`);
|
|
14
|
+
}
|
|
4
15
|
function readInput(stream) {
|
|
5
16
|
return new Promise((resolve, reject) => {
|
|
6
17
|
let bytes = 0;
|
|
@@ -51,8 +62,11 @@ function identifier(input) {
|
|
|
51
62
|
}
|
|
52
63
|
/** Hook stdout is bounded context only. Host prompts and transcript paths never leave this process. */
|
|
53
64
|
export async function runGuidance(options) {
|
|
54
|
-
// A
|
|
55
|
-
|
|
65
|
+
// A project hook names its repository and must match the checkout it sits
|
|
66
|
+
// in. A user-scope hook names none: the folder's remote decides, and a folder
|
|
67
|
+
// with no connection hears that it is untracked rather than nothing at all.
|
|
68
|
+
const projectScoped = options.repositoryId !== undefined;
|
|
69
|
+
if (projectScoped &&
|
|
56
70
|
!validateProjectGuidanceScope({
|
|
57
71
|
cwd: options.cwd,
|
|
58
72
|
hook: options.hook,
|
|
@@ -71,9 +85,22 @@ export async function runGuidance(options) {
|
|
|
71
85
|
throw new Error("invalid_hook_event");
|
|
72
86
|
event = parsedEvent;
|
|
73
87
|
const record = input;
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
const credentials = readCredentials(options.environment).agents;
|
|
89
|
+
const agents = projectScoped
|
|
90
|
+
? credentials.filter((agent) => agent.controlPlane === options.controlPlane &&
|
|
91
|
+
agent.repositoryId === options.repositoryId)
|
|
92
|
+
: (() => {
|
|
93
|
+
const selection = selectAgent(credentials, options.controlPlane, undefined, repositoryHint(options.cwd));
|
|
94
|
+
return selection.kind === "refused" ? [] : [selection.agent];
|
|
95
|
+
})();
|
|
96
|
+
if (agents.length !== 1 || !agents[0]) {
|
|
97
|
+
if (projectScoped)
|
|
98
|
+
throw new Error("guidance_connection_unavailable");
|
|
99
|
+
// Untracked. Said once, at session start, and never when silenced.
|
|
100
|
+
if (event === "SessionStart" && !isQuiet(options.cwd, options.environment))
|
|
101
|
+
emit(untrackedLine(repositoryHint(options.cwd)));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
77
104
|
const loaded = await loadGuidance({
|
|
78
105
|
agent: agents[0],
|
|
79
106
|
environment: options.environment,
|
package/dist/commands/mcp.js
CHANGED
|
@@ -3,6 +3,8 @@ 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
5
|
import { repositoryHint } 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 {
|
|
@@ -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/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.8";
|
|
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";
|
|
@@ -2411,6 +2411,11 @@ import {
|
|
|
2411
2411
|
writeSync as writeSync2
|
|
2412
2412
|
} from "node:fs";
|
|
2413
2413
|
import { basename, dirname as dirname2, isAbsolute, join as join3 } from "node:path";
|
|
2414
|
+
|
|
2415
|
+
// src/release.ts
|
|
2416
|
+
var PUBLISHED_SPECIFIER = "balladeer@latest";
|
|
2417
|
+
|
|
2418
|
+
// src/mcp-config.ts
|
|
2414
2419
|
var MCP_CONFIG_FILE = ".mcp.json";
|
|
2415
2420
|
function sameOrigin(left, right) {
|
|
2416
2421
|
try {
|
|
@@ -2865,7 +2870,129 @@ function validateProjectGuidanceScope(options) {
|
|
|
2865
2870
|
}
|
|
2866
2871
|
}
|
|
2867
2872
|
|
|
2873
|
+
// src/agent.ts
|
|
2874
|
+
var REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
2875
|
+
function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
|
|
2876
|
+
const here = agents.filter((agent) => agent.controlPlane === controlPlane);
|
|
2877
|
+
if (repositoryId !== void 0) {
|
|
2878
|
+
const named = here.find(
|
|
2879
|
+
(agent) => agent.repositoryId === repositoryId || agent.repository?.toLowerCase() === repositoryId.toLowerCase()
|
|
2880
|
+
);
|
|
2881
|
+
if (named === void 0) {
|
|
2882
|
+
return {
|
|
2883
|
+
kind: "refused",
|
|
2884
|
+
reason: `No Balladeer connection for repository ${repositoryId} is stored for ${controlPlane}.`,
|
|
2885
|
+
missingFor: repositoryId
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
return withSafeEndpoint(named);
|
|
2889
|
+
}
|
|
2890
|
+
if (!REPOSITORY_NAME.test(currentRepository) || currentRepository === "unknown/unknown") {
|
|
2891
|
+
return {
|
|
2892
|
+
kind: "refused",
|
|
2893
|
+
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>."
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
const matches = here.filter(
|
|
2897
|
+
(agent) => agent.repository?.toLowerCase() === currentRepository.toLowerCase()
|
|
2898
|
+
);
|
|
2899
|
+
const chosen = matches[matches.length - 1];
|
|
2900
|
+
if (chosen === void 0) {
|
|
2901
|
+
return {
|
|
2902
|
+
kind: "refused",
|
|
2903
|
+
reason: `No Balladeer connection for ${currentRepository} is stored for ${controlPlane}.`,
|
|
2904
|
+
missingFor: currentRepository
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
return withSafeEndpoint(chosen);
|
|
2908
|
+
}
|
|
2909
|
+
function withSafeEndpoint(agent) {
|
|
2910
|
+
try {
|
|
2911
|
+
if (new URL(agent.mcpUrl).origin !== new URL(agent.controlPlane).origin) {
|
|
2912
|
+
return {
|
|
2913
|
+
kind: "refused",
|
|
2914
|
+
reason: "That connection's MCP endpoint is not on the control plane it belongs to, so I sent its credential nowhere."
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
} catch {
|
|
2918
|
+
return {
|
|
2919
|
+
kind: "refused",
|
|
2920
|
+
reason: "That connection's MCP endpoint is not a URL, so I did not use it."
|
|
2921
|
+
};
|
|
2922
|
+
}
|
|
2923
|
+
return { kind: "agent", agent };
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
// src/repository.ts
|
|
2927
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
2928
|
+
var REPOSITORY_HINT_PATTERN = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
2929
|
+
function repositoryHint(cwd = process.cwd()) {
|
|
2930
|
+
let remote;
|
|
2931
|
+
try {
|
|
2932
|
+
remote = execFileSync2("git", ["-C", cwd, "remote", "get-url", "origin"], {
|
|
2933
|
+
encoding: "utf8",
|
|
2934
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
2935
|
+
}).trim();
|
|
2936
|
+
} catch {
|
|
2937
|
+
return "unknown/unknown";
|
|
2938
|
+
}
|
|
2939
|
+
const match = /github\.com[:/]+([A-Za-z0-9._-]{1,39})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?$/.exec(
|
|
2940
|
+
remote
|
|
2941
|
+
);
|
|
2942
|
+
if (!match) return "unknown/unknown";
|
|
2943
|
+
const hint = `${match[1]}/${match[2]}`;
|
|
2944
|
+
return REPOSITORY_HINT_PATTERN.test(hint) ? hint : "unknown/unknown";
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
// src/quiet.ts
|
|
2948
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
2949
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync5, readFileSync as readFileSync6, realpathSync } from "node:fs";
|
|
2950
|
+
import { join as join6 } from "node:path";
|
|
2951
|
+
var EMPTY2 = { schemaVersion: 1, folders: [], remotes: [] };
|
|
2952
|
+
function quietPath(environment = process.env) {
|
|
2953
|
+
return join6(configHome(environment), "quiet.json");
|
|
2954
|
+
}
|
|
2955
|
+
function readQuiet(environment = process.env) {
|
|
2956
|
+
const path = quietPath(environment);
|
|
2957
|
+
if (!existsSync3(path)) return EMPTY2;
|
|
2958
|
+
try {
|
|
2959
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
2960
|
+
if (parsed.schemaVersion !== 1) return EMPTY2;
|
|
2961
|
+
return {
|
|
2962
|
+
schemaVersion: 1,
|
|
2963
|
+
folders: Array.isArray(parsed.folders) ? parsed.folders.filter(isString) : [],
|
|
2964
|
+
remotes: Array.isArray(parsed.remotes) ? parsed.remotes.filter(isString) : []
|
|
2965
|
+
};
|
|
2966
|
+
} catch {
|
|
2967
|
+
return EMPTY2;
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2970
|
+
var isString = (value) => typeof value === "string";
|
|
2971
|
+
function folderKey(cwd) {
|
|
2972
|
+
try {
|
|
2973
|
+
return realpathSync(
|
|
2974
|
+
execFileSync3("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
2975
|
+
encoding: "utf8",
|
|
2976
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2977
|
+
timeout: 1e3
|
|
2978
|
+
}).trim()
|
|
2979
|
+
);
|
|
2980
|
+
} catch {
|
|
2981
|
+
return realpathSync(cwd);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
function isQuiet(cwd, environment = process.env) {
|
|
2985
|
+
const list = readQuiet(environment);
|
|
2986
|
+
const folder = folderKey(cwd);
|
|
2987
|
+
const remote = repositoryHint(cwd);
|
|
2988
|
+
return list.folders.includes(folder) || remote !== "unknown/unknown" && list.remotes.some((r) => r.toLowerCase() === remote.toLowerCase());
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2868
2991
|
// src/commands/guidance.ts
|
|
2992
|
+
function untrackedLine(remote) {
|
|
2993
|
+
const here = remote === "unknown/unknown" ? "This folder" : `This folder (${remote})`;
|
|
2994
|
+
return `${here} isn't tracked by Balladeer, so promises can't be tracked from here. To track it, run \`npx -y ${PUBLISHED_SPECIFIER} setup\` in this folder. To stop this message here, run \`npx -y ${PUBLISHED_SPECIFIER} quiet\` (or \`quiet --repo\` for the whole repository).`;
|
|
2995
|
+
}
|
|
2869
2996
|
function readInput(stream) {
|
|
2870
2997
|
return new Promise((resolve2, reject) => {
|
|
2871
2998
|
let bytes = 0;
|
|
@@ -2908,7 +3035,8 @@ function identifier(input) {
|
|
|
2908
3035
|
return typeof input === "string" && input.length > 0 && input.length <= 256 ? input : void 0;
|
|
2909
3036
|
}
|
|
2910
3037
|
async function runGuidance(options) {
|
|
2911
|
-
|
|
3038
|
+
const projectScoped = options.repositoryId !== void 0;
|
|
3039
|
+
if (projectScoped && !validateProjectGuidanceScope({
|
|
2912
3040
|
cwd: options.cwd,
|
|
2913
3041
|
hook: options.hook,
|
|
2914
3042
|
repositoryId: options.repositoryId,
|
|
@@ -2929,10 +3057,24 @@ async function runGuidance(options) {
|
|
|
2929
3057
|
throw new Error("invalid_hook_event");
|
|
2930
3058
|
event = parsedEvent;
|
|
2931
3059
|
const record = input;
|
|
2932
|
-
const
|
|
3060
|
+
const credentials = readCredentials(options.environment).agents;
|
|
3061
|
+
const agents = projectScoped ? credentials.filter(
|
|
2933
3062
|
(agent) => agent.controlPlane === options.controlPlane && agent.repositoryId === options.repositoryId
|
|
2934
|
-
)
|
|
2935
|
-
|
|
3063
|
+
) : (() => {
|
|
3064
|
+
const selection = selectAgent(
|
|
3065
|
+
credentials,
|
|
3066
|
+
options.controlPlane,
|
|
3067
|
+
void 0,
|
|
3068
|
+
repositoryHint(options.cwd)
|
|
3069
|
+
);
|
|
3070
|
+
return selection.kind === "refused" ? [] : [selection.agent];
|
|
3071
|
+
})();
|
|
3072
|
+
if (agents.length !== 1 || !agents[0]) {
|
|
3073
|
+
if (projectScoped) throw new Error("guidance_connection_unavailable");
|
|
3074
|
+
if (event === "SessionStart" && !isQuiet(options.cwd, options.environment))
|
|
3075
|
+
emit(untrackedLine(repositoryHint(options.cwd)));
|
|
3076
|
+
return 0;
|
|
3077
|
+
}
|
|
2936
3078
|
const loaded = await loadGuidance({
|
|
2937
3079
|
agent: agents[0],
|
|
2938
3080
|
environment: options.environment,
|
package/dist/install.d.ts
CHANGED
|
@@ -19,5 +19,7 @@ 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>;
|
|
22
24
|
}): number;
|
|
23
25
|
export {};
|
package/dist/install.js
CHANGED
|
@@ -344,6 +344,7 @@ export function runInstall(options) {
|
|
|
344
344
|
...result,
|
|
345
345
|
status: result.manualPathRequired ? "path_pending" : result.status,
|
|
346
346
|
message,
|
|
347
|
+
...(options.extra ?? {}),
|
|
347
348
|
}) + "\n"
|
|
348
349
|
: message + "\n");
|
|
349
350
|
return result.manualPathRequired && !options.allowPartial ? 4 : 0;
|
|
@@ -353,7 +354,12 @@ export function runInstall(options) {
|
|
|
353
354
|
? error.message
|
|
354
355
|
: "Installation could not access a required local file or command. Allow the reported installer operation and retry; no pairing has started.";
|
|
355
356
|
options.write(options.json
|
|
356
|
-
? JSON.stringify({
|
|
357
|
+
? JSON.stringify({
|
|
358
|
+
step: "install",
|
|
359
|
+
status: "blocked",
|
|
360
|
+
message,
|
|
361
|
+
...(options.extra ?? {}),
|
|
362
|
+
}) + "\n"
|
|
357
363
|
: message + "\n");
|
|
358
364
|
return 4;
|
|
359
365
|
}
|
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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install Balladeer once per laptop.
|
|
3
|
+
*
|
|
4
|
+
* Setup used to write two files into one repository checkout, uncommitted: an
|
|
5
|
+
* `.mcp.json` entry and a hook in `.claude/settings.json`. A coding host lists
|
|
6
|
+
* a project server only when that exact folder is open, so every other clone,
|
|
7
|
+
* every worktree and every teammate saw nothing, and `/mcp` on two laptops
|
|
8
|
+
* showed only the legacy server. This registers the same server and the same
|
|
9
|
+
* hook at the host's user scope instead: one entry that runs in every session,
|
|
10
|
+
* and decides at run time, from the folder's git remote, which connection it
|
|
11
|
+
* is for. Nothing to commit and nothing per folder.
|
|
12
|
+
*
|
|
13
|
+
* Every write here is an additive merge that refuses to touch an entry it does
|
|
14
|
+
* not own, exactly as the project writers do. What it owns is recognised by
|
|
15
|
+
* shape, never by trust in the name.
|
|
16
|
+
*/
|
|
17
|
+
export declare const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
|
|
18
|
+
export type UserScopeHost = "claude" | "codex";
|
|
19
|
+
export type UserScopeWrite = Readonly<{
|
|
20
|
+
host: UserScopeHost;
|
|
21
|
+
path: string;
|
|
22
|
+
status: "current" | "written" | "refused";
|
|
23
|
+
reason?: string;
|
|
24
|
+
}>;
|
|
25
|
+
/** The command a host runs to reach Balladeer, with no repository named: the
|
|
26
|
+
* directory decides. The published form is what a laptop gets; the checkout
|
|
27
|
+
* form exists so this repository's own tests can point a host at source. */
|
|
28
|
+
export declare function userScopeCommand(published: boolean): {
|
|
29
|
+
command: string;
|
|
30
|
+
args: string[];
|
|
31
|
+
};
|
|
32
|
+
/** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
|
|
33
|
+
export declare function mergeClaudeUserMcp(home: string, published: boolean): UserScopeWrite;
|
|
34
|
+
/** Ours when it runs `npx` on our published specifier, or `node` on this
|
|
35
|
+
* checkout's entry file, with `mcp` as the subcommand and no repository named:
|
|
36
|
+
* a repository argument at user scope would pin every folder to one connection. */
|
|
37
|
+
export declare function isOurUserEntry(entry: unknown): boolean;
|
|
38
|
+
/** `~/.claude/settings.json` carries user-scope hooks. */
|
|
39
|
+
export declare function mergeClaudeUserHooks(home: string, published: boolean): UserScopeWrite;
|
|
40
|
+
/** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
|
|
41
|
+
* one fenced block this command owns end to end. */
|
|
42
|
+
export declare function mergeCodexUserConfig(home: string, published: boolean): UserScopeWrite;
|
|
43
|
+
/** A copy of this command running out of a source checkout registers the
|
|
44
|
+
* checkout; any installed or npx copy registers the published package. */
|
|
45
|
+
export declare function runningFromCheckout(entry?: string): boolean;
|
|
46
|
+
/** The home whose host files get the entries. `BALLADEER_USER_HOME` exists for
|
|
47
|
+
* this repository's own tests, which cannot move HOME under a Volta-managed
|
|
48
|
+
* Node without losing Node. */
|
|
49
|
+
export declare function userHome(environment?: NodeJS.ProcessEnv): string;
|
|
50
|
+
/** Register both hosts. Codex is skipped, not refused, on a laptop without it. */
|
|
51
|
+
export declare function installUserScope(options: {
|
|
52
|
+
environment: NodeJS.ProcessEnv;
|
|
53
|
+
published: boolean;
|
|
54
|
+
home?: string;
|
|
55
|
+
}): UserScopeWrite[];
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { parse } from "@iarna/toml";
|
|
5
|
+
import { PUBLISHED_SPECIFIER, checkoutEntryPath } from "./release.js";
|
|
6
|
+
import { writeJsonAtomically } from "./mcp-config.js";
|
|
7
|
+
/**
|
|
8
|
+
* Install Balladeer once per laptop.
|
|
9
|
+
*
|
|
10
|
+
* Setup used to write two files into one repository checkout, uncommitted: an
|
|
11
|
+
* `.mcp.json` entry and a hook in `.claude/settings.json`. A coding host lists
|
|
12
|
+
* a project server only when that exact folder is open, so every other clone,
|
|
13
|
+
* every worktree and every teammate saw nothing, and `/mcp` on two laptops
|
|
14
|
+
* showed only the legacy server. This registers the same server and the same
|
|
15
|
+
* hook at the host's user scope instead: one entry that runs in every session,
|
|
16
|
+
* and decides at run time, from the folder's git remote, which connection it
|
|
17
|
+
* is for. Nothing to commit and nothing per folder.
|
|
18
|
+
*
|
|
19
|
+
* Every write here is an additive merge that refuses to touch an entry it does
|
|
20
|
+
* not own, exactly as the project writers do. What it owns is recognised by
|
|
21
|
+
* shape, never by trust in the name.
|
|
22
|
+
*/
|
|
23
|
+
export const USER_SCOPE_OWNER = "Balladeer current guidance (user scope, loader 1)";
|
|
24
|
+
const CLAUDE_EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart"];
|
|
25
|
+
/** The command a host runs to reach Balladeer, with no repository named: the
|
|
26
|
+
* directory decides. The published form is what a laptop gets; the checkout
|
|
27
|
+
* form exists so this repository's own tests can point a host at source. */
|
|
28
|
+
export function userScopeCommand(published) {
|
|
29
|
+
return published
|
|
30
|
+
? { command: "npx", args: ["-y", PUBLISHED_SPECIFIER] }
|
|
31
|
+
: { command: "node", args: [checkoutEntryPath()] };
|
|
32
|
+
}
|
|
33
|
+
function ordinaryFile(path) {
|
|
34
|
+
return !existsSync(path) || (lstatSync(path).isFile() && !lstatSync(path).isSymbolicLink());
|
|
35
|
+
}
|
|
36
|
+
/** `~/.claude.json` carries the user-scope MCP list under `mcpServers`. */
|
|
37
|
+
export function mergeClaudeUserMcp(home, published) {
|
|
38
|
+
const path = join(home, ".claude.json");
|
|
39
|
+
const host = "claude";
|
|
40
|
+
if (!ordinaryFile(path))
|
|
41
|
+
return { host, path, status: "refused", reason: "not an ordinary file" };
|
|
42
|
+
let root = {};
|
|
43
|
+
if (existsSync(path)) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
47
|
+
throw new Error();
|
|
48
|
+
root = parsed;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { host, path, status: "refused", reason: "could not read valid JSON" };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const servers = root.mcpServers && typeof root.mcpServers === "object" && !Array.isArray(root.mcpServers)
|
|
55
|
+
? root.mcpServers
|
|
56
|
+
: {};
|
|
57
|
+
const { command, args } = userScopeCommand(published);
|
|
58
|
+
const expected = { type: "stdio", command, args: [...args, "mcp"] };
|
|
59
|
+
const current = servers.balladeer;
|
|
60
|
+
if (current !== undefined && !isOurUserEntry(current)) {
|
|
61
|
+
return {
|
|
62
|
+
host,
|
|
63
|
+
path,
|
|
64
|
+
status: "refused",
|
|
65
|
+
reason: "an entry named balladeer that is not Balladeer's is already there",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (current !== undefined && JSON.stringify(current) === JSON.stringify(expected))
|
|
69
|
+
return { host, path, status: "current" };
|
|
70
|
+
root.mcpServers = { ...servers, balladeer: expected };
|
|
71
|
+
writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
|
|
72
|
+
return { host, path, status: "written" };
|
|
73
|
+
}
|
|
74
|
+
/** Ours when it runs `npx` on our published specifier, or `node` on this
|
|
75
|
+
* checkout's entry file, with `mcp` as the subcommand and no repository named:
|
|
76
|
+
* a repository argument at user scope would pin every folder to one connection. */
|
|
77
|
+
export function isOurUserEntry(entry) {
|
|
78
|
+
if (!entry || typeof entry !== "object")
|
|
79
|
+
return false;
|
|
80
|
+
const record = entry;
|
|
81
|
+
if (!Array.isArray(record.args) || record.args.some((a) => typeof a !== "string"))
|
|
82
|
+
return false;
|
|
83
|
+
const args = record.args;
|
|
84
|
+
if (record.command === "npx")
|
|
85
|
+
return (args.length === 3 &&
|
|
86
|
+
args[0] === "-y" &&
|
|
87
|
+
/^balladeer@(?:latest|\d+\.\d+\.\d+)$/.test(args[1]) &&
|
|
88
|
+
args[2] === "mcp");
|
|
89
|
+
if (record.command === "node" || String(record.command).endsWith("/node"))
|
|
90
|
+
return args.length === 2 && args[0] === checkoutEntryPath() && args[1] === "mcp";
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
/** `~/.claude/settings.json` carries user-scope hooks. */
|
|
94
|
+
export function mergeClaudeUserHooks(home, published) {
|
|
95
|
+
const path = join(home, ".claude", "settings.json");
|
|
96
|
+
const host = "claude";
|
|
97
|
+
if (!ordinaryFile(path))
|
|
98
|
+
return { host, path, status: "refused", reason: "not an ordinary file" };
|
|
99
|
+
let root = {};
|
|
100
|
+
if (existsSync(path)) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
103
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
104
|
+
throw new Error();
|
|
105
|
+
root = parsed;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { host, path, status: "refused", reason: "could not read valid JSON" };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const hooks = root.hooks && typeof root.hooks === "object" && !Array.isArray(root.hooks)
|
|
112
|
+
? { ...root.hooks }
|
|
113
|
+
: {};
|
|
114
|
+
const { command, args } = userScopeCommand(published);
|
|
115
|
+
const line = [command, ...args, "guidance", "--hook", "claude"].join(" ");
|
|
116
|
+
let changed = false;
|
|
117
|
+
for (const event of CLAUDE_EVENTS) {
|
|
118
|
+
const rows = hooks[event] === undefined ? [] : hooks[event];
|
|
119
|
+
if (!Array.isArray(rows))
|
|
120
|
+
return { host, path, status: "refused", reason: `hooks.${event} is not a list` };
|
|
121
|
+
const own = rows.filter((row) => row?.hooks?.some((h) => h.statusMessage === USER_SCOPE_OWNER));
|
|
122
|
+
const expected = {
|
|
123
|
+
hooks: [
|
|
124
|
+
{
|
|
125
|
+
type: "command",
|
|
126
|
+
command: `${line} --event ${event}`,
|
|
127
|
+
// npx resolves from its cache in well under this; the first run on
|
|
128
|
+
// a laptop that has never fetched the package needs the network.
|
|
129
|
+
timeout: 10,
|
|
130
|
+
statusMessage: USER_SCOPE_OWNER,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
if (own.length > 1)
|
|
135
|
+
return { host, path, status: "refused", reason: `two Balladeer hooks under ${event}` };
|
|
136
|
+
if (own.length === 1 && JSON.stringify(own[0]) === JSON.stringify(expected))
|
|
137
|
+
continue;
|
|
138
|
+
hooks[event] = [...rows.filter((row) => !own.includes(row)), expected];
|
|
139
|
+
changed = true;
|
|
140
|
+
}
|
|
141
|
+
if (!changed)
|
|
142
|
+
return { host, path, status: "current" };
|
|
143
|
+
root.hooks = hooks;
|
|
144
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
145
|
+
writeJsonAtomically(path, JSON.stringify(root, null, 2) + "\n");
|
|
146
|
+
return { host, path, status: "written" };
|
|
147
|
+
}
|
|
148
|
+
const CODEX_START = "# balladeer:user:start";
|
|
149
|
+
const CODEX_END = "# balladeer:user:end";
|
|
150
|
+
/** `~/.codex/config.toml` carries both the server and the hooks for Codex, in
|
|
151
|
+
* one fenced block this command owns end to end. */
|
|
152
|
+
export function mergeCodexUserConfig(home, published) {
|
|
153
|
+
const path = join(home, ".codex", "config.toml");
|
|
154
|
+
const host = "codex";
|
|
155
|
+
if (!ordinaryFile(path))
|
|
156
|
+
return { host, path, status: "refused", reason: "not an ordinary file" };
|
|
157
|
+
let previous = "";
|
|
158
|
+
try {
|
|
159
|
+
if (existsSync(path))
|
|
160
|
+
previous = readFileSync(path, "utf8");
|
|
161
|
+
parse(previous);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return { host, path, status: "refused", reason: "could not read valid TOML" };
|
|
165
|
+
}
|
|
166
|
+
const { command, args } = userScopeCommand(published);
|
|
167
|
+
const line = [command, ...args, "guidance", "--hook", "codex"].join(" ");
|
|
168
|
+
const block = [
|
|
169
|
+
CODEX_START,
|
|
170
|
+
"[mcp_servers.balladeer]",
|
|
171
|
+
`command = ${JSON.stringify(command)}`,
|
|
172
|
+
`args = ${JSON.stringify([...args, "mcp"])}`,
|
|
173
|
+
...CLAUDE_EVENTS.flatMap((event) => [
|
|
174
|
+
`[[hooks.${event}]]`,
|
|
175
|
+
`[[hooks.${event}.hooks]]`,
|
|
176
|
+
'type = "command"',
|
|
177
|
+
`command = ${JSON.stringify(`${line} --event ${event}`)}`,
|
|
178
|
+
"timeout = 10",
|
|
179
|
+
"additionalContextLimit = 0",
|
|
180
|
+
]),
|
|
181
|
+
CODEX_END,
|
|
182
|
+
].join("\n");
|
|
183
|
+
const starts = [...previous.matchAll(/^# balladeer:user:start\r?$/gm)];
|
|
184
|
+
const ends = [...previous.matchAll(/^# balladeer:user:end\r?$/gm)];
|
|
185
|
+
if (starts.length !== ends.length || starts.length > 1)
|
|
186
|
+
return { host, path, status: "refused", reason: "the Balladeer block is malformed" };
|
|
187
|
+
if (starts[0] && ends[0]) {
|
|
188
|
+
if (starts[0].index >= ends[0].index)
|
|
189
|
+
return { host, path, status: "refused", reason: "the Balladeer block is malformed" };
|
|
190
|
+
const old = previous.slice(starts[0].index, ends[0].index + CODEX_END.length);
|
|
191
|
+
if (old === block)
|
|
192
|
+
return { host, path, status: "current" };
|
|
193
|
+
const after = previous.slice(0, starts[0].index) + block + previous.slice(ends[0].index + CODEX_END.length);
|
|
194
|
+
parse(after);
|
|
195
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
196
|
+
writeJsonAtomically(path, after);
|
|
197
|
+
return { host, path, status: "written" };
|
|
198
|
+
}
|
|
199
|
+
if (/^\[mcp_servers\.balladeer\]/m.test(previous))
|
|
200
|
+
return {
|
|
201
|
+
host,
|
|
202
|
+
path,
|
|
203
|
+
status: "refused",
|
|
204
|
+
reason: "an mcp_servers.balladeer table that is not Balladeer's is already there",
|
|
205
|
+
};
|
|
206
|
+
const after = previous + (previous.endsWith("\n") || previous === "" ? "" : "\n") + block + "\n";
|
|
207
|
+
parse(after);
|
|
208
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
209
|
+
writeJsonAtomically(path, after);
|
|
210
|
+
return { host, path, status: "written" };
|
|
211
|
+
}
|
|
212
|
+
/** A copy of this command running out of a source checkout registers the
|
|
213
|
+
* checkout; any installed or npx copy registers the published package. */
|
|
214
|
+
export function runningFromCheckout(entry = process.argv[1] ?? "") {
|
|
215
|
+
const normalized = entry.replaceAll("\\", "/");
|
|
216
|
+
return normalized.includes("/packages/cli/dist/") && !normalized.includes("/node_modules/");
|
|
217
|
+
}
|
|
218
|
+
/** The home whose host files get the entries. `BALLADEER_USER_HOME` exists for
|
|
219
|
+
* this repository's own tests, which cannot move HOME under a Volta-managed
|
|
220
|
+
* Node without losing Node. */
|
|
221
|
+
export function userHome(environment = process.env) {
|
|
222
|
+
return resolve(environment.BALLADEER_USER_HOME?.trim() || environment.HOME?.trim() || homedir());
|
|
223
|
+
}
|
|
224
|
+
/** Register both hosts. Codex is skipped, not refused, on a laptop without it. */
|
|
225
|
+
export function installUserScope(options) {
|
|
226
|
+
const home = options.home ?? userHome(options.environment);
|
|
227
|
+
const writes = [
|
|
228
|
+
mergeClaudeUserMcp(home, options.published),
|
|
229
|
+
mergeClaudeUserHooks(home, options.published),
|
|
230
|
+
];
|
|
231
|
+
if (existsSync(join(home, ".codex")))
|
|
232
|
+
writes.push(mergeCodexUserConfig(home, options.published));
|
|
233
|
+
return writes;
|
|
234
|
+
}
|
package/dist/wire.d.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export declare const CLI_VERSION = "1.0.
|
|
8
|
+
export declare const CLI_VERSION = "1.0.8";
|
|
9
9
|
export declare const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export declare const CLIENT_HEADER = "x-balladeer-client";
|
|
11
|
-
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.
|
|
11
|
+
export declare const CLIENT_HEADER_VALUE = "balladeer/1.0.8";
|
|
12
12
|
export declare const DEFAULT_CONTROL_PLANE = "https://envelopes.balladeer.ai";
|
|
13
13
|
export type DelegatedScope = "repository:enroll" | "agent:issue" | "ci:connect" | "workspace:invite" | "candidate:propose";
|
|
14
14
|
export declare const DELEGATED_SCOPES: readonly DelegatedScope[];
|
package/dist/wire.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* runtime dependency at all: Node 22 builtins and global fetch, nothing else.
|
|
6
6
|
* A contract test compares the scope list below against the server's.
|
|
7
7
|
*/
|
|
8
|
-
export const CLI_VERSION = "1.0.
|
|
8
|
+
export const CLI_VERSION = "1.0.8";
|
|
9
9
|
export const CLI_INVOCATION = "npx -y balladeer@latest";
|
|
10
10
|
export const CLIENT_HEADER = "x-balladeer-client";
|
|
11
11
|
export const CLIENT_HEADER_VALUE = `balladeer/${CLI_VERSION}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "balladeer",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "Set up Balladeer from your terminal, or from a coding agent's.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"private": false,
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
24
|
"build": "tsc -p tsconfig.json && node build-guidance.mjs",
|
|
25
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
26
|
+
"prepublishOnly": "pnpm --dir ../.. compat:release"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"typescript": "5.9.3",
|