@basein/runner 0.1.0

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.
Files changed (100) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +276 -0
  3. package/dist/auth/client.d.ts +85 -0
  4. package/dist/auth/client.js +284 -0
  5. package/dist/bin/bir-hooks.d.ts +48 -0
  6. package/dist/bin/bir-hooks.js +201 -0
  7. package/dist/bin/bir-proxy.d.ts +45 -0
  8. package/dist/bin/bir-proxy.js +207 -0
  9. package/dist/bin/bir-scenario.d.ts +24 -0
  10. package/dist/bin/bir-scenario.js +177 -0
  11. package/dist/bin/bir.d.ts +21 -0
  12. package/dist/bin/bir.js +876 -0
  13. package/dist/config/adapters/claude-code.d.ts +76 -0
  14. package/dist/config/adapters/claude-code.js +181 -0
  15. package/dist/config/adapters/generic.d.ts +17 -0
  16. package/dist/config/adapters/generic.js +36 -0
  17. package/dist/config/generate.d.ts +127 -0
  18. package/dist/config/generate.js +114 -0
  19. package/dist/config/resolve.d.ts +68 -0
  20. package/dist/config/resolve.js +132 -0
  21. package/dist/control/client.d.ts +56 -0
  22. package/dist/control/client.js +86 -0
  23. package/dist/control/correlation.d.ts +86 -0
  24. package/dist/control/correlation.js +0 -0
  25. package/dist/control/discovery.d.ts +50 -0
  26. package/dist/control/discovery.js +123 -0
  27. package/dist/control/ordering.d.ts +38 -0
  28. package/dist/control/ordering.js +44 -0
  29. package/dist/control/paths.d.ts +32 -0
  30. package/dist/control/paths.js +56 -0
  31. package/dist/control/server.d.ts +272 -0
  32. package/dist/control/server.js +1131 -0
  33. package/dist/control/transcript.d.ts +75 -0
  34. package/dist/control/transcript.js +241 -0
  35. package/dist/index.d.ts +37 -0
  36. package/dist/index.js +32 -0
  37. package/dist/jsonrpc/framing.d.ts +49 -0
  38. package/dist/jsonrpc/framing.js +143 -0
  39. package/dist/jsonrpc/types.d.ts +52 -0
  40. package/dist/jsonrpc/types.js +46 -0
  41. package/dist/proxy/intercept.d.ts +55 -0
  42. package/dist/proxy/intercept.js +147 -0
  43. package/dist/proxy/relay.d.ts +97 -0
  44. package/dist/proxy/relay.js +166 -0
  45. package/dist/proxy/session.d.ts +116 -0
  46. package/dist/proxy/session.js +319 -0
  47. package/dist/record/housekeeping.d.ts +34 -0
  48. package/dist/record/housekeeping.js +39 -0
  49. package/dist/record/queue.d.ts +48 -0
  50. package/dist/record/queue.js +96 -0
  51. package/dist/record/recorder.d.ts +111 -0
  52. package/dist/record/recorder.js +39 -0
  53. package/dist/record/redact.d.ts +37 -0
  54. package/dist/record/redact.js +119 -0
  55. package/dist/record/remote-recorder.d.ts +110 -0
  56. package/dist/record/remote-recorder.js +301 -0
  57. package/dist/record/truncate.d.ts +36 -0
  58. package/dist/record/truncate.js +85 -0
  59. package/dist/replay/bundle.d.ts +36 -0
  60. package/dist/replay/bundle.js +89 -0
  61. package/dist/replay/controller.d.ts +300 -0
  62. package/dist/replay/controller.js +807 -0
  63. package/dist/replay/coverage.d.ts +41 -0
  64. package/dist/replay/coverage.js +56 -0
  65. package/dist/replay/derive.d.ts +58 -0
  66. package/dist/replay/derive.js +166 -0
  67. package/dist/replay/executor.d.ts +78 -0
  68. package/dist/replay/executor.js +233 -0
  69. package/dist/replay/logic.d.ts +31 -0
  70. package/dist/replay/logic.js +50 -0
  71. package/dist/replay/plan.d.ts +181 -0
  72. package/dist/replay/plan.js +397 -0
  73. package/dist/replay/pricing.d.ts +41 -0
  74. package/dist/replay/pricing.js +76 -0
  75. package/dist/replay/source-run.d.ts +50 -0
  76. package/dist/replay/source-run.js +98 -0
  77. package/dist/replay/tool-error.d.ts +22 -0
  78. package/dist/replay/tool-error.js +60 -0
  79. package/dist/replay/types.d.ts +116 -0
  80. package/dist/replay/types.js +35 -0
  81. package/dist/upstream/client.d.ts +78 -0
  82. package/dist/upstream/client.js +114 -0
  83. package/dist/upstream/http-client.d.ts +78 -0
  84. package/dist/upstream/http-client.js +261 -0
  85. package/dist/upstream/lazy-client.d.ts +31 -0
  86. package/dist/upstream/lazy-client.js +53 -0
  87. package/dist/upstream/stdio-client.d.ts +57 -0
  88. package/dist/upstream/stdio-client.js +203 -0
  89. package/dist/util/log.d.ts +27 -0
  90. package/dist/util/log.js +51 -0
  91. package/dist/util/version.d.ts +2 -0
  92. package/dist/util/version.js +40 -0
  93. package/docs/BaseInstRunner.md +621 -0
  94. package/docs/calculatedReplay.md +1185 -0
  95. package/docs/calculatedReplayGuide.md +448 -0
  96. package/docs/installRun.md +413 -0
  97. package/docs/mcpmark.md +752 -0
  98. package/docs/quickstart.md +201 -0
  99. package/docs/t-bench.md +394 -0
  100. package/package.json +56 -0
@@ -0,0 +1,876 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bir — the command line (Phase 7).
4
+ *
5
+ * bir install [--config <path>] [--server <name>]… [--local] [--no-hooks]
6
+ * bir uninstall [--config <path>]
7
+ * bir status
8
+ * bir doctor
9
+ * bir wrap --server-name <key> -- <command> [args…]
10
+ * bir login | logout
11
+ * bir --version
12
+ *
13
+ * `bir install` is reversible by construction: every file it edits is stashed
14
+ * verbatim first, so `bir uninstall` restores it byte-for-byte unless somebody
15
+ * else edited it in the meantime (in which case it repairs the entries and says
16
+ * so). `bir doctor` proves the install *works* by asking the control server which
17
+ * proxies actually registered — never by reading the config file back, and never
18
+ * from `serverInfo`, which must stay the upstream's (Phase 3).
19
+ */
20
+ import { randomBytes } from "node:crypto";
21
+ import { existsSync, writeFileSync } from "node:fs";
22
+ import { dirname, join, resolve as resolvePath } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { ControlClient } from "../control/client.js";
25
+ import { readDiscovery } from "../control/discovery.js";
26
+ import { configDir, discoveryPath } from "../control/paths.js";
27
+ import { isScenarioServer, isWrapped, PACKAGE_NAME, readSidecar, scenarioEntry, sidecarKey, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
28
+ import { isRemote, resolveServers } from "../config/resolve.js";
29
+ import { buildHooksBlock, claudeCodePaths, fileForScope, installHooks, readTextOrNull, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
30
+ import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
31
+ import { clearCredentials, authenticate, resolveAuthUrl, normalizeAuthUrl } from "../auth/client.js";
32
+ import { DEFAULT_CONTROL_PORT } from "../control/server.js";
33
+ import { errText } from "../util/log.js";
34
+ import { packageVersion } from "../util/version.js";
35
+ const VERSION = packageVersion();
36
+ const out = (line = "") => {
37
+ process.stdout.write(line + "\n");
38
+ };
39
+ function usage(code = 0) {
40
+ out(`bir ${VERSION} — a recording MCP proxy
41
+
42
+ Commands:
43
+ install wrap this project's MCP servers and wire Claude Code's hooks
44
+ uninstall restore everything install changed
45
+ status what is installed for this directory
46
+ doctor is it actually working right now?
47
+ wrap print a proxied entry for one server (any MCP client)
48
+ login sign in to the BaseIn service
49
+ logout forget the cached credentials
50
+
51
+ scenario list recorded runs and their calculated scenarios
52
+ scenario show <runId> a run's scenario: intent, params, steps
53
+ scenario calc <runId> [--force] calculate (or re-derive) a run's scenario
54
+ scenario replay <scnId> --prompt "…" [--dry]
55
+ replay --scenario <scnId> --prompt "…" [--dry]
56
+
57
+ Options:
58
+ --config <path> an explicit { "mcpServers": … } file, for clients with no adapter
59
+ --server <name> restrict install/uninstall to one server (repeatable)
60
+ --local invoke this checkout's bir-proxy instead of npx (development)
61
+ --global invoke the installed package by absolute path instead of npx
62
+ (no registry round trip, no PATH lookup — what a fleet wants)
63
+ --no-hooks do not touch .claude/settings.json (Tier 2 recording only)
64
+ --no-correlation never relax tool schemas; join steps on argument fingerprints
65
+ --replay install/remove the scenario server, enabling calculated replay
66
+ --port <n> control-server port to write into the hook URLs (default ${DEFAULT_CONTROL_PORT})
67
+ --json machine-readable output for status / doctor
68
+ --dry replay against recorded outputs only; run no real tools`);
69
+ process.exit(code);
70
+ }
71
+ function parseArgs(argv) {
72
+ const args = {
73
+ command: argv[0] ?? "",
74
+ servers: [],
75
+ local: false,
76
+ global: false,
77
+ hooks: true,
78
+ noCorrelation: false,
79
+ port: DEFAULT_CONTROL_PORT,
80
+ json: false,
81
+ rest: [],
82
+ replay: false,
83
+ positionals: [],
84
+ dry: false,
85
+ force: false,
86
+ };
87
+ for (let i = 1; i < argv.length; i += 1) {
88
+ const arg = argv[i];
89
+ if (arg === "--") {
90
+ args.rest = argv.slice(i + 1);
91
+ break;
92
+ }
93
+ switch (arg) {
94
+ case "--replay":
95
+ args.replay = true;
96
+ break;
97
+ case "--prompt":
98
+ args.prompt = argv[++i];
99
+ break;
100
+ case "--scenario":
101
+ args.scenarioId = argv[++i];
102
+ break;
103
+ case "--dry":
104
+ args.dry = true;
105
+ break;
106
+ case "--force":
107
+ args.force = true;
108
+ break;
109
+ case "--config":
110
+ args.configPath = argv[++i];
111
+ break;
112
+ case "--server":
113
+ args.servers.push(argv[++i] ?? "");
114
+ break;
115
+ case "--server-name":
116
+ args.serverName = argv[++i];
117
+ break;
118
+ case "--local":
119
+ args.local = true;
120
+ break;
121
+ case "--global":
122
+ args.global = true;
123
+ break;
124
+ case "--no-hooks":
125
+ args.hooks = false;
126
+ break;
127
+ case "--no-correlation":
128
+ args.noCorrelation = true;
129
+ break;
130
+ case "--port":
131
+ args.port = Number(argv[++i]);
132
+ break;
133
+ case "--json":
134
+ args.json = true;
135
+ break;
136
+ default:
137
+ if (arg.startsWith("-"))
138
+ usage(2);
139
+ else
140
+ args.positionals.push(arg);
141
+ }
142
+ }
143
+ return args;
144
+ }
145
+ /** Absolute path to this package's built `bir-proxy.js`, for `--local`. */
146
+ function localProxyPath() {
147
+ return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-proxy.js");
148
+ }
149
+ /** Absolute path to this package's built `bir-scenario.js`, for `--local`. */
150
+ function localScenarioPath() {
151
+ return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-scenario.js");
152
+ }
153
+ /**
154
+ * Which invocation the generated entries use — and the guard that makes
155
+ * `--global` mean something.
156
+ *
157
+ * `--global` resolves to the very same absolute path `--local` does:
158
+ * `import.meta.url` already points into whichever copy of the package is
159
+ * running. What differs is the promise. `--global` asserts the path outlives
160
+ * this shell, and that is only true when `bir` is itself running from an
161
+ * installed package — a checkout's `dist/` moves with the checkout, so writing
162
+ * it into a runner's config as though it were permanent is the kind of install
163
+ * that works today and breaks after a `git clean`. Hence the node_modules test:
164
+ * it is the one cheap signal that says "somebody installed this".
165
+ */
166
+ function resolveInvocation(args) {
167
+ if (args.local && args.global) {
168
+ process.stderr.write("[bir] --local and --global are mutually exclusive\n");
169
+ process.exit(2);
170
+ }
171
+ if (!args.global)
172
+ return args.local ? "local" : "npx";
173
+ const proxy = localProxyPath();
174
+ if (!/[\\/]node_modules[\\/]/.test(proxy)) {
175
+ process.stderr.write(`[bir] --global needs an installed package, but this bir runs from ${dirname(proxy)}\n` +
176
+ ` install it first (npm install -g ${PACKAGE_NAME}@${VERSION}) and rerun,\n` +
177
+ ` or use --local to point at this checkout on purpose.\n`);
178
+ process.exit(2);
179
+ }
180
+ return "global";
181
+ }
182
+ /** Record a file's pre-install text once, so uninstall can restore it exactly. */
183
+ function backupFile(sidecar, file) {
184
+ sidecar.files ??= {};
185
+ if (sidecar.files[file])
186
+ return;
187
+ sidecar.files[file] = { originalText: readTextOrNull(file), writtenSha: "" };
188
+ }
189
+ function noteWritten(sidecar, file, text) {
190
+ sidecar.files ??= {};
191
+ const backup = sidecar.files[file] ?? { originalText: null, writtenSha: "" };
192
+ backup.writtenSha = sha256(text);
193
+ sidecar.files[file] = backup;
194
+ }
195
+ // ── install ────────────────────────────────────────────────────────────────
196
+ function install(args) {
197
+ const invocation = resolveInvocation(args);
198
+ const cwd = process.cwd();
199
+ const sidecar = readSidecar();
200
+ sidecar.token ??= randomBytes(32).toString("hex");
201
+ sidecar.controlPort = args.port;
202
+ const controlUrl = `http://127.0.0.1:${args.port}`;
203
+ const entries = args.configPath
204
+ ? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
205
+ name,
206
+ config,
207
+ scope: "explicit",
208
+ shadowed: [],
209
+ }))
210
+ : resolveServers(cwd);
211
+ const wanted = entries.filter((e) => args.servers.length === 0 || args.servers.includes(e.name));
212
+ if (wanted.length === 0) {
213
+ out("No MCP servers found for this directory. Nothing to wrap.");
214
+ if (!args.configPath) {
215
+ out(" Looked in: ~/.claude.json (user + local scopes) and ./.mcp.json (project scope).");
216
+ }
217
+ return 0;
218
+ }
219
+ const paths = claudeCodePaths(cwd);
220
+ let wrapped = 0;
221
+ for (const entry of wanted) {
222
+ if (entry.name === SCENARIO_SERVER_KEY && isScenarioServer(entry.config)) {
223
+ out(` = ${entry.name} — the scenario server (calculated replay)`);
224
+ continue;
225
+ }
226
+ if (entry.name === SCENARIO_SERVER_KEY) {
227
+ // `mcp__bir__run_scenario` is how a direct replay's results reach the
228
+ // model. Wrapping somebody else's `bir` server would route that call to
229
+ // their server instead, silently.
230
+ out(` ! ${entry.name} — reserved by BaseInstRunner; not wrapped. Rename it to record it`);
231
+ continue;
232
+ }
233
+ if (isWrapped(entry.config)) {
234
+ // Already wrapped — but the control URL may have moved since (a second
235
+ // `install --port`). A stale `BIR_CONTROL_URL` is not cosmetic: discovery
236
+ // short-circuits on it, so the proxy would register with whatever is on
237
+ // the old port. If another project's control server is there, this
238
+ // project's MCP steps land in *its* run. Re-wrap from the stashed original
239
+ // so the entry is regenerated with the current URL.
240
+ // A wrapped entry is always a stdio entry (the proxy is what the host
241
+ // spawns), so `env` is present in practice; the cast is only to narrow the
242
+ // remote-or-stdio union.
243
+ const current = entry.config.env?.BIR_CONTROL_URL;
244
+ const stale = args.hooks && !args.configPath && current !== controlUrl;
245
+ const stashed = sidecar.servers[sidecarKey(cwd, entry.name)]?.original;
246
+ if (!stale || !stashed) {
247
+ out(` = ${entry.name} — already wrapped`);
248
+ continue;
249
+ }
250
+ entry.config = stashed;
251
+ out(` ~ ${entry.name} — re-wrapping: its control URL was ` +
252
+ `${current ?? "unset"}, now ${controlUrl}`);
253
+ }
254
+ // Phase 7.3: writing anywhere but the winning scope changes nothing.
255
+ if (entry.shadowed.length > 0) {
256
+ out(` ! ${entry.name} — also defined in ${entry.shadowed.join(", ")} scope; ` +
257
+ `writing to ${entry.scope}, which is the one Claude Code uses`);
258
+ }
259
+ const proxied = wrapEntry(entry.config, {
260
+ serverName: entry.name,
261
+ controlUrl: args.hooks ? controlUrl : undefined,
262
+ noCorrelation: args.noCorrelation,
263
+ invocation,
264
+ proxyPath: invocation === "npx" ? undefined : localProxyPath(),
265
+ version: VERSION,
266
+ });
267
+ let file;
268
+ let text;
269
+ if (args.configPath) {
270
+ file = args.configPath;
271
+ backupFile(sidecar, file);
272
+ text = setGenericServerEntry(file, entry.name, proxied);
273
+ }
274
+ else {
275
+ file = fileForScope(entry.scope, paths);
276
+ backupFile(sidecar, file);
277
+ const written = setServerEntry(entry.scope, cwd, entry.name, proxied, paths);
278
+ text = written.text;
279
+ }
280
+ noteWritten(sidecar, file, text);
281
+ sidecar.servers[sidecarKey(cwd, entry.name)] = {
282
+ original: entry.config,
283
+ scope: entry.scope,
284
+ file,
285
+ cwd,
286
+ installedAt: Date.now(),
287
+ };
288
+ const kind = isRemote(entry.config) ? `${entry.config.type} ${entry.config.url}` : entry.config.command;
289
+ out(` + ${entry.name} → bir-proxy (${entry.scope} scope, upstream: ${kind})`);
290
+ wrapped += 1;
291
+ }
292
+ // The scenario server (docs/calculatedReplay.md §6.3). Opt-in, and separate
293
+ // from the rest of install, because it is the one thing here the model can see:
294
+ // it adds `mcp__bir__run_scenario` to the tool list of every session in this
295
+ // project. That is a real cost, and it buys the delivery channel a fully
296
+ // wrapped scenario's results come back through.
297
+ if (args.replay) {
298
+ const entry = scenarioEntry({
299
+ invocation,
300
+ scenarioPath: invocation === "npx" ? undefined : localScenarioPath(),
301
+ version: VERSION,
302
+ controlUrl: args.hooks ? controlUrl : undefined,
303
+ });
304
+ // PROJECT SCOPE, NOT LOCAL — this was a bug worth the comment.
305
+ //
306
+ // The local scope lives in `~/.claude.json` under `projects[<path>]`, and
307
+ // Claude Code keys that map with FORWARD slashes, resolved to the project
308
+ // root. `process.cwd()` on Windows gives backslashes and the actual working
309
+ // directory, so an entry written from here lands under a key Claude Code
310
+ // never looks at: `bir status` shows the server, `/mcp` does not, and a
311
+ // direct replay has nowhere to deliver its results. Observed, not theorised
312
+ // — see `paths.ts`, which warns about exactly this trap for the proxy.
313
+ //
314
+ // `.mcp.json` has no such ambiguity: it is a file in this directory, read by
315
+ // path. It is also the file the proxies already go into, so the scenario
316
+ // server sits beside them and `bir uninstall` removes both the same way.
317
+ const scope = args.configPath ? "explicit" : "project";
318
+ let file;
319
+ let text;
320
+ if (args.configPath) {
321
+ file = args.configPath;
322
+ backupFile(sidecar, file);
323
+ text = setGenericServerEntry(file, SCENARIO_SERVER_KEY, entry);
324
+ }
325
+ else {
326
+ file = fileForScope(scope, paths);
327
+ backupFile(sidecar, file);
328
+ text = setServerEntry(scope, cwd, SCENARIO_SERVER_KEY, entry, paths).text;
329
+ }
330
+ noteWritten(sidecar, file, text);
331
+ sidecar.servers[sidecarKey(cwd, SCENARIO_SERVER_KEY)] = {
332
+ original: null, // it did not exist before; uninstall removes rather than restores
333
+ scope,
334
+ file,
335
+ cwd,
336
+ installedAt: Date.now(),
337
+ };
338
+ out(` + ${SCENARIO_SERVER_KEY} → bir-scenario (calculated replay; ${scope} scope)`);
339
+ }
340
+ if (args.hooks && !args.configPath) {
341
+ backupFile(sidecar, paths.settings);
342
+ const text = installHooks(paths.settings, buildHooksBlock(controlUrl, sidecar.token));
343
+ noteWritten(sidecar, paths.settings, text);
344
+ sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settings])];
345
+ out(` + hooks → ${paths.settings} (control server on ${controlUrl})`);
346
+ }
347
+ writeSidecar(sidecar);
348
+ out();
349
+ out(`Wrapped ${wrapped} server${wrapped === 1 ? "" : "s"}.`);
350
+ if (args.hooks && !args.configPath) {
351
+ out("Next: run `bir-hooks` in this directory, then start your session.");
352
+ out("Without it, proxies record standalone (Tier 2): MCP calls only, no prompt.");
353
+ }
354
+ else {
355
+ out("Recording will be standalone (Tier 2): MCP calls only, no prompt or final answer.");
356
+ }
357
+ if (args.replay) {
358
+ out();
359
+ out("Calculated replay is INSTALLED but not ON. Set BIR_REPLAY=1 where you run");
360
+ out("`bir-hooks` to enable it — and read docs/calculatedReplayGuide.md §5.1 first:");
361
+ out("a replayed step is auto-approved, and a directly executed one never reaches");
362
+ out("the permission system at all.");
363
+ }
364
+ return 0;
365
+ }
366
+ // ── uninstall ──────────────────────────────────────────────────────────────
367
+ function uninstall(args) {
368
+ const cwd = process.cwd();
369
+ const sidecar = readSidecar();
370
+ const paths = claudeCodePaths(cwd);
371
+ // `--replay` on its own is a narrow removal: take the scenario server out and
372
+ // leave every proxy and the hooks exactly as they are. Somebody turning replay
373
+ // off should not have to reinstall recording afterwards.
374
+ if (args.replay) {
375
+ const key = sidecarKey(cwd, SCENARIO_SERVER_KEY);
376
+ const record = sidecar.servers[key];
377
+ if (!record) {
378
+ out("Calculated replay is not installed for this directory.");
379
+ return 0;
380
+ }
381
+ if (record.scope === "explicit") {
382
+ setGenericServerEntry(record.file, SCENARIO_SERVER_KEY, null);
383
+ }
384
+ else {
385
+ setServerEntry(record.scope, cwd, SCENARIO_SERVER_KEY, null, paths);
386
+ }
387
+ delete sidecar.servers[key];
388
+ writeSidecar(sidecar);
389
+ out(` ← ${SCENARIO_SERVER_KEY} — scenario server removed from ${record.file}`);
390
+ out();
391
+ out("Calculated replay is uninstalled. Recording is unchanged.");
392
+ out("The prompt hook keeps its raised timeout; `bir install` rewrites it either way.");
393
+ return 0;
394
+ }
395
+ const mine = Object.entries(sidecar.servers).filter(([key]) => key.startsWith(`${cwd}::`));
396
+ if (mine.length === 0 && !(sidecar.hookFiles ?? []).length) {
397
+ out("Nothing installed for this directory.");
398
+ return 0;
399
+ }
400
+ // Byte-for-byte first: a file we wrote and nobody touched since goes back
401
+ // exactly as it was, comments-in-JSON quirks and all.
402
+ const restoredWhole = new Set();
403
+ for (const [file, backup] of Object.entries(sidecar.files ?? {})) {
404
+ const current = readTextOrNull(file);
405
+ if (current === null || !backup.writtenSha || sha256(current) !== backup.writtenSha)
406
+ continue;
407
+ if (backup.originalText === null) {
408
+ out(` - ${file} — created by install; leaving in place (now empty of our entries)`);
409
+ continue;
410
+ }
411
+ writeFileSync(file, backup.originalText, "utf8");
412
+ restoredWhole.add(file);
413
+ out(` ← ${file} — restored byte-for-byte`);
414
+ }
415
+ for (const [key, record] of mine) {
416
+ const name = key.slice(cwd.length + 2);
417
+ if (args.servers.length > 0 && !args.servers.includes(name))
418
+ continue;
419
+ if (!restoredWhole.has(record.file)) {
420
+ // Somebody edited the file after we did. Repair the entry rather than
421
+ // clobbering their work, and say which path was taken.
422
+ if (args.configPath || record.scope === "explicit") {
423
+ setGenericServerEntry(record.file, name, record.original);
424
+ }
425
+ else {
426
+ setServerEntry(record.scope, cwd, name, record.original, paths);
427
+ }
428
+ out(` ← ${name} — entry restored (file changed since install)`);
429
+ }
430
+ delete sidecar.servers[key];
431
+ }
432
+ for (const settings of sidecar.hookFiles ?? []) {
433
+ if (restoredWhole.has(settings))
434
+ continue;
435
+ if (existsSync(settings)) {
436
+ uninstallHooks(settings);
437
+ out(` ← hooks removed from ${settings}`);
438
+ }
439
+ }
440
+ sidecar.hookFiles = [];
441
+ sidecar.files = {};
442
+ writeSidecar(sidecar);
443
+ out();
444
+ out("Uninstalled.");
445
+ return 0;
446
+ }
447
+ // ── status ─────────────────────────────────────────────────────────────────
448
+ function status(args) {
449
+ const cwd = process.cwd();
450
+ const entries = args.configPath
451
+ ? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
452
+ name,
453
+ config,
454
+ scope: "explicit",
455
+ shadowed: [],
456
+ }))
457
+ : resolveServers(cwd);
458
+ const discovery = readDiscovery(cwd);
459
+ const report = {
460
+ cwd,
461
+ authUrl: resolveAuthUrl() || null,
462
+ controlServer: discovery ? { url: discovery.url, pid: discovery.pid } : null,
463
+ discoveryFile: discoveryPath(cwd),
464
+ stateDir: configDir(),
465
+ servers: entries.map((e) => ({
466
+ name: e.name,
467
+ scope: e.scope,
468
+ shadowed: e.shadowed,
469
+ wrapped: isWrapped(e.config),
470
+ upstream: isRemote(e.config) ? e.config.url : e.config.command,
471
+ })),
472
+ };
473
+ if (args.json) {
474
+ out(JSON.stringify(report, null, 2));
475
+ return 0;
476
+ }
477
+ out(`Directory : ${report.cwd}`);
478
+ out(`BaseIn : ${report.authUrl ?? "(BIR_AUTH_URL not set — nothing is recorded)"}`);
479
+ out(`Control : ${discovery ? `${discovery.url} (pid ${discovery.pid})` : "not running"}`);
480
+ out("Servers :");
481
+ if (report.servers.length === 0)
482
+ out(" (none configured for this directory)");
483
+ for (const s of report.servers) {
484
+ const shadow = s.shadowed.length ? ` shadows:${s.shadowed.join("/")}` : "";
485
+ out(` ${s.wrapped ? "[wrapped]" : "[ raw ]"} ${s.name} (${s.scope}${shadow}) → ${s.upstream}`);
486
+ }
487
+ return 0;
488
+ }
489
+ // ── doctor ─────────────────────────────────────────────────────────────────
490
+ async function doctor(args) {
491
+ const cwd = process.cwd();
492
+ const problems = [];
493
+ const entries = resolveServers(cwd);
494
+ const wantWrapped = entries.filter((e) => isWrapped(e.config)).map((e) => e.name);
495
+ const discovery = readDiscovery(cwd);
496
+ let health;
497
+ if (discovery) {
498
+ health = await new ControlClient(discovery.url, discovery.token).health();
499
+ if (!health)
500
+ problems.push(`control server at ${discovery.url} did not answer /health`);
501
+ }
502
+ else {
503
+ problems.push("no control server for this directory — recording will be Tier 2 (MCP only)");
504
+ }
505
+ const registered = new Set((health?.registeredProxies ?? []).map((p) => p.serverName));
506
+ // The guarantee check: a *registered* proxy. Not the config file (which only
507
+ // says what should happen) and not `serverInfo` (which stays the upstream's).
508
+ for (const name of wantWrapped) {
509
+ if (!registered.has(name)) {
510
+ problems.push(`${name} is wrapped in config but no proxy has registered — is the session running?`);
511
+ }
512
+ }
513
+ for (const e of entries) {
514
+ if (!isWrapped(e.config) && e.shadowed.length > 0) {
515
+ problems.push(`${e.name} is defined in ${e.shadowed.join(", ")} as well as ${e.scope}`);
516
+ }
517
+ }
518
+ // IS ANYTHING ACTUALLY BEING SAVED?
519
+ //
520
+ // `bir doctor` runs in whatever shell you happen to be in — not the one that
521
+ // started `bir-hooks`, and not the one the host spawned the proxies from. So
522
+ // its own `BIR_AUTH_URL` is not evidence about either, and treating it as
523
+ // evidence produced a confident "nothing will be recorded" on a session that
524
+ // was recording perfectly. Ask the recorder; it is the only process that knows.
525
+ const notes = [];
526
+ if (health) {
527
+ if (health.recording === false) {
528
+ problems.push("the recorder is running but has nowhere to send steps — set BIR_AUTH_URL " +
529
+ "and run `bir login` in the terminal where you start `bir-hooks`");
530
+ }
531
+ else {
532
+ notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}`);
533
+ }
534
+ }
535
+ else if (!resolveAuthUrl()) {
536
+ // No control server to ask, and nothing in this shell either. That is a
537
+ // reason to be uncertain, not a reason to declare failure.
538
+ notes.push("could not verify recording: no control server to ask, and BIR_AUTH_URL is " +
539
+ "unset in this shell (which is not where the proxies read it from)");
540
+ }
541
+ const sidecar = readSidecar();
542
+ if (sidecar.controlPort && discovery && !discovery.url.endsWith(`:${sidecar.controlPort}`)) {
543
+ problems.push(`hooks were installed for port ${sidecar.controlPort} but the control server is on ${discovery.url}`);
544
+ }
545
+ if (args.json) {
546
+ out(JSON.stringify({ ok: problems.length === 0, problems, notes, health, wrapped: wantWrapped }, null, 2));
547
+ return problems.length === 0 ? 0 : 1;
548
+ }
549
+ // Replay first, and loudly. It auto-approves tool calls in the session and
550
+ // bypasses the permission system entirely for direct steps
551
+ // (docs/calculatedReplay.md §13.2), so it must never be discoverable only by
552
+ // reading a log file.
553
+ const replay = health?.replay;
554
+ if (replay?.enabled) {
555
+ out(`Replay : ON servers=${replay.allowServers?.join(",") || "(all wrapped)"} ` +
556
+ `minSimilarity=${replay.minSimilarity ?? "?"}`);
557
+ out(` derive=${replay.deriveKey ? "anthropic" : "recorded sample values (no ANTHROPIC_API_KEY)"}`);
558
+ const idle = wantWrapped.filter((n) => !(replay.pollingProxies ?? []).includes(n));
559
+ if (idle.length > 0) {
560
+ notes.push(`these proxies are not polling for replay work: ${idle.join(", ")} — ` +
561
+ "their steps will fall back to recorded outputs");
562
+ }
563
+ }
564
+ else if (health) {
565
+ out("Replay : off");
566
+ }
567
+ out(`Wrapped in config : ${wantWrapped.join(", ") || "(none)"}`);
568
+ out(`Registered proxies: ${[...registered].join(", ") || "(none)"}`);
569
+ out(`Control server : ${discovery?.url ?? "not running"}`);
570
+ out(`Recording tier : ${discovery && health ? "bound (Tier 1)" : "standalone (Tier 2)"}`);
571
+ out(`Recording : ${health ? (health.recording === false ? "no — see below" : "yes") : "unknown (no control server to ask)"}`);
572
+ if (health?.lossy)
573
+ out("Lossy : yes — steps were dropped in this session");
574
+ out();
575
+ for (const n of notes)
576
+ out(` · ${n}`);
577
+ if (problems.length === 0) {
578
+ if (notes.length)
579
+ out();
580
+ out("No problems found.");
581
+ return 0;
582
+ }
583
+ if (notes.length)
584
+ out();
585
+ out("Problems:");
586
+ for (const p of problems)
587
+ out(` ! ${p}`);
588
+ return 1;
589
+ }
590
+ // ── scenario / replay ──────────────────────────────────────────────────────
591
+ /**
592
+ * A thin authenticated client for the BaseIn scenario endpoints.
593
+ *
594
+ * Everything below is a wrapper over routes that already exist server-side.
595
+ * They are here because telling somebody to `curl` with a token dug out of
596
+ * `~/.baseinstrunner/credentials.json` is not an interface, and because
597
+ * dry-replaying a scenario before trusting it is the cheapest useful thing an
598
+ * operator can do (docs/calculatedReplayGuide.md §3.1).
599
+ */
600
+ async function service(method, path, body) {
601
+ const configured = resolveAuthUrl();
602
+ if (!configured)
603
+ throw new Error("BIR_AUTH_URL is not set");
604
+ // A cross-origin redirect strips the bearer token; settle the URL first.
605
+ const baseUrl = await normalizeAuthUrl(configured);
606
+ const session = await authenticate({ authUrl: baseUrl });
607
+ if (!session)
608
+ throw new Error("not signed in — run `bir login`");
609
+ const res = await fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, {
610
+ method,
611
+ headers: {
612
+ "content-type": "application/json",
613
+ authorization: `Bearer ${session.accessToken}`,
614
+ },
615
+ body: body === undefined ? undefined : JSON.stringify(body),
616
+ });
617
+ const text = await res.text().catch(() => "");
618
+ let parsed = undefined;
619
+ try {
620
+ parsed = text ? JSON.parse(text) : undefined;
621
+ }
622
+ catch {
623
+ parsed = text;
624
+ }
625
+ return { status: res.status, body: parsed };
626
+ }
627
+ function pad(text, width) {
628
+ return text.length >= width ? text.slice(0, width) : text + " ".repeat(width - text.length);
629
+ }
630
+ async function scenarioCommand(args) {
631
+ const sub = args.positionals[0] ?? "";
632
+ const target = args.positionals[1];
633
+ if (sub === "list") {
634
+ const { status, body } = await service("GET", "/recordings/runs?pageSize=50");
635
+ if (status !== 200) {
636
+ out(`Could not list runs (HTTP ${status}).`);
637
+ return 1;
638
+ }
639
+ const runs = (body?.runs ?? []);
640
+ if (args.json) {
641
+ out(JSON.stringify(body, null, 2));
642
+ return 0;
643
+ }
644
+ if (runs.length === 0) {
645
+ out("No recorded runs yet.");
646
+ return 0;
647
+ }
648
+ // Ids are 40 characters and are meant to be copied into `calc` / `replay`,
649
+ // so they get their own line rather than a column that truncates them.
650
+ for (const r of runs) {
651
+ const title = r.title ?? (r.input ?? "").split("\n")[0];
652
+ out(`${r.id} ${title.slice(0, 60)}`);
653
+ const facts = [`iterations=${r.iterations ?? 1}`, `steps=${r.step_count ?? "?"}`];
654
+ if (r.scenario_id)
655
+ facts.push(`scenario=${r.scenario_id} (${r.scenario_state ?? "?"})`);
656
+ else
657
+ facts.push("scenario=none — run `bir scenario calc <runId>`");
658
+ out(` ${facts.join(" ")}`);
659
+ }
660
+ return 0;
661
+ }
662
+ if (sub === "show") {
663
+ if (!target) {
664
+ out("usage: bir scenario show <runId>");
665
+ return 2;
666
+ }
667
+ const { status, body } = await service("GET", `/recordings/runs/${target}/scenario`);
668
+ if (status === 404) {
669
+ out("No scenario for that run yet — `bir scenario calc <runId>` first.");
670
+ return 1;
671
+ }
672
+ if (status !== 200) {
673
+ out(`Could not read the scenario (HTTP ${status}).`);
674
+ return 1;
675
+ }
676
+ out(JSON.stringify(body, null, 2));
677
+ return 0;
678
+ }
679
+ if (sub === "calc") {
680
+ if (!target) {
681
+ out("usage: bir scenario calc <runId> [--force]");
682
+ return 2;
683
+ }
684
+ const { status, body } = await service("POST", `/recordings/runs/${target}/calculate`, args.force ? { force: true } : {});
685
+ if (status === 202) {
686
+ const b = body;
687
+ out(`Calculating ${b.scenarioId ?? ""} — poll with \`bir scenario show ${target}\`.`);
688
+ return 0;
689
+ }
690
+ if (status === 409) {
691
+ out("That run already has a scenario. Re-derive it in place with --force.");
692
+ return 1;
693
+ }
694
+ if (status === 503) {
695
+ out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
696
+ return 1;
697
+ }
698
+ out(`Could not start calculation (HTTP ${status}): ${JSON.stringify(body)}`);
699
+ return 1;
700
+ }
701
+ if (sub === "replay") {
702
+ return replayCommand({ ...args, scenarioId: args.scenarioId ?? target });
703
+ }
704
+ out("usage: bir scenario <list|show|calc|replay> …");
705
+ return 2;
706
+ }
707
+ /**
708
+ * `--dry` evaluates the scenario's stored logic against the **source run's
709
+ * recorded outputs**, server-side: no real tools, no side effects, one Haiku
710
+ * call. It is the cheapest way to find out whether a scenario's parameters and
711
+ * per-step inputs actually vary with the prompt, which is the difference between
712
+ * a scenario and a recording.
713
+ *
714
+ * Without `--dry` the same plan runs against the live proxies through the
715
+ * control server — the Tier 2 and debugging path (docs/calculatedReplay.md §12.1).
716
+ */
717
+ async function replayCommand(args) {
718
+ const scenarioId = args.scenarioId ?? args.positionals[0];
719
+ if (!scenarioId || !args.prompt) {
720
+ out('usage: bir replay --scenario <scnId> --prompt "…" [--dry]');
721
+ return 2;
722
+ }
723
+ if (args.dry) {
724
+ const { status, body } = await service("POST", `/scenarios/${scenarioId}/replay`, {
725
+ prompt: args.prompt,
726
+ });
727
+ if (status !== 200) {
728
+ out(`Dry replay failed (HTTP ${status}): ${JSON.stringify(body)}`);
729
+ return 1;
730
+ }
731
+ if (args.json) {
732
+ out(JSON.stringify(body, null, 2));
733
+ return 0;
734
+ }
735
+ const r = body;
736
+ out(`params ${JSON.stringify(r.params ?? {})}`);
737
+ (r.steps ?? []).forEach((s, i) => {
738
+ out(`step ${i} ${s.toolName} ${JSON.stringify(s.input)}`);
739
+ const emitted = Object.keys(s.emitted ?? {});
740
+ if (emitted.length)
741
+ out(` → emitted: ${emitted.join(", ")}`);
742
+ else
743
+ out(" → emitted: (nothing — toolOutputLogic derived no keys)");
744
+ });
745
+ out(`response ${JSON.stringify(r.response ?? {})}`);
746
+ out(`cost $${(r.costUsd ?? 0).toFixed(4)}`);
747
+ return 0;
748
+ }
749
+ // Live. The split is deliberate: this process holds the credentials, so it
750
+ // fetches the scenario; the control server holds the proxies, so it runs it.
751
+ const runId = args.positionals[1] ?? args.positionals[0];
752
+ if (!runId || !runId.startsWith("run_")) {
753
+ out("Live replay needs the scenario's source run, so it can be fetched:");
754
+ out(' bir replay --scenario <scnId> --prompt "…" <runId>');
755
+ out("Or check the logic without running anything, with --dry.");
756
+ return 2;
757
+ }
758
+ const cwd = process.cwd();
759
+ const discovery = readDiscovery(cwd);
760
+ if (!discovery) {
761
+ out("No control server for this directory — start `bir-hooks` first.");
762
+ out("(Or use --dry, which needs only the service.)");
763
+ return 1;
764
+ }
765
+ const fetched = await service("GET", `/recordings/runs/${runId}/scenario`);
766
+ if (fetched.status !== 200) {
767
+ out(`Could not fetch that run's scenario (HTTP ${fetched.status}).`);
768
+ return 1;
769
+ }
770
+ const scenario = fetched.body?.scenario;
771
+ if (!scenario) {
772
+ out("That run has no calculated scenario yet — `bir scenario calc` first.");
773
+ return 1;
774
+ }
775
+ const res = await fetch(`${discovery.url.replace(/\/+$/, "")}/scenario/replay`, {
776
+ method: "POST",
777
+ headers: {
778
+ "content-type": "application/json",
779
+ authorization: `Bearer ${discovery.token}`,
780
+ },
781
+ body: JSON.stringify({ scenario, prompt: args.prompt }),
782
+ });
783
+ const result = (await res.json().catch(() => ({})));
784
+ if (args.json) {
785
+ out(JSON.stringify(result, null, 2));
786
+ return result.ok ? 0 : 1;
787
+ }
788
+ if (!result.ok) {
789
+ out(`Replay failed: ${result.why ?? "unknown"}`);
790
+ return 1;
791
+ }
792
+ for (const t of result.trace ?? []) {
793
+ out(`step ${t.step} ${t.tool} ${JSON.stringify(t.input)} [${t.outcome}, ${t.ms}ms]`);
794
+ }
795
+ out(`response ${JSON.stringify(result.responseModel ?? {})}`);
796
+ out(`steps ${result.steps ?? 0}${result.partial ? " (partial — budget reached)" : ""}`);
797
+ return 0;
798
+ }
799
+ // ── wrap ───────────────────────────────────────────────────────────────────
800
+ /**
801
+ * Print the proxied entry for one server without touching any file. This is what
802
+ * makes D8 real: a client with no adapter never needs us to know where it keeps
803
+ * its settings — the user pastes this in.
804
+ */
805
+ function wrap(args) {
806
+ const name = args.serverName;
807
+ if (!name) {
808
+ out("usage: bir wrap --server-name <key> [--url <url> --transport http|sse] -- <command> [args…]");
809
+ return 2;
810
+ }
811
+ const rest = args.rest;
812
+ const config = rest.length
813
+ ? { command: rest[0], args: rest.slice(1) }
814
+ : (() => {
815
+ out("usage: bir wrap --server-name <key> -- <command> [args…]");
816
+ process.exit(2);
817
+ })();
818
+ const sidecar = readSidecar();
819
+ const entry = wrapEntry(config, {
820
+ serverName: name,
821
+ controlUrl: sidecar.controlPort ? `http://127.0.0.1:${sidecar.controlPort}` : undefined,
822
+ noCorrelation: args.noCorrelation,
823
+ invocation: resolveInvocation(args),
824
+ proxyPath: args.local || args.global ? localProxyPath() : undefined,
825
+ version: VERSION,
826
+ });
827
+ out(JSON.stringify({ mcpServers: { [name]: entry } }, null, 2));
828
+ return 0;
829
+ }
830
+ // ── entry point ────────────────────────────────────────────────────────────
831
+ async function main() {
832
+ const argv = process.argv.slice(2);
833
+ if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help")
834
+ usage(0);
835
+ if (argv[0] === "--version" || argv[0] === "-v") {
836
+ out(VERSION);
837
+ return 0;
838
+ }
839
+ const args = parseArgs(argv);
840
+ switch (args.command) {
841
+ case "install":
842
+ return install(args);
843
+ case "uninstall":
844
+ return uninstall(args);
845
+ case "status":
846
+ return status(args);
847
+ case "doctor":
848
+ return doctor(args);
849
+ case "wrap":
850
+ return wrap(args);
851
+ case "scenario":
852
+ return scenarioCommand(args);
853
+ case "replay":
854
+ return replayCommand(args);
855
+ case "login": {
856
+ const session = await authenticate();
857
+ if (!session)
858
+ return 1;
859
+ out(`Signed in as ${session.user.email}.`);
860
+ return 0;
861
+ }
862
+ case "logout":
863
+ clearCredentials();
864
+ out("Logged out — cached credentials cleared.");
865
+ return 0;
866
+ default:
867
+ usage(2);
868
+ }
869
+ }
870
+ main()
871
+ .then((code) => process.exit(code))
872
+ .catch((err) => {
873
+ process.stderr.write(`[bir] ${errText(err)}\n`);
874
+ process.exit(1);
875
+ });
876
+ //# sourceMappingURL=bir.js.map