@basein/runner 0.2.8 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -22
- package/dist/auth/client.d.ts +40 -1
- package/dist/auth/client.js +77 -9
- package/dist/bin/bir-hooks.d.ts +18 -3
- package/dist/bin/bir-hooks.js +124 -38
- package/dist/bin/bir-scenario.d.ts +18 -2
- package/dist/bin/bir-scenario.js +374 -4
- package/dist/bin/bir.d.ts +12 -0
- package/dist/bin/bir.js +501 -81
- package/dist/bin/investigate.js +1 -1
- package/dist/bin/scenario-edit.d.ts +173 -0
- package/dist/bin/scenario-edit.js +771 -0
- package/dist/bin/setup.d.ts +72 -0
- package/dist/bin/setup.js +286 -0
- package/dist/config/adapters/claude-code.d.ts +90 -4
- package/dist/config/adapters/claude-code.js +164 -16
- package/dist/config/generate.d.ts +114 -1
- package/dist/config/generate.js +106 -3
- package/dist/control/client.d.ts +5 -0
- package/dist/control/client.js +8 -0
- package/dist/control/daemon.d.ts +116 -0
- package/dist/control/daemon.js +339 -0
- package/dist/control/discovery.d.ts +26 -0
- package/dist/control/discovery.js +41 -9
- package/dist/control/ensure-hook.d.ts +39 -0
- package/dist/control/ensure-hook.js +98 -0
- package/dist/control/paths.d.ts +14 -0
- package/dist/control/paths.js +20 -0
- package/dist/control/server.d.ts +28 -0
- package/dist/control/server.js +15 -2
- package/dist/proxy/session.d.ts +8 -1
- package/dist/proxy/session.js +28 -6
- package/docs/calculatedReplay.md +51 -0
- package/docs/calculatedReplayGuide.md +471 -74
- package/docs/installRun.md +457 -111
- package/docs/loginWeb.md +1 -1
- package/docs/quickstart.md +195 -158
- package/package.json +2 -1
- package/scripts/install.ps1 +669 -0
- package/scripts/install.sh +586 -0
package/dist/bin/bir.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* bir — the command line (Phase 7).
|
|
4
4
|
*
|
|
5
|
+
* bir setup [--auth-url <url>] [--token <t>] [--project <dir>] ← the one command
|
|
6
|
+
* bir up [--restart] | bir down
|
|
5
7
|
* bir install [--config <path>] [--server <name>]… [--local] [--no-hooks]
|
|
6
8
|
* bir uninstall [--config <path>]
|
|
7
9
|
* bir status
|
|
@@ -10,6 +12,16 @@
|
|
|
10
12
|
* bir login | logout
|
|
11
13
|
* bir --version
|
|
12
14
|
*
|
|
15
|
+
* bir scenario list | show <runId|scnId> [--step <n>] | calc <runId> [--force [--discard-edits]]
|
|
16
|
+
* bir scenario check|edit <runId|scnId> --step <n> --input-logic <file|-> … ← fix one step
|
|
17
|
+
* bir scenario edits | undo <runId|scnId> [--edit <sedit_id>]
|
|
18
|
+
* bir scenario editing on|off|status ← may this project's `bir` MCP server change a scenario?
|
|
19
|
+
* bir scenario replay <scnId> --prompt "…" [--dry] | bir replay status|on|off|allow …
|
|
20
|
+
* bir investigate [<id>] | list | executions
|
|
21
|
+
*
|
|
22
|
+
* The `scenario` words that read or change one step live in scenario-edit.ts
|
|
23
|
+
* (editSteps.md in the BaseIn repository); `investigate` in investigate.ts.
|
|
24
|
+
*
|
|
13
25
|
* `bir install` is reversible by construction: every file it edits is stashed
|
|
14
26
|
* verbatim first, so `bir uninstall` restores it byte-for-byte unless somebody
|
|
15
27
|
* else edited it in the meantime (in which case it repairs the entries and says
|
|
@@ -18,21 +30,24 @@
|
|
|
18
30
|
* from `serverInfo`, which must stay the upstream's (Phase 3).
|
|
19
31
|
*/
|
|
20
32
|
import { randomBytes } from "node:crypto";
|
|
21
|
-
import { existsSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
22
34
|
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
23
35
|
import { fileURLToPath } from "node:url";
|
|
24
36
|
import { ControlClient } from "../control/client.js";
|
|
25
37
|
import { readDiscovery } from "../control/discovery.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
38
|
+
import { ensureDaemon, stopDaemon } from "../control/daemon.js";
|
|
39
|
+
import { configDir, discoveryPath, normalizePath } from "../control/paths.js";
|
|
40
|
+
import { allocateFreeProjectPort, isScenarioServer, isWrapped, nodeFlagsToCarry, PACKAGE_NAME, packageSpec, projectRecord, readSidecar, scenarioEntry, setProjectRecord, sidecarKey, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
|
|
41
|
+
import { runSetup } from "./setup.js";
|
|
42
|
+
import { isRemote, parseJsonFile, resolveServers } from "../config/resolve.js";
|
|
43
|
+
import { buildHooksBlock, claudeCodePaths, disableMcpjsonServers, enableMcpjsonServers, ensureGitExclude, fileForScope, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, readTextOrNull, removeGitExclude, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
|
|
30
44
|
import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
|
|
31
|
-
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
|
|
45
|
+
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
|
|
32
46
|
import { DEFAULT_CONTROL_PORT } from "../control/server.js";
|
|
33
47
|
import { errText } from "../util/log.js";
|
|
34
48
|
import { journalPath, readJournal } from "../util/journal.js";
|
|
35
49
|
import { investigateCommand } from "./investigate.js";
|
|
50
|
+
import { SCENARIO_EDIT_SUBCOMMANDS, scenarioEditCommand } from "./scenario-edit.js";
|
|
36
51
|
import { loadCredentials } from "../auth/client.js";
|
|
37
52
|
import { packageVersion } from "../util/version.js";
|
|
38
53
|
const VERSION = packageVersion();
|
|
@@ -43,6 +58,9 @@ function usage(code = 0) {
|
|
|
43
58
|
out(`bir ${VERSION} — a recording MCP proxy
|
|
44
59
|
|
|
45
60
|
Commands:
|
|
61
|
+
setup the one command: sign in, wire this project, start the recorder
|
|
62
|
+
up start the recorder for this project in the background (--restart: replace it)
|
|
63
|
+
down stop it
|
|
46
64
|
install wrap this project's MCP servers and wire Claude Code's hooks
|
|
47
65
|
uninstall restore everything install changed
|
|
48
66
|
status what is installed for this directory
|
|
@@ -52,10 +70,24 @@ Commands:
|
|
|
52
70
|
logout revoke this machine's session and forget it
|
|
53
71
|
|
|
54
72
|
scenario list recorded runs and their calculated scenarios
|
|
55
|
-
scenario show <runId>
|
|
56
|
-
|
|
73
|
+
scenario show <runId|scnId> [--step <n>]
|
|
74
|
+
a scenario: intent, params, steps (--step: just that one)
|
|
75
|
+
scenario calc <runId> [--force [--discard-edits]]
|
|
76
|
+
calculate (or re-derive) a run's scenario
|
|
77
|
+
scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
|
|
78
|
+
try a change to one step against the recording; saves nothing
|
|
79
|
+
scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
|
|
80
|
+
[--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
|
|
81
|
+
the same check, and save the change if it passes
|
|
82
|
+
scenario edits <runId|scnId> a scenario's hand edits, newest first
|
|
83
|
+
scenario undo <runId|scnId> [--edit <sedit_id>]
|
|
84
|
+
put a step back as it was before its newest edit
|
|
85
|
+
scenario editing on|off|status whether this project's bir MCP server offers the tools
|
|
86
|
+
that change a scenario (restart Claude Code after)
|
|
57
87
|
scenario replay <scnId> --prompt "…" [--dry]
|
|
58
88
|
replay --scenario <scnId> --prompt "…" [--dry]
|
|
89
|
+
replay status | on | off | allow <a,b> | allow all
|
|
90
|
+
this project's replay switches, kept for the background recorder
|
|
59
91
|
|
|
60
92
|
investigate [<id>] why the newest turn here (or run_/scn_/sexec_ <id>) did what
|
|
61
93
|
it did, what it cost, what to fix — journal + service
|
|
@@ -69,17 +101,36 @@ Options:
|
|
|
69
101
|
--local invoke this checkout's bir-proxy instead of npx (development)
|
|
70
102
|
--global invoke the installed package by absolute path instead of npx
|
|
71
103
|
(no registry round trip, no PATH lookup — what a fleet wants)
|
|
72
|
-
--no-hooks do not touch .claude/settings.json (Tier 2 recording only)
|
|
104
|
+
--no-hooks do not touch .claude/settings.local.json (Tier 2 recording only)
|
|
73
105
|
--no-correlation never relax tool schemas; join steps on argument fingerprints
|
|
74
106
|
--replay install/remove the scenario server, enabling calculated replay
|
|
75
|
-
--port <n> control-server port to write into the hook URLs
|
|
76
|
-
|
|
107
|
+
--port <n> install/setup: control-server port to write into the hook URLs
|
|
108
|
+
(default: this project's, else the lowest free one from ${DEFAULT_CONTROL_PORT})
|
|
109
|
+
--json machine-readable output for status / doctor / scenario / investigate
|
|
110
|
+
(scenario check|edit|edits|undo: the service's answer, unchanged)
|
|
77
111
|
--dry replay against recorded outputs only; run no real tools
|
|
112
|
+
--force scenario calc: recalculate in place · scenario edit: save although
|
|
113
|
+
the check refused it (needs --note; only for a recording that was wrong)
|
|
114
|
+
--step <n> scenario show|check|edit: the step, by the stepIndex \`show\` prints
|
|
115
|
+
--input-logic <file|-> scenario check|edit: the new input logic, from a file or stdin (-)
|
|
116
|
+
--output-logic <file|-> scenario check|edit: the new output logic, from a file or stdin (-)
|
|
117
|
+
--freeze scenario edit: mark the step "needs a judgement" (runs hand over before it)
|
|
118
|
+
--unfreeze scenario check|edit: ask to remove that mark; the check decides
|
|
119
|
+
--note <text> scenario edit|undo: why, kept in the history (required with --force)
|
|
120
|
+
--revision <n> scenario edit|undo: only if the plan is still at this chainRevision
|
|
121
|
+
--edit <id> scenario undo: the sedit_ id to undo (default: the newest that can be)
|
|
122
|
+
--discard-edits scenario calc --force: recalculate a plan with hand edits, replacing them
|
|
78
123
|
--no-browser login: print the link and code, open nothing (SSH, headless)
|
|
79
124
|
--token <value> login: redeem a one-time setup token from the console (no browser)
|
|
80
125
|
--password login: use the old email/password prompt (deprecated)
|
|
81
126
|
--user <ref> investigate executions: another account, by email or id (admin)
|
|
82
|
-
--limit <n> investigate: how many turns / executions to show
|
|
127
|
+
--limit <n> investigate: how many turns / executions to show
|
|
128
|
+
--auth-url <url> setup: the BaseIn API address (else BIR_AUTH_URL, else the stored one)
|
|
129
|
+
--project <dir> setup: the directory to record (default: this one)
|
|
130
|
+
--no-replay setup: wire recording only; add no scenario server
|
|
131
|
+
--allow-servers <a,b> setup: servers replay may call unattended (default: all wrapped)
|
|
132
|
+
--no-daemon setup: wire everything, start nothing
|
|
133
|
+
--restart up: replace a running recorder`);
|
|
83
134
|
process.exit(code);
|
|
84
135
|
}
|
|
85
136
|
function parseArgs(argv) {
|
|
@@ -98,6 +149,13 @@ function parseArgs(argv) {
|
|
|
98
149
|
dry: false,
|
|
99
150
|
force: false,
|
|
100
151
|
password: false,
|
|
152
|
+
noReplay: false,
|
|
153
|
+
noDaemon: false,
|
|
154
|
+
restart: false,
|
|
155
|
+
portExplicit: false,
|
|
156
|
+
freeze: false,
|
|
157
|
+
unfreeze: false,
|
|
158
|
+
discardEdits: false,
|
|
101
159
|
};
|
|
102
160
|
for (let i = 1; i < argv.length; i += 1) {
|
|
103
161
|
const arg = argv[i];
|
|
@@ -153,6 +211,31 @@ function parseArgs(argv) {
|
|
|
153
211
|
break;
|
|
154
212
|
case "--port":
|
|
155
213
|
args.port = Number(argv[++i]);
|
|
214
|
+
args.portExplicit = true;
|
|
215
|
+
break;
|
|
216
|
+
case "--auth-url":
|
|
217
|
+
args.authUrl = argv[++i];
|
|
218
|
+
if (!args.authUrl)
|
|
219
|
+
usage(2);
|
|
220
|
+
break;
|
|
221
|
+
case "--project":
|
|
222
|
+
args.project = argv[++i];
|
|
223
|
+
if (!args.project)
|
|
224
|
+
usage(2);
|
|
225
|
+
break;
|
|
226
|
+
case "--no-replay":
|
|
227
|
+
args.noReplay = true;
|
|
228
|
+
break;
|
|
229
|
+
case "--allow-servers":
|
|
230
|
+
args.allowServers = argv[++i];
|
|
231
|
+
if (args.allowServers === undefined)
|
|
232
|
+
usage(2);
|
|
233
|
+
break;
|
|
234
|
+
case "--no-daemon":
|
|
235
|
+
args.noDaemon = true;
|
|
236
|
+
break;
|
|
237
|
+
case "--restart":
|
|
238
|
+
args.restart = true;
|
|
156
239
|
break;
|
|
157
240
|
case "--json":
|
|
158
241
|
args.json = true;
|
|
@@ -169,6 +252,53 @@ function parseArgs(argv) {
|
|
|
169
252
|
args.limit = n;
|
|
170
253
|
break;
|
|
171
254
|
}
|
|
255
|
+
case "--step": {
|
|
256
|
+
// Digits only: `Number("")` is 0 and `Number("0x4")` is 4, and a step
|
|
257
|
+
// index nobody typed is a step nobody meant to change.
|
|
258
|
+
const raw = argv[++i] ?? "";
|
|
259
|
+
if (!/^\d+$/.test(raw))
|
|
260
|
+
usage(2);
|
|
261
|
+
args.step = Number(raw);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
case "--input-logic":
|
|
265
|
+
// A value flag, so a bare `-` (stdin) is taken here and never reaches
|
|
266
|
+
// the default branch, which refuses anything that starts with a dash.
|
|
267
|
+
args.inputLogic = argv[++i];
|
|
268
|
+
if (!args.inputLogic)
|
|
269
|
+
usage(2);
|
|
270
|
+
break;
|
|
271
|
+
case "--output-logic":
|
|
272
|
+
args.outputLogic = argv[++i];
|
|
273
|
+
if (!args.outputLogic)
|
|
274
|
+
usage(2);
|
|
275
|
+
break;
|
|
276
|
+
case "--freeze":
|
|
277
|
+
args.freeze = true;
|
|
278
|
+
break;
|
|
279
|
+
case "--unfreeze":
|
|
280
|
+
args.unfreeze = true;
|
|
281
|
+
break;
|
|
282
|
+
case "--note":
|
|
283
|
+
args.note = argv[++i];
|
|
284
|
+
if (args.note === undefined)
|
|
285
|
+
usage(2);
|
|
286
|
+
break;
|
|
287
|
+
case "--edit":
|
|
288
|
+
args.edit = argv[++i];
|
|
289
|
+
if (!args.edit)
|
|
290
|
+
usage(2);
|
|
291
|
+
break;
|
|
292
|
+
case "--revision": {
|
|
293
|
+
const raw = argv[++i] ?? "";
|
|
294
|
+
if (!/^\d+$/.test(raw) || Number(raw) <= 0)
|
|
295
|
+
usage(2);
|
|
296
|
+
args.revision = Number(raw);
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case "--discard-edits":
|
|
300
|
+
args.discardEdits = true;
|
|
301
|
+
break;
|
|
172
302
|
default:
|
|
173
303
|
if (arg.startsWith("-"))
|
|
174
304
|
usage(2);
|
|
@@ -176,6 +306,10 @@ function parseArgs(argv) {
|
|
|
176
306
|
args.positionals.push(arg);
|
|
177
307
|
}
|
|
178
308
|
}
|
|
309
|
+
if (args.freeze && args.unfreeze) {
|
|
310
|
+
process.stderr.write("[bir] --freeze and --unfreeze are mutually exclusive\n");
|
|
311
|
+
process.exit(2);
|
|
312
|
+
}
|
|
179
313
|
return args;
|
|
180
314
|
}
|
|
181
315
|
/** Absolute path to this package's built `bir-proxy.js`, for `--local`. */
|
|
@@ -186,6 +320,27 @@ function localProxyPath() {
|
|
|
186
320
|
function localScenarioPath() {
|
|
187
321
|
return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-scenario.js");
|
|
188
322
|
}
|
|
323
|
+
/** Absolute path to this package's built `bir-hooks.js`, for the SessionStart hook. */
|
|
324
|
+
function localHooksPath() {
|
|
325
|
+
return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-hooks.js");
|
|
326
|
+
}
|
|
327
|
+
/** True when this `bir` runs from an installed package rather than a checkout. */
|
|
328
|
+
function runsFromInstalledPackage() {
|
|
329
|
+
return /[\\/]node_modules[\\/]/.test(localProxyPath());
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The node the SessionStart hook will run. The real path, not the one this
|
|
333
|
+
* process was started through: a version manager's per-shell shim directory
|
|
334
|
+
* is gone when that shell is, and a hook that names it fails every session.
|
|
335
|
+
*/
|
|
336
|
+
function hookNodePath() {
|
|
337
|
+
try {
|
|
338
|
+
return realpathSync(process.execPath);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return process.execPath;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
189
344
|
/**
|
|
190
345
|
* Which invocation the generated entries use — and the guard that makes
|
|
191
346
|
* `--global` mean something.
|
|
@@ -199,11 +354,20 @@ function localScenarioPath() {
|
|
|
199
354
|
* that works today and breaks after a `git clean`. Hence the node_modules test:
|
|
200
355
|
* it is the one cheap signal that says "somebody installed this".
|
|
201
356
|
*/
|
|
202
|
-
function resolveInvocation(args) {
|
|
357
|
+
function resolveInvocation(args, remembered) {
|
|
203
358
|
if (args.local && args.global) {
|
|
204
359
|
process.stderr.write("[bir] --local and --global are mutually exclusive\n");
|
|
205
360
|
process.exit(2);
|
|
206
361
|
}
|
|
362
|
+
// Neither flag: what this project was installed with last time. `bir setup`
|
|
363
|
+
// installs with --global, and a later bare `bir install --replay` here must
|
|
364
|
+
// not quietly turn every entry into a pinned-npx one and drop the
|
|
365
|
+
// self-starting SessionStart hook with it.
|
|
366
|
+
if (!args.global && !args.local && remembered) {
|
|
367
|
+
if (remembered === "global" && !runsFromInstalledPackage())
|
|
368
|
+
return "local";
|
|
369
|
+
return remembered;
|
|
370
|
+
}
|
|
207
371
|
if (!args.global)
|
|
208
372
|
return args.local ? "local" : "npx";
|
|
209
373
|
const proxy = localProxyPath();
|
|
@@ -229,13 +393,20 @@ function noteWritten(sidecar, file, text) {
|
|
|
229
393
|
sidecar.files[file] = backup;
|
|
230
394
|
}
|
|
231
395
|
// ── install ────────────────────────────────────────────────────────────────
|
|
232
|
-
function install(args) {
|
|
233
|
-
const invocation = resolveInvocation(args);
|
|
396
|
+
async function install(args) {
|
|
234
397
|
const cwd = process.cwd();
|
|
235
398
|
const sidecar = readSidecar();
|
|
399
|
+
const invocation = resolveInvocation(args, projectRecord(sidecar, cwd)?.invocation);
|
|
236
400
|
sidecar.token ??= randomBytes(32).toString("hex");
|
|
237
|
-
|
|
238
|
-
|
|
401
|
+
// One port per project (generate.ts): an explicit --port is honoured, else
|
|
402
|
+
// the one this directory already has, else the lowest free one no other has.
|
|
403
|
+
const port = args.portExplicit ? args.port : await allocateFreeProjectPort(sidecar, cwd);
|
|
404
|
+
sidecar.controlPort = port;
|
|
405
|
+
// One token per project too: a token in this project's settings file opens
|
|
406
|
+
// this project's recorder, not every recorder on the machine.
|
|
407
|
+
const token = projectRecord(sidecar, cwd)?.token ?? randomBytes(32).toString("hex");
|
|
408
|
+
setProjectRecord(sidecar, cwd, { port, token, invocation });
|
|
409
|
+
const controlUrl = `http://127.0.0.1:${port}`;
|
|
239
410
|
const entries = args.configPath
|
|
240
411
|
? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
|
|
241
412
|
name,
|
|
@@ -245,18 +416,52 @@ function install(args) {
|
|
|
245
416
|
}))
|
|
246
417
|
: resolveServers(cwd);
|
|
247
418
|
const wanted = entries.filter((e) => args.servers.length === 0 || args.servers.includes(e.name));
|
|
248
|
-
if (wanted.length === 0) {
|
|
419
|
+
if (wanted.length === 0 && (args.configPath || !args.hooks)) {
|
|
420
|
+
// An explicit config file, or proxies-only: with no server there is nothing
|
|
421
|
+
// this install could change.
|
|
249
422
|
out("No MCP servers found for this directory. Nothing to wrap.");
|
|
250
423
|
if (!args.configPath) {
|
|
251
424
|
out(" Looked in: ~/.claude.json (user + local scopes) and ./.mcp.json (project scope).");
|
|
252
425
|
}
|
|
253
426
|
return 0;
|
|
254
427
|
}
|
|
428
|
+
if (wanted.length === 0) {
|
|
429
|
+
// Hooks alone are a recorder: built-in tools, the prompt and the answer are
|
|
430
|
+
// all theirs, and a scenario over built-in steps steers a session the same
|
|
431
|
+
// way. Stopping here used to leave such a project with nothing at all.
|
|
432
|
+
out("No MCP servers to wrap here (looked in ~/.claude.json and ./.mcp.json).");
|
|
433
|
+
out(" Hooks are still wired: built-in tools and prompts are recorded.");
|
|
434
|
+
}
|
|
255
435
|
const paths = claudeCodePaths(cwd);
|
|
436
|
+
/**
|
|
437
|
+
* Does a generated entry still name the runner that is running now? After
|
|
438
|
+
* an upgrade, a Node moved by a version manager, or a switch from the
|
|
439
|
+
* machine's npm prefix to the private one, the old absolute path (or the old
|
|
440
|
+
* pinned npx version) is what the host would keep spawning — and "already
|
|
441
|
+
* wrapped" is the last thing that should be printed about it.
|
|
442
|
+
*/
|
|
443
|
+
const nodeFlags = nodeFlagsToCarry();
|
|
444
|
+
const stillCurrent = (config, scriptPath) => {
|
|
445
|
+
if (isRemote(config))
|
|
446
|
+
return false;
|
|
447
|
+
const argv = config.args ?? [];
|
|
448
|
+
if (invocation === "npx")
|
|
449
|
+
return config.command === "npx" && argv.includes(packageSpec(VERSION));
|
|
450
|
+
const at = argv.indexOf(scriptPath);
|
|
451
|
+
if (config.command !== process.execPath || at < 0)
|
|
452
|
+
return false;
|
|
453
|
+
// The node flags in front of the script must be the ones this run carries.
|
|
454
|
+
return argv.slice(0, at).join(" ") === nodeFlags.join(" ");
|
|
455
|
+
};
|
|
256
456
|
let wrapped = 0;
|
|
257
457
|
for (const entry of wanted) {
|
|
258
458
|
if (entry.name === SCENARIO_SERVER_KEY && isScenarioServer(entry.config)) {
|
|
259
|
-
|
|
459
|
+
if (!args.replay || stillCurrent(entry.config, localScenarioPath())) {
|
|
460
|
+
out(` = ${entry.name} — the scenario server (calculated replay)`);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
out(` ~ ${entry.name} — re-writing the scenario server: its runner moved`);
|
|
464
|
+
}
|
|
260
465
|
continue;
|
|
261
466
|
}
|
|
262
467
|
if (entry.name === SCENARIO_SERVER_KEY) {
|
|
@@ -277,15 +482,18 @@ function install(args) {
|
|
|
277
482
|
// spawns), so `env` is present in practice; the cast is only to narrow the
|
|
278
483
|
// remote-or-stdio union.
|
|
279
484
|
const current = entry.config.env?.BIR_CONTROL_URL;
|
|
280
|
-
const
|
|
485
|
+
const urlStale = args.hooks && !args.configPath && current !== controlUrl;
|
|
486
|
+
const pathStale = !stillCurrent(entry.config, localProxyPath());
|
|
281
487
|
const stashed = sidecar.servers[sidecarKey(cwd, entry.name)]?.original;
|
|
282
|
-
if (!
|
|
488
|
+
if (!(urlStale || pathStale) || !stashed) {
|
|
283
489
|
out(` = ${entry.name} — already wrapped`);
|
|
284
490
|
continue;
|
|
285
491
|
}
|
|
492
|
+
const was = `${entry.config.command} ${(entry.config.args ?? [])[0] ?? ""}`.trim();
|
|
286
493
|
entry.config = stashed;
|
|
287
|
-
out(
|
|
288
|
-
|
|
494
|
+
out(urlStale
|
|
495
|
+
? ` ~ ${entry.name} — re-wrapping: its control URL was ${current ?? "unset"}, now ${controlUrl}`
|
|
496
|
+
: ` ~ ${entry.name} — re-wrapping: its runner moved (was ${was})`);
|
|
289
497
|
}
|
|
290
498
|
// Phase 7.3: writing anywhere but the winning scope changes nothing.
|
|
291
499
|
if (entry.shadowed.length > 0) {
|
|
@@ -299,6 +507,7 @@ function install(args) {
|
|
|
299
507
|
invocation,
|
|
300
508
|
proxyPath: invocation === "npx" ? undefined : localProxyPath(),
|
|
301
509
|
version: VERSION,
|
|
510
|
+
nodeFlags,
|
|
302
511
|
});
|
|
303
512
|
let file;
|
|
304
513
|
let text;
|
|
@@ -336,6 +545,7 @@ function install(args) {
|
|
|
336
545
|
scenarioPath: invocation === "npx" ? undefined : localScenarioPath(),
|
|
337
546
|
version: VERSION,
|
|
338
547
|
controlUrl: args.hooks ? controlUrl : undefined,
|
|
548
|
+
nodeFlags,
|
|
339
549
|
});
|
|
340
550
|
// PROJECT SCOPE, NOT LOCAL — this was a bug worth the comment.
|
|
341
551
|
//
|
|
@@ -373,17 +583,47 @@ function install(args) {
|
|
|
373
583
|
};
|
|
374
584
|
out(` + ${SCENARIO_SERVER_KEY} → bir-scenario (calculated replay; ${scope} scope)`);
|
|
375
585
|
}
|
|
586
|
+
// SessionStart starts the recorder itself when this directory has none
|
|
587
|
+
// (control/ensure-hook.ts) — but only when the binary can be named by an
|
|
588
|
+
// absolute path. A pinned-npx install keeps the HTTP hook: resolving a
|
|
589
|
+
// package inside a hook is too slow to start a recorder from.
|
|
590
|
+
const ensure = invocation === "npx" ? undefined : { node: hookNodePath(), hooksScript: localHooksPath(), nodeFlags };
|
|
376
591
|
if (args.hooks && !args.configPath) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
592
|
+
// Hooks live in `.claude/settings.local.json` — the person's own file, the
|
|
593
|
+
// one Claude Code keeps out of git — not in the `settings.json` a team
|
|
594
|
+
// commits. They carry a bearer token and an absolute path to this machine's
|
|
595
|
+
// node; a teammate's clone wants neither. An older install put them in the
|
|
596
|
+
// shared file; take them out of there on the way.
|
|
597
|
+
if (hasBirHooks(paths.settings)) {
|
|
598
|
+
backupFile(sidecar, paths.settings);
|
|
599
|
+
const cleaned = uninstallHooks(paths.settings);
|
|
600
|
+
if (cleaned !== undefined)
|
|
601
|
+
noteWritten(sidecar, paths.settings, cleaned);
|
|
602
|
+
out(` - hooks removed from ${paths.settings} (they live in settings.local.json now)`);
|
|
603
|
+
}
|
|
604
|
+
backupFile(sidecar, paths.settingsLocal);
|
|
605
|
+
let text = installHooks(paths.settingsLocal, buildHooksBlock(controlUrl, token, { ensure }));
|
|
606
|
+
if (args.replay) {
|
|
607
|
+
// Pre-approve our own scenario server. Claude Code asks before it uses a
|
|
608
|
+
// server from `.mcp.json`, and a "No" there leaves a direct replay with
|
|
609
|
+
// nowhere to deliver its results — and nothing to say so.
|
|
610
|
+
text = enableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]);
|
|
611
|
+
}
|
|
612
|
+
noteWritten(sidecar, paths.settingsLocal, text);
|
|
613
|
+
sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settingsLocal])];
|
|
614
|
+
out(` + hooks → ${paths.settingsLocal} (control server on ${controlUrl}${ensure ? "; the recorder starts itself with each session" : ""})`);
|
|
615
|
+
const exclude = ensureGitExclude(cwd);
|
|
616
|
+
if (exclude)
|
|
617
|
+
out(` + ${LOCAL_SETTINGS_EXCLUDE} is listed in ${exclude}`);
|
|
382
618
|
}
|
|
383
619
|
writeSidecar(sidecar);
|
|
384
620
|
out();
|
|
385
621
|
out(`Wrapped ${wrapped} server${wrapped === 1 ? "" : "s"}.`);
|
|
386
|
-
if (args.hooks && !args.configPath) {
|
|
622
|
+
if (args.hooks && !args.configPath && ensure) {
|
|
623
|
+
out("Next: start your session here as usual. The recorder starts itself with it;");
|
|
624
|
+
out("`bir up` starts it now, `bir down` stops it, `bir doctor` checks it.");
|
|
625
|
+
}
|
|
626
|
+
else if (args.hooks && !args.configPath) {
|
|
387
627
|
out("Next: run `bir-hooks` in this directory, then start your session.");
|
|
388
628
|
out("Without it, proxies record standalone (Tier 2): MCP calls only, no prompt.");
|
|
389
629
|
}
|
|
@@ -392,8 +632,8 @@ function install(args) {
|
|
|
392
632
|
}
|
|
393
633
|
if (args.replay) {
|
|
394
634
|
out();
|
|
395
|
-
out("Calculated replay is INSTALLED and ON
|
|
396
|
-
out("
|
|
635
|
+
out("Calculated replay is INSTALLED and ON. `bir replay off` turns it off here, and");
|
|
636
|
+
out("`bir replay allow <servers>` narrows what it may call unattended — read");
|
|
397
637
|
out("docs/calculatedReplayGuide.md §5.1 first: a replayed step is auto-approved,");
|
|
398
638
|
out("and a directly executed one never reaches the permission system at all.");
|
|
399
639
|
}
|
|
@@ -470,12 +710,24 @@ function uninstall(args) {
|
|
|
470
710
|
continue;
|
|
471
711
|
if (existsSync(settings)) {
|
|
472
712
|
uninstallHooks(settings);
|
|
713
|
+
disableMcpjsonServers(settings, [SCENARIO_SERVER_KEY]);
|
|
473
714
|
out(` ← hooks removed from ${settings}`);
|
|
474
715
|
}
|
|
475
716
|
}
|
|
717
|
+
// An install older than 0.2.9 wrote to the shared settings file; make sure
|
|
718
|
+
// nothing of ours is left there either, whether or not it was recorded.
|
|
719
|
+
if (!restoredWhole.has(paths.settings) && hasBirHooks(paths.settings)) {
|
|
720
|
+
uninstallHooks(paths.settings);
|
|
721
|
+
out(` ← hooks removed from ${paths.settings}`);
|
|
722
|
+
}
|
|
476
723
|
sidecar.hookFiles = [];
|
|
477
724
|
sidecar.files = {};
|
|
725
|
+
if (sidecar.projects)
|
|
726
|
+
delete sidecar.projects[normalizePath(cwd)];
|
|
478
727
|
writeSidecar(sidecar);
|
|
728
|
+
const exclude = removeGitExclude(cwd);
|
|
729
|
+
if (exclude)
|
|
730
|
+
out(` ← ${LOCAL_SETTINGS_EXCLUDE} removed from ${exclude}`);
|
|
479
731
|
out();
|
|
480
732
|
out("Uninstalled.");
|
|
481
733
|
return 0;
|
|
@@ -511,8 +763,12 @@ function status(args) {
|
|
|
511
763
|
return 0;
|
|
512
764
|
}
|
|
513
765
|
out(`Directory : ${report.cwd}`);
|
|
514
|
-
out(`BaseIn : ${report.authUrl ?? "(
|
|
515
|
-
out(`Control : ${discovery
|
|
766
|
+
out(`BaseIn : ${report.authUrl ?? "(not set — run `bir setup`, or set BIR_AUTH_URL; nothing is recorded)"}`);
|
|
767
|
+
out(`Control : ${discovery
|
|
768
|
+
? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})`
|
|
769
|
+
: "not running — `bir up` starts it"}`);
|
|
770
|
+
if (discovery?.logFile)
|
|
771
|
+
out(`Log : ${discovery.logFile}`);
|
|
516
772
|
out("Servers :");
|
|
517
773
|
if (report.servers.length === 0)
|
|
518
774
|
out(" (none configured for this directory)");
|
|
@@ -526,6 +782,7 @@ function status(args) {
|
|
|
526
782
|
async function doctor(args) {
|
|
527
783
|
const cwd = process.cwd();
|
|
528
784
|
const problems = [];
|
|
785
|
+
const notes = [];
|
|
529
786
|
const entries = resolveServers(cwd);
|
|
530
787
|
const wantWrapped = entries.filter((e) => isWrapped(e.config)).map((e) => e.name);
|
|
531
788
|
const discovery = readDiscovery(cwd);
|
|
@@ -536,13 +793,24 @@ async function doctor(args) {
|
|
|
536
793
|
problems.push(`control server at ${discovery.url} did not answer /health`);
|
|
537
794
|
}
|
|
538
795
|
else {
|
|
539
|
-
problems.push("no control server for this directory — recording will be Tier 2 (MCP only)"
|
|
796
|
+
problems.push("no control server for this directory — recording will be Tier 2 (MCP only); " +
|
|
797
|
+
"`bir up` starts one, and the SessionStart hook starts one with each session");
|
|
540
798
|
}
|
|
541
799
|
const registered = new Set((health?.registeredProxies ?? []).map((p) => p.serverName));
|
|
800
|
+
// A recorder with no session yet is the normal state right after `bir setup`
|
|
801
|
+
// and between sessions: the proxies are Claude Code's child processes, so
|
|
802
|
+
// there is nothing to register until a session starts. Reporting that as a
|
|
803
|
+
// problem made the first `bir doctor` a person ever ran exit non-zero on a
|
|
804
|
+
// machine where nothing was wrong.
|
|
805
|
+
const idleRecorder = Boolean(health) && (health?.sessions ?? []).length === 0;
|
|
542
806
|
// The guarantee check: a *registered* proxy. Not the config file (which only
|
|
543
807
|
// says what should happen) and not `serverInfo` (which stays the upstream's).
|
|
544
|
-
|
|
545
|
-
|
|
808
|
+
const unregistered = wantWrapped.filter((name) => !registered.has(name));
|
|
809
|
+
if (unregistered.length > 0 && idleRecorder) {
|
|
810
|
+
notes.push(`no Claude Code session is running here yet; ${unregistered.join(", ")} will register when one starts`);
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
for (const name of unregistered) {
|
|
546
814
|
problems.push(`${name} is wrapped in config but no proxy has registered — is the session running?`);
|
|
547
815
|
}
|
|
548
816
|
}
|
|
@@ -558,14 +826,16 @@ async function doctor(args) {
|
|
|
558
826
|
// its own `BIR_AUTH_URL` is not evidence about either, and treating it as
|
|
559
827
|
// evidence produced a confident "nothing will be recorded" on a session that
|
|
560
828
|
// was recording perfectly. Ask the recorder; it is the only process that knows.
|
|
561
|
-
const notes = [];
|
|
562
829
|
if (health) {
|
|
563
830
|
if (health.recording === false) {
|
|
564
|
-
problems.push("the recorder is running but has nowhere to send steps —
|
|
565
|
-
"and
|
|
831
|
+
problems.push("the recorder is running but has nowhere to send steps — run `bir setup` " +
|
|
832
|
+
"(or set BIR_AUTH_URL and `bir login`), then `bir up --restart`");
|
|
566
833
|
}
|
|
567
834
|
else {
|
|
568
|
-
notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}`);
|
|
835
|
+
notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}${health.account ? ` as ${String(health.account)}` : ""}`);
|
|
836
|
+
}
|
|
837
|
+
if (discovery?.daemon) {
|
|
838
|
+
notes.push(`the recorder runs in the background (pid ${discovery.pid}); its audit log is ${discovery.logFile ?? "under ~/.baseinstrunner/logs"}`);
|
|
569
839
|
}
|
|
570
840
|
// Whether a recurring sub-task the service found may actually run mid-task
|
|
571
841
|
// (segmented.md R-LIFE-8). Observe-only is the default and must be visible
|
|
@@ -599,6 +869,29 @@ async function doctor(args) {
|
|
|
599
869
|
problems.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
|
|
600
870
|
`\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
|
|
601
871
|
}
|
|
872
|
+
// THE SESSIONSTART HOOK NAMES A NODE BY ABSOLUTE PATH. A version manager's
|
|
873
|
+
// per-shell directory, an uninstalled Node, a moved private Node: the hook
|
|
874
|
+
// then fails on every session and the recorder is never started, which
|
|
875
|
+
// records Tier 2 and looks like nothing at all.
|
|
876
|
+
const paths = claudeCodePaths(cwd);
|
|
877
|
+
for (const file of [paths.settingsLocal, paths.settings]) {
|
|
878
|
+
let parsed;
|
|
879
|
+
try {
|
|
880
|
+
parsed = parseJsonFile(file);
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
for (const matcher of parsed?.hooks?.SessionStart ?? []) {
|
|
886
|
+
for (const hook of matcher.hooks ?? []) {
|
|
887
|
+
if (hook.type !== "command" || !hook.command || !isBirHook(hook))
|
|
888
|
+
continue;
|
|
889
|
+
if (!existsSync(hook.command)) {
|
|
890
|
+
problems.push(`the SessionStart hook in ${file} points at a Node that is gone (${hook.command}) — run \`bir setup\` here again`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
602
895
|
// THE SCENARIO SERVER IS ON THE CRITICAL PATH OF THE FIRST TURN. A plan armed
|
|
603
896
|
// at `UserPromptSubmit` is delivered through `mcp__bir__run_scenario`, so a
|
|
604
897
|
// `bir` entry that starts with `npx` has to resolve and unpack the package
|
|
@@ -633,7 +926,8 @@ async function doctor(args) {
|
|
|
633
926
|
const replay = health?.replay;
|
|
634
927
|
if (replay?.enabled) {
|
|
635
928
|
out(`Replay : ON servers=${replay.allowServers?.join(",") || "(all wrapped)"} ` +
|
|
636
|
-
`minSimilarity=${replay.minSimilarity ?? "?"}`
|
|
929
|
+
`minSimilarity=${replay.minSimilarity ?? "?"}` +
|
|
930
|
+
(replay.source ? ` source=${replay.source}` : ""));
|
|
637
931
|
// An older control server sends only `deriveKey`; read it as the two states
|
|
638
932
|
// it could describe then.
|
|
639
933
|
const via = replay.deriveVia ?? (replay.deriveKey ? "key" : "samples");
|
|
@@ -649,7 +943,7 @@ async function doctor(args) {
|
|
|
649
943
|
"matched scenario with a target is declined and the agent does the task");
|
|
650
944
|
}
|
|
651
945
|
const idle = wantWrapped.filter((n) => !(replay.pollingProxies ?? []).includes(n));
|
|
652
|
-
if (idle.length > 0) {
|
|
946
|
+
if (idle.length > 0 && !idleRecorder) {
|
|
653
947
|
notes.push(`these proxies are not polling for replay work: ${idle.join(", ")} — ` +
|
|
654
948
|
"their steps will fall back to recorded outputs");
|
|
655
949
|
}
|
|
@@ -755,51 +1049,59 @@ async function scenarioCommand(args) {
|
|
|
755
1049
|
}
|
|
756
1050
|
return 0;
|
|
757
1051
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
out("That run already has a scenario. Re-derive it in place with --force.");
|
|
788
|
-
return 1;
|
|
789
|
-
}
|
|
790
|
-
if (status === 503) {
|
|
791
|
-
out("The service has no Anthropic configuration, so it cannot calculate scenarios.");
|
|
792
|
-
return 1;
|
|
793
|
-
}
|
|
794
|
-
out(`Could not start calculation (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
795
|
-
return 1;
|
|
1052
|
+
// show, calc, and everything that reads or changes one step (editSteps.md).
|
|
1053
|
+
if (SCENARIO_EDIT_SUBCOMMANDS.has(sub)) {
|
|
1054
|
+
return scenarioEditCommand({
|
|
1055
|
+
positionals: args.positionals,
|
|
1056
|
+
json: args.json,
|
|
1057
|
+
force: args.force,
|
|
1058
|
+
step: args.step,
|
|
1059
|
+
inputLogic: args.inputLogic,
|
|
1060
|
+
outputLogic: args.outputLogic,
|
|
1061
|
+
freeze: args.freeze,
|
|
1062
|
+
unfreeze: args.unfreeze,
|
|
1063
|
+
note: args.note,
|
|
1064
|
+
edit: args.edit,
|
|
1065
|
+
revision: args.revision,
|
|
1066
|
+
discardEdits: args.discardEdits,
|
|
1067
|
+
}, {
|
|
1068
|
+
cwd: process.cwd(),
|
|
1069
|
+
out,
|
|
1070
|
+
service: async (method, path, body) => {
|
|
1071
|
+
try {
|
|
1072
|
+
return await service(method, path, body);
|
|
1073
|
+
}
|
|
1074
|
+
catch (err) {
|
|
1075
|
+
return { error: errText(err) };
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
readFile: (path) => readFileSync(resolvePath(process.cwd(), path)),
|
|
1079
|
+
readStdin,
|
|
1080
|
+
});
|
|
796
1081
|
}
|
|
797
1082
|
if (sub === "replay") {
|
|
798
1083
|
return replayCommand({ ...args, scenarioId: args.scenarioId ?? target });
|
|
799
1084
|
}
|
|
800
|
-
out("usage: bir scenario <list|show|calc|replay> …");
|
|
1085
|
+
out("usage: bir scenario <list|show|calc|check|edit|edits|undo|editing|replay> …");
|
|
801
1086
|
return 2;
|
|
802
1087
|
}
|
|
1088
|
+
/**
|
|
1089
|
+
* All of stdin, for `--input-logic -`. A person at a terminal gets told how to
|
|
1090
|
+
* end it; a pipe never sees the line (it goes to stderr, and only on a TTY).
|
|
1091
|
+
*/
|
|
1092
|
+
function readStdin() {
|
|
1093
|
+
if (process.stdin.isTTY) {
|
|
1094
|
+
process.stderr.write("[bir] reading the logic from stdin — end it with Ctrl-D (Ctrl-Z, Enter on Windows)\n");
|
|
1095
|
+
}
|
|
1096
|
+
return new Promise((resolve, reject) => {
|
|
1097
|
+
const chunks = [];
|
|
1098
|
+
process.stdin.on("data", (chunk) => {
|
|
1099
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
|
|
1100
|
+
});
|
|
1101
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1102
|
+
process.stdin.on("error", reject);
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
803
1105
|
/**
|
|
804
1106
|
* `--dry` evaluates the scenario's stored logic against the **source run's
|
|
805
1107
|
* recorded outputs**, server-side: no real tools, no side effects, one Haiku
|
|
@@ -810,7 +1112,63 @@ async function scenarioCommand(args) {
|
|
|
810
1112
|
* Without `--dry` the same plan runs against the live proxies through the
|
|
811
1113
|
* control server — the Tier 2 and debugging path (docs/calculatedReplay.md §12.1).
|
|
812
1114
|
*/
|
|
1115
|
+
/**
|
|
1116
|
+
* `bir replay status | on | off | allow <a,b> | allow all` — this project's
|
|
1117
|
+
* replay switches, stored in the sidecar so a recorder started by the
|
|
1118
|
+
* SessionStart hook keeps them (generate.ts, `ReplayPolicy`).
|
|
1119
|
+
*/
|
|
1120
|
+
function replayPolicyCommand(args) {
|
|
1121
|
+
const cwd = process.cwd();
|
|
1122
|
+
const sidecar = readSidecar();
|
|
1123
|
+
const record = projectRecord(sidecar, cwd);
|
|
1124
|
+
const word = args.positionals[0];
|
|
1125
|
+
const describe = (policy) => {
|
|
1126
|
+
out(`Replay policy for ${cwd}`);
|
|
1127
|
+
out(` replay ${policy?.enabled === false ? "off" : "on"}`);
|
|
1128
|
+
out(` direct servers ${policy?.allowServers?.length ? policy.allowServers.join(", ") : "(all wrapped)"}`);
|
|
1129
|
+
out(` minSimilarity ${policy?.minSimilarity ?? 0.92}`);
|
|
1130
|
+
const overrides = ["BIR_REPLAY", "BIR_REPLAY_ALLOW_SERVERS", "BIR_MIN_STEER_SIMILARITY"].filter((k) => process.env[k] !== undefined);
|
|
1131
|
+
if (overrides.length) {
|
|
1132
|
+
out(` (set in this shell's environment, which a recorder started from it would prefer: ${overrides.join(", ")})`);
|
|
1133
|
+
}
|
|
1134
|
+
if (!record)
|
|
1135
|
+
out(" (this directory has not been installed — `bir setup` here first)");
|
|
1136
|
+
out("Restart the recorder to apply a change: `bir up --restart`.");
|
|
1137
|
+
};
|
|
1138
|
+
if (word === "status") {
|
|
1139
|
+
describe(record?.replay);
|
|
1140
|
+
return 0;
|
|
1141
|
+
}
|
|
1142
|
+
if (!record) {
|
|
1143
|
+
out("This directory has not been installed — run `bir setup` (or `bir install --replay`) here first.");
|
|
1144
|
+
return 1;
|
|
1145
|
+
}
|
|
1146
|
+
const next = { ...(record.replay ?? {}) };
|
|
1147
|
+
if (word === "on")
|
|
1148
|
+
delete next.enabled;
|
|
1149
|
+
else if (word === "off")
|
|
1150
|
+
next.enabled = false;
|
|
1151
|
+
else if (word === "allow") {
|
|
1152
|
+
const list = args.positionals[1];
|
|
1153
|
+
if (!list) {
|
|
1154
|
+
out("usage: bir replay allow <server,server> | bir replay allow all");
|
|
1155
|
+
return 2;
|
|
1156
|
+
}
|
|
1157
|
+
if (list === "all")
|
|
1158
|
+
delete next.allowServers;
|
|
1159
|
+
else
|
|
1160
|
+
next.allowServers = list.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1161
|
+
}
|
|
1162
|
+
setProjectRecord(sidecar, cwd, { port: record.port, replay: next });
|
|
1163
|
+
writeSidecar(sidecar);
|
|
1164
|
+
describe(next);
|
|
1165
|
+
return 0;
|
|
1166
|
+
}
|
|
813
1167
|
async function replayCommand(args) {
|
|
1168
|
+
const word = args.positionals[0];
|
|
1169
|
+
if (word === "status" || word === "on" || word === "off" || word === "allow") {
|
|
1170
|
+
return replayPolicyCommand(args);
|
|
1171
|
+
}
|
|
814
1172
|
const scenarioId = args.scenarioId ?? args.positionals[0];
|
|
815
1173
|
if (!scenarioId || !args.prompt) {
|
|
816
1174
|
out('usage: bir replay --scenario <scnId> --prompt "…" [--dry]');
|
|
@@ -972,6 +1330,65 @@ async function main() {
|
|
|
972
1330
|
}
|
|
973
1331
|
},
|
|
974
1332
|
});
|
|
1333
|
+
case "setup": {
|
|
1334
|
+
// The invocation is decided here, not asked: an installed package gets
|
|
1335
|
+
// absolute paths (--global), a checkout points at itself (--local).
|
|
1336
|
+
const installed = runsFromInstalledPackage();
|
|
1337
|
+
return runSetup({
|
|
1338
|
+
authUrl: args.authUrl,
|
|
1339
|
+
token: args.token,
|
|
1340
|
+
project: args.project,
|
|
1341
|
+
browser: args.browser,
|
|
1342
|
+
replay: !args.noReplay,
|
|
1343
|
+
daemon: !args.noDaemon,
|
|
1344
|
+
allowServers: args.allowServers,
|
|
1345
|
+
}, {
|
|
1346
|
+
out,
|
|
1347
|
+
err: (line) => process.stderr.write(`[bir] ${line}\n`),
|
|
1348
|
+
install: ({ replay }) => install({ ...args, global: installed, local: !installed, replay, hooks: true, servers: [] }),
|
|
1349
|
+
hooksScript: localHooksPath(),
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
case "up": {
|
|
1353
|
+
const cwd = process.cwd();
|
|
1354
|
+
if (args.restart)
|
|
1355
|
+
await stopDaemon(cwd);
|
|
1356
|
+
const { status, started, restarted } = await ensureDaemon(cwd, {
|
|
1357
|
+
hooksScript: localHooksPath(),
|
|
1358
|
+
restartOnSkew: true,
|
|
1359
|
+
});
|
|
1360
|
+
const { info } = status;
|
|
1361
|
+
out(`Recorder ${started ? (restarted ? "restarted" : "started") : "already running"} for ${cwd}`);
|
|
1362
|
+
out(` ${info.url} (pid ${info.pid}${info.daemon ? "; background" : "; in a terminal"})`);
|
|
1363
|
+
if (info.logFile)
|
|
1364
|
+
out(` log ${info.logFile}`);
|
|
1365
|
+
if (status.health.recording === false) {
|
|
1366
|
+
out(" !! it has no session to record with — run `bir setup` or `bir login`, then `bir up --restart`");
|
|
1367
|
+
return 1;
|
|
1368
|
+
}
|
|
1369
|
+
return 0;
|
|
1370
|
+
}
|
|
1371
|
+
case "down": {
|
|
1372
|
+
const cwd = process.cwd();
|
|
1373
|
+
const before = readDiscovery(cwd);
|
|
1374
|
+
if (!before) {
|
|
1375
|
+
out("No recorder is running for this directory.");
|
|
1376
|
+
return 0;
|
|
1377
|
+
}
|
|
1378
|
+
const result = await stopDaemon(cwd);
|
|
1379
|
+
if (result.stale) {
|
|
1380
|
+
out(`No recorder answered for this directory; a stale record (pid ${before.pid}) was removed.`);
|
|
1381
|
+
return 0;
|
|
1382
|
+
}
|
|
1383
|
+
if (!result.stopped) {
|
|
1384
|
+
out(`The recorder (pid ${before.pid}) did not stop.`);
|
|
1385
|
+
return 1;
|
|
1386
|
+
}
|
|
1387
|
+
out(result.forced
|
|
1388
|
+
? `Recorder stopped (pid ${before.pid}; forced — it did not answer, so its last steps may be missing).`
|
|
1389
|
+
: `Recorder stopped (pid ${before.pid}).`);
|
|
1390
|
+
return 0;
|
|
1391
|
+
}
|
|
975
1392
|
case "login": {
|
|
976
1393
|
// Settle the URL the way every other command does, then refuse to go on
|
|
977
1394
|
// when what is there is not the service. "Signed in" must mean the service
|
|
@@ -1041,6 +1458,9 @@ async function main() {
|
|
|
1041
1458
|
}
|
|
1042
1459
|
if (!session)
|
|
1043
1460
|
return 1;
|
|
1461
|
+
// From here on no terminal needs BIR_AUTH_URL: the address a session was
|
|
1462
|
+
// just issued by is the one every process should use.
|
|
1463
|
+
rememberAuthUrl(baseUrl);
|
|
1044
1464
|
out(`Signed in to ${baseUrl} as ${session.user.email}.`);
|
|
1045
1465
|
return 0;
|
|
1046
1466
|
}
|