balladeer 1.0.6 → 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/README.md +21 -4
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +95 -6
- package/dist/commands/guidance.d.ts +19 -0
- package/dist/commands/guidance.js +136 -0
- package/dist/commands/mcp.js +52 -1
- package/dist/commands/propose.js +7 -2
- package/dist/commands/setup.js +26 -3
- package/dist/conventions.d.ts +1 -1
- package/dist/conventions.js +1 -1
- package/dist/copy.d.ts +7 -5
- package/dist/copy.js +78 -85
- package/dist/guidance-hook.mjs +3114 -0
- package/dist/guidance-install.d.ts +29 -0
- package/dist/guidance-install.js +420 -0
- package/dist/guidance-runtime-entry.d.ts +1 -0
- package/dist/guidance-runtime-entry.js +2 -0
- package/dist/guidance-runtime.d.ts +15 -0
- package/dist/guidance-runtime.js +149 -0
- package/dist/guidance.d.ts +39 -0
- package/dist/guidance.js +293 -0
- 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 +8 -2
- package/dist/wire.js +1 -1
- package/package.json +6 -4
|
@@ -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[];
|
|
@@ -222,6 +222,12 @@ export type ClaudeDesktopStep = Readonly<{
|
|
|
222
222
|
reason?: string;
|
|
223
223
|
}>;
|
|
224
224
|
export type JsonStep = Readonly<{
|
|
225
|
+
step: "guidance_loader";
|
|
226
|
+
status: "installed_needs_host_trust" | "installed_trust_unverified" | "unavailable" | "not_applicable";
|
|
227
|
+
changed: boolean;
|
|
228
|
+
hosts: readonly ("codex" | "claude")[];
|
|
229
|
+
reason?: string;
|
|
230
|
+
}> | Readonly<{
|
|
225
231
|
step: "explain";
|
|
226
232
|
version: string;
|
|
227
233
|
}> | Readonly<{
|
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,
|
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
"node": ">=22.0.0"
|
|
22
22
|
},
|
|
23
23
|
"scripts": {
|
|
24
|
-
"build": "tsc -p tsconfig.json",
|
|
25
|
-
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
24
|
+
"build": "tsc -p tsconfig.json && node build-guidance.mjs",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
26
|
+
"prepublishOnly": "pnpm --dir ../.. compat:release"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
|
-
"typescript": "5.9.3"
|
|
29
|
+
"typescript": "5.9.3",
|
|
30
|
+
"esbuild": "0.25.12"
|
|
29
31
|
},
|
|
30
32
|
"dependencies": {
|
|
31
33
|
"@iarna/toml": "2.2.5"
|