balladeer 0.0.5 → 1.0.1
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/LICENSE +200 -5
- package/README.md +167 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +531 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.js +142 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +123 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +403 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +198 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/prepare.d.ts +74 -0
- package/dist/commands/prepare.js +217 -0
- package/dist/commands/propose.d.ts +69 -0
- package/dist/commands/propose.js +284 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/session.d.ts +35 -0
- package/dist/commands/session.js +118 -0
- package/dist/commands/setup.d.ts +98 -0
- package/dist/commands/setup.js +1600 -0
- package/dist/commands/status.d.ts +51 -0
- package/dist/commands/status.js +542 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +80 -0
- package/dist/conventions.d.ts +77 -0
- package/dist/conventions.js +183 -0
- package/dist/copy.d.ts +224 -0
- package/dist/copy.js +641 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/desktop-config.d.ts +85 -0
- package/dist/desktop-config.js +217 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +91 -0
- package/dist/git.js +226 -0
- package/dist/legacy.d.ts +41 -0
- package/dist/legacy.js +143 -0
- package/dist/local-time.d.ts +66 -0
- package/dist/local-time.js +84 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +109 -0
- package/dist/mcp-config.js +234 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/session.d.ts +84 -0
- package/dist/session.js +135 -0
- package/dist/store.d.ts +108 -0
- package/dist/store.js +237 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +674 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -161
package/dist/agent.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { noteServerVersion } from "./currency.js";
|
|
2
|
+
import { commandLine } from "./release.js";
|
|
3
|
+
import {} from "./store.js";
|
|
4
|
+
import { CLIENT_HEADER, CLIENT_HEADER_VALUE } from "./wire.js";
|
|
5
|
+
const REPOSITORY_NAME = /^[A-Za-z0-9._-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
|
|
6
|
+
/**
|
|
7
|
+
* Which stored credential a command uses, or a refusal saying why none.
|
|
8
|
+
*
|
|
9
|
+
* There are exactly two ways to be right about this, and taking whichever entry
|
|
10
|
+
* comes first is neither. Either the caller named a repository, in which case
|
|
11
|
+
* only that repository's credential will do, or it did not, in which case the
|
|
12
|
+
* only defensible answer is the credential belonging to the repository this
|
|
13
|
+
* process was started in. A machine set up for two repositories otherwise binds
|
|
14
|
+
* an agent working in one of them to the other one's promises, silently, and
|
|
15
|
+
* everything that agent then reads and proposes is about the wrong repository.
|
|
16
|
+
*
|
|
17
|
+
* The bearer is never written to stdout or stderr, and an entry whose MCP URL
|
|
18
|
+
* points somewhere other than the control plane it belongs to is refused: a
|
|
19
|
+
* server response must not be able to send this credential to a new host.
|
|
20
|
+
*/
|
|
21
|
+
export function selectAgent(agents, controlPlane, repositoryId, currentRepository) {
|
|
22
|
+
const here = agents.filter((agent) => agent.controlPlane === controlPlane);
|
|
23
|
+
if (repositoryId !== undefined) {
|
|
24
|
+
const named = here.find((agent) => agent.repositoryId === repositoryId);
|
|
25
|
+
if (named === undefined) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "refused",
|
|
28
|
+
reason: `No Balladeer connection for repository ${repositoryId} is stored for ${controlPlane}.`,
|
|
29
|
+
missingFor: repositoryId,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return withSafeEndpoint(named);
|
|
33
|
+
}
|
|
34
|
+
// `unknown/unknown` is what the origin parse answers when it read nothing, so
|
|
35
|
+
// it is the absence of a repository rather than the name of one.
|
|
36
|
+
if (!REPOSITORY_NAME.test(currentRepository) || currentRepository === "unknown/unknown") {
|
|
37
|
+
return {
|
|
38
|
+
kind: "refused",
|
|
39
|
+
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>.",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const matches = here.filter((agent) => agent.repository?.toLowerCase() === currentRepository.toLowerCase());
|
|
43
|
+
// Newest wins among the matches, which is the one the last setup wrote.
|
|
44
|
+
const chosen = matches[matches.length - 1];
|
|
45
|
+
if (chosen === undefined) {
|
|
46
|
+
return {
|
|
47
|
+
kind: "refused",
|
|
48
|
+
reason: `No Balladeer connection for ${currentRepository} is stored for ${controlPlane}.`,
|
|
49
|
+
missingFor: currentRepository,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return withSafeEndpoint(chosen);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* What a command says when this machine holds no credential for the repository.
|
|
56
|
+
*
|
|
57
|
+
* A credential belongs to the machine it was issued on. A repository can be
|
|
58
|
+
* connected in a browser, or from a colleague's laptop, and this machine still
|
|
59
|
+
* hold nothing: the bearer is shown once, to whoever was there. Saying
|
|
60
|
+
* "Balladeer refused this workspace, run setup to pair again" in that state sent
|
|
61
|
+
* the founder to re-pair a machine whose pairing was never the problem, and a
|
|
62
|
+
* new setup session would not have helped, because these commands do not run
|
|
63
|
+
* over one.
|
|
64
|
+
*
|
|
65
|
+
* The invocation named is the checkout form, which is the one this copy was run
|
|
66
|
+
* as: nothing here has read the server's answer about publication yet.
|
|
67
|
+
*/
|
|
68
|
+
export function noAgentCredentialSentence(repository) {
|
|
69
|
+
return `This machine holds no agent credential for ${repository}. Run \`${commandLine(null, "setup")}\` to connect this machine; a setup session alone cannot propose.`;
|
|
70
|
+
}
|
|
71
|
+
export function withSafeEndpoint(agent) {
|
|
72
|
+
try {
|
|
73
|
+
if (new URL(agent.mcpUrl).origin !== new URL(agent.controlPlane).origin) {
|
|
74
|
+
return {
|
|
75
|
+
kind: "refused",
|
|
76
|
+
reason: "That connection's MCP endpoint is not on the control plane it belongs to, so I sent its credential nowhere.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return {
|
|
82
|
+
kind: "refused",
|
|
83
|
+
reason: "That connection's MCP endpoint is not a URL, so I did not use it.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return { kind: "agent", agent };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The headers every frame this program sends over an agent connection carries,
|
|
90
|
+
* and the one place they are written.
|
|
91
|
+
*
|
|
92
|
+
* `accept` names both media types because the Streamable HTTP transport refuses
|
|
93
|
+
* a POST that does not: a request accepting only JSON is answered 406 by the
|
|
94
|
+
* server before any tool runs. Nothing in this repository noticed, because every
|
|
95
|
+
* test that drove the endpoint wrote its own headers rather than the ones the
|
|
96
|
+
* forwarder sends. The setup probe now sends its one real call through this same
|
|
97
|
+
* constant, so a forwarder that cannot talk to the server is a failed setup step
|
|
98
|
+
* rather than a connection reported as working.
|
|
99
|
+
*
|
|
100
|
+
* `x-balladeer-client` is what lets the server refuse a copy of this program
|
|
101
|
+
* that is too old to talk to it, and answer with the line that updates it.
|
|
102
|
+
*/
|
|
103
|
+
export function forwarderHeaders(token) {
|
|
104
|
+
return {
|
|
105
|
+
authorization: `Bearer ${token}`,
|
|
106
|
+
"content-type": "application/json",
|
|
107
|
+
accept: "application/json, text/event-stream",
|
|
108
|
+
[CLIENT_HEADER]: CLIENT_HEADER_VALUE,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* One JSON-RPC tool call to the endpoint the forwarder would use, with the
|
|
113
|
+
* headers the forwarder sends and the same refusal of an endpoint on another
|
|
114
|
+
* origin. Nothing here reads the bearer back out or writes it anywhere.
|
|
115
|
+
*/
|
|
116
|
+
export async function callAgentTool(agent, name, argumentsValue, timeoutMs = 20_000) {
|
|
117
|
+
const safe = withSafeEndpoint(agent);
|
|
118
|
+
if (safe.kind === "refused")
|
|
119
|
+
return { kind: "endpoint_refused", reason: safe.reason };
|
|
120
|
+
let response;
|
|
121
|
+
try {
|
|
122
|
+
response = await fetch(agent.mcpUrl, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: forwarderHeaders(agent.token),
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
jsonrpc: "2.0",
|
|
127
|
+
id: 1,
|
|
128
|
+
method: "tools/call",
|
|
129
|
+
params: { name, arguments: argumentsValue },
|
|
130
|
+
}),
|
|
131
|
+
redirect: "manual",
|
|
132
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { kind: "unreachable" };
|
|
137
|
+
}
|
|
138
|
+
noteServerVersion(response.headers);
|
|
139
|
+
if (response.status === 426) {
|
|
140
|
+
return { kind: "client_too_old", update: updateLine(await safeJson(response)) };
|
|
141
|
+
}
|
|
142
|
+
if (response.status === 401 || response.status === 403)
|
|
143
|
+
return { kind: "unauthorized" };
|
|
144
|
+
if (!response.ok)
|
|
145
|
+
return { kind: "http", status: response.status };
|
|
146
|
+
const frame = await safeJson(response);
|
|
147
|
+
const result = resultOf(frame);
|
|
148
|
+
if (result === undefined)
|
|
149
|
+
return { kind: "malformed" };
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
export async function safeJson(response) {
|
|
153
|
+
try {
|
|
154
|
+
return (await response.json());
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The server's own remedy, read from the 426 body.
|
|
162
|
+
*
|
|
163
|
+
* Never a literal written here. Only the control plane knows whether this
|
|
164
|
+
* program is published, and a remedy naming a specifier the registry cannot
|
|
165
|
+
* serve leaves the person worse off than the refusal did.
|
|
166
|
+
*/
|
|
167
|
+
export function updateLine(payload) {
|
|
168
|
+
const update = payload?.update;
|
|
169
|
+
return typeof update === "string" && update.length > 0 && update.length < 200
|
|
170
|
+
? update
|
|
171
|
+
: "the update line Balladeer prints on its setup page";
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The result inside a JSON-RPC frame, read with `unknown` at every step. A shape
|
|
175
|
+
* that does not match is absence rather than a guess.
|
|
176
|
+
*/
|
|
177
|
+
function resultOf(payload) {
|
|
178
|
+
if (payload === null || typeof payload !== "object")
|
|
179
|
+
return undefined;
|
|
180
|
+
const result = payload.result;
|
|
181
|
+
if (result === null || typeof result !== "object")
|
|
182
|
+
return undefined;
|
|
183
|
+
const structured = result.structuredContent;
|
|
184
|
+
if (result.isError === true) {
|
|
185
|
+
return { kind: "tool_refusal", text: refusalText(result), structured };
|
|
186
|
+
}
|
|
187
|
+
if (structured === null || typeof structured !== "object")
|
|
188
|
+
return undefined;
|
|
189
|
+
return { kind: "result", structured };
|
|
190
|
+
}
|
|
191
|
+
/** The first text block of a refusing tool result, bounded. */
|
|
192
|
+
function refusalText(result) {
|
|
193
|
+
const content = result.content;
|
|
194
|
+
if (!Array.isArray(content))
|
|
195
|
+
return "Balladeer refused this call.";
|
|
196
|
+
for (const block of content) {
|
|
197
|
+
const text = block?.text;
|
|
198
|
+
if (typeof text === "string" && text.length > 0)
|
|
199
|
+
return text.slice(0, 800);
|
|
200
|
+
}
|
|
201
|
+
return "Balladeer refused this call.";
|
|
202
|
+
}
|
|
203
|
+
/** One field of a tool result, when it is a string. Never a guess. */
|
|
204
|
+
export function structuredString(structured, field) {
|
|
205
|
+
if (structured === null || typeof structured !== "object")
|
|
206
|
+
return undefined;
|
|
207
|
+
const value = structured[field];
|
|
208
|
+
return typeof value === "string" ? value : undefined;
|
|
209
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
type Parsed = Readonly<{
|
|
3
|
+
command: string;
|
|
4
|
+
subcommand: string | undefined;
|
|
5
|
+
json: boolean;
|
|
6
|
+
wait: boolean;
|
|
7
|
+
/** Repair the files a previous setup wrote, and do nothing else. */
|
|
8
|
+
refresh: boolean;
|
|
9
|
+
/** `setup --force`: set up even though an earlier Balladeer is still installed. */
|
|
10
|
+
force: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* `setup --claude-desktop` / `--no-claude-desktop`. Undefined is neither
|
|
13
|
+
* asked for nor refused, which is the ordinary run: connect the chat client
|
|
14
|
+
* where it is installed and say nothing where it is not.
|
|
15
|
+
*/
|
|
16
|
+
claudeDesktop: boolean | undefined;
|
|
17
|
+
repo: string | undefined;
|
|
18
|
+
file: string | undefined;
|
|
19
|
+
repository: string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Every repository named on this run, in order.
|
|
22
|
+
*
|
|
23
|
+
* `setup` is the one command that takes more than one, because adding a
|
|
24
|
+
* repository is the one step it can do for a repository this directory is not
|
|
25
|
+
* a checkout of. Everywhere else a second name is a mistake, and is refused
|
|
26
|
+
* rather than quietly resolved to the last one.
|
|
27
|
+
*/
|
|
28
|
+
repositories: readonly string[];
|
|
29
|
+
owner: string | undefined;
|
|
30
|
+
/** `check-seals --install-hook`: write the optional pre-push hook. */
|
|
31
|
+
installHook: boolean;
|
|
32
|
+
/** `session --new`: start a different session even though one is current. */
|
|
33
|
+
fresh: boolean;
|
|
34
|
+
/** `session --record`: read the trailer out of HEAD and record that commit. */
|
|
35
|
+
record: boolean;
|
|
36
|
+
/** `prepare --again`: mint a replacement packet and invalidate the unspent one. */
|
|
37
|
+
again: boolean;
|
|
38
|
+
/** `check-seals --runner`: where the pinned runner is, when it has moved. */
|
|
39
|
+
runner: string | undefined;
|
|
40
|
+
createWorkspace: string | undefined;
|
|
41
|
+
/** Everything that was not a flag: the addresses `invite` sends to. */
|
|
42
|
+
positional: readonly string[];
|
|
43
|
+
role: string | undefined;
|
|
44
|
+
controlPlane: string;
|
|
45
|
+
}>;
|
|
46
|
+
export declare function parseArguments(argv: readonly string[]): Parsed;
|
|
47
|
+
export declare function main(argv: readonly string[]): Promise<number>;
|
|
48
|
+
export {};
|