@basein/runner 0.2.11 → 0.3.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.
- package/README.md +540 -350
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -10
- package/dist/bin/bir-scenario.js +704 -135
- package/dist/bin/bir.d.ts +14 -8
- package/dist/bin/bir.js +2937 -488
- package/dist/bin/investigate.js +41 -16
- package/dist/bin/scenario-edit.d.ts +43 -10
- package/dist/bin/scenario-edit.js +281 -80
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2803 -306
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +371 -20
- package/dist/replay/controller.js +1185 -111
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1244
- package/docs/calculatedReplayGuide.md +1212 -879
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -271
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- package/scripts/install.sh +104 -7
package/dist/bin/bir.js
CHANGED
|
@@ -4,17 +4,18 @@
|
|
|
4
4
|
*
|
|
5
5
|
* bir setup [--auth-url <url>] [--token <t>] [--project <dir>] ← the one command
|
|
6
6
|
* bir up [--restart] | bir down
|
|
7
|
+
* bir off [--here] | bir on [--here] ← the kill switch: inert at once, nothing uninstalled
|
|
7
8
|
* bir install [--config <path>] [--server <name>]… [--local] [--no-hooks]
|
|
8
|
-
* bir uninstall [--config <path>]
|
|
9
|
+
* bir uninstall [--config <path>] [--server <name>]… | bir uninstall --all
|
|
9
10
|
* bir status
|
|
10
|
-
* bir doctor
|
|
11
|
+
* bir doctor [--quick]
|
|
11
12
|
* bir wrap --server-name <key> -- <command> [args…]
|
|
12
13
|
* bir login | logout
|
|
13
14
|
* bir --version
|
|
14
15
|
*
|
|
15
|
-
* bir scenario list | show <runId|scnId> [--step <n>] | calc <runId> [--force [--discard-edits]]
|
|
16
|
+
* bir scenario list | show <runId|scnId> [--step <n>] | calc <runId|scnId> [--force [--discard-edits]]
|
|
16
17
|
* 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 edits | undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
|
|
18
19
|
* bir scenario editing on|off|status ← may this project's `bir` MCP server change a scenario?
|
|
19
20
|
* bir scenario replay <scnId> --prompt "…" [--dry] | bir replay status|on|off|allow …
|
|
20
21
|
* bir investigate [<id>] | list | executions
|
|
@@ -22,115 +23,157 @@
|
|
|
22
23
|
* The `scenario` words that read or change one step live in scenario-edit.ts
|
|
23
24
|
* (editSteps.md in the BaseIn repository); `investigate` in investigate.ts.
|
|
24
25
|
*
|
|
25
|
-
* `bir install` is reversible by construction: every file it edits is
|
|
26
|
-
* verbatim first, so `bir uninstall` restores it byte-for-byte unless
|
|
27
|
-
* else edited it in the meantime (in which case it repairs the entries
|
|
28
|
-
* so).
|
|
26
|
+
* `bir install` is reversible by construction: every project file it edits is
|
|
27
|
+
* stashed verbatim first, so `bir uninstall` restores it byte-for-byte unless
|
|
28
|
+
* somebody else edited it in the meantime (in which case it repairs the entries
|
|
29
|
+
* and says so); `~/.claude.json` and a `--config` file are always repaired entry
|
|
30
|
+
* by entry. What install writes is chosen so it cannot break a working setup:
|
|
31
|
+
* wrapped copies go to this project's local scope (never the team's `.mcp.json`,
|
|
32
|
+
* never the user scope), a remote server Claude Code signs in itself is left
|
|
33
|
+
* alone, and a file that is not plain JSON is refused rather than rewritten.
|
|
34
|
+
* `bir doctor` proves the install *works* by asking the control server which
|
|
29
35
|
* proxies actually registered — never by reading the config file back, and never
|
|
30
36
|
* from `serverInfo`, which must stay the upstream's (Phase 3).
|
|
31
37
|
*/
|
|
32
38
|
import { randomBytes } from "node:crypto";
|
|
33
|
-
import { existsSync, readFileSync,
|
|
34
|
-
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
39
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
40
|
+
import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
|
|
35
41
|
import { fileURLToPath } from "node:url";
|
|
36
42
|
import { ControlClient } from "../control/client.js";
|
|
37
|
-
import { readDiscovery } from "../control/discovery.js";
|
|
38
|
-
import
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
43
|
+
import { pidAlive, readDiscovery } from "../control/discovery.js";
|
|
44
|
+
import * as daemonApi from "../control/daemon.js";
|
|
45
|
+
import { activeSessions, daemonStatus, ensureDaemon, probeHealth, readPortProblem, stopDaemon, } from "../control/daemon.js";
|
|
46
|
+
import { configDir, controlDir, controlKey, discoveryPath, installedPath, normalizePath } from "../control/paths.js";
|
|
47
|
+
import { offFlagPath, offSource, projectOffFlagPath, setRunnerOff } from "../util/off-switch.js";
|
|
48
|
+
import { allocateFreeProjectPort, backupIsCreation, backupOriginalText, configSha, envProxyHint, exemptLoopbackFromProxy, isNpxEntry, isScenarioServer, isWrapped, nodeFlagsToCarry, normaliseRecordKeys, npxHookInvocation, protectBackupText, recordKey, sameServerConfig, PACKAGE_NAME, projectRecord, readSidecar, redactSecrets, restoreSecrets, remoteWrapBlocker, scenarioEntry, setProjectRecord, settleReplaySwitch, sidecarKey, unwrapEntry, UPSTREAM_HEADERS_ENV, wrapEntry, writeSidecar, SCENARIO_SERVER_KEY, } from "../config/generate.js";
|
|
49
|
+
import { chooseNodePath } from "../config/node-path.js";
|
|
50
|
+
import { managedMcpPolicyBlocker, mcpjsonApprovals } from "../config/mcp-approval.js";
|
|
51
|
+
import { checkOverrides, recordServerName } from "../config/overrides.js";
|
|
52
|
+
import { awaitWithin, isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal } from "../util/service-health.js";
|
|
41
53
|
import { runSetup } from "./setup.js";
|
|
42
|
-
import { isRemote, parseJsonFile, resolveServers } from "../config/resolve.js";
|
|
43
|
-
import { buildHooksBlock, claudeCodePaths, disableMcpjsonServers, enableMcpjsonServers,
|
|
54
|
+
import { claudeConfigHome, claudeProjectKey, claudeProjectRoot, isRemote, linkedWorktrees, parseJsonFile, projectKeyFor, projectKeysFor, projectScope, resolveServers, } from "../config/resolve.js";
|
|
55
|
+
import { assertEditableJson, buildHooksBlock, claudeCodePaths, disableMcpjsonServers, enableMcpjsonServers, ENSURE_ARG, ensureGitExcludeLine, hasBirHooks, installHooks, isBirHook, LOCAL_SETTINGS_EXCLUDE, readTextOrNull, removeGitExclude, setLocalEntries, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
|
|
56
|
+
import { writeFileAtomic } from "../util/atomic-write.js";
|
|
44
57
|
import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
|
|
45
|
-
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin,
|
|
58
|
+
import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, clearCredentials, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, normalizeAuthUrl, rememberAuthUrl, resolveAuthUrl, } from "../auth/client.js";
|
|
46
59
|
import { DEFAULT_CONTROL_PORT } from "../control/server.js";
|
|
47
|
-
import { errText } from "../util/log.js";
|
|
60
|
+
import { errText, logLine } from "../util/log.js";
|
|
48
61
|
import { journalPath, readJournal } from "../util/journal.js";
|
|
49
62
|
import { investigateCommand } from "./investigate.js";
|
|
50
63
|
import { SCENARIO_EDIT_SUBCOMMANDS, scenarioEditCommand } from "./scenario-edit.js";
|
|
51
64
|
import { loadCredentials } from "../auth/client.js";
|
|
52
65
|
import { packageVersion } from "../util/version.js";
|
|
53
66
|
const VERSION = packageVersion();
|
|
67
|
+
/**
|
|
68
|
+
* The npx SessionStart hook's own budget, passed as `ensure --budget-ms <n>`
|
|
69
|
+
* (bin/bir-hooks.ts, control/ensure-hook.ts). npx resolves the package — about
|
|
70
|
+
* a second cached, more when it has to look — before the hook's clock starts,
|
|
71
|
+
* and the host cancels the hook at ENSURE_TIMEOUT_S (15 s): the default 11 s
|
|
72
|
+
* budget plus its 0.5 s ceiling left npx under 3.5 s. 8 s leaves it 6.5.
|
|
73
|
+
*/
|
|
74
|
+
const NPX_ENSURE_BUDGET_ARGS = ["--budget-ms", "8000"];
|
|
54
75
|
const out = (line = "") => {
|
|
55
76
|
process.stdout.write(line + "\n");
|
|
56
77
|
};
|
|
57
78
|
function usage(code = 0) {
|
|
58
|
-
out(`bir ${VERSION} — a recording MCP proxy
|
|
59
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
scenario
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
--
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
--
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
--
|
|
116
|
-
--
|
|
117
|
-
--
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
--
|
|
121
|
-
--
|
|
122
|
-
--
|
|
123
|
-
--
|
|
124
|
-
|
|
125
|
-
--
|
|
126
|
-
|
|
127
|
-
--
|
|
128
|
-
--
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
--
|
|
132
|
-
--
|
|
133
|
-
--
|
|
79
|
+
out(`bir ${VERSION} — a recording MCP proxy
|
|
80
|
+
|
|
81
|
+
Commands:
|
|
82
|
+
setup the one command: sign in, wire this project, start the recorder
|
|
83
|
+
up start the recorder for this project in the background (--restart: replace it)
|
|
84
|
+
down stop it (open sessions here then show a hook error until they restart;
|
|
85
|
+
\`bir off --here\` is the quiet way to take it out of the way)
|
|
86
|
+
off take the runner out of the way at once, uninstalling nothing: hooks answer
|
|
87
|
+
with nothing within a second, wrapped servers stop recording at once and run
|
|
88
|
+
as plain pipes from their next start, nothing is recorded or replayed
|
|
89
|
+
(--here: this project only)
|
|
90
|
+
on undo \`bir off\` (without --here: every project on this machine)
|
|
91
|
+
install wrap this project's MCP servers and wire Claude Code's hooks
|
|
92
|
+
uninstall restore everything install changed here and stop this project's recorder
|
|
93
|
+
(--all: every project on this machine — run it before removing the package)
|
|
94
|
+
status what is installed for this directory
|
|
95
|
+
doctor is it actually working right now? (exits 1 only for what breaks a session;
|
|
96
|
+
--quick: no network)
|
|
97
|
+
wrap print a proxied entry for one server (any MCP client)
|
|
98
|
+
login sign in to the BaseIn service (opens your browser)
|
|
99
|
+
logout revoke this machine's session and forget it
|
|
100
|
+
|
|
101
|
+
scenario list recorded runs and their calculated scenarios
|
|
102
|
+
scenario show <runId|scnId> [--step <n>]
|
|
103
|
+
a scenario: intent, params, steps (--step: just that one)
|
|
104
|
+
scenario calc <runId|scnId> [--force [--discard-edits]]
|
|
105
|
+
calculate (or re-derive) a run's scenario; a scn_ id
|
|
106
|
+
(a sub-task's too) is recalculated in place, with --force
|
|
107
|
+
scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze]
|
|
108
|
+
try a change to one step against the recording; saves nothing
|
|
109
|
+
scenario edit <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->]
|
|
110
|
+
[--freeze | --unfreeze] [--note "why"] [--force --note "why"] [--revision <n>]
|
|
111
|
+
the same check, and save the change if it passes
|
|
112
|
+
scenario edits <runId|scnId> a scenario's hand edits, newest first
|
|
113
|
+
scenario undo <runId|scnId> [--edit <sedit_id> | --step <n>] [--force --note "why"]
|
|
114
|
+
take back the newest edit (of step n); run it again to go
|
|
115
|
+
one further back; undo an undo's sedit_ id to redo
|
|
116
|
+
scenario editing on|off|status whether this project's bir MCP server offers the tools
|
|
117
|
+
that change a scenario (restart Claude Code after)
|
|
118
|
+
scenario replay <scnId> --prompt "…" [--dry]
|
|
119
|
+
replay --scenario <scnId> --prompt "…" [--dry]
|
|
120
|
+
replay status | on | off | allow <a,b> | allow all
|
|
121
|
+
this project's replay switches, kept for the background recorder
|
|
122
|
+
|
|
123
|
+
investigate [<id>] why the newest turn here (or run_/scn_/sexec_ <id>) did what
|
|
124
|
+
it did, what it cost, what to fix — journal + service
|
|
125
|
+
investigate list [--limit <n>] recent turns in this directory and their verdicts
|
|
126
|
+
investigate executions [--limit <n>] [--user <email|id>]
|
|
127
|
+
the ledger, newest first (--user: admins only)
|
|
128
|
+
|
|
129
|
+
Options:
|
|
130
|
+
--config <path> an explicit { "mcpServers": … } file, for clients with no adapter
|
|
131
|
+
--server <name> restrict install/uninstall to one server (repeatable); also wraps a
|
|
132
|
+
remote server Claude Code signs in itself (OAuth), which is
|
|
133
|
+
otherwise left unwrapped — its calls are recorded by the hooks
|
|
134
|
+
--global invoke the installed package by absolute path (the default when
|
|
135
|
+
bir runs from an installed package: no registry, works offline)
|
|
136
|
+
--local invoke this checkout's bir-proxy (development)
|
|
137
|
+
--npx invoke a pinned npx package instead (~1 s more per server start)
|
|
138
|
+
--mcp-json wrap .mcp.json servers in place and add the scenario server there
|
|
139
|
+
(the old shape; never for a .mcp.json the team commits).
|
|
140
|
+
Default: a local-scope copy in ~/.claude.json, for this project only
|
|
141
|
+
--no-hooks do not touch .claude/settings.local.json (Tier 2 recording only)
|
|
142
|
+
--no-correlation never relax tool schemas; join steps on argument fingerprints
|
|
143
|
+
--replay install/remove the scenario server, enabling calculated replay
|
|
144
|
+
--port <n> install/setup: control-server port to write into the hook URLs
|
|
145
|
+
(default: this project's, else the lowest free one from ${DEFAULT_CONTROL_PORT})
|
|
146
|
+
--json machine-readable output for status / doctor / scenario / investigate
|
|
147
|
+
(scenario check|edit|edits|undo: the service's answer, unchanged)
|
|
148
|
+
--dry replay against recorded outputs only; run no real tools
|
|
149
|
+
--force scenario calc: recalculate in place · scenario edit: save although
|
|
150
|
+
the check refused it (needs --note; only for a recording that was wrong)
|
|
151
|
+
· scenario undo: undo although a later step or the answer would break
|
|
152
|
+
--step <n> scenario show|check|edit|undo: the step, by the stepIndex \`show\` prints
|
|
153
|
+
--input-logic <file|-> scenario check|edit: the new input logic, from a file or stdin (-)
|
|
154
|
+
--output-logic <file|-> scenario check|edit: the new output logic, from a file or stdin (-)
|
|
155
|
+
--freeze scenario edit: mark the step "needs a judgement" (runs hand over before it)
|
|
156
|
+
--unfreeze scenario check|edit: ask to remove that mark; the check decides
|
|
157
|
+
--note <text> scenario edit|undo: why, kept in the history (required with --force)
|
|
158
|
+
--revision <n> scenario edit|undo: only if the plan is still at this chainRevision
|
|
159
|
+
--edit <id> scenario undo: the sedit_ id to undo (default: the newest edit that can be)
|
|
160
|
+
--discard-edits scenario calc --force: recalculate a plan with hand edits, replacing them
|
|
161
|
+
--no-browser login: print the link and code, open nothing (SSH, headless)
|
|
162
|
+
--token <value> login: redeem a one-time setup token from the console (no browser)
|
|
163
|
+
--password login: use the old email/password prompt (deprecated)
|
|
164
|
+
--user <ref> investigate executions: another account, by email or id (admin)
|
|
165
|
+
--limit <n> investigate: how many turns / executions to show
|
|
166
|
+
--auth-url <url> setup: the BaseIn API address (else BIR_AUTH_URL, else the stored one)
|
|
167
|
+
--project <dir> setup: the directory to record (default: this one)
|
|
168
|
+
--no-replay setup: wire recording only; add no scenario server
|
|
169
|
+
--allow-servers <a,b> setup: servers replay may call unattended (default: all wrapped)
|
|
170
|
+
--no-daemon setup: wire everything, start nothing
|
|
171
|
+
--restart up: replace a running recorder
|
|
172
|
+
--here off/on: this project only (the installed project this directory is in)
|
|
173
|
+
--all uninstall: every project on this machine, every recorder, the user scope
|
|
174
|
+
--quick doctor: local checks only, no network call to the service
|
|
175
|
+
--strict doctor: exit 1 on warnings too (not recording, a proxy not registered, …)
|
|
176
|
+
--keep-recorder uninstall: leave this project's recorder running`);
|
|
134
177
|
process.exit(code);
|
|
135
178
|
}
|
|
136
179
|
function parseArgs(argv) {
|
|
@@ -139,6 +182,8 @@ function parseArgs(argv) {
|
|
|
139
182
|
servers: [],
|
|
140
183
|
local: false,
|
|
141
184
|
global: false,
|
|
185
|
+
npx: false,
|
|
186
|
+
mcpJson: false,
|
|
142
187
|
hooks: true,
|
|
143
188
|
noCorrelation: false,
|
|
144
189
|
port: DEFAULT_CONTROL_PORT,
|
|
@@ -156,6 +201,11 @@ function parseArgs(argv) {
|
|
|
156
201
|
freeze: false,
|
|
157
202
|
unfreeze: false,
|
|
158
203
|
discardEdits: false,
|
|
204
|
+
here: false,
|
|
205
|
+
all: false,
|
|
206
|
+
quick: false,
|
|
207
|
+
strict: false,
|
|
208
|
+
keepRecorder: false,
|
|
159
209
|
};
|
|
160
210
|
for (let i = 1; i < argv.length; i += 1) {
|
|
161
211
|
const arg = argv[i];
|
|
@@ -203,6 +253,12 @@ function parseArgs(argv) {
|
|
|
203
253
|
case "--global":
|
|
204
254
|
args.global = true;
|
|
205
255
|
break;
|
|
256
|
+
case "--npx":
|
|
257
|
+
args.npx = true;
|
|
258
|
+
break;
|
|
259
|
+
case "--mcp-json":
|
|
260
|
+
args.mcpJson = true;
|
|
261
|
+
break;
|
|
206
262
|
case "--no-hooks":
|
|
207
263
|
args.hooks = false;
|
|
208
264
|
break;
|
|
@@ -299,6 +355,21 @@ function parseArgs(argv) {
|
|
|
299
355
|
case "--discard-edits":
|
|
300
356
|
args.discardEdits = true;
|
|
301
357
|
break;
|
|
358
|
+
case "--here":
|
|
359
|
+
args.here = true;
|
|
360
|
+
break;
|
|
361
|
+
case "--all":
|
|
362
|
+
args.all = true;
|
|
363
|
+
break;
|
|
364
|
+
case "--quick":
|
|
365
|
+
args.quick = true;
|
|
366
|
+
break;
|
|
367
|
+
case "--strict":
|
|
368
|
+
args.strict = true;
|
|
369
|
+
break;
|
|
370
|
+
case "--keep-recorder":
|
|
371
|
+
args.keepRecorder = true;
|
|
372
|
+
break;
|
|
302
373
|
default:
|
|
303
374
|
if (arg.startsWith("-"))
|
|
304
375
|
usage(2);
|
|
@@ -324,22 +395,26 @@ function localScenarioPath() {
|
|
|
324
395
|
function localHooksPath() {
|
|
325
396
|
return resolvePath(dirname(fileURLToPath(import.meta.url)), "bir-hooks.js");
|
|
326
397
|
}
|
|
327
|
-
/**
|
|
398
|
+
/**
|
|
399
|
+
* True when this `bir` runs from npx's cache (`…/_npx/<hash>/node_modules/…`):
|
|
400
|
+
* installed-looking, but npm cleans that cache whenever it likes, so an
|
|
401
|
+
* absolute path into it is a path that will one day be gone.
|
|
402
|
+
*/
|
|
403
|
+
function runsFromNpxCache() {
|
|
404
|
+
return /[\\/]_npx[\\/]/.test(localProxyPath());
|
|
405
|
+
}
|
|
406
|
+
/** True when this `bir` runs from an installed package rather than a checkout (or the npx cache). */
|
|
328
407
|
function runsFromInstalledPackage() {
|
|
329
|
-
return /[\\/]node_modules[\\/]/.test(localProxyPath());
|
|
408
|
+
return /[\\/]node_modules[\\/]/.test(localProxyPath()) && !runsFromNpxCache();
|
|
330
409
|
}
|
|
331
410
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
411
|
+
* What a flag-less first install (and `bir setup`) generates: an installed
|
|
412
|
+
* package names itself by absolute path — no registry round trip, no ~1 s npx
|
|
413
|
+
* start per server, works offline, and gets the self-starting SessionStart
|
|
414
|
+
* hook. The npx cache and a checkout keep pinned npx, which is what they are.
|
|
335
415
|
*/
|
|
336
|
-
function
|
|
337
|
-
|
|
338
|
-
return realpathSync(process.execPath);
|
|
339
|
-
}
|
|
340
|
-
catch {
|
|
341
|
-
return process.execPath;
|
|
342
|
-
}
|
|
416
|
+
function defaultInvocation() {
|
|
417
|
+
return runsFromInstalledPackage() ? "global" : "npx";
|
|
343
418
|
}
|
|
344
419
|
/**
|
|
345
420
|
* Which invocation the generated entries use — and the guard that makes
|
|
@@ -354,49 +429,211 @@ function hookNodePath() {
|
|
|
354
429
|
* that works today and breaks after a `git clean`. Hence the node_modules test:
|
|
355
430
|
* it is the one cheap signal that says "somebody installed this".
|
|
356
431
|
*/
|
|
357
|
-
function resolveInvocation(args, remembered) {
|
|
358
|
-
if (args.local
|
|
359
|
-
process.stderr.write("[bir] --local and --
|
|
432
|
+
function resolveInvocation(args, remembered, rememberedChosen) {
|
|
433
|
+
if ([args.local, args.global, args.npx].filter(Boolean).length > 1) {
|
|
434
|
+
process.stderr.write("[bir] --local, --global and --npx are mutually exclusive\n");
|
|
360
435
|
process.exit(2);
|
|
361
436
|
}
|
|
362
|
-
|
|
437
|
+
if (args.forceInvocation)
|
|
438
|
+
return args.forceInvocation;
|
|
439
|
+
if (args.npx)
|
|
440
|
+
return "npx";
|
|
441
|
+
if (args.local)
|
|
442
|
+
return "local";
|
|
443
|
+
if (args.global) {
|
|
444
|
+
const proxy = localProxyPath();
|
|
445
|
+
if (!runsFromInstalledPackage()) {
|
|
446
|
+
process.stderr.write(`[bir] --global needs an installed package, but this bir runs from ${dirname(proxy)}` +
|
|
447
|
+
`${runsFromNpxCache() ? " (npx's cache, which npm cleans)" : ""}\n` +
|
|
448
|
+
` install it first (npm install -g ${PACKAGE_NAME}@${VERSION}) and rerun,\n` +
|
|
449
|
+
` or use --local to point at this checkout on purpose.\n`);
|
|
450
|
+
process.exit(2);
|
|
451
|
+
}
|
|
452
|
+
return "global";
|
|
453
|
+
}
|
|
454
|
+
// No flag: what this project was installed with last time. `bir setup`
|
|
363
455
|
// installs with --global, and a later bare `bir install --replay` here must
|
|
364
|
-
// not quietly turn every entry into a pinned-npx one
|
|
365
|
-
|
|
366
|
-
if (!args.global && !args.local && remembered) {
|
|
456
|
+
// not quietly turn every entry into a pinned-npx one.
|
|
457
|
+
if (remembered) {
|
|
367
458
|
if (remembered === "global" && !runsFromInstalledPackage())
|
|
368
|
-
return "local";
|
|
459
|
+
return runsFromNpxCache() ? "npx" : "local";
|
|
460
|
+
// A remembered npx nobody asked for is the old flag-less default; an
|
|
461
|
+
// installed `bir` replaces it with its absolute path (and the hook that
|
|
462
|
+
// starts the recorder), which is what it would pick today.
|
|
463
|
+
if (remembered === "npx" && !rememberedChosen && runsFromInstalledPackage())
|
|
464
|
+
return "global";
|
|
369
465
|
return remembered;
|
|
370
466
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
` install it first (npm install -g ${PACKAGE_NAME}@${VERSION}) and rerun,\n` +
|
|
377
|
-
` or use --local to point at this checkout on purpose.\n`);
|
|
378
|
-
process.exit(2);
|
|
379
|
-
}
|
|
380
|
-
return "global";
|
|
467
|
+
return defaultInvocation();
|
|
468
|
+
}
|
|
469
|
+
/** True when `file` is `~/.claude.json` (or whatever BIR_CLAUDE_JSON names). */
|
|
470
|
+
function isClaudeJson(file, claudeJson) {
|
|
471
|
+
return normalizePath(file) === normalizePath(claudeJson);
|
|
381
472
|
}
|
|
382
|
-
/**
|
|
383
|
-
|
|
473
|
+
/**
|
|
474
|
+
* Record a file's pre-install text once, so uninstall can restore it exactly —
|
|
475
|
+
* and notice when that snapshot has gone stale.
|
|
476
|
+
*
|
|
477
|
+
* The snapshot is taken at the FIRST install only. If somebody else changed
|
|
478
|
+
* the file after our last write (a server added by hand, Claude Code's own
|
|
479
|
+
* edits), a later install writes over their version, the file hashes to our
|
|
480
|
+
* write again, and a byte-for-byte restore would bring back the first-install
|
|
481
|
+
* text — deleting their change. So a changed file marks the snapshot stale, and
|
|
482
|
+
* uninstall repairs entries instead. `~/.claude.json` is never snapshotted: it
|
|
483
|
+
* is Claude Code's, rewritten constantly, and holds every project's settings;
|
|
484
|
+
* a copy of it in installed.json was also a copy of every project's secrets.
|
|
485
|
+
* Neither is a `--config` file (`entryOnly`), which is usually a client's one
|
|
486
|
+
* global config, with every server's credentials in it. And a `.mcp.json`
|
|
487
|
+
* (the `--mcp-json` shape) is kept without its env and header VALUES
|
|
488
|
+
* (generate.ts, protectBackupText): uninstall reads them back from the file's
|
|
489
|
+
* own entries, and restores it byte-for-byte only when that gives the original
|
|
490
|
+
* exactly. A backup an older runner kept verbatim is converted the same way
|
|
491
|
+
* the next time the file is touched.
|
|
492
|
+
*/
|
|
493
|
+
function backupFile(sidecar, file, claudeJson, entryOnly = false) {
|
|
384
494
|
sidecar.files ??= {};
|
|
385
|
-
if (
|
|
495
|
+
if (entryOnly || isClaudeJson(file, claudeJson)) {
|
|
496
|
+
delete sidecar.files[file];
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const existing = sidecar.files[file];
|
|
500
|
+
if (existing) {
|
|
501
|
+
if (!existing.stale && existing.writtenSha) {
|
|
502
|
+
const current = readTextOrNull(file);
|
|
503
|
+
if (current === null || sha256(current) !== existing.writtenSha) {
|
|
504
|
+
sidecar.files[file] = { originalText: null, writtenSha: existing.writtenSha, stale: true };
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (existing.originalText !== null && !existing.stale) {
|
|
509
|
+
const kept = protectBackupText(existing.originalText);
|
|
510
|
+
if (kept.originalText === null) {
|
|
511
|
+
sidecar.files[file] = { ...kept, writtenSha: existing.writtenSha };
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const text = readTextOrNull(file);
|
|
517
|
+
if (text !== null && holdsRunnerEntries(file, text)) {
|
|
518
|
+
// Not an original: an install that stopped halfway, an older runner, or a
|
|
519
|
+
// teammate's commit already put entries of ours here. Restoring this text
|
|
520
|
+
// "byte-for-byte" would put the runner back and report it gone, so the
|
|
521
|
+
// file is repaired entry by entry, always.
|
|
522
|
+
sidecar.files[file] = { originalText: null, writtenSha: "", stale: true };
|
|
386
523
|
return;
|
|
387
|
-
|
|
524
|
+
}
|
|
525
|
+
sidecar.files[file] = text === null ? { originalText: null, writtenSha: "" } : protectBackupText(text);
|
|
388
526
|
}
|
|
389
|
-
|
|
527
|
+
/** True when a config file's text already carries a proxy, the scenario server or one of our hooks. */
|
|
528
|
+
function holdsRunnerEntries(file, text) {
|
|
529
|
+
return /\bbir-(proxy|scenario|hooks)\b/.test(text) || hasBirHooks(file);
|
|
530
|
+
}
|
|
531
|
+
function noteWritten(sidecar, file, text, claudeJson, entryOnly = false) {
|
|
532
|
+
if (entryOnly || isClaudeJson(file, claudeJson))
|
|
533
|
+
return;
|
|
390
534
|
sidecar.files ??= {};
|
|
391
535
|
const backup = sidecar.files[file] ?? { originalText: null, writtenSha: "" };
|
|
392
536
|
backup.writtenSha = sha256(text);
|
|
393
537
|
sidecar.files[file] = backup;
|
|
394
538
|
}
|
|
539
|
+
/** The server name a sidecar key names, given the record's own cwd. */
|
|
540
|
+
const recordName = recordServerName;
|
|
541
|
+
/**
|
|
542
|
+
* Store `record` for `name`, replacing `found` (its earlier record, under
|
|
543
|
+
* whichever key and cwd spelling that had). A record whose stash is redacted
|
|
544
|
+
* goes under the key an older runner never reads (generate.ts,
|
|
545
|
+
* REDACTED_KEY_PREFIX): that runner would write the blanked values back.
|
|
546
|
+
*/
|
|
547
|
+
function putRecord(sidecar, cwd, name, found, record) {
|
|
548
|
+
if (found)
|
|
549
|
+
delete sidecar.servers[found.key];
|
|
550
|
+
sidecar.servers[recordKey(cwd, name, record.secretsRedacted === true)] = record;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* This project's record for `name`: by the exact key, else by any key whose
|
|
554
|
+
* cwd is the same directory spelled differently (a lowercase drive letter).
|
|
555
|
+
*/
|
|
556
|
+
function findRecord(sidecar, cwd, name) {
|
|
557
|
+
const exact = sidecarKey(cwd, name);
|
|
558
|
+
if (sidecar.servers[exact])
|
|
559
|
+
return { key: exact, record: sidecar.servers[exact] };
|
|
560
|
+
const want = normalizePath(cwd);
|
|
561
|
+
for (const [key, record] of Object.entries(sidecar.servers)) {
|
|
562
|
+
if (normalizePath(record.cwd) === want && recordName(key, record) === name)
|
|
563
|
+
return { key, record };
|
|
564
|
+
}
|
|
565
|
+
return undefined;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The original entry a record stashed, with its header and env values read
|
|
569
|
+
* back from the wrapped entry (the sidecar keeps names only). Undefined when
|
|
570
|
+
* those values cannot be recovered — then the entry is left alone rather than
|
|
571
|
+
* restored without its credentials.
|
|
572
|
+
*/
|
|
573
|
+
function recordedOriginal(record, wrapped) {
|
|
574
|
+
if (!record.original)
|
|
575
|
+
return undefined;
|
|
576
|
+
if (!record.secretsRedacted)
|
|
577
|
+
return record.original;
|
|
578
|
+
return restoreSecrets(record.original, wrapped);
|
|
579
|
+
}
|
|
580
|
+
/** Why an existing wrapped entry is being written again, for the log line. */
|
|
581
|
+
function rewrapReason(current, next) {
|
|
582
|
+
if (!current || isRemote(current) || !isWrapped(current))
|
|
583
|
+
return undefined;
|
|
584
|
+
const was = current.env?.BIR_CONTROL_URL;
|
|
585
|
+
const now = next.env?.BIR_CONTROL_URL;
|
|
586
|
+
if (was !== now)
|
|
587
|
+
return `its control URL was ${was ?? "unset"}, now ${now ?? "unset"}`;
|
|
588
|
+
const argv = current.args ?? [];
|
|
589
|
+
if (argv.includes("--header") && next.env?.[UPSTREAM_HEADERS_ENV] !== undefined) {
|
|
590
|
+
// An older runner passed a remote server's headers as `--header K:V`,
|
|
591
|
+
// readable by any local user through `ps`; they travel in the env now.
|
|
592
|
+
return "its headers move off the command line, into its env";
|
|
593
|
+
}
|
|
594
|
+
if (current.command !== next.command || argv.join("\u0000") !== next.args.slice(0, argv.length).join("\u0000")) {
|
|
595
|
+
const at = argv.findIndex((a) => /bir-proxy/.test(a));
|
|
596
|
+
return `its runner moved (was ${`${current.command} ${at >= 0 ? argv.slice(0, at + 1).join(" ") : argv[0] ?? ""}`.trim()})`;
|
|
597
|
+
}
|
|
598
|
+
return "its configuration changed";
|
|
599
|
+
}
|
|
600
|
+
function sameEntry(a, b) {
|
|
601
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Every `projects` key that holds `name` for this directory: the keys Claude
|
|
605
|
+
* Code reads, then legacy spellings of the directory itself (where an older
|
|
606
|
+
* install may have put an entry).
|
|
607
|
+
*/
|
|
608
|
+
function localHolders(claude, cwd, name) {
|
|
609
|
+
const projects = claude.projects ?? {};
|
|
610
|
+
const want = [normalizePath(claudeProjectKey(cwd)), normalizePath(cwd)];
|
|
611
|
+
return Object.keys(projects).filter((key) => want.includes(normalizePath(key)) && projects[key]?.mcpServers?.[name] !== undefined);
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* True when the servers this directory would use (or the `--config` file)
|
|
615
|
+
* include our scenario server — the delivery tool a direct replay needs.
|
|
616
|
+
* An unreadable configuration answers false: replay then stays off.
|
|
617
|
+
*/
|
|
618
|
+
function scenarioServerHere(args, cwd) {
|
|
619
|
+
try {
|
|
620
|
+
const servers = args.configPath
|
|
621
|
+
? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({ name, config }))
|
|
622
|
+
: resolveServers(cwd);
|
|
623
|
+
return servers.some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config));
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
395
629
|
// ── install ────────────────────────────────────────────────────────────────
|
|
396
630
|
async function install(args) {
|
|
397
631
|
const cwd = process.cwd();
|
|
398
632
|
const sidecar = readSidecar();
|
|
399
|
-
const
|
|
633
|
+
const before = projectRecord(sidecar, cwd);
|
|
634
|
+
const invocation = resolveInvocation(args, before?.invocation, before?.invocationChosen);
|
|
635
|
+
const flagged = args.local || args.global || args.npx;
|
|
636
|
+
const invocationChosen = flagged || (before?.invocationChosen === true && before.invocation === invocation);
|
|
400
637
|
sidecar.token ??= randomBytes(32).toString("hex");
|
|
401
638
|
// One port per project (generate.ts): an explicit --port is honoured, else
|
|
402
639
|
// the one this directory already has, else the lowest free one no other has.
|
|
@@ -404,336 +641,1952 @@ async function install(args) {
|
|
|
404
641
|
sidecar.controlPort = port;
|
|
405
642
|
// One token per project too: a token in this project's settings file opens
|
|
406
643
|
// this project's recorder, not every recorder on the machine.
|
|
407
|
-
const token =
|
|
408
|
-
setProjectRecord(sidecar, cwd, { port, token, invocation });
|
|
644
|
+
const token = before?.token ?? randomBytes(32).toString("hex");
|
|
645
|
+
setProjectRecord(sidecar, cwd, { port, token, invocation, invocationChosen: invocationChosen || undefined });
|
|
409
646
|
const controlUrl = `http://127.0.0.1:${port}`;
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
647
|
+
const paths = claudeCodePaths(cwd);
|
|
648
|
+
const claudeJson = paths.claudeJson;
|
|
649
|
+
// The old shape, on request: wrap `.mcp.json` servers in place and put the
|
|
650
|
+
// scenario server there too. Only for a `.mcp.json` nobody commits.
|
|
651
|
+
const mcpJsonMode = args.mcpJson || process.env.BIR_WRAP_MCP_JSON === "1";
|
|
652
|
+
// A leftover snapshot of ~/.claude.json from an older install: never used
|
|
653
|
+
// again, and a full copy of every project's settings. Gone.
|
|
654
|
+
for (const file of Object.keys(sidecar.files ?? {})) {
|
|
655
|
+
if (isClaudeJson(file, claudeJson))
|
|
656
|
+
delete sidecar.files?.[file];
|
|
657
|
+
}
|
|
658
|
+
// REFUSE BEFORE WRITING ANYTHING. A config with comments or a trailing comma
|
|
659
|
+
// is not JSON; rewriting it would drop the comments (or, worse, the file).
|
|
660
|
+
// Checking every file up front means a refusal leaves nothing half-done.
|
|
661
|
+
try {
|
|
662
|
+
if (args.configPath)
|
|
663
|
+
assertEditableJson(args.configPath);
|
|
664
|
+
else {
|
|
665
|
+
assertEditableJson(claudeJson);
|
|
666
|
+
assertEditableJson(paths.mcpJson);
|
|
667
|
+
if (args.hooks)
|
|
668
|
+
assertEditableJson(paths.settingsLocal);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
process.stderr.write(`[bir] ${errText(err)}\n` +
|
|
673
|
+
"[bir] refusing to rewrite it: fix or remove the comments/trailing commas, then run this again. Nothing was changed.\n");
|
|
674
|
+
return 1;
|
|
675
|
+
}
|
|
676
|
+
// An uninstall that found this project's recorder still serving an open
|
|
677
|
+
// session left the project switched off (see retireRecorder). Installing
|
|
678
|
+
// again is the end of that; a switch the person set with `bir off` is theirs.
|
|
679
|
+
if (clearUninstallMark(cwd))
|
|
680
|
+
out(" ~ the runner is on again here (an earlier uninstall had switched it off)");
|
|
681
|
+
try {
|
|
682
|
+
return await installWriting(args, cwd, sidecar, { before, invocation, port, token, controlUrl, paths, mcpJsonMode });
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
// WRITE-AHEAD, the cheap way: whatever this run already changed is in
|
|
686
|
+
// `sidecar` — the originals it stashed, the files it snapshotted. Losing
|
|
687
|
+
// that to an EBUSY or a Ctrl-C halfway through left entries wrapped with
|
|
688
|
+
// no record, which `bir uninstall` then reported as nothing to undo.
|
|
689
|
+
try {
|
|
690
|
+
writeSidecar(sidecar);
|
|
425
691
|
}
|
|
692
|
+
catch {
|
|
693
|
+
/* the original error is the one to report */
|
|
694
|
+
}
|
|
695
|
+
throw err;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/** Everything `install` writes, after every file it will touch was checked. */
|
|
699
|
+
async function installWriting(args, cwd, sidecar, plan) {
|
|
700
|
+
const { before, invocation, port, token, controlUrl, paths, mcpJsonMode } = plan;
|
|
701
|
+
const claudeJson = paths.claudeJson;
|
|
702
|
+
const nodeFlags = nodeFlagsToCarry();
|
|
703
|
+
const nodeChoice = invocation === "npx" ? undefined : chooseNodePath();
|
|
704
|
+
const gen = {
|
|
705
|
+
controlUrl: args.hooks ? controlUrl : undefined,
|
|
706
|
+
noCorrelation: args.noCorrelation,
|
|
707
|
+
invocation,
|
|
708
|
+
proxyPath: invocation === "npx" ? undefined : localProxyPath(),
|
|
709
|
+
version: VERSION,
|
|
710
|
+
nodeFlags,
|
|
711
|
+
node: nodeChoice?.path,
|
|
712
|
+
};
|
|
713
|
+
const named = (name) => args.servers.includes(name);
|
|
714
|
+
let wrapped = 0;
|
|
715
|
+
const done = args.configPath
|
|
716
|
+
? installExplicit(args, cwd, sidecar, gen, named, claudeJson)
|
|
717
|
+
: installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode);
|
|
718
|
+
if (done.nothing)
|
|
426
719
|
return 0;
|
|
720
|
+
wrapped = done.wrapped;
|
|
721
|
+
// The scenario server (docs/calculatedReplay.md §6.3). Opt-in, and separate
|
|
722
|
+
// from the rest of install, because it is the one thing here the model can see:
|
|
723
|
+
// it adds `mcp__bir__run_scenario` to the tool list of every session in this
|
|
724
|
+
// project. That is a real cost, and it buys the delivery channel a fully
|
|
725
|
+
// wrapped scenario's results come back through.
|
|
726
|
+
installScenarioServer(args, cwd, sidecar, gen, paths, mcpJsonMode, done.theirBir, done.sharedKey);
|
|
727
|
+
if (!args.configPath) {
|
|
728
|
+
// An older install wrapped `.mcp.json` in place; the steps above put every
|
|
729
|
+
// entry back. Put the bytes back too, so git shows the team's file clean.
|
|
730
|
+
if (!mcpJsonMode)
|
|
731
|
+
restoreMcpJsonBytes(sidecar, paths);
|
|
732
|
+
// Which ~/.claude.json this install wrote to: CLAUDE_CONFIG_DIR moves it,
|
|
733
|
+
// and an uninstall from another environment must still find it.
|
|
734
|
+
const files = projectRecord(sidecar, cwd)?.claudeJsonFiles ?? [];
|
|
735
|
+
if (!files.some((f) => isClaudeJson(f, claudeJson))) {
|
|
736
|
+
setProjectRecord(sidecar, cwd, { port, claudeJsonFiles: [...files, claudeJson] });
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// WHETHER REPLAY IS ON HERE — the switch the recorder reads at start
|
|
740
|
+
// (generate.ts, settleReplaySwitch). It used to be on in every installed
|
|
741
|
+
// project: `--no-replay` printed "not installed" while matched prompts were
|
|
742
|
+
// replayed anyway. Not asked for, it is off now; asked for, on — except
|
|
743
|
+
// where the person turned it off themselves.
|
|
744
|
+
const intent = args.replay ? "wanted" : args.noReplay ? "refused" : "unasked";
|
|
745
|
+
const replaySwitch = settleReplaySwitch(sidecar, cwd, intent, intent === "unasked" && scenarioServerHere(args, cwd));
|
|
746
|
+
if (replaySwitch === "on")
|
|
747
|
+
out(" ~ calculated replay is on again here (an earlier install had turned it off)");
|
|
748
|
+
else if (replaySwitch === "off") {
|
|
749
|
+
out(intent === "refused"
|
|
750
|
+
? " - calculated replay is off here (--no-replay); `bir replay on` turns it on"
|
|
751
|
+
: " - calculated replay is off here: it was not asked for (`bir install --replay` adds it)");
|
|
752
|
+
}
|
|
753
|
+
// SessionStart starts the recorder itself when this directory has none
|
|
754
|
+
// (control/ensure-hook.ts). Every invocation gets it — an npx one through
|
|
755
|
+
// npx, once per session — because without it nothing starts the recorder,
|
|
756
|
+
// and every other hook fails against a port nobody listens on. The npx form
|
|
757
|
+
// asks for a smaller budget of its own: npx resolves the package before the
|
|
758
|
+
// hook's clock starts, and the two together must stay inside the host's
|
|
759
|
+
// ENSURE_TIMEOUT_S, or the session start shows the hook as timed out.
|
|
760
|
+
const ensure = invocation === "npx"
|
|
761
|
+
? (() => {
|
|
762
|
+
const via = npxHookInvocation("bir-hooks", VERSION);
|
|
763
|
+
return { command: via.command, args: [...via.args, ENSURE_ARG, ...NPX_ENSURE_BUDGET_ARGS] };
|
|
764
|
+
})()
|
|
765
|
+
: { node: nodeChoice?.path ?? process.execPath, hooksScript: localHooksPath(), nodeFlags };
|
|
766
|
+
if (args.hooks && !args.configPath) {
|
|
767
|
+
// Hooks live in `.claude/settings.local.json` — the person's own file, the
|
|
768
|
+
// one Claude Code keeps out of git — not in the `settings.json` a team
|
|
769
|
+
// commits. They carry a bearer token and an absolute path to this machine's
|
|
770
|
+
// node; a teammate's clone wants neither. An older install put them in the
|
|
771
|
+
// shared file; take them out of there on the way.
|
|
772
|
+
if (hasBirHooks(paths.settings)) {
|
|
773
|
+
try {
|
|
774
|
+
backupFile(sidecar, paths.settings, claudeJson);
|
|
775
|
+
const cleaned = uninstallHooks(paths.settings);
|
|
776
|
+
if (cleaned !== undefined)
|
|
777
|
+
noteWritten(sidecar, paths.settings, cleaned, claudeJson);
|
|
778
|
+
out(` - hooks removed from ${paths.settings} (they live in settings.local.json now)`);
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
out(` ! ${paths.settings} still holds hooks of ours and could not be cleaned: ${errText(err)}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
backupFile(sidecar, paths.settingsLocal, claudeJson);
|
|
785
|
+
let text = installHooks(paths.settingsLocal, buildHooksBlock(controlUrl, token, { ensure }));
|
|
786
|
+
if (args.replay && mcpJsonMode) {
|
|
787
|
+
// Pre-approve our own scenario server. Claude Code asks before it uses a
|
|
788
|
+
// server from `.mcp.json`, and a "No" there leaves a direct replay with
|
|
789
|
+
// nowhere to deliver its results — and nothing to say so.
|
|
790
|
+
text = enableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]);
|
|
791
|
+
}
|
|
792
|
+
else if (!mcpJsonMode) {
|
|
793
|
+
// The scenario server is a local-scope server now, which Claude Code
|
|
794
|
+
// never asks about; an older install's pre-approval has nothing to approve.
|
|
795
|
+
text = disableMcpjsonServers(paths.settingsLocal, [SCENARIO_SERVER_KEY]) ?? text;
|
|
796
|
+
}
|
|
797
|
+
noteWritten(sidecar, paths.settingsLocal, text, claudeJson);
|
|
798
|
+
sidecar.hookFiles = [...new Set([...(sidecar.hookFiles ?? []), paths.settingsLocal])];
|
|
799
|
+
out(` + hooks → ${paths.settingsLocal} (control server on ${controlUrl}; the recorder starts itself with each session${invocation === "npx" ? ", through npx" : ""})`);
|
|
800
|
+
const exclude = ensureGitExcludeLine(cwd);
|
|
801
|
+
if (exclude) {
|
|
802
|
+
out(` + ${LOCAL_SETTINGS_EXCLUDE} is listed in ${exclude.path}`);
|
|
803
|
+
// Remember whether the line is ours, so uninstall removes only that. An
|
|
804
|
+
// install older than this field is not asked (undefined: removed, as then).
|
|
805
|
+
const excludeAdded = exclude.added ? true : before && before.excludeAdded === undefined ? undefined : (before?.excludeAdded ?? false);
|
|
806
|
+
setProjectRecord(sidecar, cwd, { port, excludeAdded });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
if (nodeChoice?.warning)
|
|
810
|
+
out(` ! ${nodeChoice.warning}`);
|
|
811
|
+
const proxyHint = envProxyHint();
|
|
812
|
+
if (proxyHint)
|
|
813
|
+
out(` ! ${proxyHint}`);
|
|
814
|
+
// THE UPGRADE NOTE ABOUT APPROVAL, said once per project. 0.2.12's recorder
|
|
815
|
+
// answered every wrapped MCP call's PreToolUse with "allow"; this one leaves
|
|
816
|
+
// the decision to Claude Code (no silent auto-approval). Sessions that
|
|
817
|
+
// relied on it — headless `claude -p` in a workspace whose permissions.allow
|
|
818
|
+
// is not read — start getting those calls denied, and nothing else says why.
|
|
819
|
+
const approvalNote = args.hooks && !args.configPath && before !== undefined && before.approvalNoteShown !== true && wrappedHere(cwd);
|
|
820
|
+
if (args.hooks && !args.configPath && projectRecord(sidecar, cwd)?.approvalNoteShown !== true) {
|
|
821
|
+
setProjectRecord(sidecar, cwd, { port, approvalNoteShown: true });
|
|
822
|
+
}
|
|
823
|
+
writeSidecar(sidecar);
|
|
824
|
+
out();
|
|
825
|
+
out(`Wrapped ${wrapped} server${wrapped === 1 ? "" : "s"}.`);
|
|
826
|
+
if (approvalNote) {
|
|
827
|
+
out("Note: the runner no longer approves wrapped MCP tool calls itself (0.2.12 answered every one \"allow\"):");
|
|
828
|
+
out(" Claude Code asks about them as it would without the runner. For headless runs (`claude -p`), allow");
|
|
829
|
+
out(" them in permissions.allow or with --allowedTools, or opt back in with BIR_CORRELATION_DECISION=allow");
|
|
830
|
+
out(" in the recorder's environment (then `bir up --restart`).");
|
|
831
|
+
}
|
|
832
|
+
if (args.hooks && !args.configPath) {
|
|
833
|
+
out("Next: start your session here as usual. The recorder starts itself with it;");
|
|
834
|
+
out("`bir up` starts it now, `bir off --here` takes it out of the way, `bir doctor` checks it.");
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
out("Recording will be standalone (Tier 2): MCP calls only, no prompt or final answer.");
|
|
427
838
|
}
|
|
839
|
+
const off = offSource(cwd);
|
|
840
|
+
if (off) {
|
|
841
|
+
out(`Note: the runner is OFF ${describeOff(off, cwd)}, so nothing is recorded or replayed until \`bir on\`.`);
|
|
842
|
+
}
|
|
843
|
+
if (args.replay && projectRecord(sidecar, cwd)?.replay?.enabled === false) {
|
|
844
|
+
out();
|
|
845
|
+
out("Calculated replay is INSTALLED but OFF here (`bir replay off`); `bir replay on` turns it on.");
|
|
846
|
+
}
|
|
847
|
+
else if (args.replay) {
|
|
848
|
+
out();
|
|
849
|
+
out("Calculated replay is INSTALLED and ON. `bir replay off` turns it off here, and");
|
|
850
|
+
out("`bir replay allow <servers>` narrows what it may call unattended — read");
|
|
851
|
+
out("docs/calculatedReplayGuide.md §5.1 first: a replayed step is auto-approved,");
|
|
852
|
+
out("and a directly executed one never reaches the permission system at all.");
|
|
853
|
+
}
|
|
854
|
+
return 0;
|
|
855
|
+
}
|
|
856
|
+
/** `--config <file>`: the file is the whole configuration, wrapped in place. */
|
|
857
|
+
function installExplicit(args, cwd, sidecar, gen, named, claudeJson) {
|
|
858
|
+
const file = args.configPath;
|
|
859
|
+
const all = readGenericServers(file);
|
|
860
|
+
const wanted = Object.entries(all).filter(([name]) => args.servers.length === 0 || named(name));
|
|
428
861
|
if (wanted.length === 0) {
|
|
862
|
+
out("No MCP servers found for this directory. Nothing to wrap.");
|
|
863
|
+
return { wrapped: 0, nothing: true };
|
|
864
|
+
}
|
|
865
|
+
let wrapped = 0;
|
|
866
|
+
let theirBir;
|
|
867
|
+
for (const [name, config] of wanted) {
|
|
868
|
+
if (name === SCENARIO_SERVER_KEY) {
|
|
869
|
+
if (isScenarioServer(config))
|
|
870
|
+
out(` = ${name} — the scenario server (calculated replay)`);
|
|
871
|
+
else {
|
|
872
|
+
theirBir = file;
|
|
873
|
+
out(` ! ${name} — reserved by BaseInstRunner; not wrapped. Rename it to record it`);
|
|
874
|
+
}
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
const found = findRecord(sidecar, cwd, name);
|
|
878
|
+
let source = config;
|
|
879
|
+
if (isWrapped(config)) {
|
|
880
|
+
source = (found ? recordedOriginal(found.record, config) : undefined) ?? unwrapEntry(config);
|
|
881
|
+
if (!source) {
|
|
882
|
+
out(` = ${name} — already wrapped`);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const blocked = isRemote(source) ? remoteWrapBlocker(source) : undefined;
|
|
887
|
+
const optedIn = named(name) || found?.record.optedIn === true;
|
|
888
|
+
if (blocked && !optedIn) {
|
|
889
|
+
if (isWrapped(config)) {
|
|
890
|
+
backupFile(sidecar, file, claudeJson, true);
|
|
891
|
+
noteWritten(sidecar, file, setGenericServerEntry(file, name, source), claudeJson, true);
|
|
892
|
+
if (found)
|
|
893
|
+
delete sidecar.servers[found.key];
|
|
894
|
+
out(` ~ ${name} — un-wrapped: ${blocked}`);
|
|
895
|
+
}
|
|
896
|
+
else {
|
|
897
|
+
out(` - ${name} — left unwrapped: ${blocked} (\`--server ${name}\` wraps it anyway)`);
|
|
898
|
+
}
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (blocked && named(name))
|
|
902
|
+
out(` ! ${name} — wrapping it because --server names it, although ${blocked}`);
|
|
903
|
+
const proxied = wrapEntry(source, { ...gen, serverName: name });
|
|
904
|
+
if (sameEntry(config, proxied)) {
|
|
905
|
+
out(` = ${name} — already wrapped`);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
const reason = rewrapReason(config, proxied);
|
|
909
|
+
backupFile(sidecar, file, claudeJson, true);
|
|
910
|
+
noteWritten(sidecar, file, setGenericServerEntry(file, name, proxied), claudeJson, true);
|
|
911
|
+
const kept = redactSecrets(found?.record.original ?? source);
|
|
912
|
+
putRecord(sidecar, cwd, name, found, {
|
|
913
|
+
original: kept.config,
|
|
914
|
+
scope: "explicit",
|
|
915
|
+
file,
|
|
916
|
+
cwd,
|
|
917
|
+
installedAt: Date.now(),
|
|
918
|
+
...(kept.redacted ? { secretsRedacted: true } : {}),
|
|
919
|
+
...(blocked ? { optedIn: true } : {}),
|
|
920
|
+
});
|
|
921
|
+
const kind = isRemote(source) ? `${source.type} ${source.url}` : source.command;
|
|
922
|
+
out(reason ? ` ~ ${name} — re-wrapping: ${reason}` : ` + ${name} → bir-proxy (upstream: ${kind})`);
|
|
923
|
+
wrapped += 1;
|
|
924
|
+
}
|
|
925
|
+
return { wrapped, theirBir };
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Claude Code: every server this directory would use, wrapped as a LOCAL-scope
|
|
929
|
+
* entry of the same name.
|
|
930
|
+
*
|
|
931
|
+
* Local scope wins over project and user scope for this project only, and it
|
|
932
|
+
* lives in `~/.claude.json`, which nobody commits. So:
|
|
933
|
+
*
|
|
934
|
+
* - a `.mcp.json` server is overridden, not rewritten — the team's file never
|
|
935
|
+
* carries this machine's node path, runner path or control port, and a
|
|
936
|
+
* teammate's clone keeps working;
|
|
937
|
+
* - a user-scope server is overridden for this project only — it used to be
|
|
938
|
+
* rewritten in place, which routed it through the proxy in EVERY project,
|
|
939
|
+
* including ones never set up;
|
|
940
|
+
* - a local-scope server is wrapped where it is.
|
|
941
|
+
*
|
|
942
|
+
* Entries an older install wrapped in `.mcp.json` or the user scope are put
|
|
943
|
+
* back first (from the stash, else rebuilt from the wrapped arguments).
|
|
944
|
+
*/
|
|
945
|
+
function installClaudeCode(args, cwd, sidecar, gen, named, paths, mcpJsonMode) {
|
|
946
|
+
const claudeJson = paths.claudeJson;
|
|
947
|
+
const readClaude = () => parseJsonFile(claudeJson) ?? {};
|
|
948
|
+
let claude = readClaude();
|
|
949
|
+
const projectServers = () => (parseJsonFile(paths.mcpJson)?.mcpServers ?? {});
|
|
950
|
+
const overrideKeys = projectKeysFor(claude.projects, cwd);
|
|
951
|
+
const names = [
|
|
952
|
+
...new Set([
|
|
953
|
+
...Object.keys(projectScope(claude.projects, cwd)),
|
|
954
|
+
...Object.keys(projectServers()),
|
|
955
|
+
...Object.keys(claude.mcpServers ?? {}),
|
|
956
|
+
]),
|
|
957
|
+
].filter((name) => args.servers.length === 0 || named(name));
|
|
958
|
+
const servers = names.filter((n) => n !== SCENARIO_SERVER_KEY);
|
|
959
|
+
// Named as Claude Code finds it: CLAUDE_CONFIG_DIR moves it out of the home directory.
|
|
960
|
+
const claudeLabel = claudeConfigHome() && !process.env.BIR_CLAUDE_JSON ? claudeJson : "~/.claude.json";
|
|
961
|
+
if (servers.length === 0 && !args.hooks) {
|
|
962
|
+
out("No MCP servers found for this directory. Nothing to wrap.");
|
|
963
|
+
out(` Looked in: ${claudeLabel} (user + local scopes) and ./.mcp.json (project scope).`);
|
|
964
|
+
return { wrapped: 0, nothing: true };
|
|
965
|
+
}
|
|
966
|
+
if (servers.length === 0) {
|
|
429
967
|
// Hooks alone are a recorder: built-in tools, the prompt and the answer are
|
|
430
968
|
// all theirs, and a scenario over built-in steps steers a session the same
|
|
431
969
|
// way. Stopping here used to leave such a project with nothing at all.
|
|
432
|
-
out(
|
|
970
|
+
out(`No MCP servers to wrap here (looked in ${claudeLabel} and ./.mcp.json).`);
|
|
433
971
|
out(" Hooks are still wired: built-in tools and prompts are recorded.");
|
|
434
972
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
973
|
+
// A SHARED KEY. Claude Code keeps the local scope per repository — the
|
|
974
|
+
// canonical git root — so for a subdirectory or a linked worktree, a local
|
|
975
|
+
// copy written here would also apply to the root checkout and every other
|
|
976
|
+
// worktree: directories nobody set up, whose sessions would then be routed
|
|
977
|
+
// through this project's proxies and recorder. Those are left unwrapped (the
|
|
978
|
+
// hooks still record their calls) unless the old `.mcp.json` shape is asked for.
|
|
979
|
+
const root = claudeProjectRoot(cwd);
|
|
980
|
+
const sharedKey = !mcpJsonMode && normalizePath(root) !== normalizePath(cwd);
|
|
981
|
+
if (sharedKey && servers.length > 0) {
|
|
982
|
+
out(` ! not wrapped here: Claude Code keeps local-scope servers per repository (${root}), so a ` +
|
|
983
|
+
"wrapped copy would also apply there and to its other worktrees. Their calls are still recorded " +
|
|
984
|
+
`by the hooks; \`bir setup\` in ${root} wraps them for the repository`);
|
|
985
|
+
}
|
|
986
|
+
// WHAT CLAUDE CODE ITSELF WOULD START WITHOUT ASKING (config/mcp-approval.ts).
|
|
987
|
+
// It asks before it starts a `.mcp.json` server, and never about a
|
|
988
|
+
// local-scope one — so a local copy of a server the person rejected, or was
|
|
989
|
+
// never asked about, would start silently. Only an approved one is copied.
|
|
990
|
+
const approval = mcpjsonApprovals(cwd, claude);
|
|
991
|
+
// An organisation's policy that matches servers by command: wrapping changes
|
|
992
|
+
// the command, so nothing is wrapped under it, `--server` or not.
|
|
993
|
+
const policy = managedMcpPolicyBlocker();
|
|
994
|
+
// Claude Code applies this project's local scope to its linked worktrees too.
|
|
995
|
+
const worktrees = sharedKey || mcpJsonMode ? [] : linkedWorktrees(root);
|
|
996
|
+
/** A worktree's own `.mcp.json` entry for `name`; null when that file is not plain JSON. */
|
|
997
|
+
const worktreeEntry = (dir, name) => {
|
|
998
|
+
try {
|
|
999
|
+
return parseJsonFile(join(dir, ".mcp.json"))?.mcpServers?.[name];
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
return null;
|
|
1003
|
+
}
|
|
455
1004
|
};
|
|
1005
|
+
let wroteLocal = false;
|
|
1006
|
+
let theirBir;
|
|
1007
|
+
const bir = projectScope(claude.projects, cwd)[SCENARIO_SERVER_KEY] ??
|
|
1008
|
+
projectServers()[SCENARIO_SERVER_KEY] ??
|
|
1009
|
+
claude.mcpServers?.[SCENARIO_SERVER_KEY];
|
|
1010
|
+
if (bir && !isScenarioServer(bir)) {
|
|
1011
|
+
// `mcp__bir__run_scenario` is how a direct replay's results reach the
|
|
1012
|
+
// model. Wrapping somebody else's `bir` server would route that call to
|
|
1013
|
+
// their server instead, silently — and shadowing it with ours would hide it.
|
|
1014
|
+
theirBir = "yours";
|
|
1015
|
+
out(` ! ${SCENARIO_SERVER_KEY} — reserved by BaseInstRunner; not wrapped. Rename it to record it`);
|
|
1016
|
+
}
|
|
456
1017
|
let wrapped = 0;
|
|
457
|
-
for (const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1018
|
+
for (const name of servers) {
|
|
1019
|
+
const found = findRecord(sidecar, cwd, name);
|
|
1020
|
+
let P = projectServers()[name];
|
|
1021
|
+
let U = claude.mcpServers?.[name];
|
|
1022
|
+
// 1. MIGRATION: an older install rewrote the shared scopes in place.
|
|
1023
|
+
if (P !== undefined && isWrapped(P) && !mcpJsonMode) {
|
|
1024
|
+
const restored = found?.record.scope === "project" ? recordedOriginal(found.record, P) : undefined;
|
|
1025
|
+
if (restored) {
|
|
1026
|
+
backupFile(sidecar, paths.mcpJson, claudeJson);
|
|
1027
|
+
noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, restored, paths).text, claudeJson);
|
|
1028
|
+
delete sidecar.servers[found.key];
|
|
1029
|
+
out(` ~ ${name} — put back as it was in ${paths.mcpJson}: a file a team commits must not carry this machine's paths`);
|
|
1030
|
+
P = restored;
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
out(` ! ${name} — ${paths.mcpJson} holds an entry wrapped by a runner, not by this install ` +
|
|
1034
|
+
"(another machine's, or an older copy): it is overridden here, but teammates without the runner need it put back");
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (U !== undefined && isWrapped(U)) {
|
|
1038
|
+
const userRecords = Object.entries(sidecar.servers).filter(([key, r]) => r.scope === "user" && recordName(key, r) === name);
|
|
1039
|
+
const mineFirst = [...userRecords].sort((a, b) => Number(normalizePath(b[1].cwd) === normalizePath(cwd)) - Number(normalizePath(a[1].cwd) === normalizePath(cwd)));
|
|
1040
|
+
const restored = mineFirst.length ? recordedOriginal(mineFirst[0][1], U) ?? unwrapEntry(U) : undefined;
|
|
1041
|
+
if (restored) {
|
|
1042
|
+
setServerEntry("user", cwd, name, restored, paths);
|
|
1043
|
+
const others = userRecords.map(([, r]) => r.cwd).filter((c) => normalizePath(c) !== normalizePath(cwd));
|
|
1044
|
+
for (const [key] of userRecords)
|
|
1045
|
+
delete sidecar.servers[key];
|
|
1046
|
+
out(` ~ ${name} — put back as it was in your user scope, which applies to every project; recorded here through a local copy instead`);
|
|
1047
|
+
if (others.length)
|
|
1048
|
+
out(` (${[...new Set(others)].join(", ")} recorded it that way too — run \`bir setup\` there again)`);
|
|
1049
|
+
U = restored;
|
|
1050
|
+
claude = readClaude();
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
out(` ! ${name} — wrapped in your user scope (every project), not by this install; left as it is`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// 2. SOURCE (what to wrap) and TARGET (where the wrapped entry goes).
|
|
1057
|
+
const L = projectScope(claude.projects, cwd)[name];
|
|
1058
|
+
const holders = localHolders(claude, cwd, name);
|
|
1059
|
+
let source;
|
|
1060
|
+
let target;
|
|
1061
|
+
const unwrapped = (c) => c !== undefined && isWrapped(c) ? unwrapEntry(c) : c;
|
|
1062
|
+
if (L !== undefined && isWrapped(L) && found?.record.override) {
|
|
1063
|
+
source = unwrapped(P ?? U);
|
|
1064
|
+
target = { kind: "override", from: P !== undefined ? "project" : "user", keys: [...new Set([...overrideKeys, ...holders])] };
|
|
1065
|
+
if (source === undefined) {
|
|
1066
|
+
// The server it overrode is gone from .mcp.json and the user scope.
|
|
1067
|
+
setLocalEntries(claudeJson, target.keys, name, null, (cur) => cur !== undefined && isWrapped(cur));
|
|
1068
|
+
delete sidecar.servers[found.key];
|
|
1069
|
+
out(` - ${name} — its local copy is removed: the server is no longer configured anywhere else`);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
else if (L !== undefined) {
|
|
1074
|
+
source = isWrapped(L)
|
|
1075
|
+
? (found?.record.scope === "local" ? recordedOriginal(found.record, L) : undefined) ?? unwrapEntry(L)
|
|
1076
|
+
: L;
|
|
1077
|
+
target = { kind: "inplace", keys: holders };
|
|
1078
|
+
}
|
|
1079
|
+
else if (P !== undefined) {
|
|
1080
|
+
source = unwrapped(P);
|
|
1081
|
+
target = mcpJsonMode ? { kind: "mcpjson" } : { kind: "override", from: "project", keys: overrideKeys };
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
source = unwrapped(U);
|
|
1085
|
+
target = { kind: "override", from: "user", keys: overrideKeys };
|
|
1086
|
+
}
|
|
1087
|
+
if (source === undefined) {
|
|
1088
|
+
out(` = ${name} — already wrapped`);
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
const current = target.kind === "mcpjson" ? P : L;
|
|
1092
|
+
if (sharedKey)
|
|
1093
|
+
continue; // said once, above
|
|
1094
|
+
// 3. Reasons to leave a server exactly as Claude Code would run it. Each
|
|
1095
|
+
// one also TAKES BACK a wrap an earlier install made, so re-running
|
|
1096
|
+
// `bir setup` is always the repair.
|
|
1097
|
+
const src = source;
|
|
1098
|
+
const tgt = target;
|
|
1099
|
+
const leaveUnwrapped = (why, after) => {
|
|
1100
|
+
if (current !== undefined && isWrapped(current)) {
|
|
1101
|
+
if (tgt.kind === "override")
|
|
1102
|
+
setLocalEntries(claudeJson, tgt.keys, name, null, (cur) => cur !== undefined && isWrapped(cur));
|
|
1103
|
+
else if (tgt.kind === "inplace")
|
|
1104
|
+
setLocalEntries(claudeJson, tgt.keys, name, src);
|
|
1105
|
+
else {
|
|
1106
|
+
backupFile(sidecar, paths.mcpJson, claudeJson);
|
|
1107
|
+
noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, src, paths).text, claudeJson);
|
|
1108
|
+
}
|
|
1109
|
+
if (found)
|
|
1110
|
+
delete sidecar.servers[found.key];
|
|
1111
|
+
claude = readClaude();
|
|
1112
|
+
out(` ~ ${name} — un-wrapped: ${why}. ${after.charAt(0).toUpperCase()}${after.slice(1)}`);
|
|
461
1113
|
}
|
|
462
1114
|
else {
|
|
463
|
-
out(`
|
|
1115
|
+
out(` - ${name} — left unwrapped: ${why}; ${after}`);
|
|
464
1116
|
}
|
|
1117
|
+
};
|
|
1118
|
+
if (policy) {
|
|
1119
|
+
leaveUnwrapped(policy, "its calls are still recorded by the hooks");
|
|
465
1120
|
continue;
|
|
466
1121
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
//
|
|
471
|
-
|
|
1122
|
+
const optedIn = named(name) || found?.record.optedIn === true;
|
|
1123
|
+
let pendingOptIn = false;
|
|
1124
|
+
if (target.kind === "override" && target.from === "project") {
|
|
1125
|
+
// 3a. Not approved in Claude Code: a local copy would skip its question.
|
|
1126
|
+
const verdict = approval(name);
|
|
1127
|
+
if (verdict.state === "rejected" || (verdict.state === "pending" && !optedIn)) {
|
|
1128
|
+
leaveUnwrapped(verdict.why, verdict.state === "rejected"
|
|
1129
|
+
? "a local copy would start it anyway, so none is made"
|
|
1130
|
+
: `the hooks record its calls once you approve it there; run \`bir setup\` again after approving to wrap it (\`--server ${name}\` wraps it now)`);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
pendingOptIn = verdict.state === "pending";
|
|
1134
|
+
// 3b. A linked worktree whose own `.mcp.json` says something else would
|
|
1135
|
+
// run this checkout's copy instead of its own entry.
|
|
1136
|
+
const differs = worktrees.filter((dir) => {
|
|
1137
|
+
const theirs = worktreeEntry(dir, name);
|
|
1138
|
+
if (theirs === undefined)
|
|
1139
|
+
return false;
|
|
1140
|
+
return theirs === null || !sameServerConfig(isWrapped(theirs) ? unwrapEntry(theirs) : theirs, source);
|
|
1141
|
+
});
|
|
1142
|
+
if (differs.length > 0 && !named(name)) {
|
|
1143
|
+
leaveUnwrapped(`the worktree${differs.length === 1 ? "" : "s"} ${differs.join(", ")} define${differs.length === 1 ? "s" : ""} it differently, ` +
|
|
1144
|
+
"and Claude Code would run this checkout's copy there too", `its calls are still recorded by the hooks (\`--server ${name}\` wraps it anyway)`);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
// 3c. A remote server Claude Code signs in itself is never wrapped by default.
|
|
1149
|
+
const blocked = isRemote(source) ? remoteWrapBlocker(source) : undefined;
|
|
1150
|
+
if (blocked && !optedIn) {
|
|
1151
|
+
// An older install may have wrapped it, which broke it: taken back.
|
|
1152
|
+
leaveUnwrapped(blocked, `its calls are still recorded by the hooks (\`--server ${name}\` wraps it anyway)`);
|
|
472
1153
|
continue;
|
|
473
1154
|
}
|
|
474
|
-
if (
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
1155
|
+
if (blocked && named(name)) {
|
|
1156
|
+
out(` ! ${name} — wrapping it because --server names it, although ${blocked}; if it answers 401, \`bir uninstall --server ${name}\``);
|
|
1157
|
+
}
|
|
1158
|
+
if (pendingOptIn && named(name)) {
|
|
1159
|
+
out(` ! ${name} — wrapping it because --server names it, although Claude Code has not been told to use it: ` +
|
|
1160
|
+
"the local copy starts without asking");
|
|
1161
|
+
}
|
|
1162
|
+
const optIn = blocked || pendingOptIn ? { optedIn: true } : {};
|
|
1163
|
+
// 4. Generate, and write only when it differs — which is what makes a
|
|
1164
|
+
// re-run idempotent and catches every kind of staleness at once: a moved
|
|
1165
|
+
// port, a moved runner or node, changed node flags, a changed upstream.
|
|
1166
|
+
const proxied = wrapEntry(source, { ...gen, serverName: name });
|
|
1167
|
+
const reason = rewrapReason(current, proxied);
|
|
1168
|
+
const kind = isRemote(source) ? `${source.type} ${source.url}` : source.command;
|
|
1169
|
+
if (target.kind === "mcpjson") {
|
|
1170
|
+
if (current !== undefined && sameEntry(current, proxied)) {
|
|
1171
|
+
out(` = ${name} — already wrapped`);
|
|
490
1172
|
continue;
|
|
491
1173
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
:
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
serverName: entry.name,
|
|
505
|
-
controlUrl: args.hooks ? controlUrl : undefined,
|
|
506
|
-
noCorrelation: args.noCorrelation,
|
|
507
|
-
invocation,
|
|
508
|
-
proxyPath: invocation === "npx" ? undefined : localProxyPath(),
|
|
509
|
-
version: VERSION,
|
|
510
|
-
nodeFlags,
|
|
511
|
-
});
|
|
512
|
-
let file;
|
|
513
|
-
let text;
|
|
514
|
-
if (args.configPath) {
|
|
515
|
-
file = args.configPath;
|
|
516
|
-
backupFile(sidecar, file);
|
|
517
|
-
text = setGenericServerEntry(file, entry.name, proxied);
|
|
1174
|
+
backupFile(sidecar, paths.mcpJson, claudeJson);
|
|
1175
|
+
noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, name, proxied, paths).text, claudeJson);
|
|
1176
|
+
const kept = redactSecrets((found?.record.scope === "project" ? found.record.original : undefined) ?? source);
|
|
1177
|
+
putRecord(sidecar, cwd, name, found, {
|
|
1178
|
+
original: kept.config,
|
|
1179
|
+
scope: "project",
|
|
1180
|
+
file: paths.mcpJson,
|
|
1181
|
+
cwd,
|
|
1182
|
+
installedAt: Date.now(),
|
|
1183
|
+
...(kept.redacted ? { secretsRedacted: true } : {}),
|
|
1184
|
+
...optIn,
|
|
1185
|
+
});
|
|
518
1186
|
}
|
|
519
1187
|
else {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1188
|
+
// Never over a user's own entry under another spelling of this project.
|
|
1189
|
+
const guard = target.kind === "override"
|
|
1190
|
+
? (cur) => cur === undefined || isWrapped(cur)
|
|
1191
|
+
: (cur) => cur !== undefined;
|
|
1192
|
+
const { keys } = setLocalEntries(claudeJson, target.keys, name, proxied, guard);
|
|
1193
|
+
claude = readClaude();
|
|
1194
|
+
wroteLocal = true;
|
|
1195
|
+
const allKeys = [...new Set([...(found?.record.keys ?? []), ...keys, ...localHolders(claude, cwd, name)])];
|
|
1196
|
+
if (target.kind === "override") {
|
|
1197
|
+
putRecord(sidecar, cwd, name, found, {
|
|
1198
|
+
original: null,
|
|
1199
|
+
scope: "local",
|
|
1200
|
+
file: claudeJson,
|
|
1201
|
+
cwd,
|
|
1202
|
+
installedAt: Date.now(),
|
|
1203
|
+
override: { source: target.from },
|
|
1204
|
+
// What it copied, so `bir doctor` notices when that entry changes
|
|
1205
|
+
// (config/overrides.ts): the copy outranks it from now on.
|
|
1206
|
+
sourceSha: configSha(source),
|
|
1207
|
+
keys: allKeys,
|
|
1208
|
+
...optIn,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
else {
|
|
1212
|
+
const kept = redactSecrets((found?.record.scope === "local" ? found.record.original : undefined) ?? source);
|
|
1213
|
+
putRecord(sidecar, cwd, name, found, {
|
|
1214
|
+
original: kept.config,
|
|
1215
|
+
scope: "local",
|
|
1216
|
+
file: claudeJson,
|
|
1217
|
+
cwd,
|
|
1218
|
+
installedAt: Date.now(),
|
|
1219
|
+
keys: allKeys,
|
|
1220
|
+
...(kept.redacted ? { secretsRedacted: true } : {}),
|
|
1221
|
+
...optIn,
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
if (keys.length === 0) {
|
|
1225
|
+
out(` = ${name} — already wrapped`);
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
const where = target.kind === "override"
|
|
1230
|
+
? `local scope, over the ${target.from === "project" ? ".mcp.json" : "user-scope"} entry`
|
|
1231
|
+
: target.kind === "mcpjson"
|
|
1232
|
+
? "project scope, in .mcp.json"
|
|
1233
|
+
: "local scope";
|
|
1234
|
+
out(reason ? ` ~ ${name} — re-wrapping: ${reason}` : ` + ${name} → bir-proxy (${where}; upstream: ${kind})`);
|
|
535
1235
|
wrapped += 1;
|
|
536
1236
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
file = args.configPath;
|
|
568
|
-
backupFile(sidecar, file);
|
|
569
|
-
text = setGenericServerEntry(file, SCENARIO_SERVER_KEY, entry);
|
|
570
|
-
}
|
|
571
|
-
else {
|
|
572
|
-
file = fileForScope(scope, paths);
|
|
573
|
-
backupFile(sidecar, file);
|
|
574
|
-
text = setServerEntry(scope, cwd, SCENARIO_SERVER_KEY, entry, paths).text;
|
|
1237
|
+
if (wroteLocal && worktrees.length > 0) {
|
|
1238
|
+
// Not a refusal — the main checkout is where `bir setup` was asked for —
|
|
1239
|
+
// but sessions in those directories now run these entries too.
|
|
1240
|
+
out(` · Claude Code shares this local scope with the linked worktree${worktrees.length === 1 ? "" : "s"} ` +
|
|
1241
|
+
`${worktrees.join(", ")}: sessions there run the wrapped servers too, recorded by this project's recorder ` +
|
|
1242
|
+
"(`bir doctor` there says so)");
|
|
1243
|
+
}
|
|
1244
|
+
return { wrapped, theirBir, sharedKey };
|
|
1245
|
+
}
|
|
1246
|
+
/** The `bir` scenario server: local scope (or the explicit file, or `.mcp.json` on request). */
|
|
1247
|
+
function installScenarioServer(args, cwd, sidecar, gen, paths, mcpJsonMode, theirBir, sharedKey = false) {
|
|
1248
|
+
const claudeJson = paths.claudeJson;
|
|
1249
|
+
const found = findRecord(sidecar, cwd, SCENARIO_SERVER_KEY);
|
|
1250
|
+
// Migration: an older install put it in `.mcp.json`, the file a team commits.
|
|
1251
|
+
// A project that had it keeps it — a plain `bir install` (the by-hand path,
|
|
1252
|
+
// or `--server x`) must not take direct replay's delivery tool away while
|
|
1253
|
+
// saying it was moved — unless it cannot go to the local scope at all.
|
|
1254
|
+
let migrated = false;
|
|
1255
|
+
if (!args.configPath && !mcpJsonMode && found?.record.scope === "project") {
|
|
1256
|
+
const inMcp = parseJsonFile(paths.mcpJson)?.mcpServers?.[SCENARIO_SERVER_KEY];
|
|
1257
|
+
if (inMcp !== undefined && isScenarioServer(inMcp)) {
|
|
1258
|
+
backupFile(sidecar, paths.mcpJson, claudeJson);
|
|
1259
|
+
const text = setServerEntry("project", cwd, SCENARIO_SERVER_KEY, null, paths).text;
|
|
1260
|
+
noteWritten(sidecar, paths.mcpJson, text, claudeJson);
|
|
1261
|
+
removeIfOnlyOurs(sidecar, paths.mcpJson);
|
|
1262
|
+
migrated = true;
|
|
1263
|
+
const staysOut = sharedKey || Boolean(theirBir);
|
|
1264
|
+
out(staysOut
|
|
1265
|
+
? ` ~ ${SCENARIO_SERVER_KEY} — removed from ${paths.mcpJson} (a file a team commits); it could not be put in the local scope (see below)`
|
|
1266
|
+
: ` ~ ${SCENARIO_SERVER_KEY} — moved out of ${paths.mcpJson} (a file a team commits) into the local scope`);
|
|
575
1267
|
}
|
|
576
|
-
|
|
1268
|
+
delete sidecar.servers[found.key];
|
|
1269
|
+
}
|
|
1270
|
+
if (!args.replay && !migrated) {
|
|
1271
|
+
const present = args.configPath
|
|
1272
|
+
? readGenericServers(args.configPath)[SCENARIO_SERVER_KEY]
|
|
1273
|
+
: projectScope((parseJsonFile(claudeJson) ?? {}).projects, cwd)[SCENARIO_SERVER_KEY];
|
|
1274
|
+
if (present !== undefined && isScenarioServer(present))
|
|
1275
|
+
out(` = ${SCENARIO_SERVER_KEY} — the scenario server (calculated replay)`);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (sharedKey && !args.configPath && !mcpJsonMode) {
|
|
1279
|
+
out(` ! ${SCENARIO_SERVER_KEY} — the scenario server was not added: in the local scope it would apply to the whole repository (see above); replay steers without it`);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (theirBir) {
|
|
1283
|
+
out(` ! ${SCENARIO_SERVER_KEY} — the scenario server was not added: a server of yours is called ${SCENARIO_SERVER_KEY}`);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const entry = scenarioEntry({
|
|
1287
|
+
invocation: gen.invocation,
|
|
1288
|
+
scenarioPath: gen.invocation === "npx" ? undefined : localScenarioPath(),
|
|
1289
|
+
version: VERSION,
|
|
1290
|
+
controlUrl: gen.controlUrl,
|
|
1291
|
+
nodeFlags: gen.nodeFlags,
|
|
1292
|
+
node: gen.node,
|
|
1293
|
+
});
|
|
1294
|
+
const record = (scope, file, keys) => {
|
|
577
1295
|
sidecar.servers[sidecarKey(cwd, SCENARIO_SERVER_KEY)] = {
|
|
578
1296
|
original: null, // it did not exist before; uninstall removes rather than restores
|
|
579
1297
|
scope,
|
|
580
1298
|
file,
|
|
581
1299
|
cwd,
|
|
582
1300
|
installedAt: Date.now(),
|
|
1301
|
+
...(keys ? { keys } : {}),
|
|
583
1302
|
};
|
|
584
|
-
|
|
1303
|
+
};
|
|
1304
|
+
let changed;
|
|
1305
|
+
let current;
|
|
1306
|
+
if (args.configPath) {
|
|
1307
|
+
current = readGenericServers(args.configPath)[SCENARIO_SERVER_KEY];
|
|
1308
|
+
changed = !sameEntry(current, entry);
|
|
1309
|
+
if (changed) {
|
|
1310
|
+
backupFile(sidecar, args.configPath, claudeJson, true);
|
|
1311
|
+
noteWritten(sidecar, args.configPath, setGenericServerEntry(args.configPath, SCENARIO_SERVER_KEY, entry), claudeJson, true);
|
|
1312
|
+
}
|
|
1313
|
+
if (changed || !found)
|
|
1314
|
+
record("explicit", args.configPath);
|
|
1315
|
+
}
|
|
1316
|
+
else if (mcpJsonMode) {
|
|
1317
|
+
current = parseJsonFile(paths.mcpJson)?.mcpServers?.[SCENARIO_SERVER_KEY];
|
|
1318
|
+
changed = !sameEntry(current, entry);
|
|
1319
|
+
if (changed) {
|
|
1320
|
+
backupFile(sidecar, paths.mcpJson, claudeJson);
|
|
1321
|
+
noteWritten(sidecar, paths.mcpJson, setServerEntry("project", cwd, SCENARIO_SERVER_KEY, entry, paths).text, claudeJson);
|
|
1322
|
+
}
|
|
1323
|
+
record("project", paths.mcpJson);
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
// LOCAL SCOPE, under the key Claude Code itself reads (resolve.ts,
|
|
1327
|
+
// claudeProjectKey). An older runner wrote it under the raw working
|
|
1328
|
+
// directory — backslashes on Windows — which Claude Code never reads, and
|
|
1329
|
+
// moved it to `.mcp.json` to escape that; the key is right now, and
|
|
1330
|
+
// `.mcp.json` is a file a team commits.
|
|
1331
|
+
const claude = parseJsonFile(claudeJson) ?? {};
|
|
1332
|
+
const keys = projectKeysFor(claude.projects, cwd);
|
|
1333
|
+
current = projectScope(claude.projects, cwd)[SCENARIO_SERVER_KEY];
|
|
1334
|
+
const written = setLocalEntries(claudeJson, keys, SCENARIO_SERVER_KEY, entry, (cur) => cur === undefined || isScenarioServer(cur));
|
|
1335
|
+
changed = written.keys.length > 0;
|
|
1336
|
+
record("local", claudeJson, [...new Set([...keys, ...(found?.record.keys ?? [])])]);
|
|
1337
|
+
}
|
|
1338
|
+
if (!changed)
|
|
1339
|
+
out(` = ${SCENARIO_SERVER_KEY} — the scenario server (calculated replay)`);
|
|
1340
|
+
else if (current !== undefined && isScenarioServer(current))
|
|
1341
|
+
out(` ~ ${SCENARIO_SERVER_KEY} — re-writing the scenario server: its runner or control URL moved`);
|
|
1342
|
+
else
|
|
1343
|
+
out(` + ${SCENARIO_SERVER_KEY} → bir-scenario (calculated replay; ${args.configPath ? "explicit" : mcpJsonMode ? "project" : "local"} scope)`);
|
|
1344
|
+
}
|
|
1345
|
+
/** True when a server this directory's sessions would start runs through the runner. */
|
|
1346
|
+
function wrappedHere(cwd) {
|
|
1347
|
+
try {
|
|
1348
|
+
return resolveServers(cwd).some((s) => s.name !== SCENARIO_SERVER_KEY && isWrapped(s.config));
|
|
1349
|
+
}
|
|
1350
|
+
catch {
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
/** A JSON value with every object's keys sorted, for comparing two texts by content. */
|
|
1355
|
+
function canonicalJson(value) {
|
|
1356
|
+
const sort = (v) => Array.isArray(v)
|
|
1357
|
+
? v.map(sort)
|
|
1358
|
+
: v && typeof v === "object"
|
|
1359
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sort(v[k])]))
|
|
1360
|
+
: v;
|
|
1361
|
+
return JSON.stringify(sort(value));
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* After a migration took every runner entry an older install wrote into
|
|
1365
|
+
* `.mcp.json` back out (each restored through a JSON edit, which re-indents
|
|
1366
|
+
* the whole file), put the file's own bytes back when it now says exactly
|
|
1367
|
+
* what it said before that install — so the team's committed file shows no
|
|
1368
|
+
* diff at all, as "put back as it was" promises. Only from a backup nobody
|
|
1369
|
+
* changed the file under (not stale), only once nothing of ours is left in
|
|
1370
|
+
* it, and only when the content is equal; anything else is left as written.
|
|
1371
|
+
*/
|
|
1372
|
+
function restoreMcpJsonBytes(sidecar, paths) {
|
|
1373
|
+
try {
|
|
1374
|
+
const file = paths.mcpJson;
|
|
1375
|
+
const backup = sidecar.files?.[file];
|
|
1376
|
+
if (!backup || backup.stale || backupIsCreation(backup))
|
|
1377
|
+
return;
|
|
1378
|
+
const current = readTextOrNull(file);
|
|
1379
|
+
if (current === null || holdsRunnerEntries(file, current))
|
|
1380
|
+
return;
|
|
1381
|
+
if (!backup.writtenSha || sha256(current) !== backup.writtenSha)
|
|
1382
|
+
return;
|
|
1383
|
+
const original = backupOriginalText(backup, current);
|
|
1384
|
+
if (typeof original !== "string" || original === current)
|
|
1385
|
+
return;
|
|
1386
|
+
const strip = (t) => (t.charCodeAt(0) === 0xfeff ? t.slice(1) : t);
|
|
1387
|
+
if (canonicalJson(JSON.parse(strip(current))) !== canonicalJson(JSON.parse(strip(original))))
|
|
1388
|
+
return;
|
|
1389
|
+
writeFileAtomic(file, original);
|
|
1390
|
+
noteWritten(sidecar, file, original, paths.claudeJson);
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
/* left as the migration wrote it: same content, other whitespace */
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Delete a `.mcp.json` that an install created and that now holds nothing but
|
|
1398
|
+
* an empty `mcpServers` — what taking our entries out of it leaves behind.
|
|
1399
|
+
*/
|
|
1400
|
+
function removeIfOnlyOurs(sidecar, file) {
|
|
1401
|
+
const backup = sidecar.files?.[file];
|
|
1402
|
+
if (!backup || !backupIsCreation(backup) || backup.stale)
|
|
1403
|
+
return;
|
|
1404
|
+
const parsed = parseJsonFile(file);
|
|
1405
|
+
if (!parsed)
|
|
1406
|
+
return;
|
|
1407
|
+
const keys = Object.keys(parsed);
|
|
1408
|
+
const servers = parsed.mcpServers;
|
|
1409
|
+
if (keys.length === 1 && keys[0] === "mcpServers" && servers && Object.keys(servers).length === 0) {
|
|
1410
|
+
try {
|
|
1411
|
+
rmSync(file, { force: true });
|
|
1412
|
+
delete sidecar.files?.[file];
|
|
1413
|
+
}
|
|
1414
|
+
catch {
|
|
1415
|
+
/* leave it */
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
// ── uninstall ──────────────────────────────────────────────────────────────
|
|
1420
|
+
/**
|
|
1421
|
+
* The project directory a settings file or `.mcp.json` sits in (normalised),
|
|
1422
|
+
* or undefined for any other file. What a file belongs to is decided by where
|
|
1423
|
+
* it is, not by whether it is somewhere under the directory being uninstalled:
|
|
1424
|
+
* a project nested inside another keeps its own settings, and uninstalling the
|
|
1425
|
+
* outer one must not take the inner one's hooks with it.
|
|
1426
|
+
*/
|
|
1427
|
+
function fileProjectDir(file) {
|
|
1428
|
+
const n = normalizePath(file);
|
|
1429
|
+
return (/^(.*)\/\.claude\/settings(?:\.local)?\.json$/i.exec(n) ?? /^(.*)\/\.mcp\.json$/i.exec(n))?.[1];
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Every directory an install on this machine touched: normalised → as
|
|
1433
|
+
* spelled (a record's own spelling first, so messages name it as it was typed).
|
|
1434
|
+
*/
|
|
1435
|
+
function installedProjects(sidecar) {
|
|
1436
|
+
const found = new Map();
|
|
1437
|
+
const add = (dir) => {
|
|
1438
|
+
if (!dir)
|
|
1439
|
+
return;
|
|
1440
|
+
const key = normalizePath(dir);
|
|
1441
|
+
if (!found.has(key))
|
|
1442
|
+
found.set(key, dir);
|
|
1443
|
+
};
|
|
1444
|
+
for (const record of Object.values(sidecar.servers))
|
|
1445
|
+
add(record.cwd);
|
|
1446
|
+
for (const file of sidecar.hookFiles ?? [])
|
|
1447
|
+
add(dirname(dirname(file)));
|
|
1448
|
+
for (const key of Object.keys(sidecar.projects ?? {}))
|
|
1449
|
+
add(key);
|
|
1450
|
+
return found;
|
|
1451
|
+
}
|
|
1452
|
+
/** The installed projects other than `cwd`, as spelled. */
|
|
1453
|
+
function otherProjects(sidecar, cwd) {
|
|
1454
|
+
const here = normalizePath(cwd);
|
|
1455
|
+
return [...installedProjects(sidecar)].filter(([key]) => key !== here).map(([, dir]) => dir);
|
|
1456
|
+
}
|
|
1457
|
+
/** A proxy or the scenario server: an entry that stops working when the runner is removed. */
|
|
1458
|
+
function ourEntry(e) {
|
|
1459
|
+
return e !== undefined && (isWrapped(e) || isScenarioServer(e));
|
|
1460
|
+
}
|
|
1461
|
+
function leftoverWhere(e) {
|
|
1462
|
+
const other = e.file ? ` of ${e.file}` : "";
|
|
1463
|
+
if (e.scope === "local")
|
|
1464
|
+
return `local scope${other}`;
|
|
1465
|
+
if (e.scope === "user")
|
|
1466
|
+
return `your user scope${other}, which every project uses`;
|
|
1467
|
+
return e.file ?? e.scope;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* The other `.claude.json` files this project's install wrote to — named by its
|
|
1471
|
+
* records and its project record, and not the one this environment reads
|
|
1472
|
+
* (claudeJsonPath follows CLAUDE_CONFIG_DIR). Only files that exist.
|
|
1473
|
+
*/
|
|
1474
|
+
function otherClaudeFiles(sidecar, cwd, claudeJson) {
|
|
1475
|
+
const here = normalizePath(cwd);
|
|
1476
|
+
const named = [
|
|
1477
|
+
...Object.values(sidecar.servers)
|
|
1478
|
+
.filter((r) => normalizePath(r.cwd) === here && (r.scope === "local" || r.scope === "user"))
|
|
1479
|
+
.map((r) => r.file),
|
|
1480
|
+
...(projectRecord(sidecar, cwd)?.claudeJsonFiles ?? []),
|
|
1481
|
+
];
|
|
1482
|
+
const seen = new Set([normalizePath(claudeJson)]);
|
|
1483
|
+
const found = [];
|
|
1484
|
+
for (const file of named) {
|
|
1485
|
+
if (typeof file !== "string" || !file || seen.has(normalizePath(file)))
|
|
1486
|
+
continue;
|
|
1487
|
+
seen.add(normalizePath(file));
|
|
1488
|
+
if (existsSync(file))
|
|
1489
|
+
found.push(file);
|
|
1490
|
+
}
|
|
1491
|
+
return found;
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Every entry of ours this directory's sessions could still spawn, scope by
|
|
1495
|
+
* scope — not only the winning one: taking a local copy out lets the
|
|
1496
|
+
* `.mcp.json` or user-scope entry under it win, and that one may be wrapped too.
|
|
1497
|
+
*
|
|
1498
|
+
* The local scope is read under this directory's own spellings, and under the
|
|
1499
|
+
* repository's key only when that repository is not an installed project of
|
|
1500
|
+
* its own: a subdirectory shares its root's local scope, and what the root's
|
|
1501
|
+
* install wrapped there is the root's to undo. A file that cannot be read is
|
|
1502
|
+
* returned as such, so the caller reports it instead of calling it clean.
|
|
1503
|
+
*/
|
|
1504
|
+
function runnerLeftovers(cwd, sidecar, configPath, otherClaude = []) {
|
|
1505
|
+
const unreadable = [];
|
|
1506
|
+
const found = [];
|
|
1507
|
+
if (configPath) {
|
|
1508
|
+
try {
|
|
1509
|
+
for (const [name, config] of Object.entries(readGenericServers(configPath))) {
|
|
1510
|
+
if (ourEntry(config))
|
|
1511
|
+
found.push({ name, scope: "explicit", config, file: configPath });
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
catch (err) {
|
|
1515
|
+
if (existsSync(configPath))
|
|
1516
|
+
unreadable.push(`${configPath} (${errText(err)})`);
|
|
1517
|
+
}
|
|
1518
|
+
return { leftovers: found, unreadable };
|
|
1519
|
+
}
|
|
1520
|
+
const paths = claudeCodePaths(cwd);
|
|
1521
|
+
const here = normalizePath(cwd);
|
|
1522
|
+
const root = normalizePath(claudeProjectRoot(cwd));
|
|
1523
|
+
const want = new Set([here]);
|
|
1524
|
+
if (root === here || !installedProjects(sidecar).has(root))
|
|
1525
|
+
want.add(normalizePath(claudeProjectKey(cwd)));
|
|
1526
|
+
// This environment's `.claude.json` first, then any other one this project's
|
|
1527
|
+
// install wrote to (another CLAUDE_CONFIG_DIR), named as such.
|
|
1528
|
+
const claudeFiles = [
|
|
1529
|
+
{ file: paths.claudeJson, other: false },
|
|
1530
|
+
...otherClaude.filter((f) => !isClaudeJson(f, paths.claudeJson)).map((file) => ({ file, other: true })),
|
|
1531
|
+
];
|
|
1532
|
+
const users = [];
|
|
1533
|
+
for (const { file, other } of claudeFiles) {
|
|
1534
|
+
let claude = {};
|
|
1535
|
+
try {
|
|
1536
|
+
claude = parseJsonFile(file) ?? {};
|
|
1537
|
+
}
|
|
1538
|
+
catch (err) {
|
|
1539
|
+
unreadable.push(`${file} (${errText(err)})`);
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
const local = new Map();
|
|
1543
|
+
for (const [key, project] of Object.entries(claude.projects ?? {})) {
|
|
1544
|
+
if (!want.has(normalizePath(key)))
|
|
1545
|
+
continue;
|
|
1546
|
+
for (const [name, config] of Object.entries(project?.mcpServers ?? {})) {
|
|
1547
|
+
if (!ourEntry(config))
|
|
1548
|
+
continue;
|
|
1549
|
+
const seen = local.get(name);
|
|
1550
|
+
if (seen)
|
|
1551
|
+
seen.keys?.push(key);
|
|
1552
|
+
else
|
|
1553
|
+
local.set(name, { name, scope: "local", config, keys: [key], ...(other ? { file } : {}) });
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
found.push(...local.values());
|
|
1557
|
+
for (const [name, config] of Object.entries(claude.mcpServers ?? {})) {
|
|
1558
|
+
if (ourEntry(config))
|
|
1559
|
+
users.push({ name, scope: "user", config, ...(other ? { file } : {}) });
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
const mcp = parseJsonFile(paths.mcpJson)?.mcpServers;
|
|
1564
|
+
for (const [name, config] of Object.entries(mcp ?? {})) {
|
|
1565
|
+
if (ourEntry(config))
|
|
1566
|
+
found.push({ name, scope: "project", config, file: paths.mcpJson });
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
catch (err) {
|
|
1570
|
+
unreadable.push(`${paths.mcpJson} (${errText(err)})`);
|
|
1571
|
+
}
|
|
1572
|
+
found.push(...users);
|
|
1573
|
+
return { leftovers: found, unreadable };
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* The `.mcp.json` or user-scope entry a local entry of `name` shadows, if any —
|
|
1577
|
+
* unwrapped when an older runner wrapped it there, so it compares with what a
|
|
1578
|
+
* local copy of it was made from. `claudeJson`: the file whose user scope is
|
|
1579
|
+
* below (default: this environment's).
|
|
1580
|
+
*/
|
|
1581
|
+
function entryBelow(cwd, name, claudeJson) {
|
|
1582
|
+
try {
|
|
1583
|
+
const paths = claudeCodePaths(cwd, claudeJson);
|
|
1584
|
+
const mcp = parseJsonFile(paths.mcpJson)?.mcpServers;
|
|
1585
|
+
const below = mcp?.[name] ?? (parseJsonFile(paths.claudeJson)?.mcpServers ?? {})[name];
|
|
1586
|
+
return below !== undefined && isWrapped(below) ? unwrapEntry(below) : below;
|
|
1587
|
+
}
|
|
1588
|
+
catch {
|
|
1589
|
+
return undefined;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
/** True when another installed project lives in the same repository, and so shares its exclude file. */
|
|
1593
|
+
function sharesRepository(cwd, sidecar) {
|
|
1594
|
+
const root = normalizePath(claudeProjectRoot(cwd));
|
|
1595
|
+
return otherProjects(sidecar, cwd).some((dir) => normalizePath(claudeProjectRoot(dir)) === root);
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Undo `bir install` for ONE directory — the whole of it, or only
|
|
1599
|
+
* `opts.servers` — and leave every other project exactly as it is.
|
|
1600
|
+
*
|
|
1601
|
+
* In order: the byte-for-byte restores (a project file we wrote and nobody
|
|
1602
|
+
* touched since); the entries a record stashed; every entry of ours no record
|
|
1603
|
+
* explains, rebuilt from the wrapped entry's own arguments (an install that
|
|
1604
|
+
* stopped halfway, an older runner, a lost installed.json, a cwd spelled with
|
|
1605
|
+
* another drive-letter case); then the hooks. Last it READS BACK what a
|
|
1606
|
+
* session here would spawn, and reports anything of ours still there —
|
|
1607
|
+
* "Uninstalled." followed by removing the package is exactly how a user's
|
|
1608
|
+
* servers end up pointing at files that are gone.
|
|
1609
|
+
*
|
|
1610
|
+
* Bookkeeping is dropped only for what was undone, so running it again
|
|
1611
|
+
* retries the rest. Writes the sidecar; never touches the recorder.
|
|
1612
|
+
*/
|
|
1613
|
+
function uninstallProject(cwd, sidecar, opts) {
|
|
1614
|
+
const paths = claudeCodePaths(cwd);
|
|
1615
|
+
const claudeJson = paths.claudeJson;
|
|
1616
|
+
const here = normalizePath(cwd);
|
|
1617
|
+
const narrow = opts.servers.length > 0;
|
|
1618
|
+
const wanted = (name) => !narrow || opts.servers.includes(name);
|
|
1619
|
+
const mine = Object.entries(sidecar.servers)
|
|
1620
|
+
.filter(([, record]) => normalizePath(record.cwd) === here)
|
|
1621
|
+
.map(([key, record]) => ({ key, record, name: recordName(key, record) }));
|
|
1622
|
+
const remaining = new Map();
|
|
1623
|
+
// Every `.claude.json` this project's install wrote to, besides this
|
|
1624
|
+
// environment's: known now, before the records that name them are dropped.
|
|
1625
|
+
const otherClaude = opts.configPath ? [] : otherClaudeFiles(sidecar, cwd, claudeJson);
|
|
1626
|
+
/**
|
|
1627
|
+
* The `.claude.json` a local- or user-scope record's entry is in: the file
|
|
1628
|
+
* the install wrote, which CLAUDE_CONFIG_DIR may have made another one than
|
|
1629
|
+
* this environment's — not whichever this shell happens to read.
|
|
1630
|
+
*/
|
|
1631
|
+
const claudeFileOf = (record) => (record.scope === "local" || record.scope === "user") && typeof record.file === "string" && record.file
|
|
1632
|
+
? record.file
|
|
1633
|
+
: claudeJson;
|
|
1634
|
+
const readClaude = (file = claudeJson) => parseJsonFile(file) ?? {};
|
|
1635
|
+
const currentEntry = (record, name) => {
|
|
1636
|
+
if (record.scope === "explicit")
|
|
1637
|
+
return readGenericServers(record.file)[name];
|
|
1638
|
+
if (record.scope === "local") {
|
|
1639
|
+
const claude = readClaude(claudeFileOf(record));
|
|
1640
|
+
for (const key of record.keys ?? [projectKeyFor(claude.projects, record.cwd)]) {
|
|
1641
|
+
const e = claude.projects?.[key]?.mcpServers?.[name];
|
|
1642
|
+
if (e !== undefined)
|
|
1643
|
+
return e;
|
|
1644
|
+
}
|
|
1645
|
+
return undefined;
|
|
1646
|
+
}
|
|
1647
|
+
if (record.scope === "user")
|
|
1648
|
+
return readClaude(claudeFileOf(record)).mcpServers?.[name];
|
|
1649
|
+
return parseJsonFile(record.file)?.mcpServers?.[name];
|
|
1650
|
+
};
|
|
1651
|
+
/** Put one entry back (or take it out), touching it only while it is still ours. */
|
|
1652
|
+
const repair = (record, name) => {
|
|
1653
|
+
if (record.scope === "explicit" && !existsSync(record.file)) {
|
|
1654
|
+
return { what: `its config file ${record.file} is gone; nothing to undo`, done: true };
|
|
1655
|
+
}
|
|
1656
|
+
const file = claudeFileOf(record);
|
|
1657
|
+
const elsewhere = (record.scope === "local" || record.scope === "user") && !isClaudeJson(file, claudeJson);
|
|
1658
|
+
if (elsewhere && !existsSync(file)) {
|
|
1659
|
+
return { what: `its config file ${file} is gone; nothing to undo`, done: true };
|
|
1660
|
+
}
|
|
1661
|
+
const where = elsewhere ? ` in ${file}, not this environment's ${claudeJson}` : "";
|
|
1662
|
+
const now = currentEntry(record, name);
|
|
1663
|
+
if (!ourEntry(now))
|
|
1664
|
+
return { what: `left as it is (changed since install)${where}`, done: true };
|
|
1665
|
+
// The stash first; else the original rebuilt from the wrapped entry's own
|
|
1666
|
+
// arguments. An entry left wrapped breaks the day the runner is removed.
|
|
1667
|
+
const restored = record.original === null ? null : (recordedOriginal(record, now) ?? unwrapEntry(now));
|
|
1668
|
+
if (restored === undefined)
|
|
1669
|
+
return { what: `left as it is (its original could not be rebuilt)${where}`, done: false };
|
|
1670
|
+
if (record.scope === "explicit")
|
|
1671
|
+
setGenericServerEntry(record.file, name, restored);
|
|
1672
|
+
else if (record.scope === "local") {
|
|
1673
|
+
const keys = record.keys ?? [projectKeyFor(readClaude(file).projects, record.cwd)];
|
|
1674
|
+
setLocalEntries(file, keys, name, restored, ourEntry);
|
|
1675
|
+
}
|
|
1676
|
+
else if (record.scope === "user")
|
|
1677
|
+
setServerEntry("user", record.cwd, name, restored, { ...paths, claudeJson: file });
|
|
1678
|
+
else
|
|
1679
|
+
setServerEntry(record.scope, record.cwd, name, restored, paths);
|
|
1680
|
+
return { what: `${restored === null ? "removed" : "restored"}${where}`, done: true };
|
|
1681
|
+
};
|
|
1682
|
+
const myRecordFiles = new Set(mine.map((m) => normalizePath(m.record.file)));
|
|
1683
|
+
const ownsFile = (file) => {
|
|
1684
|
+
const dir = fileProjectDir(file);
|
|
1685
|
+
if (dir !== undefined)
|
|
1686
|
+
return dir === here;
|
|
1687
|
+
return normalizePath(file).startsWith(`${here}/`) || myRecordFiles.has(normalizePath(file));
|
|
1688
|
+
};
|
|
1689
|
+
const myHookFiles = (sidecar.hookFiles ?? []).filter((f) => fileProjectDir(f) === here);
|
|
1690
|
+
const hookedNow = [paths.settingsLocal, paths.settings].filter((f) => hasBirHooks(f));
|
|
1691
|
+
const before = runnerLeftovers(cwd, sidecar, opts.configPath, otherClaude);
|
|
1692
|
+
if (mine.length === 0 &&
|
|
1693
|
+
myHookFiles.length === 0 &&
|
|
1694
|
+
hookedNow.length === 0 &&
|
|
1695
|
+
before.leftovers.length === 0 &&
|
|
1696
|
+
before.unreadable.length === 0) {
|
|
1697
|
+
return { nothing: true, remaining: [] };
|
|
1698
|
+
}
|
|
1699
|
+
// 1. Byte-for-byte: a project file we wrote, nobody touched since, and whose
|
|
1700
|
+
// snapshot is not stale goes back exactly as it was. Never ~/.claude.json,
|
|
1701
|
+
// and never a file another project's install also wrote.
|
|
1702
|
+
const restoredWhole = new Set();
|
|
1703
|
+
const othersFiles = new Set(Object.values(sidecar.servers)
|
|
1704
|
+
.filter((r) => normalizePath(r.cwd) !== here)
|
|
1705
|
+
.map((r) => normalizePath(r.file)));
|
|
1706
|
+
if (!narrow) {
|
|
1707
|
+
for (const [file, backup] of Object.entries(sidecar.files ?? {})) {
|
|
1708
|
+
if (isClaudeJson(file, claudeJson) || othersFiles.has(normalizePath(file)) || !ownsFile(file))
|
|
1709
|
+
continue;
|
|
1710
|
+
const current = readTextOrNull(file);
|
|
1711
|
+
if (current === null || !backup.writtenSha || backup.stale || sha256(current) !== backup.writtenSha)
|
|
1712
|
+
continue;
|
|
1713
|
+
if (backupIsCreation(backup)) {
|
|
1714
|
+
out(` - ${file} — created by install; leaving in place (now empty of our entries)`);
|
|
1715
|
+
continue;
|
|
1716
|
+
}
|
|
1717
|
+
// Kept without its env and header values (generate.ts,
|
|
1718
|
+
// protectBackupText): read back from the entries in the file as we left
|
|
1719
|
+
// it, and used only when that gives the original exactly.
|
|
1720
|
+
const original = backupOriginalText(backup, current);
|
|
1721
|
+
if (typeof original !== "string") {
|
|
1722
|
+
out(` ~ ${file} — its entries are repaired one by one (the values in it could not all be read back)`);
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
try {
|
|
1726
|
+
writeFileAtomic(file, original);
|
|
1727
|
+
}
|
|
1728
|
+
catch (err) {
|
|
1729
|
+
out(` ! ${file} — could not be restored whole (${errText(err)}); repairing its entries instead`);
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
restoredWhole.add(normalizePath(file));
|
|
1733
|
+
out(` ← ${file} — restored byte-for-byte`);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
// 2. What the records stashed.
|
|
1737
|
+
for (const { key, record, name } of mine) {
|
|
1738
|
+
if (!wanted(name))
|
|
1739
|
+
continue;
|
|
1740
|
+
if (restoredWhole.has(normalizePath(record.file))) {
|
|
1741
|
+
delete sidecar.servers[key];
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
// Repair the entry rather than clobbering the file, and say what happened.
|
|
1745
|
+
let result;
|
|
1746
|
+
try {
|
|
1747
|
+
result = repair(record, name);
|
|
1748
|
+
}
|
|
1749
|
+
catch (err) {
|
|
1750
|
+
result = { what: `left as it is (${errText(err)})`, done: false };
|
|
1751
|
+
}
|
|
1752
|
+
out(` ← ${name} — ${result.what}`);
|
|
1753
|
+
if (result.done)
|
|
1754
|
+
delete sidecar.servers[key];
|
|
1755
|
+
else
|
|
1756
|
+
remaining.set(name, `${name} (${record.file}) — ${result.what}`);
|
|
1757
|
+
}
|
|
1758
|
+
// 3. Entries of ours that no record explains.
|
|
1759
|
+
for (const stray of runnerLeftovers(cwd, sidecar, opts.configPath, otherClaude).leftovers) {
|
|
1760
|
+
if (!wanted(stray.name) || remaining.has(stray.name))
|
|
1761
|
+
continue;
|
|
1762
|
+
if (stray.scope === "user" && !opts.all) {
|
|
1763
|
+
// Every project uses the user scope. One that another project's install
|
|
1764
|
+
// wrapped is that project's to undo.
|
|
1765
|
+
const owner = Object.entries(sidecar.servers).find(([key, r]) => r.scope === "user" && recordName(key, r) === stray.name && normalizePath(r.cwd) !== here);
|
|
1766
|
+
if (owner) {
|
|
1767
|
+
remaining.set(stray.name, `${stray.name} — wrapped in your user scope (every project) by the install in ${owner[1].cwd}: ` +
|
|
1768
|
+
"`bir uninstall` there, or `bir uninstall --all`");
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
let back = isScenarioServer(stray.config) ? null : unwrapEntry(stray.config);
|
|
1773
|
+
const where = leftoverWhere(stray);
|
|
1774
|
+
// Local and user scope: the `.claude.json` it sits in (another profile's, named as such).
|
|
1775
|
+
const strayClaude = stray.scope === "local" || stray.scope === "user" ? (stray.file ?? claudeJson) : claudeJson;
|
|
1776
|
+
if (back && stray.scope === "local" && sameServerConfig(back, entryBelow(cwd, stray.name, strayClaude))) {
|
|
1777
|
+
// An override (what install writes over a `.mcp.json` or user-scope
|
|
1778
|
+
// server) whose record is lost: the entry under it is the original, so
|
|
1779
|
+
// the copy goes rather than shadowing the team's entry from now on.
|
|
1780
|
+
back = null;
|
|
1781
|
+
}
|
|
1782
|
+
if (back === undefined) {
|
|
1783
|
+
remaining.set(stray.name, `${stray.name} (${where}) — its original could not be rebuilt from the wrapped entry; edit it by hand`);
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
try {
|
|
1787
|
+
if (stray.scope === "explicit")
|
|
1788
|
+
setGenericServerEntry(stray.file, stray.name, back);
|
|
1789
|
+
else if (stray.scope === "local")
|
|
1790
|
+
setLocalEntries(strayClaude, stray.keys ?? [], stray.name, back, ourEntry);
|
|
1791
|
+
else
|
|
1792
|
+
setServerEntry(stray.scope, cwd, stray.name, back, { ...paths, claudeJson: strayClaude });
|
|
1793
|
+
const how = back !== null
|
|
1794
|
+
? `put back as the plain server in ${where} (no record of it here; rebuilt from the wrapped entry)`
|
|
1795
|
+
: isScenarioServer(stray.config)
|
|
1796
|
+
? `removed from ${where} (no record of it here)`
|
|
1797
|
+
: `its wrapped copy removed from ${where}; the entry under it is the original (no record of it here)`;
|
|
1798
|
+
out(` ← ${stray.name} — ${how}${stray.scope === "project" ? " — commit that if your team shares .mcp.json" : ""}`);
|
|
1799
|
+
}
|
|
1800
|
+
catch (err) {
|
|
1801
|
+
remaining.set(stray.name, `${stray.name} (${where}) — ${errText(err)}`);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
// 4. The hooks.
|
|
1805
|
+
if (!narrow) {
|
|
1806
|
+
const seen = new Set();
|
|
1807
|
+
for (const settings of [...myHookFiles, ...hookedNow]) {
|
|
1808
|
+
const norm = normalizePath(settings);
|
|
1809
|
+
if (seen.has(norm))
|
|
1810
|
+
continue;
|
|
1811
|
+
seen.add(norm);
|
|
1812
|
+
if (restoredWhole.has(norm) || !existsSync(settings))
|
|
1813
|
+
continue;
|
|
1814
|
+
try {
|
|
1815
|
+
const had = hasBirHooks(settings);
|
|
1816
|
+
uninstallHooks(settings);
|
|
1817
|
+
// The scenario server's pre-approval lives beside the hooks in the
|
|
1818
|
+
// person's own file; the team's settings.json never got one.
|
|
1819
|
+
if (norm !== normalizePath(paths.settings) || myHookFiles.some((f) => normalizePath(f) === norm)) {
|
|
1820
|
+
disableMcpjsonServers(settings, [SCENARIO_SERVER_KEY]);
|
|
1821
|
+
}
|
|
1822
|
+
if (had)
|
|
1823
|
+
out(` ← hooks removed from ${settings}`);
|
|
1824
|
+
}
|
|
1825
|
+
catch (err) {
|
|
1826
|
+
remaining.set(`hooks:${norm}`, `the hooks in ${settings} — ${errText(err)}`);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
// 5. Read it back: what would a session here spawn now — in this
|
|
1831
|
+
// environment, and in any other profile the install wrote to?
|
|
1832
|
+
const after = runnerLeftovers(cwd, sidecar, opts.configPath, otherClaude);
|
|
1833
|
+
for (const e of after.leftovers) {
|
|
1834
|
+
if (!wanted(e.name) || remaining.has(e.name))
|
|
1835
|
+
continue;
|
|
1836
|
+
remaining.set(e.name, `${e.name} (${leftoverWhere(e)}) still runs through the runner`);
|
|
1837
|
+
}
|
|
1838
|
+
for (const file of after.unreadable)
|
|
1839
|
+
remaining.set(`read:${file}`, `${file} could not be read, so nothing in it was undone`);
|
|
1840
|
+
if (!narrow) {
|
|
1841
|
+
for (const file of [paths.settingsLocal, paths.settings]) {
|
|
1842
|
+
const key = `hooks:${normalizePath(file)}`;
|
|
1843
|
+
if (hasBirHooks(file) && !remaining.has(key))
|
|
1844
|
+
remaining.set(key, `hooks of ours are still in ${file}`);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
if (!opts.configPath) {
|
|
1848
|
+
// Not ours to undo from here, but a session here still spawns them: the
|
|
1849
|
+
// repository's install owns the local scope this subdirectory shares.
|
|
1850
|
+
const listed = new Set(after.leftovers.map((e) => e.name));
|
|
1851
|
+
let live = [];
|
|
1852
|
+
try {
|
|
1853
|
+
live = resolveServers(cwd);
|
|
1854
|
+
}
|
|
1855
|
+
catch {
|
|
1856
|
+
/* reported above as unreadable */
|
|
1857
|
+
}
|
|
1858
|
+
const theirs = live.filter((s) => ourEntry(s.config) && !listed.has(s.name) && wanted(s.name)).map((s) => s.name);
|
|
1859
|
+
if (theirs.length) {
|
|
1860
|
+
out(` · ${theirs.join(", ")} stay wrapped here: they belong to the install in ${claudeProjectRoot(cwd)} ` +
|
|
1861
|
+
"(Claude Code shares the local scope per repository) — `bir uninstall` there");
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
// 6. The bookkeeping: only this project's, and only once nothing is left to
|
|
1865
|
+
// retry — a second run needs the snapshots and records to finish the job.
|
|
1866
|
+
if (!narrow) {
|
|
1867
|
+
sidecar.hookFiles = (sidecar.hookFiles ?? []).filter((f) => fileProjectDir(f) !== here || remaining.has(`hooks:${normalizePath(f)}`));
|
|
1868
|
+
if (remaining.size === 0) {
|
|
1869
|
+
for (const file of Object.keys(sidecar.files ?? {})) {
|
|
1870
|
+
if (isClaudeJson(file, claudeJson) || (ownsFile(file) && !othersFiles.has(normalizePath(file))))
|
|
1871
|
+
delete sidecar.files?.[file];
|
|
1872
|
+
}
|
|
1873
|
+
const record = projectRecord(sidecar, cwd);
|
|
1874
|
+
if (sidecar.projects)
|
|
1875
|
+
delete sidecar.projects[here];
|
|
1876
|
+
if (record?.excludeAdded !== false && existsSync(cwd) && !sharesRepository(cwd, sidecar)) {
|
|
1877
|
+
const exclude = removeGitExclude(cwd);
|
|
1878
|
+
if (exclude)
|
|
1879
|
+
out(` ← ${LOCAL_SETTINGS_EXCLUDE} removed from ${exclude}`);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
writeSidecar(sidecar);
|
|
1884
|
+
return { nothing: false, remaining: [...remaining.values()] };
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* True when a Claude Code session is using this recorder right now: a turn is
|
|
1888
|
+
* mid-run, or a registered proxy — a session's own child process — is alive.
|
|
1889
|
+
*/
|
|
1890
|
+
function recorderInUse(health) {
|
|
1891
|
+
if (activeSessions(health) > 0)
|
|
1892
|
+
return true;
|
|
1893
|
+
const proxies = health.registeredProxies ?? [];
|
|
1894
|
+
return proxies.some((p) => typeof p.pid === "number" && p.pid > 0 && p.pid !== process.pid && pidAlive(p.pid));
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Stop this project's recorder at the end of an uninstall — unless a Claude
|
|
1898
|
+
* Code session here is still using it.
|
|
1899
|
+
*
|
|
1900
|
+
* An open session keeps the hooks it started with, and its MCP servers are
|
|
1901
|
+
* already running. Stop the recorder under it and every prompt and tool call
|
|
1902
|
+
* there shows a hook error until the session restarts. So a recorder that is
|
|
1903
|
+
* serving one is left running with this project switched OFF — it answers
|
|
1904
|
+
* those hooks with nothing at once and records nothing — and the person is
|
|
1905
|
+
* told to restart the session, then `bir down`. `bir install` here again, or
|
|
1906
|
+
* `bir on --here`, ends that switch.
|
|
1907
|
+
*/
|
|
1908
|
+
async function retireRecorder(cwd) {
|
|
1909
|
+
// A recorder a reinstall on another port left serving an older window
|
|
1910
|
+
// (LC-1): stopped when nothing uses it, else it stays for that window and
|
|
1911
|
+
// is named — it is not reachable through the discovery file.
|
|
1912
|
+
for (const record of await supersededRecorders(cwd)) {
|
|
1913
|
+
const health = await supersededHealth(record);
|
|
1914
|
+
if (health && recorderInUse(health)) {
|
|
1915
|
+
out(` · a superseded recorder (${describeSuperseded(record)}) still serves a Claude Code session opened before ` +
|
|
1916
|
+
"the last reinstall; restart that session, then `bir down` here");
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
const result = await stopSuperseded(record);
|
|
1920
|
+
if (result.stopped)
|
|
1921
|
+
out(` ← superseded recorder stopped (${describeSuperseded(record)})`);
|
|
1922
|
+
}
|
|
1923
|
+
const live = await daemonStatus(cwd);
|
|
1924
|
+
if (!live) {
|
|
1925
|
+
clearUninstallMark(cwd);
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
if (recorderInUse(live.health)) {
|
|
1929
|
+
try {
|
|
1930
|
+
markOffForUninstall(cwd);
|
|
1931
|
+
if (predatesOffSwitch(live.health)) {
|
|
1932
|
+
// 0.2.12 or older: it has no off switch, so the mark changes nothing
|
|
1933
|
+
// for it — it keeps recording that session, and approving its wrapped
|
|
1934
|
+
// MCP calls, as before. Said as it is; stopping it instead would put a
|
|
1935
|
+
// hook error on every prompt of a session the person is using.
|
|
1936
|
+
out(` · the recorder (pid ${live.info.pid}, ${recorderVersion(live.health, live.info) || "an older version"}) stays up for ` +
|
|
1937
|
+
"the Claude Code session open here. It predates the off switch, so it keeps recording that session " +
|
|
1938
|
+
"(and approving its wrapped MCP calls) until the session ends. Restart the session, then `bir down`");
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
out(` · the recorder (pid ${live.info.pid}) stays up for the Claude Code session open here, switched OFF: ` +
|
|
1942
|
+
"it answers that session's hooks with nothing and records nothing. Restart the session, then `bir down`");
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
catch {
|
|
1946
|
+
/* no switch to leave it with: stop it after all */
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
const result = await stopDaemon(cwd);
|
|
1950
|
+
if (result.stopped || result.stale) {
|
|
1951
|
+
clearUninstallMark(cwd);
|
|
1952
|
+
if (result.stopped)
|
|
1953
|
+
out(` ← recorder stopped (pid ${live.info.pid})`);
|
|
1954
|
+
}
|
|
1955
|
+
else {
|
|
1956
|
+
out(` ! the recorder (pid ${live.info.pid}) did not stop — \`bir down\` here`);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* True when a recorder's `/health` has no `off` field: it is older than the
|
|
1961
|
+
* off switch (0.2.12 and before) and ignores it — keeps recording, and
|
|
1962
|
+
* approving wrapped MCP calls, whatever `bir off` or an uninstall's mark says.
|
|
1963
|
+
*/
|
|
1964
|
+
function predatesOffSwitch(health) {
|
|
1965
|
+
return !("off" in health);
|
|
1966
|
+
}
|
|
1967
|
+
/** The version a recorder reports, or "". */
|
|
1968
|
+
function recorderVersion(health, info) {
|
|
1969
|
+
const v = health.hooksVersion ?? info?.version;
|
|
1970
|
+
return typeof v === "string" ? v : "";
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* A running recorder reads the replay switch once, when it starts
|
|
1974
|
+
* (control/recorder-main.ts), so after the switch went off it would go on
|
|
1975
|
+
* replaying until something restarted it. Restart one no session is using;
|
|
1976
|
+
* leave one that serves an open session — stopping it would put a hook error
|
|
1977
|
+
* on every prompt there — and say what is left to do.
|
|
1978
|
+
*/
|
|
1979
|
+
async function restartForReplaySwitch(cwd) {
|
|
1980
|
+
const live = await daemonStatus(cwd);
|
|
1981
|
+
const replay = live?.health.replay;
|
|
1982
|
+
const want = projectRecord(readSidecar(), cwd)?.replay?.enabled !== false;
|
|
1983
|
+
// `source: "env"`: BIR_REPLAY where it was started outranks the switch, and
|
|
1984
|
+
// a restart from here would not carry that environment's answer.
|
|
1985
|
+
if (!live || typeof replay?.enabled !== "boolean" || replay.enabled === want || replay.source === "env")
|
|
1986
|
+
return;
|
|
1987
|
+
if (recorderInUse(live.health)) {
|
|
1988
|
+
out(` · the recorder (pid ${live.info.pid}) keeps replay ${replay.enabled ? "on" : "off"} for the Claude Code session ` +
|
|
1989
|
+
"open here — once it ends, `bir up --restart` applies the change");
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
try {
|
|
1993
|
+
await stopDaemon(cwd);
|
|
1994
|
+
const { status } = await ensureDaemon(cwd, { hooksScript: localHooksPath() });
|
|
1995
|
+
out(` ~ recorder restarted (pid ${status.info.pid}) so the replay switch applies`);
|
|
1996
|
+
}
|
|
1997
|
+
catch (err) {
|
|
1998
|
+
out(` ! the recorder did not restart (${errText(err)}); the next session here starts it with the new switch`);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
async function uninstall(args) {
|
|
2002
|
+
if (args.all)
|
|
2003
|
+
return uninstallAll(args);
|
|
2004
|
+
const cwd = process.cwd();
|
|
2005
|
+
const sidecar = readSidecar();
|
|
2006
|
+
// `--replay` on its own is a narrow removal: take the scenario server out and
|
|
2007
|
+
// leave every proxy and the hooks exactly as they are. Somebody turning replay
|
|
2008
|
+
// off should not have to reinstall recording afterwards.
|
|
2009
|
+
if (args.replay) {
|
|
2010
|
+
const target = findRecord(sidecar, cwd, SCENARIO_SERVER_KEY);
|
|
2011
|
+
// Switched off as well as taken out: without the switch the recorder kept
|
|
2012
|
+
// replaying matched prompts — steering, or running steps unattended for a
|
|
2013
|
+
// delivery tool that is no longer there. `bir replay on` turns it back on.
|
|
2014
|
+
const switched = settleReplaySwitch(sidecar, cwd, "refused", false);
|
|
2015
|
+
if (!target) {
|
|
2016
|
+
if (switched)
|
|
2017
|
+
writeSidecar(sidecar);
|
|
2018
|
+
out(`Calculated replay is not installed for this directory${switched ? "; it is switched off here now" : ""}.`);
|
|
2019
|
+
if (switched)
|
|
2020
|
+
await restartForReplaySwitch(cwd);
|
|
2021
|
+
return 0;
|
|
2022
|
+
}
|
|
2023
|
+
const result = uninstallProject(cwd, sidecar, { servers: [SCENARIO_SERVER_KEY], configPath: args.configPath });
|
|
2024
|
+
await restartForReplaySwitch(cwd);
|
|
2025
|
+
out();
|
|
2026
|
+
if (result.remaining.length) {
|
|
2027
|
+
for (const r of result.remaining)
|
|
2028
|
+
out(` ! ${r}`);
|
|
2029
|
+
return 1;
|
|
2030
|
+
}
|
|
2031
|
+
out("Calculated replay is uninstalled and switched off here. Recording is unchanged.");
|
|
2032
|
+
out("The prompt hook keeps its timeout; `bir install` rewrites it either way.");
|
|
2033
|
+
return 0;
|
|
2034
|
+
}
|
|
2035
|
+
const narrow = args.servers.length > 0;
|
|
2036
|
+
const result = uninstallProject(cwd, sidecar, { servers: args.servers, configPath: args.configPath });
|
|
2037
|
+
if (result.nothing) {
|
|
2038
|
+
out("Nothing installed for this directory.");
|
|
2039
|
+
const others = otherProjects(sidecar, cwd);
|
|
2040
|
+
if (others.length)
|
|
2041
|
+
out(`(The runner is set up in ${others.join(", ")}; \`bir uninstall --all\` removes it everywhere.)`);
|
|
2042
|
+
return 0;
|
|
2043
|
+
}
|
|
2044
|
+
if (!narrow && !args.keepRecorder)
|
|
2045
|
+
await retireRecorder(cwd);
|
|
2046
|
+
out();
|
|
2047
|
+
if (result.remaining.length) {
|
|
2048
|
+
out("Not everything could be undone. These still run through the runner here:");
|
|
2049
|
+
for (const r of result.remaining)
|
|
2050
|
+
out(` ! ${r}`);
|
|
2051
|
+
out("Fix what is named, then run `bir uninstall` again. Do not remove the runner before it says Uninstalled:");
|
|
2052
|
+
out("these entries name its files, and would stop working without them.");
|
|
2053
|
+
return 1;
|
|
2054
|
+
}
|
|
2055
|
+
out(narrow ? `Uninstalled ${args.servers.join(", ")}. Hooks and the other servers are unchanged.` : "Uninstalled.");
|
|
2056
|
+
if (!narrow) {
|
|
2057
|
+
const others = otherProjects(readSidecar(), cwd);
|
|
2058
|
+
if (others.length) {
|
|
2059
|
+
out(`The runner is still set up in ${others.length} other project${others.length === 1 ? "" : "s"}: ${others.join(", ")}.`);
|
|
2060
|
+
out("`bir uninstall --all` removes it from every one — run that before removing the package.");
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return 0;
|
|
2064
|
+
}
|
|
2065
|
+
/** Directories named in ~/.claude.json whose files hold something of ours, though no record here says so. */
|
|
2066
|
+
function projectsWithRunnerFiles(claude) {
|
|
2067
|
+
const found = [];
|
|
2068
|
+
for (const dir of Object.keys(claude.projects ?? {})) {
|
|
2069
|
+
try {
|
|
2070
|
+
if (!existsSync(dir))
|
|
2071
|
+
continue;
|
|
2072
|
+
const settings = claudeCodePaths(dir);
|
|
2073
|
+
if (hasBirHooks(settings.settingsLocal) || hasBirHooks(settings.settings)) {
|
|
2074
|
+
found.push(dir);
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
const mcp = parseJsonFile(settings.mcpJson)?.mcpServers;
|
|
2078
|
+
if (Object.values(mcp ?? {}).some((c) => ourEntry(c)))
|
|
2079
|
+
found.push(dir);
|
|
2080
|
+
}
|
|
2081
|
+
catch {
|
|
2082
|
+
/* unreadable: not ours to guess at */
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
return found;
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Put back every entry of ours left anywhere in ~/.claude.json: the user
|
|
2089
|
+
* scope (every project) and the local scope of every project, including ones
|
|
2090
|
+
* no record here knows — an older runner's, or a lost installed.json's.
|
|
2091
|
+
*/
|
|
2092
|
+
function sweepClaudeJson(claudeJson, remaining) {
|
|
2093
|
+
let claude;
|
|
2094
|
+
try {
|
|
2095
|
+
claude = parseJsonFile(claudeJson) ?? {};
|
|
2096
|
+
}
|
|
2097
|
+
catch (err) {
|
|
2098
|
+
remaining.push(`${claudeJson} could not be read (${errText(err)}), so nothing in it was undone`);
|
|
2099
|
+
return 0;
|
|
2100
|
+
}
|
|
2101
|
+
let fixed = 0;
|
|
2102
|
+
// This file, whichever profile it is: not this environment's by default.
|
|
2103
|
+
const paths = claudeCodePaths(process.cwd(), claudeJson);
|
|
2104
|
+
for (const [name, config] of Object.entries(claude.mcpServers ?? {})) {
|
|
2105
|
+
if (!ourEntry(config))
|
|
2106
|
+
continue;
|
|
2107
|
+
const back = isScenarioServer(config) ? null : unwrapEntry(config);
|
|
2108
|
+
if (back === undefined) {
|
|
2109
|
+
remaining.push(`${name} in your user scope — its original could not be rebuilt; edit ${claudeJson} by hand`);
|
|
2110
|
+
continue;
|
|
2111
|
+
}
|
|
2112
|
+
try {
|
|
2113
|
+
setServerEntry("user", process.cwd(), name, back, paths);
|
|
2114
|
+
out(` ← ${name} — ${back === null ? "removed from" : "put back in"} your user scope`);
|
|
2115
|
+
fixed += 1;
|
|
2116
|
+
}
|
|
2117
|
+
catch (err) {
|
|
2118
|
+
remaining.push(`${name} in your user scope — ${errText(err)}`);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
for (const [key, project] of Object.entries(claude.projects ?? {})) {
|
|
2122
|
+
for (const [name, config] of Object.entries(project?.mcpServers ?? {})) {
|
|
2123
|
+
if (!ourEntry(config))
|
|
2124
|
+
continue;
|
|
2125
|
+
let back = isScenarioServer(config) ? null : unwrapEntry(config);
|
|
2126
|
+
if (back === undefined) {
|
|
2127
|
+
remaining.push(`${name} in the local scope of ${key} — its original could not be rebuilt; edit ${claudeJson} by hand`);
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
// An override whose record is lost (what install writes over a
|
|
2131
|
+
// `.mcp.json` or user-scope server): the entry under it is the original,
|
|
2132
|
+
// so the copy goes. Rebuilt as a plain local entry instead, it would
|
|
2133
|
+
// shadow that entry for good — every later change to it hidden.
|
|
2134
|
+
let override = false;
|
|
2135
|
+
if (back && sameServerConfig(back, entryBelow(key, name, claudeJson))) {
|
|
2136
|
+
back = null;
|
|
2137
|
+
override = true;
|
|
2138
|
+
}
|
|
2139
|
+
try {
|
|
2140
|
+
setLocalEntries(claudeJson, [key], name, back, ourEntry);
|
|
2141
|
+
out(` ← ${name} — ${override ? "its wrapped copy removed; the entry under it is the original" : back === null ? "removed" : "put back"} (local scope of ${key})`);
|
|
2142
|
+
fixed += 1;
|
|
2143
|
+
}
|
|
2144
|
+
catch (err) {
|
|
2145
|
+
remaining.push(`${name} in the local scope of ${key} — ${errText(err)}`);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return fixed;
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* control/daemon.ts's `listSupersededRecorders` and `stopSupersededRecorder`,
|
|
2153
|
+
* looked up at run time rather than imported by name: they arrive with the
|
|
2154
|
+
* daemon's side of LC-1, and until they do there is nothing to list — which
|
|
2155
|
+
* is exactly what a machine without that side has.
|
|
2156
|
+
*/
|
|
2157
|
+
const supersededApi = daemonApi;
|
|
2158
|
+
/** The superseded recorders of `cwd` (of every project, without it) that are still alive. Never throws. */
|
|
2159
|
+
async function supersededRecorders(cwd) {
|
|
2160
|
+
try {
|
|
2161
|
+
const list = supersededApi.listSupersededRecorders;
|
|
2162
|
+
if (typeof list !== "function")
|
|
2163
|
+
return [];
|
|
2164
|
+
const found = await list(cwd);
|
|
2165
|
+
return Array.isArray(found)
|
|
2166
|
+
? found.filter((r) => r && typeof r.pid === "number" && typeof r.url === "string" && typeof r.token === "string")
|
|
2167
|
+
: [];
|
|
2168
|
+
}
|
|
2169
|
+
catch {
|
|
2170
|
+
return [];
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* Stop one superseded recorder the way stopDaemon stops the project's own:
|
|
2175
|
+
* asked, and signalled only by the daemon's own code after it answered
|
|
2176
|
+
* `/health` as that very pid — never by a pid alone, which may be recycled.
|
|
2177
|
+
*/
|
|
2178
|
+
async function stopSuperseded(record) {
|
|
2179
|
+
try {
|
|
2180
|
+
if (typeof supersededApi.stopSupersededRecorder === "function") {
|
|
2181
|
+
return await supersededApi.stopSupersededRecorder(record);
|
|
2182
|
+
}
|
|
2183
|
+
const probe = await probeHealth(record.url, record.token);
|
|
2184
|
+
if (probe.kind !== "ok" || probe.health.pid !== record.pid)
|
|
2185
|
+
return { stopped: false, stale: probe.kind !== "timeout" };
|
|
2186
|
+
const asked = await new ControlClient(record.url, record.token, 3_000).stop();
|
|
2187
|
+
const deadline = Date.now() + 10_000;
|
|
2188
|
+
while (asked && pidAlive(record.pid) && Date.now() < deadline)
|
|
2189
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2190
|
+
return { stopped: !pidAlive(record.pid) };
|
|
2191
|
+
}
|
|
2192
|
+
catch {
|
|
2193
|
+
return { stopped: false };
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
/** `/health` of a superseded recorder, or undefined when it does not answer as itself. */
|
|
2197
|
+
async function supersededHealth(record) {
|
|
2198
|
+
try {
|
|
2199
|
+
const probe = await probeHealth(record.url, record.token);
|
|
2200
|
+
return probe.kind === "ok" && probe.health.pid === record.pid ? probe.health : undefined;
|
|
2201
|
+
}
|
|
2202
|
+
catch {
|
|
2203
|
+
return undefined;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
/** One line naming a superseded recorder. */
|
|
2207
|
+
function describeSuperseded(record) {
|
|
2208
|
+
return `pid ${record.pid} on ${record.url}${record.version ? `, version ${record.version}` : ""}${record.since ? `, superseded ${ago(record.since)}` : ""}`;
|
|
2209
|
+
}
|
|
2210
|
+
/** Stop every recorder this machine has a discovery file for, and every superseded one. */
|
|
2211
|
+
async function stopAllRecorders() {
|
|
2212
|
+
let files = [];
|
|
2213
|
+
try {
|
|
2214
|
+
files = readdirSync(controlDir()).filter((f) => f.endsWith(".json"));
|
|
2215
|
+
}
|
|
2216
|
+
catch {
|
|
2217
|
+
return 0;
|
|
2218
|
+
}
|
|
2219
|
+
let stopped = 0;
|
|
2220
|
+
for (const file of files) {
|
|
2221
|
+
let cwd;
|
|
2222
|
+
try {
|
|
2223
|
+
cwd = JSON.parse(readFileSync(join(controlDir(), file), "utf8")).cwd;
|
|
2224
|
+
}
|
|
2225
|
+
catch {
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
2228
|
+
if (typeof cwd !== "string" || !readDiscovery(cwd))
|
|
2229
|
+
continue;
|
|
2230
|
+
// stopDaemon signals only a process that answers /health as the recorder
|
|
2231
|
+
// with that file's pid: a recycled pid is never touched.
|
|
2232
|
+
const result = await stopDaemon(cwd);
|
|
2233
|
+
if (result.stopped) {
|
|
2234
|
+
out(` ← recorder stopped for ${cwd} (pid ${result.pid})`);
|
|
2235
|
+
stopped += 1;
|
|
2236
|
+
}
|
|
2237
|
+
else if (!result.stale) {
|
|
2238
|
+
out(` ! the recorder for ${cwd} (pid ${result.pid}) did not stop — \`bir down\` there, or end that process`);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
for (const record of await supersededRecorders()) {
|
|
2242
|
+
const result = await stopSuperseded(record);
|
|
2243
|
+
if (result.stopped) {
|
|
2244
|
+
out(` ← superseded recorder stopped for ${record.cwd} (${describeSuperseded(record)})`);
|
|
2245
|
+
stopped += 1;
|
|
2246
|
+
}
|
|
2247
|
+
else if (!result.stale) {
|
|
2248
|
+
out(` ! a superseded recorder for ${record.cwd} (pid ${record.pid}) did not stop — end that process`);
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
return stopped;
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* `bir uninstall --all`: the runner out of every project on this machine,
|
|
2255
|
+
* so that removing the package afterwards cannot break anything.
|
|
2256
|
+
*
|
|
2257
|
+
* Every installed project is uninstalled as `bir uninstall` would do it there
|
|
2258
|
+
* (its hooks, its entries, its files); then everything of ours still in
|
|
2259
|
+
* ~/.claude.json, for projects no record knows; then every recorder is
|
|
2260
|
+
* stopped and every off switch removed. What could not be undone is listed,
|
|
2261
|
+
* kept in installed.json for a second run, and the exit code says so — the
|
|
2262
|
+
* one thing this must never do is say "safe to remove" while something still
|
|
2263
|
+
* names the runner's files.
|
|
2264
|
+
*/
|
|
2265
|
+
async function uninstallAll(args) {
|
|
2266
|
+
if (args.servers.length > 0 || args.replay || args.configPath) {
|
|
2267
|
+
process.stderr.write("[bir] --all removes everything; it takes no --server, --replay or --config\n");
|
|
2268
|
+
return 2;
|
|
2269
|
+
}
|
|
2270
|
+
const sidecar = readSidecar();
|
|
2271
|
+
const paths = claudeCodePaths(process.cwd());
|
|
2272
|
+
const projects = installedProjects(sidecar);
|
|
2273
|
+
// Every `.claude.json` an install wrote to: this environment's, and any other
|
|
2274
|
+
// profile's (CLAUDE_CONFIG_DIR) the records name — collected now, before the
|
|
2275
|
+
// records that name them are dropped.
|
|
2276
|
+
const claudeFiles = new Map([[normalizePath(paths.claudeJson), paths.claudeJson]]);
|
|
2277
|
+
const noteFile = (file) => {
|
|
2278
|
+
if (typeof file === "string" && file && !claudeFiles.has(normalizePath(file)) && existsSync(file)) {
|
|
2279
|
+
claudeFiles.set(normalizePath(file), file);
|
|
2280
|
+
}
|
|
2281
|
+
};
|
|
2282
|
+
for (const record of Object.values(sidecar.servers)) {
|
|
2283
|
+
if (record && (record.scope === "local" || record.scope === "user"))
|
|
2284
|
+
noteFile(record.file);
|
|
2285
|
+
}
|
|
2286
|
+
for (const project of Object.values(sidecar.projects ?? {}))
|
|
2287
|
+
for (const file of project?.claudeJsonFiles ?? [])
|
|
2288
|
+
noteFile(file);
|
|
2289
|
+
for (const file of claudeFiles.values()) {
|
|
2290
|
+
let claude = {};
|
|
2291
|
+
try {
|
|
2292
|
+
claude = parseJsonFile(file) ?? {};
|
|
2293
|
+
}
|
|
2294
|
+
catch {
|
|
2295
|
+
/* sweepClaudeJson reports it */
|
|
2296
|
+
}
|
|
2297
|
+
for (const dir of projectsWithRunnerFiles(claude)) {
|
|
2298
|
+
if (!projects.has(normalizePath(dir)))
|
|
2299
|
+
projects.set(normalizePath(dir), dir);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
out("Removing the runner from every project on this machine");
|
|
2303
|
+
const remaining = [];
|
|
2304
|
+
let count = 0;
|
|
2305
|
+
for (const dir of [...projects.values()].sort()) {
|
|
2306
|
+
out(`── ${dir}`);
|
|
2307
|
+
try {
|
|
2308
|
+
const result = uninstallProject(dir, sidecar, { servers: [], all: true });
|
|
2309
|
+
if (result.nothing)
|
|
2310
|
+
out(" (nothing of ours left here)");
|
|
2311
|
+
else
|
|
2312
|
+
count += 1;
|
|
2313
|
+
remaining.push(...result.remaining.map((r) => `${dir}: ${r}`));
|
|
2314
|
+
}
|
|
2315
|
+
catch (err) {
|
|
2316
|
+
remaining.push(`${dir}: ${errText(err)}`);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
for (const file of claudeFiles.values()) {
|
|
2320
|
+
out(isClaudeJson(file, paths.claudeJson)
|
|
2321
|
+
? `── ${file} (your user scope, and projects no install record names)`
|
|
2322
|
+
: `── ${file} (another Claude Code profile an install wrote to: its user scope and projects)`);
|
|
2323
|
+
if (sweepClaudeJson(file, remaining) === 0)
|
|
2324
|
+
out(" (nothing of ours left)");
|
|
585
2325
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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)`);
|
|
2326
|
+
out("── recorders");
|
|
2327
|
+
if ((await stopAllRecorders()) === 0)
|
|
2328
|
+
out(" (none running)");
|
|
2329
|
+
if (remaining.length === 0) {
|
|
2330
|
+
// The switches: a reinstall later must not start out switched off. Only
|
|
2331
|
+
// once nothing of ours is left — a person who turned the runner off because
|
|
2332
|
+
// it misbehaved, and whose uninstall could not undo everything, keeps the
|
|
2333
|
+
// switch holding what is still wrapped inert.
|
|
2334
|
+
try {
|
|
2335
|
+
for (const flag of readdirSync(dirname(projectOffFlagPath(process.cwd())))) {
|
|
2336
|
+
rmSync(join(dirname(projectOffFlagPath(process.cwd())), flag), { force: true });
|
|
2337
|
+
}
|
|
603
2338
|
}
|
|
604
|
-
|
|
605
|
-
|
|
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]);
|
|
2339
|
+
catch {
|
|
2340
|
+
/* no per-project switches */
|
|
611
2341
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
2342
|
+
try {
|
|
2343
|
+
setRunnerOff(false);
|
|
2344
|
+
}
|
|
2345
|
+
catch {
|
|
2346
|
+
/* nothing to remove */
|
|
2347
|
+
}
|
|
2348
|
+
// Nothing left to retry: the bookkeeping goes too. The sign-in stays
|
|
2349
|
+
// (`bir logout` revokes it) and so does the stored service address.
|
|
2350
|
+
sidecar.servers = {};
|
|
2351
|
+
sidecar.files = {};
|
|
2352
|
+
sidecar.hookFiles = [];
|
|
2353
|
+
sidecar.projects = {};
|
|
2354
|
+
delete sidecar.controlPort;
|
|
2355
|
+
delete sidecar.token;
|
|
618
2356
|
}
|
|
619
2357
|
writeSidecar(sidecar);
|
|
620
2358
|
out();
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
2359
|
+
if (remaining.length) {
|
|
2360
|
+
out("Not everything could be undone:");
|
|
2361
|
+
for (const r of remaining)
|
|
2362
|
+
out(` ! ${r}`);
|
|
2363
|
+
out("Fix what is named, then run `bir uninstall --all` again. Do not remove the runner before it");
|
|
2364
|
+
out("finishes cleanly: these entries name its files, and would stop working without them.");
|
|
2365
|
+
if (offSource())
|
|
2366
|
+
out("The off switch stays as you set it until then, so what is left stays inert.");
|
|
2367
|
+
return 1;
|
|
625
2368
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
2369
|
+
out(count === 0
|
|
2370
|
+
? "Nothing of the runner's was set up on this machine."
|
|
2371
|
+
: `Uninstalled from ${count} project${count === 1 ? "" : "s"}. Nothing on this machine runs through the runner any more.`);
|
|
2372
|
+
out("Restart any Claude Code session that is open now: until then it may show a hook error on each prompt.");
|
|
2373
|
+
out(`You can now remove the runner: \`npm rm -g ${PACKAGE_NAME}\`, or delete ${configDir()}`);
|
|
2374
|
+
out("(the install script's copy lives there). `bir logout` first revokes this machine's sign-in.");
|
|
2375
|
+
return 0;
|
|
2376
|
+
}
|
|
2377
|
+
// ── off / on ───────────────────────────────────────────────────────────────
|
|
2378
|
+
/** The first word of a per-project off switch `bir uninstall` wrote (and `bir install` takes back). */
|
|
2379
|
+
const UNINSTALL_MARK = "set-by-uninstall";
|
|
2380
|
+
function markOffForUninstall(cwd) {
|
|
2381
|
+
setRunnerOff(true, cwd);
|
|
2382
|
+
writeFileSync(projectOffFlagPath(cwd), `${UNINSTALL_MARK} ${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
2383
|
+
}
|
|
2384
|
+
function isUninstallMark(cwd) {
|
|
2385
|
+
try {
|
|
2386
|
+
return readFileSync(projectOffFlagPath(cwd), "utf8").startsWith(UNINSTALL_MARK);
|
|
629
2387
|
}
|
|
630
|
-
|
|
631
|
-
|
|
2388
|
+
catch {
|
|
2389
|
+
return false;
|
|
632
2390
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
2391
|
+
}
|
|
2392
|
+
/** Remove this project's off switch when `bir uninstall` set it. True when it did. */
|
|
2393
|
+
function clearUninstallMark(cwd) {
|
|
2394
|
+
if (!isUninstallMark(cwd))
|
|
2395
|
+
return false;
|
|
2396
|
+
try {
|
|
2397
|
+
setRunnerOff(false, cwd);
|
|
2398
|
+
return true;
|
|
2399
|
+
}
|
|
2400
|
+
catch {
|
|
2401
|
+
return false;
|
|
639
2402
|
}
|
|
640
|
-
return 0;
|
|
641
2403
|
}
|
|
642
|
-
|
|
643
|
-
function
|
|
644
|
-
|
|
2404
|
+
/** Why the runner is off, in words. */
|
|
2405
|
+
function describeOff(source, dir) {
|
|
2406
|
+
if (source === "env")
|
|
2407
|
+
return "(BIR_OFF=1 in this environment)";
|
|
2408
|
+
if (source === "global")
|
|
2409
|
+
return `everywhere on this machine (\`bir off\`: ${offFlagPath()})`;
|
|
2410
|
+
return isUninstallMark(dir)
|
|
2411
|
+
? `for ${dir} (left by \`bir uninstall\` for a session still open there)`
|
|
2412
|
+
: `for ${dir} (\`bir off --here\`)`;
|
|
2413
|
+
}
|
|
2414
|
+
/**
|
|
2415
|
+
* The project directory `cwd` belongs to, as the recorder and the proxies key
|
|
2416
|
+
* it: an installed project that is `cwd` or contains it, else `cwd`. The
|
|
2417
|
+
* recorder runs where Claude Code was started — where `bir setup` ran — so a
|
|
2418
|
+
* `bir off --here` typed in a subdirectory must name that directory, or it
|
|
2419
|
+
* would switch off a project nothing runs in.
|
|
2420
|
+
*/
|
|
2421
|
+
function projectDirFor(cwd, sidecar = readSidecar()) {
|
|
2422
|
+
const here = normalizePath(cwd);
|
|
2423
|
+
let best;
|
|
2424
|
+
for (const [key, dir] of installedProjects(sidecar)) {
|
|
2425
|
+
if ((here === key || here.startsWith(`${key}/`)) && (!best || key.length > best[0].length))
|
|
2426
|
+
best = [key, dir];
|
|
2427
|
+
}
|
|
2428
|
+
return !best || best[0] === here ? cwd : best[1];
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* After `bir off`: the live recorders among `dirs` that predate the switch
|
|
2432
|
+
* (a 0.2.12 one left running across the upgrade), each replaced by a current
|
|
2433
|
+
* one when no session is using it (ensureDaemon's skew restart, with the
|
|
2434
|
+
* two-probe idle test for a recorder too old to say — control/daemon.ts), else
|
|
2435
|
+
* reported. A directory with no discovery file is not asked at all.
|
|
2436
|
+
*/
|
|
2437
|
+
async function olderRecordersAfterOff(dirs) {
|
|
2438
|
+
const found = [];
|
|
2439
|
+
const seen = new Set();
|
|
2440
|
+
for (const dir of dirs) {
|
|
2441
|
+
const key = normalizePath(dir);
|
|
2442
|
+
if (seen.has(key))
|
|
2443
|
+
continue;
|
|
2444
|
+
seen.add(key);
|
|
2445
|
+
try {
|
|
2446
|
+
if (!readDiscovery(dir))
|
|
2447
|
+
continue;
|
|
2448
|
+
const live = await daemonStatus(dir);
|
|
2449
|
+
if (!live || !predatesOffSwitch(live.health))
|
|
2450
|
+
continue;
|
|
2451
|
+
let replaced = false;
|
|
2452
|
+
try {
|
|
2453
|
+
const opts = {
|
|
2454
|
+
hooksScript: localHooksPath(),
|
|
2455
|
+
restartOnSkew: true,
|
|
2456
|
+
probeIdle: true,
|
|
2457
|
+
};
|
|
2458
|
+
const result = await ensureDaemon(dir, opts);
|
|
2459
|
+
// Replaced means gone: one started beside it (another port) leaves it
|
|
2460
|
+
// serving its sessions, still ignoring the switch.
|
|
2461
|
+
replaced = !pidAlive(live.info.pid) && !predatesOffSwitch(result.status.health);
|
|
2462
|
+
}
|
|
2463
|
+
catch {
|
|
2464
|
+
replaced = false;
|
|
2465
|
+
}
|
|
2466
|
+
found.push({ dir, pid: live.info.pid, version: recorderVersion(live.health, live.info), replaced });
|
|
2467
|
+
}
|
|
2468
|
+
catch {
|
|
2469
|
+
/* nothing to say about a recorder that could not be asked */
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
return found;
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* `bir off [--here]` / `bir on [--here]` — the kill switch (util/off-switch.ts).
|
|
2476
|
+
*
|
|
2477
|
+
* Off is instant and reversible and uninstalls nothing: the hooks answer with
|
|
2478
|
+
* nothing within a second (the recorder checks the switch on every call), the
|
|
2479
|
+
* wrapped servers become plain pipes the next time they start, and the service
|
|
2480
|
+
* is never called. `on` without `--here` means on everywhere: the machine-wide
|
|
2481
|
+
* switch and every project's.
|
|
2482
|
+
*/
|
|
2483
|
+
async function offCommand(args, off) {
|
|
645
2484
|
const sidecar = readSidecar();
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if (args.replay) {
|
|
651
|
-
const key = sidecarKey(cwd, SCENARIO_SERVER_KEY);
|
|
652
|
-
const record = sidecar.servers[key];
|
|
653
|
-
if (!record) {
|
|
654
|
-
out("Calculated replay is not installed for this directory.");
|
|
655
|
-
return 0;
|
|
2485
|
+
const dir = projectDirFor(process.cwd(), sidecar);
|
|
2486
|
+
if (off) {
|
|
2487
|
+
try {
|
|
2488
|
+
setRunnerOff(true, args.here ? dir : undefined);
|
|
656
2489
|
}
|
|
657
|
-
|
|
658
|
-
|
|
2490
|
+
catch (err) {
|
|
2491
|
+
process.stderr.write(`[bir] could not write the switch: ${errText(err)}\n`);
|
|
2492
|
+
return 1;
|
|
659
2493
|
}
|
|
660
|
-
|
|
661
|
-
|
|
2494
|
+
// A recorder older than the switch (0.2.12) ignores it. Asked, not
|
|
2495
|
+
// assumed: one no session uses is stopped (the next session starts a
|
|
2496
|
+
// current one, which honours the switch); one in use is named, and the
|
|
2497
|
+
// "answers with nothing" promise is not made for it.
|
|
2498
|
+
const older = await olderRecordersAfterOff(args.here ? [dir] : [...installedProjects(sidecar).values(), dir]);
|
|
2499
|
+
out(args.here ? `The runner is OFF for ${dir}.` : "The runner is OFF on this machine, in every project.");
|
|
2500
|
+
out(older.length
|
|
2501
|
+
? " hooks answer at once with nothing, within about a second — in sessions already open too, except"
|
|
2502
|
+
: " hooks answer at once with nothing, within about a second — in sessions already open too;");
|
|
2503
|
+
out(older.length
|
|
2504
|
+
? " the ones served by the older recorder named below; nothing else is recorded, replayed or sent"
|
|
2505
|
+
: " nothing is recorded, replayed or sent to the service");
|
|
2506
|
+
out(" MCP servers the ones it wraps stop recording within about a second, and run as plain pipes");
|
|
2507
|
+
out(" to the real server from their next start (a new session, or /mcp → reconnect)");
|
|
2508
|
+
out(" bir server withdraws its tools within a few seconds (it tells Claude Code its list changed)");
|
|
2509
|
+
out(`Nothing is uninstalled and your servers keep working. \`bir on${args.here ? " --here" : ""}\` turns it back on.`);
|
|
2510
|
+
for (const o of older) {
|
|
2511
|
+
out(o.replaced
|
|
2512
|
+
? ` ~ the recorder for ${o.dir} (pid ${o.pid}, ${o.version || "an older version"}) predated the off switch; ` +
|
|
2513
|
+
"nothing was using it, so a current one replaced it, which honours the switch"
|
|
2514
|
+
: ` ! the running recorder for ${o.dir} (pid ${o.pid}, ${o.version || "an older version"}) predates the off switch ` +
|
|
2515
|
+
"and keeps recording and approving until `bir up --restart` (once the session open there ends)");
|
|
2516
|
+
}
|
|
2517
|
+
if (process.env.BIR_OFF === "0" || process.env.BIR_OFF === "false") {
|
|
2518
|
+
out(" ! BIR_OFF=0 is set in this shell: anything started from it ignores the switch");
|
|
662
2519
|
}
|
|
663
|
-
delete sidecar.servers[key];
|
|
664
|
-
writeSidecar(sidecar);
|
|
665
|
-
out(` ← ${SCENARIO_SERVER_KEY} — scenario server removed from ${record.file}`);
|
|
666
|
-
out();
|
|
667
|
-
out("Calculated replay is uninstalled. Recording is unchanged.");
|
|
668
|
-
out("The prompt hook keeps its raised timeout; `bir install` rewrites it either way.");
|
|
669
|
-
return 0;
|
|
670
|
-
}
|
|
671
|
-
const mine = Object.entries(sidecar.servers).filter(([key]) => key.startsWith(`${cwd}::`));
|
|
672
|
-
if (mine.length === 0 && !(sidecar.hookFiles ?? []).length) {
|
|
673
|
-
out("Nothing installed for this directory.");
|
|
674
2520
|
return 0;
|
|
675
2521
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
continue;
|
|
683
|
-
if (backup.originalText === null) {
|
|
684
|
-
out(` - ${file} — created by install; leaving in place (now empty of our entries)`);
|
|
685
|
-
continue;
|
|
2522
|
+
const turnedOn = [];
|
|
2523
|
+
try {
|
|
2524
|
+
if (args.here) {
|
|
2525
|
+
if (existsSync(projectOffFlagPath(dir)))
|
|
2526
|
+
turnedOn.push(dir);
|
|
2527
|
+
setRunnerOff(false, dir);
|
|
686
2528
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (args.servers.length > 0 && !args.servers.includes(name))
|
|
694
|
-
continue;
|
|
695
|
-
if (!restoredWhole.has(record.file)) {
|
|
696
|
-
// Somebody edited the file after we did. Repair the entry rather than
|
|
697
|
-
// clobbering their work, and say which path was taken.
|
|
698
|
-
if (args.configPath || record.scope === "explicit") {
|
|
699
|
-
setGenericServerEntry(record.file, name, record.original);
|
|
2529
|
+
else {
|
|
2530
|
+
const flags = dirname(projectOffFlagPath(dir));
|
|
2531
|
+
const names = new Map([...installedProjects(sidecar).values()].map((d) => [controlKey(d), d]));
|
|
2532
|
+
let entries = [];
|
|
2533
|
+
try {
|
|
2534
|
+
entries = readdirSync(flags);
|
|
700
2535
|
}
|
|
701
|
-
|
|
702
|
-
|
|
2536
|
+
catch {
|
|
2537
|
+
/* no per-project switches */
|
|
703
2538
|
}
|
|
704
|
-
|
|
2539
|
+
for (const flag of entries) {
|
|
2540
|
+
rmSync(join(flags, flag), { force: true });
|
|
2541
|
+
turnedOn.push(names.get(flag) ?? `a project (${flag})`);
|
|
2542
|
+
}
|
|
2543
|
+
if (existsSync(offFlagPath()))
|
|
2544
|
+
turnedOn.unshift("the machine-wide switch");
|
|
2545
|
+
setRunnerOff(false);
|
|
705
2546
|
}
|
|
706
|
-
delete sidecar.servers[key];
|
|
707
2547
|
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
if (
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
sidecar.hookFiles = [];
|
|
724
|
-
sidecar.files = {};
|
|
725
|
-
if (sidecar.projects)
|
|
726
|
-
delete sidecar.projects[normalizePath(cwd)];
|
|
727
|
-
writeSidecar(sidecar);
|
|
728
|
-
const exclude = removeGitExclude(cwd);
|
|
729
|
-
if (exclude)
|
|
730
|
-
out(` ← ${LOCAL_SETTINGS_EXCLUDE} removed from ${exclude}`);
|
|
731
|
-
out();
|
|
732
|
-
out("Uninstalled.");
|
|
2548
|
+
catch (err) {
|
|
2549
|
+
process.stderr.write(`[bir] could not remove the switch: ${errText(err)}\n`);
|
|
2550
|
+
return 1;
|
|
2551
|
+
}
|
|
2552
|
+
const still = offSource(dir);
|
|
2553
|
+
if (still) {
|
|
2554
|
+
out(`The runner is still OFF here, ${describeOff(still, dir)}.`);
|
|
2555
|
+
out(still === "env" ? " Unset BIR_OFF (or set BIR_OFF=0) where Claude Code is started." : " `bir on` (without --here) turns that off too.");
|
|
2556
|
+
return 1;
|
|
2557
|
+
}
|
|
2558
|
+
out(args.here ? `The runner is ON for ${dir}.` : "The runner is ON, in every project on this machine.");
|
|
2559
|
+
if (turnedOn.length)
|
|
2560
|
+
out(` switched back on: ${turnedOn.join(", ")}`);
|
|
2561
|
+
out(" hooks record again within about a second, and so do wrapped MCP servers started before `bir off`;");
|
|
2562
|
+
out(" ones started while it was off stay plain pipes until their next start");
|
|
733
2563
|
return 0;
|
|
734
2564
|
}
|
|
2565
|
+
/**
|
|
2566
|
+
* What `bir down` leaves behind, said once — because it is not obvious.
|
|
2567
|
+
*
|
|
2568
|
+
* Stopping the recorder does not take the hooks out. A Claude Code session
|
|
2569
|
+
* open here keeps calling them, and each call now fails: a "hook error" line
|
|
2570
|
+
* on every prompt and tool call (the session itself keeps working) until the
|
|
2571
|
+
* session ends, and the next session's SessionStart hook starts the recorder
|
|
2572
|
+
* again. That is what `down` has always meant — stop this process — so it
|
|
2573
|
+
* stays; the quiet way to take the runner out of a session is `bir off
|
|
2574
|
+
* --here`, which keeps the recorder up to answer those hooks with nothing.
|
|
2575
|
+
*/
|
|
2576
|
+
function explainDown(cwd, inUse) {
|
|
2577
|
+
const paths = claudeCodePaths(cwd);
|
|
2578
|
+
if (!hasBirHooks(paths.settingsLocal) && !hasBirHooks(paths.settings))
|
|
2579
|
+
return;
|
|
2580
|
+
out(inUse
|
|
2581
|
+
? ' A Claude Code session is open here: until it ends it shows a "hook error" on each prompt and tool call'
|
|
2582
|
+
: ' Its hooks stay installed: a Claude Code session open here shows a "hook error" on each prompt and tool call');
|
|
2583
|
+
out(" (the session keeps working), and the next session here starts the recorder again.");
|
|
2584
|
+
out(" To take the runner out of the way without that: `bir up`, then `bir off --here`.");
|
|
2585
|
+
}
|
|
735
2586
|
// ── status ─────────────────────────────────────────────────────────────────
|
|
736
|
-
|
|
2587
|
+
/** How long `bir status` waits for the recorder's /health: a status line, not a probe. */
|
|
2588
|
+
const STATUS_HEALTH_TIMEOUT_MS = 1_500;
|
|
2589
|
+
async function status(args) {
|
|
737
2590
|
const cwd = process.cwd();
|
|
738
2591
|
const entries = args.configPath
|
|
739
2592
|
? Object.entries(readGenericServers(args.configPath)).map(([name, config]) => ({
|
|
@@ -744,18 +2597,48 @@ function status(args) {
|
|
|
744
2597
|
}))
|
|
745
2598
|
: resolveServers(cwd);
|
|
746
2599
|
const discovery = readDiscovery(cwd);
|
|
2600
|
+
const sidecar = readSidecar();
|
|
2601
|
+
const projectDir = projectDirFor(cwd, sidecar);
|
|
2602
|
+
const off = offSource(projectDir);
|
|
2603
|
+
// The recorder's own view (C8): whether the service answers it, and whether
|
|
2604
|
+
// it sees the runner off. Both fields are absent from an older recorder.
|
|
2605
|
+
const health = discovery
|
|
2606
|
+
? await new ControlClient(discovery.url, discovery.token, STATUS_HEALTH_TIMEOUT_MS).health()
|
|
2607
|
+
: undefined;
|
|
2608
|
+
const serviceView = (health?.service ?? null);
|
|
2609
|
+
// Overrides whose source changed since they were copied (config/overrides.ts).
|
|
2610
|
+
const drift = new Map(args.configPath ? [] : checkOverrides(cwd, sidecar).filter((c) => c.state !== "current").map((c) => [c.name, c]));
|
|
2611
|
+
// The port a recorder here last could not take, and why (control/daemon.ts).
|
|
2612
|
+
const hookPorts = args.configPath ? [] : hookFacts(claudeCodePaths(cwd)).ports;
|
|
2613
|
+
const portProblem = openPortProblem(cwd, health ? portOf(discovery?.url) : undefined, hookPorts.length === 1 ? hookPorts[0] : undefined);
|
|
2614
|
+
// Recorders a reinstall on another port left serving older windows (LC-1).
|
|
2615
|
+
const superseded = await supersededRecorders(projectDir);
|
|
747
2616
|
const report = {
|
|
748
2617
|
cwd,
|
|
749
2618
|
authUrl: resolveAuthUrl() || null,
|
|
750
|
-
controlServer: discovery ? { url: discovery.url, pid: discovery.pid } : null,
|
|
2619
|
+
controlServer: discovery ? { url: discovery.url, pid: discovery.pid, answering: health !== undefined } : null,
|
|
751
2620
|
discoveryFile: discoveryPath(cwd),
|
|
2621
|
+
// null while the recorder can take its port (and from a runner older than the file).
|
|
2622
|
+
portProblem: portProblem ?? null,
|
|
752
2623
|
stateDir: configDir(),
|
|
2624
|
+
// The kill switch (`bir off`): null while the runner is on.
|
|
2625
|
+
off: off ? { source: off, detail: describeOff(off, projectDir) } : null,
|
|
2626
|
+
recorder: health
|
|
2627
|
+
? {
|
|
2628
|
+
service: serviceView,
|
|
2629
|
+
off: typeof health.off === "string" ? health.off : health.off === null ? null : undefined,
|
|
2630
|
+
version: recorderVersion(health) || null,
|
|
2631
|
+
}
|
|
2632
|
+
: null,
|
|
2633
|
+
superseded: superseded.map((r) => ({ pid: r.pid, url: r.url, version: r.version ?? null, since: r.since ?? null })),
|
|
2634
|
+
otherProjects: otherProjects(sidecar, projectDir),
|
|
753
2635
|
servers: entries.map((e) => ({
|
|
754
2636
|
name: e.name,
|
|
755
2637
|
scope: e.scope,
|
|
756
2638
|
shadowed: e.shadowed,
|
|
757
2639
|
wrapped: isWrapped(e.config),
|
|
758
2640
|
upstream: isRemote(e.config) ? e.config.url : e.config.command,
|
|
2641
|
+
...(drift.has(e.name) ? { stale: { state: drift.get(e.name).state, why: drift.get(e.name).why } } : {}),
|
|
759
2642
|
})),
|
|
760
2643
|
};
|
|
761
2644
|
if (args.json) {
|
|
@@ -765,58 +2648,280 @@ function status(args) {
|
|
|
765
2648
|
out(`Directory : ${report.cwd}`);
|
|
766
2649
|
out(`BaseIn : ${report.authUrl ?? "(not set — run `bir setup`, or set BIR_AUTH_URL; nothing is recorded)"}`);
|
|
767
2650
|
out(`Control : ${discovery
|
|
768
|
-
? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})`
|
|
2651
|
+
? `${discovery.url} (pid ${discovery.pid}${discovery.daemon ? "; background" : ""})${health ? "" : " — not answering; `bir doctor` says more"}`
|
|
769
2652
|
: "not running — `bir up` starts it"}`);
|
|
2653
|
+
if (portProblem)
|
|
2654
|
+
out(`Port : ${describePortProblem(portProblem)}`);
|
|
2655
|
+
for (const r of superseded) {
|
|
2656
|
+
out(`Superseded: ${describeSuperseded(r)} — serves windows opened before the last reinstall; \`bir down\` stops it`);
|
|
2657
|
+
}
|
|
770
2658
|
if (discovery?.logFile)
|
|
771
2659
|
out(`Log : ${discovery.logFile}`);
|
|
2660
|
+
if (serviceView) {
|
|
2661
|
+
out(`Service : ${serviceView.healthy === false
|
|
2662
|
+
? `not answering the recorder (${serviceView.lastError ?? "no answer"}) — sessions do not wait for it meanwhile`
|
|
2663
|
+
: serviceView.lastOkAt
|
|
2664
|
+
? `answering the recorder (last ${ago(serviceView.lastOkAt)})`
|
|
2665
|
+
: "no call yet"}`);
|
|
2666
|
+
}
|
|
2667
|
+
out(`Runner : ${report.off
|
|
2668
|
+
? `OFF ${report.off.detail}: hooks answer nothing, wrapped servers are plain pipes — \`bir on${off === "project" ? " --here" : ""}\` turns it back on`
|
|
2669
|
+
: "on (`bir off --here` takes it out of the way without uninstalling)"}`);
|
|
2670
|
+
if (health && typeof health.off === "string" && !off) {
|
|
2671
|
+
out(` the recorder reports it OFF (${health.off}) — BIR_OFF=1 where it was started?`);
|
|
2672
|
+
}
|
|
2673
|
+
if (health && off && predatesOffSwitch(health)) {
|
|
2674
|
+
out(` but the running recorder (${recorderVersion(health) || "an older version"}) predates the off switch and keeps ` +
|
|
2675
|
+
"recording — `bir up --restart` replaces it");
|
|
2676
|
+
}
|
|
2677
|
+
if (report.otherProjects.length)
|
|
2678
|
+
out(`Elsewhere : also set up in ${report.otherProjects.join(", ")}`);
|
|
772
2679
|
out("Servers :");
|
|
773
2680
|
if (report.servers.length === 0)
|
|
774
2681
|
out(" (none configured for this directory)");
|
|
775
2682
|
for (const s of report.servers) {
|
|
776
2683
|
const shadow = s.shadowed.length ? ` shadows:${s.shadowed.join("/")}` : "";
|
|
777
|
-
|
|
2684
|
+
const stale = s.stale ? ` — STALE: ${s.stale.why}; \`bir setup\` here fixes it` : "";
|
|
2685
|
+
out(` ${s.wrapped ? "[wrapped]" : "[ raw ]"} ${s.name} (${s.scope}${shadow}) → ${s.upstream}${stale}`);
|
|
778
2686
|
}
|
|
779
2687
|
return 0;
|
|
780
2688
|
}
|
|
781
|
-
|
|
2689
|
+
/**
|
|
2690
|
+
* Read the hooks as Claude Code will run them. The settings file is the
|
|
2691
|
+
* ground truth for the port: it is what every hook posts to, whatever
|
|
2692
|
+
* installed.json or the recorder believe.
|
|
2693
|
+
*/
|
|
2694
|
+
function hookFacts(paths) {
|
|
2695
|
+
const facts = { files: [], selfStarting: false, ports: [], ensure: [], unreadable: [] };
|
|
2696
|
+
for (const file of [paths.settingsLocal, paths.settings]) {
|
|
2697
|
+
let parsed;
|
|
2698
|
+
try {
|
|
2699
|
+
parsed = parseJsonFile(file);
|
|
2700
|
+
}
|
|
2701
|
+
catch {
|
|
2702
|
+
facts.unreadable.push(file);
|
|
2703
|
+
continue;
|
|
2704
|
+
}
|
|
2705
|
+
let ours = false;
|
|
2706
|
+
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
2707
|
+
if (!Array.isArray(matchers))
|
|
2708
|
+
continue;
|
|
2709
|
+
for (const matcher of matchers) {
|
|
2710
|
+
for (const hook of Array.isArray(matcher?.hooks) ? matcher.hooks : []) {
|
|
2711
|
+
if (!isBirHook(hook))
|
|
2712
|
+
continue;
|
|
2713
|
+
ours = true;
|
|
2714
|
+
if (hook.type === "command" && typeof hook.command === "string") {
|
|
2715
|
+
if (event === "SessionStart") {
|
|
2716
|
+
facts.selfStarting = true;
|
|
2717
|
+
facts.ensure.push({ file, command: hook.command, args: Array.isArray(hook.args) ? hook.args.map(String) : [] });
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
else if (typeof hook.url === "string") {
|
|
2721
|
+
try {
|
|
2722
|
+
const port = Number(new URL(hook.url).port);
|
|
2723
|
+
if (port > 0 && !facts.ports.includes(port))
|
|
2724
|
+
facts.ports.push(port);
|
|
2725
|
+
}
|
|
2726
|
+
catch {
|
|
2727
|
+
/* isBirHook parsed it already */
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
if (ours)
|
|
2734
|
+
facts.files.push(file);
|
|
2735
|
+
}
|
|
2736
|
+
return facts;
|
|
2737
|
+
}
|
|
2738
|
+
/** "3 min ago", for a timestamp in the past. */
|
|
2739
|
+
function ago(at) {
|
|
2740
|
+
const s = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
2741
|
+
if (s < 90)
|
|
2742
|
+
return `${s} s ago`;
|
|
2743
|
+
if (s < 5400)
|
|
2744
|
+
return `${Math.round(s / 60)} min ago`;
|
|
2745
|
+
return `${Math.round(s / 3600)} h ago`;
|
|
2746
|
+
}
|
|
2747
|
+
/** The port of a loopback URL, or undefined. */
|
|
2748
|
+
function portOf(url) {
|
|
2749
|
+
if (!url)
|
|
2750
|
+
return undefined;
|
|
2751
|
+
try {
|
|
2752
|
+
const port = Number(new URL(url).port);
|
|
2753
|
+
return port > 0 ? port : undefined;
|
|
2754
|
+
}
|
|
2755
|
+
catch {
|
|
2756
|
+
return undefined;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
/**
|
|
2760
|
+
* The port this directory's recorder last could not take (control/daemon.ts,
|
|
2761
|
+
* readPortProblem), while it still matters: not once a recorder of ours
|
|
2762
|
+
* answers on it (the first one that binds it removes the file, but a status
|
|
2763
|
+
* may look in between), and not after the hooks moved to another port.
|
|
2764
|
+
* Absent — the usual case, or a runner older than the file — is undefined.
|
|
2765
|
+
*/
|
|
2766
|
+
function openPortProblem(cwd, livePort, hookPort) {
|
|
2767
|
+
let problem;
|
|
2768
|
+
try {
|
|
2769
|
+
problem = readPortProblem(cwd);
|
|
2770
|
+
}
|
|
2771
|
+
catch {
|
|
2772
|
+
return undefined;
|
|
2773
|
+
}
|
|
2774
|
+
if (!problem)
|
|
2775
|
+
return undefined;
|
|
2776
|
+
if (livePort !== undefined && livePort === problem.port)
|
|
2777
|
+
return undefined;
|
|
2778
|
+
if (hookPort !== undefined && hookPort !== problem.port)
|
|
2779
|
+
return undefined;
|
|
2780
|
+
return problem;
|
|
2781
|
+
}
|
|
2782
|
+
/** One line for a person: what happened to the port, and what to run. */
|
|
2783
|
+
function describePortProblem(problem) {
|
|
2784
|
+
const when = typeof problem.at === "number" && problem.at > 0 ? ` (${ago(problem.at)})` : "";
|
|
2785
|
+
const fix = typeof problem.fix === "string" && problem.fix.trim() ? ` — ${problem.fix.trim()}` : "";
|
|
2786
|
+
return `the recorder could not take port ${problem.port}${when}: ${problem.detail}${fix}`;
|
|
2787
|
+
}
|
|
782
2788
|
async function doctor(args) {
|
|
783
2789
|
const cwd = process.cwd();
|
|
2790
|
+
// Three kinds of finding. PROBLEMS break a Claude Code session — a server or
|
|
2791
|
+
// hook that cannot start, hooks posting to a port nobody answers — and only
|
|
2792
|
+
// they make doctor exit 1. WARNINGS are the runner not doing all its job, or
|
|
2793
|
+
// a configuration it should fix; sessions work. NOTES are facts. A doctor
|
|
2794
|
+
// that failed machines where every session worked (a second project's port,
|
|
2795
|
+
// a recorder not started yet between sessions) was support noise, and it
|
|
2796
|
+
// failed the scripts that gate on it.
|
|
784
2797
|
const problems = [];
|
|
2798
|
+
const warnings = [];
|
|
785
2799
|
const notes = [];
|
|
786
|
-
const
|
|
2800
|
+
const paths = claudeCodePaths(cwd);
|
|
2801
|
+
const sidecar = readSidecar();
|
|
2802
|
+
const projectDir = projectDirFor(cwd, sidecar);
|
|
2803
|
+
const off = offSource(projectDir);
|
|
2804
|
+
let entries = [];
|
|
2805
|
+
try {
|
|
2806
|
+
entries = resolveServers(cwd);
|
|
2807
|
+
}
|
|
2808
|
+
catch (err) {
|
|
2809
|
+
problems.push(`${errText(err)} — Claude Code cannot read it either`);
|
|
2810
|
+
}
|
|
787
2811
|
const wantWrapped = entries.filter((e) => isWrapped(e.config)).map((e) => e.name);
|
|
2812
|
+
const hooks = hookFacts(paths);
|
|
2813
|
+
for (const file of hooks.unreadable) {
|
|
2814
|
+
warnings.push(`${file} is not plain JSON, so its hooks could not be checked (and \`bir setup\` will not rewrite it)`);
|
|
2815
|
+
}
|
|
2816
|
+
if (off) {
|
|
2817
|
+
notes.push(`the runner is OFF ${describeOff(off, projectDir)}: hooks answer with nothing, wrapped servers record nothing ` +
|
|
2818
|
+
`(and run as plain pipes from their next start), nothing is replayed — \`bir on${off === "project" ? " --here" : ""}\` turns it back on`);
|
|
2819
|
+
}
|
|
788
2820
|
const discovery = readDiscovery(cwd);
|
|
789
2821
|
let health;
|
|
790
2822
|
if (discovery) {
|
|
791
|
-
health = await new ControlClient(discovery.url, discovery.token).health();
|
|
792
|
-
if (!health)
|
|
793
|
-
problems.push(`control server at ${discovery.url} did not answer /health`
|
|
2823
|
+
health = await new ControlClient(discovery.url, discovery.token, 3_000).health();
|
|
2824
|
+
if (!health) {
|
|
2825
|
+
problems.push(`the control server at ${discovery.url} (pid ${discovery.pid}) did not answer /health — hooks here wait on it ` +
|
|
2826
|
+
"or fail; `bir up --restart` replaces it");
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
else if (hooks.files.length > 0 && !hooks.selfStarting) {
|
|
2830
|
+
problems.push("hooks are installed here but nothing starts the recorder, so every hook fails until `bir up` — " +
|
|
2831
|
+
"run `bir setup` here for the SessionStart hook that starts it");
|
|
2832
|
+
}
|
|
2833
|
+
else if (hooks.files.length > 0) {
|
|
2834
|
+
notes.push("the recorder is not running; the SessionStart hook starts it with the next session (`bir up` starts it now)" +
|
|
2835
|
+
(wantWrapped.length && !off ? ` — ${wantWrapped.join(", ")} register with it then` : ""));
|
|
794
2836
|
}
|
|
795
2837
|
else {
|
|
796
|
-
|
|
797
|
-
"`bir up` starts one, and the SessionStart hook starts one with each session");
|
|
2838
|
+
notes.push("no hooks here: only MCP calls are recorded (standalone, Tier 2) — `bir setup` wires the hooks");
|
|
798
2839
|
}
|
|
799
2840
|
const registered = new Set((health?.registeredProxies ?? []).map((p) => p.serverName));
|
|
800
2841
|
// A recorder with no session yet is the normal state right after `bir setup`
|
|
801
2842
|
// and between sessions: the proxies are Claude Code's child processes, so
|
|
802
|
-
// there is nothing to register until a session starts.
|
|
803
|
-
// problem made the first `bir doctor` a person ever ran exit non-zero on a
|
|
804
|
-
// machine where nothing was wrong.
|
|
2843
|
+
// there is nothing to register until a session starts.
|
|
805
2844
|
const idleRecorder = Boolean(health) && (health?.sessions ?? []).length === 0;
|
|
806
2845
|
// The guarantee check: a *registered* proxy. Not the config file (which only
|
|
807
2846
|
// says what should happen) and not `serverInfo` (which stays the upstream's).
|
|
808
|
-
const unregistered = wantWrapped.filter((name) => !registered.has(name));
|
|
809
|
-
if (unregistered.length > 0
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
2847
|
+
const unregistered = health ? wantWrapped.filter((name) => !registered.has(name)) : [];
|
|
2848
|
+
if (unregistered.length > 0) {
|
|
2849
|
+
if (off) {
|
|
2850
|
+
notes.push(`${unregistered.join(", ")} run as plain pipes while the runner is off, so they do not register`);
|
|
2851
|
+
}
|
|
2852
|
+
else if (idleRecorder) {
|
|
2853
|
+
notes.push(`no Claude Code session is running here yet; ${unregistered.join(", ")} will register when one starts`);
|
|
2854
|
+
}
|
|
2855
|
+
else {
|
|
2856
|
+
for (const name of unregistered) {
|
|
2857
|
+
warnings.push(`${name} is wrapped in config but no proxy has registered — is the session running? ` +
|
|
2858
|
+
"(one started before `bir setup` needs a restart; until then its calls record standalone)");
|
|
2859
|
+
}
|
|
815
2860
|
}
|
|
816
2861
|
}
|
|
817
2862
|
for (const e of entries) {
|
|
818
2863
|
if (!isWrapped(e.config) && e.shadowed.length > 0) {
|
|
819
|
-
|
|
2864
|
+
warnings.push(`${e.name} is defined in ${e.shadowed.join(", ")} as well as ${e.scope}`);
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
// AN ORGANISATION POLICY THAT MATCHES SERVERS BY COMMAND (config/mcp-approval.ts).
|
|
2868
|
+
// A wrapped entry's command is the runner's: an allow-list then blocks it,
|
|
2869
|
+
// a deny-list no longer sees it. `bir setup` leaves such servers unwrapped.
|
|
2870
|
+
const policy = wantWrapped.length > 0 ? managedMcpPolicyBlocker() : undefined;
|
|
2871
|
+
if (policy) {
|
|
2872
|
+
warnings.push(`${wantWrapped.join(", ")} run through the runner, but ${policy} — Claude Code may refuse to start them; ` +
|
|
2873
|
+
"run `bir setup` here, which leaves them unwrapped");
|
|
2874
|
+
}
|
|
2875
|
+
// A LOCAL COPY THAT NO LONGER COPIES ITS SOURCE (config/overrides.ts). The
|
|
2876
|
+
// copy outranks the `.mcp.json` or user-scope entry it was made from, so a
|
|
2877
|
+
// teammate's change there, or a rotated token, never reaches this project:
|
|
2878
|
+
// Claude Code keeps starting the old server — broken because of the runner.
|
|
2879
|
+
const overrides = checkOverrides(cwd, sidecar);
|
|
2880
|
+
for (const c of overrides) {
|
|
2881
|
+
const src = c.record.override?.source === "project" ? ".mcp.json" : "your user scope";
|
|
2882
|
+
if (c.state === "changed") {
|
|
2883
|
+
problems.push(`${c.name}'s local copy is out of date with ${src}: ${c.why} — run \`bir setup\` here`);
|
|
2884
|
+
}
|
|
2885
|
+
else if (c.state === "removed") {
|
|
2886
|
+
problems.push(`${c.name}'s local copy outlives its source: ${c.why} — run \`bir setup\` here`);
|
|
2887
|
+
}
|
|
2888
|
+
else if (c.state === "unapproved") {
|
|
2889
|
+
warnings.push(`${c.name} runs here through a local copy although ${c.why} — so it starts without Claude Code asking; ` +
|
|
2890
|
+
"run `bir setup` here to take the copy out");
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
// THE LOCAL SCOPE IS PER REPOSITORY. In a linked worktree or a subdirectory
|
|
2894
|
+
// Claude Code reads the main checkout's, so the wrapped entries here are
|
|
2895
|
+
// that install's; and from the main checkout, the worktrees that share them.
|
|
2896
|
+
const repoRoot = claudeProjectRoot(cwd);
|
|
2897
|
+
const localWrapped = entries.filter((e) => e.scope === "local" && ourEntry(e.config)).map((e) => e.name);
|
|
2898
|
+
if (localWrapped.length > 0 && normalizePath(repoRoot) !== normalizePath(cwd)) {
|
|
2899
|
+
notes.push(`${localWrapped.join(", ")} run through the runner here because Claude Code shares the local scope of ${repoRoot} ` +
|
|
2900
|
+
"with this directory — they record to that project's recorder; `bir uninstall` there takes them out");
|
|
2901
|
+
}
|
|
2902
|
+
else if (localWrapped.length > 0) {
|
|
2903
|
+
const shared = linkedWorktrees(repoRoot);
|
|
2904
|
+
if (shared.length > 0) {
|
|
2905
|
+
notes.push(`the linked worktree${shared.length === 1 ? "" : "s"} ${shared.join(", ")} share${shared.length === 1 ? "s" : ""} this ` +
|
|
2906
|
+
`local scope, so sessions there run ${localWrapped.join(", ")} through the runner too`);
|
|
2907
|
+
for (const dir of shared) {
|
|
2908
|
+
for (const c of overrides) {
|
|
2909
|
+
if (c.from !== "project" || !c.source)
|
|
2910
|
+
continue;
|
|
2911
|
+
let theirs;
|
|
2912
|
+
try {
|
|
2913
|
+
theirs = parseJsonFile(join(dir, ".mcp.json"))
|
|
2914
|
+
?.mcpServers?.[c.name];
|
|
2915
|
+
}
|
|
2916
|
+
catch {
|
|
2917
|
+
theirs = undefined;
|
|
2918
|
+
}
|
|
2919
|
+
if (theirs !== undefined && !sameServerConfig(isWrapped(theirs) ? unwrapEntry(theirs) : theirs, c.source)) {
|
|
2920
|
+
warnings.push(`${dir} defines ${c.name} differently in its own .mcp.json, but sessions there run this checkout's copy — ` +
|
|
2921
|
+
`\`bir uninstall --server ${c.name}\` here if that worktree needs its own`);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
820
2925
|
}
|
|
821
2926
|
}
|
|
822
2927
|
// IS ANYTHING ACTUALLY BEING SAVED?
|
|
@@ -828,8 +2933,8 @@ async function doctor(args) {
|
|
|
828
2933
|
// was recording perfectly. Ask the recorder; it is the only process that knows.
|
|
829
2934
|
if (health) {
|
|
830
2935
|
if (health.recording === false) {
|
|
831
|
-
|
|
832
|
-
"(or set BIR_AUTH_URL and `bir login`)
|
|
2936
|
+
warnings.push("the recorder is running but has nowhere to send steps — run `bir setup` " +
|
|
2937
|
+
"(or set BIR_AUTH_URL and `bir login`); it starts recording by itself after that");
|
|
833
2938
|
}
|
|
834
2939
|
else {
|
|
835
2940
|
notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}${health.account ? ` as ${String(health.account)}` : ""}`);
|
|
@@ -837,6 +2942,68 @@ async function doctor(args) {
|
|
|
837
2942
|
if (discovery?.daemon) {
|
|
838
2943
|
notes.push(`the recorder runs in the background (pid ${discovery.pid}); its audit log is ${discovery.logFile ?? "under ~/.baseinstrunner/logs"}`);
|
|
839
2944
|
}
|
|
2945
|
+
// WHETHER THE SERVICE IS ANSWERING THE RECORDER (util/service-health.ts).
|
|
2946
|
+
// A recorder older than that field says nothing, and nothing is claimed.
|
|
2947
|
+
const service = health.service;
|
|
2948
|
+
if (service && service.healthy === false) {
|
|
2949
|
+
warnings.push(`the BaseIn service is not answering the recorder (${service.lastError ?? "no answer"}; ` +
|
|
2950
|
+
`${service.consecutiveFailures ?? "several"} failure${service.consecutiveFailures === 1 ? "" : "s"} in a row). ` +
|
|
2951
|
+
"Hooks do not wait for it meanwhile, so sessions are unaffected; the recorder's log has the details");
|
|
2952
|
+
}
|
|
2953
|
+
else if (service?.lastOkAt) {
|
|
2954
|
+
notes.push(`the BaseIn service last answered the recorder ${ago(service.lastOkAt)}`);
|
|
2955
|
+
}
|
|
2956
|
+
// The recorder's own view of the switch. `off` is absent from a recorder
|
|
2957
|
+
// that predates it — which keeps recording whatever the switch says.
|
|
2958
|
+
if (off && predatesOffSwitch(health)) {
|
|
2959
|
+
warnings.push("this recorder is older than the off switch and keeps recording — `bir up --restart` replaces it");
|
|
2960
|
+
}
|
|
2961
|
+
else if (!off && typeof health.off === "string") {
|
|
2962
|
+
notes.push(`the recorder reports the runner OFF (${health.off}) — BIR_OFF=1 in the environment it was started with?`);
|
|
2963
|
+
}
|
|
2964
|
+
// A RECORDER OF ANOTHER VERSION. After an upgrade the old one keeps running
|
|
2965
|
+
// until something replaces it — and a 0.2.12 one still auto-approves every
|
|
2966
|
+
// wrapped MCP call, ignores `bir off`, and waits on the service in hooks.
|
|
2967
|
+
const theirs = recorderVersion(health, discovery ?? undefined);
|
|
2968
|
+
if (theirs && theirs !== VERSION) {
|
|
2969
|
+
warnings.push(`the recorder running here is ${theirs}, this bir is ${VERSION} — \`bir up --restart\` replaces it ` +
|
|
2970
|
+
"(once no Claude Code session here is mid-turn)");
|
|
2971
|
+
}
|
|
2972
|
+
// HOOKS THIS RECORDER DID NOT RECOGNISE. It answers them `{}` so the session
|
|
2973
|
+
// never shows an error, which also means nothing else would ever say so:
|
|
2974
|
+
// their steps are not recorded. A stale token is the usual cause (a
|
|
2975
|
+
// reinstall while this recorder kept running).
|
|
2976
|
+
const hookCounters = health.hooks;
|
|
2977
|
+
const unauthorized = typeof hookCounters?.unauthorized === "number" ? hookCounters.unauthorized : 0;
|
|
2978
|
+
if (unauthorized > 0) {
|
|
2979
|
+
warnings.push(`${unauthorized} hook call(s) carried a token this recorder does not know, so they were answered ` +
|
|
2980
|
+
"with nothing and not recorded — the hooks were reinstalled while it kept running: `bir up --restart`");
|
|
2981
|
+
}
|
|
2982
|
+
const overran = typeof hookCounters?.overran === "number" ? hookCounters.overran : 0;
|
|
2983
|
+
if (overran > 0) {
|
|
2984
|
+
notes.push(`${overran} hook call(s) hit their deadline and were answered early (control.hook_deadline in the log); ` +
|
|
2985
|
+
"the session was not held");
|
|
2986
|
+
}
|
|
2987
|
+
// WHO APPROVES A WRAPPED MCP CALL. 0.2.12 answered each one "allow"; now
|
|
2988
|
+
// Claude Code asks as it would without the runner, unless the recorder was
|
|
2989
|
+
// started with BIR_CORRELATION_DECISION=allow. A headless fleet that relied
|
|
2990
|
+
// on the old answer sees its MCP calls denied, so this is always said.
|
|
2991
|
+
const correlation = health.correlation;
|
|
2992
|
+
if (wantWrapped.length > 0 || correlation === "allow") {
|
|
2993
|
+
notes.push(correlation === "allow"
|
|
2994
|
+
? "wrapped MCP calls are APPROVED by the recorder (BIR_CORRELATION_DECISION=allow where it was started): " +
|
|
2995
|
+
"Claude Code does not ask about them"
|
|
2996
|
+
: correlation === "ask"
|
|
2997
|
+
? "wrapped MCP calls are left to Claude Code, which asks about each (BIR_CORRELATION_DECISION=ask)"
|
|
2998
|
+
: correlation === "fingerprint"
|
|
2999
|
+
? "wrapped MCP calls are left to Claude Code's own permissions (correlation off: steps joined by fingerprint)"
|
|
3000
|
+
: correlation === undefined
|
|
3001
|
+
? "this recorder predates the approval change: it answers every wrapped MCP call \"allow\" " +
|
|
3002
|
+
"(`bir up --restart` replaces it with one that leaves them to Claude Code)"
|
|
3003
|
+
: "wrapped MCP calls are not approved by the runner: Claude Code asks as it would without it. " +
|
|
3004
|
+
"For headless runs (`claude -p`) allow them in permissions.allow or --allowedTools, or set " +
|
|
3005
|
+
"BIR_CORRELATION_DECISION=allow where the recorder starts");
|
|
3006
|
+
}
|
|
840
3007
|
// Whether a recurring sub-task the service found may actually run mid-task
|
|
841
3008
|
// (segmented.md R-LIFE-8). Observe-only is the default and must be visible
|
|
842
3009
|
// without reading a log: an operator turns arming on by reading what *would*
|
|
@@ -862,35 +3029,108 @@ async function doctor(args) {
|
|
|
862
3029
|
// so this shell's BIR_AUTH_URL is evidence about them, if not about the
|
|
863
3030
|
// recorder. The one mistake worth catching is a URL that is a website rather
|
|
864
3031
|
// than the API: it answers `GET /health` with a page, so nothing else notices
|
|
865
|
-
// until a password has been posted to it and it says `405`.
|
|
3032
|
+
// until a password has been posted to it and it says `405`. A warning, not a
|
|
3033
|
+
// problem: sessions do not use this shell. `--quick` skips the network call.
|
|
866
3034
|
const shellAuthUrl = resolveAuthUrl();
|
|
867
|
-
const shellAuthProblem = shellAuthUrl ? await describeAuthService(shellAuthUrl) : undefined;
|
|
3035
|
+
const shellAuthProblem = shellAuthUrl && !args.quick ? await describeAuthService(shellAuthUrl) : undefined;
|
|
868
3036
|
if (shellAuthProblem) {
|
|
869
|
-
|
|
3037
|
+
warnings.push(`BIR_AUTH_URL in this shell (${shellAuthUrl}) is not a BaseIn service: ${shellAuthProblem}. ` +
|
|
870
3038
|
`\`bir login\` and \`bir scenario\` here will fail — ${AUTH_URL_HINT}`);
|
|
871
3039
|
}
|
|
872
3040
|
// THE SESSIONSTART HOOK NAMES A NODE BY ABSOLUTE PATH. A version manager's
|
|
873
3041
|
// per-shell directory, an uninstalled Node, a moved private Node: the hook
|
|
874
3042
|
// then fails on every session and the recorder is never started, which
|
|
875
3043
|
// records Tier 2 and looks like nothing at all.
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
try {
|
|
880
|
-
parsed = parseJsonFile(file);
|
|
3044
|
+
for (const hook of hooks.ensure) {
|
|
3045
|
+
if (isAbsolute(hook.command) && !existsSync(hook.command)) {
|
|
3046
|
+
problems.push(`the SessionStart hook in ${hook.file} points at a Node that is gone (${hook.command}) — run \`bir setup\` here again`);
|
|
881
3047
|
}
|
|
882
|
-
|
|
3048
|
+
const script = hook.args.find((a) => /\.js$/.test(a));
|
|
3049
|
+
if (script && isAbsolute(script) && !existsSync(script)) {
|
|
3050
|
+
problems.push(`the SessionStart hook in ${hook.file} runs a runner that is gone (${script}) — run \`bir setup\` here again`);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
// EVERY WRAPPED ENTRY NAMES A NODE AND A SCRIPT BY ABSOLUTE PATH, and the
|
|
3054
|
+
// host spawns them for the user's own servers now. A Node removed by a
|
|
3055
|
+
// version manager, or a runner moved by an upgrade, and every one of those
|
|
3056
|
+
// servers fails to start. Two `existsSync` calls each; no network.
|
|
3057
|
+
for (const e of entries) {
|
|
3058
|
+
if (isRemote(e.config) || !ourEntry(e.config))
|
|
3059
|
+
continue;
|
|
3060
|
+
const command = e.config.command;
|
|
3061
|
+
if (isAbsolute(command) && !existsSync(command)) {
|
|
3062
|
+
problems.push(`${e.name} cannot start: ${command} is gone — run \`bir setup\` here again`);
|
|
883
3063
|
continue;
|
|
884
3064
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
3065
|
+
const script = (e.config.args ?? []).find((a) => /bir-(proxy|scenario)\.js$/.test(a));
|
|
3066
|
+
if (script && isAbsolute(script) && !existsSync(script)) {
|
|
3067
|
+
problems.push(`${e.name} cannot start: ${script} is gone — run \`bir setup\` here again`);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
// A REMOTE SERVER WRAPPED WITHOUT CREDENTIALS. The proxy sends only what the
|
|
3071
|
+
// entry spells out; a server Claude Code signs in itself (OAuth) then answers
|
|
3072
|
+
// 401 to every call, and /mcp no longer offers to sign in. An older install
|
|
3073
|
+
// did this by default — `bir setup` un-wraps it; one wrapped because
|
|
3074
|
+
// `--server` named it is the person's call, and only a warning.
|
|
3075
|
+
for (const e of entries) {
|
|
3076
|
+
if (!isWrapped(e.config))
|
|
3077
|
+
continue;
|
|
3078
|
+
const original = unwrapEntry(e.config);
|
|
3079
|
+
if (!original || !isRemote(original))
|
|
3080
|
+
continue;
|
|
3081
|
+
const blocked = remoteWrapBlocker(original);
|
|
3082
|
+
if (!blocked)
|
|
3083
|
+
continue;
|
|
3084
|
+
if (findRecord(sidecar, cwd, e.name)?.record.optedIn === true) {
|
|
3085
|
+
warnings.push(`${e.name} is wrapped although ${blocked} (\`--server ${e.name}\` asked for it): if its calls answer 401, ` +
|
|
3086
|
+
`\`bir uninstall --server ${e.name}\``);
|
|
3087
|
+
}
|
|
3088
|
+
else {
|
|
3089
|
+
problems.push(`${e.name} is a remote server wrapped without credentials — ${blocked}, and the proxy cannot, so its calls ` +
|
|
3090
|
+
"fail (401). Run `bir setup` here: it un-wraps it (the hooks still record its calls)");
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
// WRAPPED ENTRIES WHERE THEY DO NOT BELONG, left by an older install: the
|
|
3094
|
+
// user scope applies to every project on the machine, and `.mcp.json` is a
|
|
3095
|
+
// file a team commits — a teammate's clone would spawn this machine's paths.
|
|
3096
|
+
// `bir setup` moves both to this project's local scope.
|
|
3097
|
+
try {
|
|
3098
|
+
const userServers = (parseJsonFile(paths.claudeJson)
|
|
3099
|
+
?.mcpServers ?? {});
|
|
3100
|
+
for (const [name, config] of Object.entries(userServers)) {
|
|
3101
|
+
if (isWrapped(config)) {
|
|
3102
|
+
warnings.push(`${name} is wrapped in your user scope, so it runs through the runner in EVERY project — run \`bir setup\` here ` +
|
|
3103
|
+
"to limit it to this one (and `bir uninstall --all` before removing the runner)");
|
|
892
3104
|
}
|
|
893
3105
|
}
|
|
3106
|
+
const mcpServers = (parseJsonFile(paths.mcpJson)
|
|
3107
|
+
?.mcpServers ?? {});
|
|
3108
|
+
const inMcpJson = Object.entries(mcpServers).filter(([, c]) => ourEntry(c)).map(([n]) => n);
|
|
3109
|
+
if (inMcpJson.length > 0) {
|
|
3110
|
+
// A note, not a problem: `--mcp-json` writes this shape on purpose.
|
|
3111
|
+
notes.push(`${paths.mcpJson} holds runner entries (${inMcpJson.join(", ")}) with this machine's paths — do not commit ` +
|
|
3112
|
+
"it; `bir setup` (without --mcp-json) moves them to the local scope");
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
catch {
|
|
3116
|
+
/* an unreadable config is reported by the checks above */
|
|
3117
|
+
}
|
|
3118
|
+
// ANOTHER CLAUDE CODE PROFILE. CLAUDE_CONFIG_DIR moves `.claude.json`, so an
|
|
3119
|
+
// install run with another value wrote its local-scope entries to another
|
|
3120
|
+
// file than the one this shell's Claude Code reads. Sessions started with
|
|
3121
|
+
// that profile run them; `bir uninstall` finds both.
|
|
3122
|
+
const elsewhere = otherClaudeFiles(sidecar, projectDir, paths.claudeJson);
|
|
3123
|
+
if (elsewhere.length > 0) {
|
|
3124
|
+
warnings.push(`this project was also installed into ${elsewhere.join(", ")} — not ${paths.claudeJson}, which Claude Code started ` +
|
|
3125
|
+
"from this shell reads (CLAUDE_CONFIG_DIR differs). Sessions of that profile run those entries; " +
|
|
3126
|
+
"`bir setup` from there updates them, and `bir uninstall` takes them out of both");
|
|
3127
|
+
}
|
|
3128
|
+
// RECORDERS A REINSTALL LEFT BEHIND (LC-1): a port change while a window was
|
|
3129
|
+
// open starts a second recorder for the new port; the old one keeps serving
|
|
3130
|
+
// that window and is found only through its own record.
|
|
3131
|
+
for (const r of await supersededRecorders(projectDir)) {
|
|
3132
|
+
notes.push(`a superseded recorder is still running (${describeSuperseded(r)}) for windows opened before the last ` +
|
|
3133
|
+
"reinstall — `bir down` stops it");
|
|
894
3134
|
}
|
|
895
3135
|
// THE SCENARIO SERVER IS ON THE CRITICAL PATH OF THE FIRST TURN. A plan armed
|
|
896
3136
|
// at `UserPromptSubmit` is delivered through `mcp__bir__run_scenario`, so a
|
|
@@ -899,26 +3139,68 @@ async function doctor(args) {
|
|
|
899
3139
|
// ~6s in. The turn does not fail; it just quietly runs the ordinary way, which
|
|
900
3140
|
// is the failure this whole command exists to make visible.
|
|
901
3141
|
const scenarioEntry = entries.find((e) => e.name === SCENARIO_SERVER_KEY);
|
|
902
|
-
if (scenarioEntry &&
|
|
3142
|
+
if (scenarioEntry && isNpxEntry(scenarioEntry.config)) {
|
|
903
3143
|
notes.push("the bir scenario server starts via npx, which can take longer than the first tool call " +
|
|
904
|
-
"of a turn —
|
|
3144
|
+
"of a turn — run `bir setup` (or `bir install --replay --global`) to pin it to the installed copy");
|
|
905
3145
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
3146
|
+
// THE PORT. The hooks' own URLs are the ground truth — they are what every
|
|
3147
|
+
// hook posts to — checked against the recorder actually answering here. Not
|
|
3148
|
+
// the machine-wide "last port any install wrote", which named another
|
|
3149
|
+
// project's port whenever two were installed and failed a working machine.
|
|
3150
|
+
const hookPort = hooks.ports.length === 1 ? hooks.ports[0] : undefined;
|
|
3151
|
+
if (hooks.ports.length > 1) {
|
|
3152
|
+
warnings.push(`the hooks here post to several ports (${hooks.ports.join(", ")}) — run \`bir setup\` here`);
|
|
3153
|
+
}
|
|
3154
|
+
if (hookPort !== undefined && discovery && health) {
|
|
3155
|
+
let livePort;
|
|
3156
|
+
try {
|
|
3157
|
+
livePort = Number(new URL(discovery.url).port);
|
|
3158
|
+
}
|
|
3159
|
+
catch {
|
|
3160
|
+
livePort = undefined;
|
|
3161
|
+
}
|
|
3162
|
+
if (livePort && livePort !== hookPort) {
|
|
3163
|
+
problems.push(`the hooks here post to port ${hookPort}, but this directory's recorder is on ${discovery.url}, so every hook ` +
|
|
3164
|
+
"fails — run `bir setup` here");
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
const recordPort = projectRecord(sidecar, cwd)?.port;
|
|
3168
|
+
if (hookPort !== undefined && recordPort !== undefined && recordPort !== hookPort) {
|
|
3169
|
+
warnings.push(`installed.json gives this project port ${recordPort}, but its hooks name ${hookPort} — run \`bir setup\` here`);
|
|
3170
|
+
}
|
|
3171
|
+
// THE PORT THE RECORDER COULD NOT TAKE (control/daemon.ts). A recorder that
|
|
3172
|
+
// exits because the port its hooks name is busy or reserved says why, and by
|
|
3173
|
+
// whom; until one binds it, every hook here fails. Its own fix text names
|
|
3174
|
+
// the way out, which the generic advice above cannot.
|
|
3175
|
+
const portProblem = openPortProblem(cwd, health ? portOf(discovery?.url) : undefined, hookPort);
|
|
3176
|
+
if (portProblem)
|
|
3177
|
+
problems.push(describePortProblem(portProblem));
|
|
3178
|
+
// THE OTHER PROJECTS. Uninstalling here leaves them as they are; removing
|
|
3179
|
+
// the package without `bir uninstall --all` would break every one of them.
|
|
3180
|
+
const others = otherProjects(sidecar, cwd);
|
|
3181
|
+
if (others.length) {
|
|
3182
|
+
notes.push(`also set up on this machine: ${others.join(", ")} — \`bir uninstall --all\` removes the runner from every ` +
|
|
3183
|
+
"project (do that before removing the package)");
|
|
909
3184
|
}
|
|
910
3185
|
if (args.json) {
|
|
911
3186
|
out(JSON.stringify({
|
|
912
3187
|
ok: problems.length === 0,
|
|
913
3188
|
problems,
|
|
3189
|
+
warnings,
|
|
914
3190
|
notes,
|
|
915
3191
|
health,
|
|
916
3192
|
wrapped: wantWrapped,
|
|
3193
|
+
off: off ?? null,
|
|
3194
|
+
service: health?.service ?? null,
|
|
3195
|
+
otherProjects: others,
|
|
917
3196
|
shellAuthUrl: shellAuthUrl || null,
|
|
918
3197
|
shellAuthProblem: shellAuthProblem ?? null,
|
|
3198
|
+
portProblem: portProblem ?? null,
|
|
919
3199
|
}, null, 2));
|
|
920
|
-
return problems.length === 0 ? 0 : 1;
|
|
3200
|
+
return problems.length === 0 && !(args.strict && warnings.length > 0) ? 0 : 1;
|
|
921
3201
|
}
|
|
3202
|
+
if (off)
|
|
3203
|
+
out(`Runner : OFF ${describeOff(off, projectDir)}`);
|
|
922
3204
|
// Replay first, and loudly. It auto-approves tool calls in the session and
|
|
923
3205
|
// bypasses the permission system entirely for direct steps
|
|
924
3206
|
// (docs/calculatedReplay.md §13.2), so it must never be discoverable only by
|
|
@@ -932,20 +3214,21 @@ async function doctor(args) {
|
|
|
932
3214
|
// it could describe then.
|
|
933
3215
|
const via = replay.deriveVia ?? (replay.deriveKey ? "key" : "samples");
|
|
934
3216
|
const derive = via === "key"
|
|
935
|
-
? "this machine (
|
|
3217
|
+
? "this machine (a derivation key: BIR_DERIVE_API_KEY)"
|
|
936
3218
|
: via === "service"
|
|
937
3219
|
? "the service (no key needed here)"
|
|
938
3220
|
: "recorded sample values — scenarios with a target will NOT run";
|
|
939
3221
|
out(` derive=${derive}`);
|
|
940
3222
|
if (via === "samples") {
|
|
941
3223
|
notes.push("nothing can read what this turn acts on: sign in with `bir login` so the " +
|
|
942
|
-
"service derives, or set
|
|
3224
|
+
"service derives, or set BIR_DERIVE_API_KEY where Claude Code starts to derive " +
|
|
3225
|
+
"here (the background recorder ignores an inherited ANTHROPIC_API_KEY). Until then a " +
|
|
943
3226
|
"matched scenario with a target is declined and the agent does the task");
|
|
944
3227
|
}
|
|
945
3228
|
const idle = wantWrapped.filter((n) => !(replay.pollingProxies ?? []).includes(n));
|
|
946
|
-
if (idle.length > 0 && !idleRecorder) {
|
|
3229
|
+
if (idle.length > 0 && !idleRecorder && !off) {
|
|
947
3230
|
notes.push(`these proxies are not polling for replay work: ${idle.join(", ")} — ` +
|
|
948
|
-
"
|
|
3231
|
+
"a direct plan that needs one of them stops at that step and hands the task back to the agent");
|
|
949
3232
|
}
|
|
950
3233
|
}
|
|
951
3234
|
else if (health) {
|
|
@@ -955,24 +3238,31 @@ async function doctor(args) {
|
|
|
955
3238
|
out(`Registered proxies: ${[...registered].join(", ") || "(none)"}`);
|
|
956
3239
|
out(`Control server : ${discovery?.url ?? "not running"}`);
|
|
957
3240
|
out(`BaseIn (shell) : ${shellAuthUrl
|
|
958
|
-
? `${shellAuthUrl}${shellAuthProblem ? " — NOT a BaseIn service, see below" : " (answers /health)"}`
|
|
3241
|
+
? `${shellAuthUrl}${shellAuthProblem ? " — NOT a BaseIn service, see below" : args.quick ? " (not checked: --quick)" : " (answers /health)"}`
|
|
959
3242
|
: "(BIR_AUTH_URL not set in this shell)"}`);
|
|
960
3243
|
out(`Recording tier : ${discovery && health ? "bound (Tier 1)" : "standalone (Tier 2)"}`);
|
|
961
|
-
out(`Recording : ${health ? (health.recording === false ? "no — see below" : "yes") : "unknown (no control server to ask)"}`);
|
|
3244
|
+
out(`Recording : ${off ? "no — the runner is off" : health ? (health.recording === false ? "no — see below" : "yes") : "unknown (no control server to ask)"}`);
|
|
962
3245
|
if (health?.lossy)
|
|
963
3246
|
out("Lossy : yes — steps were dropped in this session");
|
|
964
3247
|
out();
|
|
965
3248
|
for (const n of notes)
|
|
966
3249
|
out(` · ${n}`);
|
|
967
|
-
if (
|
|
3250
|
+
if (warnings.length) {
|
|
968
3251
|
if (notes.length)
|
|
969
3252
|
out();
|
|
3253
|
+
out("Warnings (sessions work, but the runner is not doing all it should):");
|
|
3254
|
+
for (const w of warnings)
|
|
3255
|
+
out(` ~ ${w}`);
|
|
3256
|
+
}
|
|
3257
|
+
if (problems.length === 0) {
|
|
3258
|
+
if (notes.length || warnings.length)
|
|
3259
|
+
out();
|
|
970
3260
|
out("No problems found.");
|
|
971
|
-
return 0;
|
|
3261
|
+
return args.strict && warnings.length > 0 ? 1 : 0;
|
|
972
3262
|
}
|
|
973
|
-
if (notes.length)
|
|
3263
|
+
if (notes.length || warnings.length)
|
|
974
3264
|
out();
|
|
975
|
-
out("Problems:");
|
|
3265
|
+
out("Problems (these break a Claude Code session here):");
|
|
976
3266
|
for (const p of problems)
|
|
977
3267
|
out(` ! ${p}`);
|
|
978
3268
|
return 1;
|
|
@@ -996,15 +3286,35 @@ async function service(method, path, body) {
|
|
|
996
3286
|
const session = await authenticate({ authUrl: baseUrl });
|
|
997
3287
|
if (!session)
|
|
998
3288
|
throw new Error("not signed in — run `bir login`");
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
3289
|
+
// A ceiling (BIR_SERVICE_TIMEOUT_MS): a service that accepts the connection
|
|
3290
|
+
// and never answers would otherwise hold `bir scenario list` for undici's
|
|
3291
|
+
// 300 s. The verdict goes to the process-wide breaker, like every call.
|
|
3292
|
+
let res;
|
|
3293
|
+
let text;
|
|
3294
|
+
try {
|
|
3295
|
+
res = await fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, {
|
|
3296
|
+
method,
|
|
3297
|
+
headers: {
|
|
3298
|
+
"content-type": "application/json",
|
|
3299
|
+
authorization: `Bearer ${session.accessToken}`,
|
|
3300
|
+
},
|
|
3301
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
3302
|
+
signal: timeoutSignal(serviceTimeoutMs()),
|
|
3303
|
+
});
|
|
3304
|
+
text = await res.text();
|
|
3305
|
+
}
|
|
3306
|
+
catch (err) {
|
|
3307
|
+
serviceHealth.fail(errText(err));
|
|
3308
|
+
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
3309
|
+
const ms = serviceTimeoutMs();
|
|
3310
|
+
throw new Error(timedOut
|
|
3311
|
+
? `the BaseIn service did not answer within ${ms < 1_000 ? `${ms} ms` : `${Math.round(ms / 1_000)} s`} (BIR_SERVICE_TIMEOUT_MS)`
|
|
3312
|
+
: `the BaseIn service is unreachable (${errText(err)})`);
|
|
3313
|
+
}
|
|
3314
|
+
if (isUnhealthyStatus(res.status))
|
|
3315
|
+
serviceHealth.fail(`HTTP ${res.status}`);
|
|
3316
|
+
else
|
|
3317
|
+
serviceHealth.ok();
|
|
1008
3318
|
let parsed = undefined;
|
|
1009
3319
|
try {
|
|
1010
3320
|
parsed = text ? JSON.parse(text) : undefined;
|
|
@@ -1124,7 +3434,11 @@ function replayPolicyCommand(args) {
|
|
|
1124
3434
|
const word = args.positionals[0];
|
|
1125
3435
|
const describe = (policy) => {
|
|
1126
3436
|
out(`Replay policy for ${cwd}`);
|
|
1127
|
-
out(` replay ${policy?.enabled
|
|
3437
|
+
out(` replay ${policy?.enabled !== false
|
|
3438
|
+
? "on"
|
|
3439
|
+
: policy.disabledBy === "install"
|
|
3440
|
+
? "off (not asked for at install: --no-replay, `bir uninstall --replay`, or no --replay) — `bir replay on` turns it on"
|
|
3441
|
+
: "off"}`);
|
|
1128
3442
|
out(` direct servers ${policy?.allowServers?.length ? policy.allowServers.join(", ") : "(all wrapped)"}`);
|
|
1129
3443
|
out(` minSimilarity ${policy?.minSimilarity ?? 0.92}`);
|
|
1130
3444
|
const overrides = ["BIR_REPLAY", "BIR_REPLAY_ALLOW_SERVERS", "BIR_MIN_STEER_SIMILARITY"].filter((k) => process.env[k] !== undefined);
|
|
@@ -1144,10 +3458,13 @@ function replayPolicyCommand(args) {
|
|
|
1144
3458
|
return 1;
|
|
1145
3459
|
}
|
|
1146
3460
|
const next = { ...(record.replay ?? {}) };
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
3461
|
+
// On or off, the person's own answer: kept as they said it (true, not just
|
|
3462
|
+
// "absent"), so a later install without --replay does not overrule it, and
|
|
3463
|
+
// no longer marked as an install's (generate.ts, settleReplaySwitch).
|
|
3464
|
+
if (word === "on" || word === "off") {
|
|
3465
|
+
next.enabled = word === "on";
|
|
3466
|
+
delete next.disabledBy;
|
|
3467
|
+
}
|
|
1151
3468
|
else if (word === "allow") {
|
|
1152
3469
|
const list = args.positionals[1];
|
|
1153
3470
|
if (!list) {
|
|
@@ -1277,20 +3594,86 @@ function wrap(args) {
|
|
|
1277
3594
|
out("usage: bir wrap --server-name <key> -- <command> [args…]");
|
|
1278
3595
|
process.exit(2);
|
|
1279
3596
|
})();
|
|
1280
|
-
|
|
3597
|
+
// This directory's port, not the machine-wide "last port any install wrote",
|
|
3598
|
+
// which is another project's whenever two are installed. None recorded: no
|
|
3599
|
+
// control URL, and the proxy finds a recorder by its own directory.
|
|
3600
|
+
const port = projectRecord(readSidecar(), process.cwd())?.port;
|
|
3601
|
+
const invocation = resolveInvocation(args);
|
|
1281
3602
|
const entry = wrapEntry(config, {
|
|
1282
3603
|
serverName: name,
|
|
1283
|
-
controlUrl:
|
|
3604
|
+
controlUrl: port ? `http://127.0.0.1:${port}` : undefined,
|
|
1284
3605
|
noCorrelation: args.noCorrelation,
|
|
1285
|
-
invocation
|
|
1286
|
-
proxyPath:
|
|
3606
|
+
invocation,
|
|
3607
|
+
proxyPath: invocation === "npx" ? undefined : localProxyPath(),
|
|
1287
3608
|
version: VERSION,
|
|
3609
|
+
nodeFlags: invocation === "npx" ? undefined : nodeFlagsToCarry(),
|
|
3610
|
+
node: invocation === "npx" ? undefined : chooseNodePath().path,
|
|
1288
3611
|
});
|
|
1289
3612
|
out(JSON.stringify({ mcpServers: { [name]: entry } }, null, 2));
|
|
1290
3613
|
return 0;
|
|
1291
3614
|
}
|
|
3615
|
+
// ── logout ─────────────────────────────────────────────────────────────────
|
|
3616
|
+
/** How long `bir logout` waits on the service in all: finding its address, then the revoke. */
|
|
3617
|
+
const LOGOUT_REVOKE_BUDGET_MS = 8_000;
|
|
3618
|
+
/**
|
|
3619
|
+
* Ask the service to revoke a refresh token this machine no longer keeps.
|
|
3620
|
+
* Never throws: the local sign-out already happened, and a service that does
|
|
3621
|
+
* not answer only means the token lapses on its own; the audit log says so.
|
|
3622
|
+
*/
|
|
3623
|
+
async function revokeRefreshToken(configured, refreshToken) {
|
|
3624
|
+
try {
|
|
3625
|
+
const authUrl = await normalizeAuthUrl(configured);
|
|
3626
|
+
const res = await fetch(`${authUrl}/auth/logout`, {
|
|
3627
|
+
method: "POST",
|
|
3628
|
+
headers: { "content-type": "application/json" },
|
|
3629
|
+
body: JSON.stringify({ refreshToken }),
|
|
3630
|
+
signal: timeoutSignal(LOGOUT_REVOKE_BUDGET_MS),
|
|
3631
|
+
});
|
|
3632
|
+
if (isUnhealthyStatus(res.status))
|
|
3633
|
+
serviceHealth.fail(`HTTP ${res.status}`);
|
|
3634
|
+
else
|
|
3635
|
+
serviceHealth.ok();
|
|
3636
|
+
res.body?.cancel().catch(() => undefined);
|
|
3637
|
+
}
|
|
3638
|
+
catch (err) {
|
|
3639
|
+
logLine("auth.logout_remote_failed", {
|
|
3640
|
+
error: errText(err),
|
|
3641
|
+
why: "the local credentials are cleared regardless",
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
1292
3645
|
// ── entry point ────────────────────────────────────────────────────────────
|
|
3646
|
+
/**
|
|
3647
|
+
* Move records whose stash is redacted out of an older runner's sight NOW
|
|
3648
|
+
* (generate.ts, REDACTED_KEY_PREFIX), not at the next install: a build before
|
|
3649
|
+
* this one kept them under the plain key, and a rollback to 0.2.12 could come
|
|
3650
|
+
* first — whose uninstall would write the blanked credentials back. One read
|
|
3651
|
+
* of installed.json; a write only when something moved. Never throws.
|
|
3652
|
+
*/
|
|
3653
|
+
function migrateRecordKeys() {
|
|
3654
|
+
try {
|
|
3655
|
+
const path = installedPath();
|
|
3656
|
+
if (!existsSync(path))
|
|
3657
|
+
return;
|
|
3658
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
3659
|
+
const servers = raw && typeof raw === "object" ? raw.servers : undefined;
|
|
3660
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
3661
|
+
return;
|
|
3662
|
+
if (!normaliseRecordKeys({ ...servers }))
|
|
3663
|
+
return;
|
|
3664
|
+
writeSidecar(readSidecar());
|
|
3665
|
+
}
|
|
3666
|
+
catch {
|
|
3667
|
+
/* the next install moves them */
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
1293
3670
|
async function main() {
|
|
3671
|
+
// Started with --use-env-proxy (a launcher written behind a proxy) in a shell
|
|
3672
|
+
// whose NO_PROXY does not name 127.0.0.1: without this, every call `bir`
|
|
3673
|
+
// makes to the recorder — `bir up`, `bir doctor`, setup's wait — goes to the
|
|
3674
|
+
// proxy and fails. Entries and hooks are still decided from the environment
|
|
3675
|
+
// as it was (generate.ts, nodeFlagsToCarry).
|
|
3676
|
+
exemptLoopbackFromProxy();
|
|
1294
3677
|
const argv = process.argv.slice(2);
|
|
1295
3678
|
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help")
|
|
1296
3679
|
usage(0);
|
|
@@ -1299,13 +3682,14 @@ async function main() {
|
|
|
1299
3682
|
return 0;
|
|
1300
3683
|
}
|
|
1301
3684
|
const args = parseArgs(argv);
|
|
3685
|
+
migrateRecordKeys();
|
|
1302
3686
|
switch (args.command) {
|
|
1303
3687
|
case "install":
|
|
1304
3688
|
return install(args);
|
|
1305
3689
|
case "uninstall":
|
|
1306
|
-
return uninstall(args);
|
|
3690
|
+
return await uninstall(args);
|
|
1307
3691
|
case "status":
|
|
1308
|
-
return status(args);
|
|
3692
|
+
return await status(args);
|
|
1309
3693
|
case "doctor":
|
|
1310
3694
|
return doctor(args);
|
|
1311
3695
|
case "wrap":
|
|
@@ -1332,8 +3716,10 @@ async function main() {
|
|
|
1332
3716
|
});
|
|
1333
3717
|
case "setup": {
|
|
1334
3718
|
// The invocation is decided here, not asked: an installed package gets
|
|
1335
|
-
// absolute paths (
|
|
1336
|
-
|
|
3719
|
+
// absolute paths (global), a checkout points at itself (local), and a
|
|
3720
|
+
// `npx @basein/runner setup` gets pinned npx — never an absolute path
|
|
3721
|
+
// into npx's cache, which npm deletes when it likes.
|
|
3722
|
+
const invocation = runsFromInstalledPackage() ? "global" : runsFromNpxCache() ? "npx" : "local";
|
|
1337
3723
|
return runSetup({
|
|
1338
3724
|
authUrl: args.authUrl,
|
|
1339
3725
|
token: args.token,
|
|
@@ -1342,10 +3728,20 @@ async function main() {
|
|
|
1342
3728
|
replay: !args.noReplay,
|
|
1343
3729
|
daemon: !args.noDaemon,
|
|
1344
3730
|
allowServers: args.allowServers,
|
|
3731
|
+
portExplicit: args.portExplicit,
|
|
1345
3732
|
}, {
|
|
1346
3733
|
out,
|
|
1347
3734
|
err: (line) => process.stderr.write(`[bir] ${line}\n`),
|
|
1348
|
-
install: ({ replay }) => install({
|
|
3735
|
+
install: ({ replay }) => install({
|
|
3736
|
+
...args,
|
|
3737
|
+
global: false,
|
|
3738
|
+
local: false,
|
|
3739
|
+
npx: false,
|
|
3740
|
+
forceInvocation: invocation,
|
|
3741
|
+
replay,
|
|
3742
|
+
hooks: true,
|
|
3743
|
+
servers: [],
|
|
3744
|
+
}),
|
|
1349
3745
|
hooksScript: localHooksPath(),
|
|
1350
3746
|
});
|
|
1351
3747
|
}
|
|
@@ -1353,30 +3749,58 @@ async function main() {
|
|
|
1353
3749
|
const cwd = process.cwd();
|
|
1354
3750
|
if (args.restart)
|
|
1355
3751
|
await stopDaemon(cwd);
|
|
1356
|
-
|
|
3752
|
+
// probeIdle: a recorder too old to say whether a turn is really open
|
|
3753
|
+
// (0.2.12 keeps an interrupted turn "active" for its whole life) is
|
|
3754
|
+
// replaced when two /health probes show it idle (control/daemon.ts).
|
|
3755
|
+
const ensureOpts = {
|
|
1357
3756
|
hooksScript: localHooksPath(),
|
|
1358
3757
|
restartOnSkew: true,
|
|
1359
|
-
|
|
3758
|
+
probeIdle: true,
|
|
3759
|
+
};
|
|
3760
|
+
const { status, started, restarted } = await ensureDaemon(cwd, ensureOpts);
|
|
1360
3761
|
const { info } = status;
|
|
1361
3762
|
out(`Recorder ${started ? (restarted ? "restarted" : "started") : "already running"} for ${cwd}`);
|
|
1362
3763
|
out(` ${info.url} (pid ${info.pid}${info.daemon ? "; background" : "; in a terminal"})`);
|
|
1363
3764
|
if (info.logFile)
|
|
1364
3765
|
out(` log ${info.logFile}`);
|
|
3766
|
+
const running = recorderVersion(status.health, info);
|
|
3767
|
+
if (!started && running && running !== VERSION) {
|
|
3768
|
+
out(` it is ${running}, not ${VERSION}: a Claude Code session here is mid-turn, so it was left running — ` +
|
|
3769
|
+
"`bir up --restart` once that turn ends replaces it");
|
|
3770
|
+
}
|
|
1365
3771
|
if (status.health.recording === false) {
|
|
1366
|
-
out(" !! it has no session to record with — run `bir setup` or `bir login
|
|
3772
|
+
out(" !! it has no session to record with — run `bir setup` or `bir login`; it starts recording by itself after that");
|
|
1367
3773
|
return 1;
|
|
1368
3774
|
}
|
|
1369
3775
|
return 0;
|
|
1370
3776
|
}
|
|
1371
3777
|
case "down": {
|
|
1372
3778
|
const cwd = process.cwd();
|
|
3779
|
+
// Superseded recorders first (LC-1): a reinstall on another port left
|
|
3780
|
+
// them serving older windows, and the discovery file no longer names them.
|
|
3781
|
+
let supersededStopped = 0;
|
|
3782
|
+
for (const record of await supersededRecorders(cwd)) {
|
|
3783
|
+
const result = await stopSuperseded(record);
|
|
3784
|
+
if (result.stopped) {
|
|
3785
|
+
supersededStopped += 1;
|
|
3786
|
+
out(`Superseded recorder stopped (${describeSuperseded(record)}).`);
|
|
3787
|
+
}
|
|
3788
|
+
else if (!result.stale) {
|
|
3789
|
+
out(`A superseded recorder (pid ${record.pid}) did not stop.`);
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
1373
3792
|
const before = readDiscovery(cwd);
|
|
1374
3793
|
if (!before) {
|
|
1375
|
-
|
|
3794
|
+
clearUninstallMark(cwd);
|
|
3795
|
+
if (supersededStopped === 0)
|
|
3796
|
+
out("No recorder is running for this directory.");
|
|
1376
3797
|
return 0;
|
|
1377
3798
|
}
|
|
3799
|
+
const live = await daemonStatus(cwd);
|
|
3800
|
+
const inUse = live ? recorderInUse(live.health) : false;
|
|
1378
3801
|
const result = await stopDaemon(cwd);
|
|
1379
3802
|
if (result.stale) {
|
|
3803
|
+
clearUninstallMark(cwd);
|
|
1380
3804
|
out(`No recorder answered for this directory; a stale record (pid ${before.pid}) was removed.`);
|
|
1381
3805
|
return 0;
|
|
1382
3806
|
}
|
|
@@ -1384,11 +3808,16 @@ async function main() {
|
|
|
1384
3808
|
out(`The recorder (pid ${before.pid}) did not stop.`);
|
|
1385
3809
|
return 1;
|
|
1386
3810
|
}
|
|
3811
|
+
clearUninstallMark(cwd);
|
|
1387
3812
|
out(result.forced
|
|
1388
3813
|
? `Recorder stopped (pid ${before.pid}; forced — it did not answer, so its last steps may be missing).`
|
|
1389
3814
|
: `Recorder stopped (pid ${before.pid}).`);
|
|
3815
|
+
explainDown(cwd, inUse);
|
|
1390
3816
|
return 0;
|
|
1391
3817
|
}
|
|
3818
|
+
case "off":
|
|
3819
|
+
case "on":
|
|
3820
|
+
return offCommand(args, args.command === "off");
|
|
1392
3821
|
case "login": {
|
|
1393
3822
|
// Settle the URL the way every other command does, then refuse to go on
|
|
1394
3823
|
// when what is there is not the service. "Signed in" must mean the service
|
|
@@ -1465,11 +3894,31 @@ async function main() {
|
|
|
1465
3894
|
return 0;
|
|
1466
3895
|
}
|
|
1467
3896
|
case "logout": {
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
|
|
1471
|
-
|
|
3897
|
+
// LOCAL FIRST, before anything touches the network: the session file
|
|
3898
|
+
// goes, then the service is asked to revoke the refresh token it held.
|
|
3899
|
+
// Finding the service's address is a /health round trip (a redirect to
|
|
3900
|
+
// https is followed), and a hung service or a Ctrl-C during it used to
|
|
3901
|
+
// leave this machine signed in. Running recorders notice the missing
|
|
3902
|
+
// file within seconds and stop sending.
|
|
3903
|
+
const cached = loadCredentials();
|
|
3904
|
+
clearCredentials();
|
|
1472
3905
|
out("Logged out — cached credentials cleared.");
|
|
3906
|
+
const configured = resolveAuthUrl();
|
|
3907
|
+
// THE SERVICE THAT ISSUED IT, not whatever this shell names. A session is
|
|
3908
|
+
// bound to its service (auth/client.ts, issuedFor); a stale or mistyped
|
|
3909
|
+
// BIR_AUTH_URL here must not receive the refresh token, and the service
|
|
3910
|
+
// that can revoke it is the one that issued it. A file from before the
|
|
3911
|
+
// binding names none: the configured address, as before.
|
|
3912
|
+
const bound = (cached?.authUrl ?? "").replace(/\/+$/, "");
|
|
3913
|
+
const target = bound || configured;
|
|
3914
|
+
if (target && cached?.refreshToken) {
|
|
3915
|
+
if (bound && configured && bound !== configured.replace(/\/+$/, "")) {
|
|
3916
|
+
out(` revoking the session at ${bound}, the service that issued it (not ${configured})`);
|
|
3917
|
+
}
|
|
3918
|
+
// Best effort, and bounded as a whole: revoking makes a copy of the
|
|
3919
|
+
// file taken elsewhere dead rather than merely absent from here.
|
|
3920
|
+
await awaitWithin(revokeRefreshToken(target, cached.refreshToken), LOGOUT_REVOKE_BUDGET_MS, "logout");
|
|
3921
|
+
}
|
|
1473
3922
|
return 0;
|
|
1474
3923
|
}
|
|
1475
3924
|
default:
|