@basein/runner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +276 -0
  3. package/dist/auth/client.d.ts +85 -0
  4. package/dist/auth/client.js +284 -0
  5. package/dist/bin/bir-hooks.d.ts +48 -0
  6. package/dist/bin/bir-hooks.js +201 -0
  7. package/dist/bin/bir-proxy.d.ts +45 -0
  8. package/dist/bin/bir-proxy.js +207 -0
  9. package/dist/bin/bir-scenario.d.ts +24 -0
  10. package/dist/bin/bir-scenario.js +177 -0
  11. package/dist/bin/bir.d.ts +21 -0
  12. package/dist/bin/bir.js +876 -0
  13. package/dist/config/adapters/claude-code.d.ts +76 -0
  14. package/dist/config/adapters/claude-code.js +181 -0
  15. package/dist/config/adapters/generic.d.ts +17 -0
  16. package/dist/config/adapters/generic.js +36 -0
  17. package/dist/config/generate.d.ts +127 -0
  18. package/dist/config/generate.js +114 -0
  19. package/dist/config/resolve.d.ts +68 -0
  20. package/dist/config/resolve.js +132 -0
  21. package/dist/control/client.d.ts +56 -0
  22. package/dist/control/client.js +86 -0
  23. package/dist/control/correlation.d.ts +86 -0
  24. package/dist/control/correlation.js +0 -0
  25. package/dist/control/discovery.d.ts +50 -0
  26. package/dist/control/discovery.js +123 -0
  27. package/dist/control/ordering.d.ts +38 -0
  28. package/dist/control/ordering.js +44 -0
  29. package/dist/control/paths.d.ts +32 -0
  30. package/dist/control/paths.js +56 -0
  31. package/dist/control/server.d.ts +272 -0
  32. package/dist/control/server.js +1131 -0
  33. package/dist/control/transcript.d.ts +75 -0
  34. package/dist/control/transcript.js +241 -0
  35. package/dist/index.d.ts +37 -0
  36. package/dist/index.js +32 -0
  37. package/dist/jsonrpc/framing.d.ts +49 -0
  38. package/dist/jsonrpc/framing.js +143 -0
  39. package/dist/jsonrpc/types.d.ts +52 -0
  40. package/dist/jsonrpc/types.js +46 -0
  41. package/dist/proxy/intercept.d.ts +55 -0
  42. package/dist/proxy/intercept.js +147 -0
  43. package/dist/proxy/relay.d.ts +97 -0
  44. package/dist/proxy/relay.js +166 -0
  45. package/dist/proxy/session.d.ts +116 -0
  46. package/dist/proxy/session.js +319 -0
  47. package/dist/record/housekeeping.d.ts +34 -0
  48. package/dist/record/housekeeping.js +39 -0
  49. package/dist/record/queue.d.ts +48 -0
  50. package/dist/record/queue.js +96 -0
  51. package/dist/record/recorder.d.ts +111 -0
  52. package/dist/record/recorder.js +39 -0
  53. package/dist/record/redact.d.ts +37 -0
  54. package/dist/record/redact.js +119 -0
  55. package/dist/record/remote-recorder.d.ts +110 -0
  56. package/dist/record/remote-recorder.js +301 -0
  57. package/dist/record/truncate.d.ts +36 -0
  58. package/dist/record/truncate.js +85 -0
  59. package/dist/replay/bundle.d.ts +36 -0
  60. package/dist/replay/bundle.js +89 -0
  61. package/dist/replay/controller.d.ts +300 -0
  62. package/dist/replay/controller.js +807 -0
  63. package/dist/replay/coverage.d.ts +41 -0
  64. package/dist/replay/coverage.js +56 -0
  65. package/dist/replay/derive.d.ts +58 -0
  66. package/dist/replay/derive.js +166 -0
  67. package/dist/replay/executor.d.ts +78 -0
  68. package/dist/replay/executor.js +233 -0
  69. package/dist/replay/logic.d.ts +31 -0
  70. package/dist/replay/logic.js +50 -0
  71. package/dist/replay/plan.d.ts +181 -0
  72. package/dist/replay/plan.js +397 -0
  73. package/dist/replay/pricing.d.ts +41 -0
  74. package/dist/replay/pricing.js +76 -0
  75. package/dist/replay/source-run.d.ts +50 -0
  76. package/dist/replay/source-run.js +98 -0
  77. package/dist/replay/tool-error.d.ts +22 -0
  78. package/dist/replay/tool-error.js +60 -0
  79. package/dist/replay/types.d.ts +116 -0
  80. package/dist/replay/types.js +35 -0
  81. package/dist/upstream/client.d.ts +78 -0
  82. package/dist/upstream/client.js +114 -0
  83. package/dist/upstream/http-client.d.ts +78 -0
  84. package/dist/upstream/http-client.js +261 -0
  85. package/dist/upstream/lazy-client.d.ts +31 -0
  86. package/dist/upstream/lazy-client.js +53 -0
  87. package/dist/upstream/stdio-client.d.ts +57 -0
  88. package/dist/upstream/stdio-client.js +203 -0
  89. package/dist/util/log.d.ts +27 -0
  90. package/dist/util/log.js +51 -0
  91. package/dist/util/version.d.ts +2 -0
  92. package/dist/util/version.js +40 -0
  93. package/docs/BaseInstRunner.md +621 -0
  94. package/docs/calculatedReplay.md +1185 -0
  95. package/docs/calculatedReplayGuide.md +448 -0
  96. package/docs/installRun.md +413 -0
  97. package/docs/mcpmark.md +752 -0
  98. package/docs/quickstart.md +201 -0
  99. package/docs/t-bench.md +394 -0
  100. package/package.json +56 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * adapters/claude-code — read and rewrite Claude Code's own configuration.
3
+ *
4
+ * Two files, three scopes, and one rule that decides everything (Phase 7.3):
5
+ * **write to the winning scope.** If a local-scope entry shadows the project one,
6
+ * rewriting `.mcp.json` changes nothing — the install looks successful and never
7
+ * runs. `resolveServers` reports the winner; this module writes there and warns
8
+ * loudly when a lower scope also defines the name.
9
+ *
10
+ * Byte-for-byte reversibility: the *text* of every file touched is stashed before
11
+ * the first edit, together with a hash of what we wrote. On uninstall, a file
12
+ * still matching that hash is restored verbatim; a file edited since is repaired
13
+ * entry-by-entry instead, with a warning. That is the difference between "we put
14
+ * the entries back" and "the file is as you left it".
15
+ */
16
+ import { type McpServerConfig, type Scope } from "../resolve.js";
17
+ export interface ClaudeCodePaths {
18
+ /** `~/.claude.json` (user + local scopes). */
19
+ claudeJson: string;
20
+ /** `<cwd>/.mcp.json` (project scope). */
21
+ mcpJson: string;
22
+ /** `<cwd>/.claude/settings.json` (hooks). */
23
+ settings: string;
24
+ }
25
+ export declare function claudeCodePaths(cwd: string, claudeJsonOverride?: string): ClaudeCodePaths;
26
+ /** Which file holds a given scope's `mcpServers` for this cwd. */
27
+ export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): string;
28
+ export declare function sha256(text: string): string;
29
+ /** Read a file's text, or null when it does not exist. */
30
+ export declare function readTextOrNull(path: string): string | null;
31
+ /**
32
+ * Set (or delete, with `entry === null`) one MCP server entry in `scope`.
33
+ * Returns the file's new text so the caller can hash it for the sidecar.
34
+ */
35
+ export declare function setServerEntry(scope: Scope, cwd: string, name: string, entry: McpServerConfig | null, paths: ClaudeCodePaths): {
36
+ file: string;
37
+ text: string;
38
+ };
39
+ /**
40
+ * The hook events `bir-hooks` registers, and the control route each drives.
41
+ *
42
+ * `PostToolUseFailure` is registered rather than inferred from a `PostToolUse`
43
+ * payload's shape: a failing tool fires the *failure* event, and a recorder that
44
+ * only listens to the success one records a step that never closes.
45
+ * `SubagentStart` / `SubagentStop` carry `agent_id`, which is what lets a
46
+ * subagent's steps be attributed rather than silently interleaved.
47
+ */
48
+ export declare const HOOK_ROUTES: Array<{
49
+ event: string;
50
+ route: string;
51
+ matcher?: string;
52
+ timeout: number;
53
+ async?: boolean;
54
+ }>;
55
+ export interface HookEntry {
56
+ type: "http";
57
+ url: string;
58
+ timeout: number;
59
+ headers?: Record<string, string>;
60
+ async?: boolean;
61
+ }
62
+ export type HooksBlock = Record<string, Array<{
63
+ matcher?: string;
64
+ hooks: HookEntry[];
65
+ }>>;
66
+ /** Build the `hooks` block pointing at a control server. */
67
+ export declare function buildHooksBlock(controlUrl: string, token: string): HooksBlock;
68
+ /**
69
+ * Merge our hooks into a settings file, leaving every other hook alone. Returns
70
+ * the new text. Our own entries are replaced rather than appended, so repeated
71
+ * installs stay idempotent.
72
+ */
73
+ export declare function installHooks(settingsPath: string, block: HooksBlock): string;
74
+ /** Remove only our hook entries, and drop events left empty. */
75
+ export declare function uninstallHooks(settingsPath: string): string | undefined;
76
+ //# sourceMappingURL=claude-code.d.ts.map
@@ -0,0 +1,181 @@
1
+ /**
2
+ * adapters/claude-code — read and rewrite Claude Code's own configuration.
3
+ *
4
+ * Two files, three scopes, and one rule that decides everything (Phase 7.3):
5
+ * **write to the winning scope.** If a local-scope entry shadows the project one,
6
+ * rewriting `.mcp.json` changes nothing — the install looks successful and never
7
+ * runs. `resolveServers` reports the winner; this module writes there and warns
8
+ * loudly when a lower scope also defines the name.
9
+ *
10
+ * Byte-for-byte reversibility: the *text* of every file touched is stashed before
11
+ * the first edit, together with a hash of what we wrote. On uninstall, a file
12
+ * still matching that hash is restored verbatim; a file edited since is repaired
13
+ * entry-by-entry instead, with a warning. That is the difference between "we put
14
+ * the entries back" and "the file is as you left it".
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { claudeJsonPath, parseJsonFile, projectKeyFor } from "../resolve.js";
20
+ export function claudeCodePaths(cwd, claudeJsonOverride) {
21
+ return {
22
+ claudeJson: claudeJsonPath(claudeJsonOverride),
23
+ mcpJson: join(cwd, ".mcp.json"),
24
+ settings: join(cwd, ".claude", "settings.json"),
25
+ };
26
+ }
27
+ /** Which file holds a given scope's `mcpServers` for this cwd. */
28
+ export function fileForScope(scope, paths) {
29
+ switch (scope) {
30
+ case "project":
31
+ return paths.mcpJson;
32
+ case "local":
33
+ case "user":
34
+ return paths.claudeJson;
35
+ default:
36
+ throw new Error(`scope ${scope} has no Claude Code file`);
37
+ }
38
+ }
39
+ export function sha256(text) {
40
+ return createHash("sha256").update(text).digest("hex");
41
+ }
42
+ /** Read a file's text, or null when it does not exist. */
43
+ export function readTextOrNull(path) {
44
+ try {
45
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function writeJson(path, value) {
52
+ const text = JSON.stringify(value, null, 2) + "\n";
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ writeFileSync(path, text, "utf8");
55
+ return text;
56
+ }
57
+ /**
58
+ * Set (or delete, with `entry === null`) one MCP server entry in `scope`.
59
+ * Returns the file's new text so the caller can hash it for the sidecar.
60
+ */
61
+ export function setServerEntry(scope, cwd, name, entry, paths) {
62
+ const file = fileForScope(scope, paths);
63
+ const parsed = parseJsonFile(file) ?? {};
64
+ if (scope === "local") {
65
+ const projects = parsed.projects ?? {};
66
+ const key = projectKeyFor(projects, cwd);
67
+ const project = projects[key] ?? {};
68
+ const servers = { ...(project.mcpServers ?? {}) };
69
+ if (entry === null)
70
+ delete servers[name];
71
+ else
72
+ servers[name] = entry;
73
+ parsed.projects = { ...projects, [key]: { ...project, mcpServers: servers } };
74
+ }
75
+ else {
76
+ const servers = { ...(parsed.mcpServers ?? {}) };
77
+ if (entry === null)
78
+ delete servers[name];
79
+ else
80
+ servers[name] = entry;
81
+ parsed.mcpServers = servers;
82
+ }
83
+ return { file, text: writeJson(file, parsed) };
84
+ }
85
+ // ── hooks ──────────────────────────────────────────────────────────────────
86
+ /**
87
+ * The hook events `bir-hooks` registers, and the control route each drives.
88
+ *
89
+ * `PostToolUseFailure` is registered rather than inferred from a `PostToolUse`
90
+ * payload's shape: a failing tool fires the *failure* event, and a recorder that
91
+ * only listens to the success one records a step that never closes.
92
+ * `SubagentStart` / `SubagentStop` carry `agent_id`, which is what lets a
93
+ * subagent's steps be attributed rather than silently interleaved.
94
+ */
95
+ export const HOOK_ROUTES = [
96
+ { event: "SessionStart", route: "/session/start", matcher: "", timeout: 10 },
97
+ // 15s, not 5s, because this hook now awaits the similar-prompt match before it
98
+ // can return a steering directive (docs/calculatedReplay.md §10). The wait is
99
+ // itself bounded by `BIR_MATCH_BUDGET_MS` (2.5s), well inside this; the extra
100
+ // headroom is for a slow first embed on the service, not for us to spend.
101
+ //
102
+ // It must NEVER become `async`. An async hook is fire-and-forget, so it cannot
103
+ // return `additionalContext` at all — replay would silently never arm.
104
+ { event: "UserPromptSubmit", route: "/session/prompt", matcher: "", timeout: 15 },
105
+ // PreToolUse and PostToolUse are likewise NOT async, and that is load-bearing
106
+ // twice over: correlation needs `updatedInput` back, and a steered replay needs
107
+ // step N's output threaded into `respParams` *before* step N+1's input is
108
+ // computed (docs/calculatedReplay.md §7.3).
109
+ { event: "PreToolUse", route: "/tool/pre", matcher: "*", timeout: 30 },
110
+ { event: "PostToolUse", route: "/tool/post", matcher: "*", timeout: 30 },
111
+ { event: "PostToolUseFailure", route: "/tool/post", matcher: "*", timeout: 30 },
112
+ { event: "SubagentStart", route: "/session/agent", matcher: "", timeout: 5 },
113
+ { event: "SubagentStop", route: "/session/agent", matcher: "", timeout: 5 },
114
+ { event: "Stop", route: "/session/stop", matcher: "", timeout: 10 },
115
+ { event: "StopFailure", route: "/session/stop", matcher: "", timeout: 10 },
116
+ // NOT async. An async hook is fire-and-forget, and `SessionEnd` fires while the
117
+ // host is exiting — the exit wins that race and the request is never sent, so
118
+ // every run was left unfinished on the server. It is cheap to wait for now
119
+ // (`Stop` has already sealed the run; this only drains the queue), and a run
120
+ // that is actually finished is worth a few hundred milliseconds at shutdown.
121
+ { event: "SessionEnd", route: "/session/end", matcher: "", timeout: 30 },
122
+ ];
123
+ /** Build the `hooks` block pointing at a control server. */
124
+ export function buildHooksBlock(controlUrl, token) {
125
+ const base = controlUrl.replace(/\/+$/, "");
126
+ const block = {};
127
+ for (const { event, route, matcher, timeout, async } of HOOK_ROUTES) {
128
+ const hook = {
129
+ type: "http",
130
+ url: `${base}${route}`,
131
+ timeout,
132
+ headers: { Authorization: `Bearer ${token}` },
133
+ };
134
+ if (async)
135
+ hook.async = true;
136
+ block[event] = [{ matcher: matcher ?? "", hooks: [hook] }];
137
+ }
138
+ return block;
139
+ }
140
+ /** True when a hook entry points at a BaseInstRunner control server. */
141
+ function isBirHook(entry) {
142
+ const url = entry.url ?? "";
143
+ return HOOK_ROUTES.some(({ route }) => url.endsWith(route));
144
+ }
145
+ /**
146
+ * Merge our hooks into a settings file, leaving every other hook alone. Returns
147
+ * the new text. Our own entries are replaced rather than appended, so repeated
148
+ * installs stay idempotent.
149
+ */
150
+ export function installHooks(settingsPath, block) {
151
+ const parsed = parseJsonFile(settingsPath) ?? {};
152
+ const hooks = { ...(parsed.hooks ?? {}) };
153
+ for (const [event, matchers] of Object.entries(block)) {
154
+ const existing = (hooks[event] ?? []).filter((m) => !(m.hooks ?? []).some((h) => isBirHook(h)));
155
+ hooks[event] = [...existing, ...matchers];
156
+ }
157
+ parsed.hooks = hooks;
158
+ return writeJson(settingsPath, parsed);
159
+ }
160
+ /** Remove only our hook entries, and drop events left empty. */
161
+ export function uninstallHooks(settingsPath) {
162
+ const parsed = parseJsonFile(settingsPath);
163
+ if (!parsed?.hooks)
164
+ return undefined;
165
+ const hooks = { ...parsed.hooks };
166
+ for (const [event, matchers] of Object.entries(hooks)) {
167
+ const kept = (matchers ?? [])
168
+ .map((m) => ({ ...m, hooks: (m.hooks ?? []).filter((h) => !isBirHook(h)) }))
169
+ .filter((m) => m.hooks.length > 0);
170
+ if (kept.length === 0)
171
+ delete hooks[event];
172
+ else
173
+ hooks[event] = kept;
174
+ }
175
+ if (Object.keys(hooks).length === 0)
176
+ delete parsed.hooks;
177
+ else
178
+ parsed.hooks = hooks;
179
+ return writeJson(settingsPath, parsed);
180
+ }
181
+ //# sourceMappingURL=claude-code.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * adapters/generic — the `--config <path>` adapter (Phase 7.6, D8).
3
+ *
4
+ * This is what makes "any MCP client" real. Cursor, Codex, an Agent SDK harness
5
+ * and everything not yet written keep their servers in a file of the shape
6
+ * `{ "mcpServers": { … } }` — the de-facto standard — but in a location this
7
+ * project has no business guessing. So the generic path takes the path from the
8
+ * user and never looks for one.
9
+ *
10
+ * There are no scopes here: the file given is the whole configuration, and it is
11
+ * both the read source and the write target.
12
+ */
13
+ import { type McpServerConfig } from "../resolve.js";
14
+ export declare function readGenericServers(path: string): Record<string, McpServerConfig>;
15
+ /** Set or delete one entry, preserving every other key in the file. */
16
+ export declare function setGenericServerEntry(path: string, name: string, entry: McpServerConfig | null): string;
17
+ //# sourceMappingURL=generic.d.ts.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * adapters/generic — the `--config <path>` adapter (Phase 7.6, D8).
3
+ *
4
+ * This is what makes "any MCP client" real. Cursor, Codex, an Agent SDK harness
5
+ * and everything not yet written keep their servers in a file of the shape
6
+ * `{ "mcpServers": { … } }` — the de-facto standard — but in a location this
7
+ * project has no business guessing. So the generic path takes the path from the
8
+ * user and never looks for one.
9
+ *
10
+ * There are no scopes here: the file given is the whole configuration, and it is
11
+ * both the read source and the write target.
12
+ */
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+ import { parseJsonFile } from "../resolve.js";
16
+ export function readGenericServers(path) {
17
+ const parsed = parseJsonFile(path);
18
+ if (parsed === undefined)
19
+ throw new Error(`MCP config not readable at ${path}`);
20
+ return parsed.mcpServers ?? {};
21
+ }
22
+ /** Set or delete one entry, preserving every other key in the file. */
23
+ export function setGenericServerEntry(path, name, entry) {
24
+ const parsed = parseJsonFile(path) ?? {};
25
+ const servers = { ...(parsed.mcpServers ?? {}) };
26
+ if (entry === null)
27
+ delete servers[name];
28
+ else
29
+ servers[name] = entry;
30
+ parsed.mcpServers = servers;
31
+ const text = JSON.stringify(parsed, null, 2) + "\n";
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ writeFileSync(path, text, "utf8");
34
+ return text;
35
+ }
36
+ //# sourceMappingURL=generic.js.map
@@ -0,0 +1,127 @@
1
+ /**
2
+ * config/generate — turn a resolved MCP entry into its proxied twin (Phase 7.2).
3
+ *
4
+ * The **key is preserved**, always. The model emits
5
+ * `mcp__chrome-devtools__navigate_page` and the client routes that name by the
6
+ * config key; change the key and the name changes with it, which would be a
7
+ * model-visible edit (a non-goal) as well as a broken scenario corpus.
8
+ *
9
+ * Reversibility is not an afterthought (Phase 7.4): the original entry is stashed
10
+ * verbatim in `~/.baseinstrunner/installed.json` before anything is rewritten, so
11
+ * `bir uninstall` restores exactly what was there — including fields
12
+ * BaseInstRunner never understood.
13
+ */
14
+ import { type McpServerConfig, type Scope } from "./resolve.js";
15
+ /** npm package name, as it appears in a generated `npx -p` invocation. */
16
+ export declare const PACKAGE_NAME = "@basein/runner";
17
+ /**
18
+ * How the generated entry reaches a `bir` binary.
19
+ *
20
+ * npx `npx -y -p @basein/runner@<version> bir-proxy` — resolves through the
21
+ * registry, so it survives a missing global install. Always pinned:
22
+ * an unpinned spec lets two runners provisioned a week apart run
23
+ * different proxy versions against the same service, with nothing in
24
+ * the recording to say which.
25
+ * local an absolute path into this checkout — developing BaseInstRunner.
26
+ * global an absolute path into the globally installed package. No registry
27
+ * round trip, no PATH lookup, works offline. What a fleet wants.
28
+ *
29
+ * `local` and `global` produce the same shape; they differ only in where the
30
+ * caller resolved the path from, and `bir` refuses `--global` when it is not
31
+ * itself running from an installed package.
32
+ */
33
+ export type Invocation = "npx" | "local" | "global";
34
+ /**
35
+ * The `-p` argument for an npx invocation. Unpinned only when no version is
36
+ * known — every caller inside `bir` knows one.
37
+ */
38
+ export declare function packageSpec(version?: string): string;
39
+ /**
40
+ * The config key of the first-party scenario server (docs/calculatedReplay.md §6.3).
41
+ *
42
+ * Reserved: `bir install` refuses to wrap a user's own server under this name,
43
+ * because `mcp__bir__run_scenario` is how a `direct` replay's results reach the
44
+ * model, and a collision would route that call somewhere else entirely.
45
+ */
46
+ export declare const SCENARIO_SERVER_KEY = "bir";
47
+ export interface WrapOptions {
48
+ serverName: string;
49
+ /** Injected as `BIR_CONTROL_URL` so an SDK session needs no discovery file. */
50
+ controlUrl?: string;
51
+ /** Emit `--no-correlation` so this server is never schema-relaxed. */
52
+ noCorrelation?: boolean;
53
+ /**
54
+ * How to invoke the proxy. `"npx"` (default) survives a global install being
55
+ * absent; `"local"` points at this checkout's built entry point, which is what
56
+ * you want while developing BaseInstRunner itself.
57
+ */
58
+ invocation?: Invocation;
59
+ /** Absolute path to `dist/bin/bir-proxy.js`, for `local` and `global`. */
60
+ proxyPath?: string;
61
+ /** Pins the npx spec. Omitted only by tests that do not care which version. */
62
+ version?: string;
63
+ }
64
+ /** A wrapped entry is always a stdio entry: the proxy is the process the host spawns. */
65
+ export interface WrappedEntry {
66
+ command: string;
67
+ args: string[];
68
+ env?: Record<string, string>;
69
+ }
70
+ /** True when this entry already points at `bir-proxy` — makes install idempotent. */
71
+ export declare function isWrapped(config: McpServerConfig): boolean;
72
+ /**
73
+ * Build the proxied entry for `config`. Everything after `--` is the original
74
+ * invocation, passed through untouched so the upstream is spawned exactly as the
75
+ * host would have spawned it.
76
+ */
77
+ export declare function wrapEntry(config: McpServerConfig, opts: WrapOptions): WrappedEntry;
78
+ /**
79
+ * The entry for the scenario server itself. Not a proxy: it wraps nothing and
80
+ * spawns nothing, it just forwards one call to the control server.
81
+ */
82
+ export declare function scenarioEntry(opts: {
83
+ invocation?: Invocation;
84
+ scenarioPath?: string;
85
+ controlUrl?: string;
86
+ version?: string;
87
+ }): WrappedEntry;
88
+ /** True when this entry is the scenario server — makes `install --replay` idempotent. */
89
+ export declare function isScenarioServer(config: McpServerConfig): boolean;
90
+ export interface InstalledRecord {
91
+ /** Original entry, verbatim. `null` means the entry did not exist before. */
92
+ original: McpServerConfig | null;
93
+ scope: Scope;
94
+ /** File the entry was written to, so uninstall edits the same one. */
95
+ file: string;
96
+ cwd: string;
97
+ installedAt: number;
98
+ }
99
+ /**
100
+ * A file we edited, captured before the first edit.
101
+ *
102
+ * `writtenSha` is the hash of what *we* left behind. On uninstall a file that
103
+ * still hashes to it is restored from `originalText` **byte-for-byte**; a file
104
+ * edited since is repaired entry-by-entry instead, because clobbering somebody's
105
+ * later edit is worse than an imperfect restore.
106
+ */
107
+ export interface FileBackup {
108
+ originalText: string | null;
109
+ writtenSha: string;
110
+ }
111
+ export interface InstalledSidecar {
112
+ version: 1;
113
+ /** Stable loopback token written into the hook settings at install time. */
114
+ token?: string;
115
+ /** Port the hook settings were written with. */
116
+ controlPort?: number;
117
+ /** `<cwd>::<serverName>` → record. */
118
+ servers: Record<string, InstalledRecord>;
119
+ /** Absolute path → the text that was there before we touched it. */
120
+ files?: Record<string, FileBackup>;
121
+ /** Settings files whose `hooks` block we added, for a clean uninstall. */
122
+ hookFiles?: string[];
123
+ }
124
+ export declare function readSidecar(): InstalledSidecar;
125
+ export declare function writeSidecar(sidecar: InstalledSidecar): void;
126
+ export declare function sidecarKey(cwd: string, serverName: string): string;
127
+ //# sourceMappingURL=generate.d.ts.map
@@ -0,0 +1,114 @@
1
+ /**
2
+ * config/generate — turn a resolved MCP entry into its proxied twin (Phase 7.2).
3
+ *
4
+ * The **key is preserved**, always. The model emits
5
+ * `mcp__chrome-devtools__navigate_page` and the client routes that name by the
6
+ * config key; change the key and the name changes with it, which would be a
7
+ * model-visible edit (a non-goal) as well as a broken scenario corpus.
8
+ *
9
+ * Reversibility is not an afterthought (Phase 7.4): the original entry is stashed
10
+ * verbatim in `~/.baseinstrunner/installed.json` before anything is rewritten, so
11
+ * `bir uninstall` restores exactly what was there — including fields
12
+ * BaseInstRunner never understood.
13
+ */
14
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { configDir, ensureDir, installedPath } from "../control/paths.js";
16
+ import { isRemote } from "./resolve.js";
17
+ /** npm package name, as it appears in a generated `npx -p` invocation. */
18
+ export const PACKAGE_NAME = "@basein/runner";
19
+ /**
20
+ * The `-p` argument for an npx invocation. Unpinned only when no version is
21
+ * known — every caller inside `bir` knows one.
22
+ */
23
+ export function packageSpec(version) {
24
+ return version ? `${PACKAGE_NAME}@${version}` : PACKAGE_NAME;
25
+ }
26
+ /**
27
+ * The config key of the first-party scenario server (docs/calculatedReplay.md §6.3).
28
+ *
29
+ * Reserved: `bir install` refuses to wrap a user's own server under this name,
30
+ * because `mcp__bir__run_scenario` is how a `direct` replay's results reach the
31
+ * model, and a collision would route that call somewhere else entirely.
32
+ */
33
+ export const SCENARIO_SERVER_KEY = "bir";
34
+ /** True when this entry already points at `bir-proxy` — makes install idempotent. */
35
+ export function isWrapped(config) {
36
+ if (isRemote(config))
37
+ return false;
38
+ const argv = [config.command, ...(config.args ?? [])].join(" ");
39
+ return /\bbir-proxy\b/.test(argv);
40
+ }
41
+ /**
42
+ * Build the proxied entry for `config`. Everything after `--` is the original
43
+ * invocation, passed through untouched so the upstream is spawned exactly as the
44
+ * host would have spawned it.
45
+ */
46
+ export function wrapEntry(config, opts) {
47
+ const proxyArgs = ["--server-name", opts.serverName];
48
+ if (opts.noCorrelation)
49
+ proxyArgs.push("--no-correlation");
50
+ if (isRemote(config)) {
51
+ proxyArgs.push("--url", config.url, "--transport", config.type);
52
+ for (const [key, value] of Object.entries(config.headers ?? {})) {
53
+ // §9: the proxy holds the credential for a remote upstream, because the
54
+ // host's own OAuth now authenticates *us*. It is passed at spawn and never
55
+ // written to BaseInstRunner's own state.
56
+ proxyArgs.push("--header", `${key}:${value}`);
57
+ }
58
+ }
59
+ else {
60
+ proxyArgs.push("--", config.command, ...(config.args ?? []));
61
+ }
62
+ const env = {};
63
+ if (opts.controlUrl)
64
+ env.BIR_CONTROL_URL = opts.controlUrl;
65
+ if (!isRemote(config)) {
66
+ for (const [key, value] of Object.entries(config.env ?? {}))
67
+ env[key] = value;
68
+ }
69
+ const invocation = opts.invocation ?? "npx";
70
+ const entry = invocation !== "npx" && opts.proxyPath
71
+ ? { command: process.execPath, args: [opts.proxyPath, ...proxyArgs] }
72
+ : { command: "npx", args: ["-y", "-p", packageSpec(opts.version), "bir-proxy", ...proxyArgs] };
73
+ if (Object.keys(env).length > 0)
74
+ entry.env = env;
75
+ return entry;
76
+ }
77
+ /**
78
+ * The entry for the scenario server itself. Not a proxy: it wraps nothing and
79
+ * spawns nothing, it just forwards one call to the control server.
80
+ */
81
+ export function scenarioEntry(opts) {
82
+ const entry = opts.invocation && opts.invocation !== "npx" && opts.scenarioPath
83
+ ? { command: process.execPath, args: [opts.scenarioPath] }
84
+ : { command: "npx", args: ["-y", "-p", packageSpec(opts.version), "bir-scenario"] };
85
+ if (opts.controlUrl)
86
+ entry.env = { BIR_CONTROL_URL: opts.controlUrl };
87
+ return entry;
88
+ }
89
+ /** True when this entry is the scenario server — makes `install --replay` idempotent. */
90
+ export function isScenarioServer(config) {
91
+ if (isRemote(config))
92
+ return false;
93
+ return /\bbir-scenario\b/.test([config.command, ...(config.args ?? [])].join(" "));
94
+ }
95
+ export function readSidecar() {
96
+ const path = installedPath();
97
+ if (!existsSync(path))
98
+ return { version: 1, servers: {} };
99
+ try {
100
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
101
+ return { version: 1, servers: parsed.servers ?? {}, ...parsed };
102
+ }
103
+ catch {
104
+ return { version: 1, servers: {} };
105
+ }
106
+ }
107
+ export function writeSidecar(sidecar) {
108
+ ensureDir(configDir());
109
+ writeFileSync(installedPath(), JSON.stringify(sidecar, null, 2), { mode: 0o600 });
110
+ }
111
+ export function sidecarKey(cwd, serverName) {
112
+ return `${cwd}::${serverName}`;
113
+ }
114
+ //# sourceMappingURL=generate.js.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * config/resolve — which MCP servers a client would actually use for a cwd.
3
+ *
4
+ * Ported from RRepeat's `mcp-registry.ts`, and extended to report **which scope
5
+ * won**, because that is what Phase 7.3 needs: rewriting a file whose entry is
6
+ * shadowed by a higher-precedence scope changes nothing at all, and the user is
7
+ * left with an install that never runs.
8
+ *
9
+ * Claude Code merges three scopes, highest precedence first. The whole winning
10
+ * entry is used — fields are never merged across scopes:
11
+ * 1. **Local** — `~/.claude.json` → `projects[<cwd>].mcpServers`
12
+ * 2. **Project** — `<cwd>/.mcp.json` → `mcpServers`
13
+ * 3. **User** — `~/.claude.json` → top-level `mcpServers`
14
+ *
15
+ * THE WINDOWS PATH TRAP (Phase 7.1). Claude Code stores the local-scope project
16
+ * key with forward slashes while `process.cwd()` returns backslashes, and it
17
+ * keeps one entry per spelling it has seen — so the backslash twin routinely
18
+ * exists carrying `mcpServers: {}` while the forward-slash one holds the real
19
+ * config. Matching the literal key misses every time; matching a *normalised*
20
+ * path, and refusing to stop at an empty twin, is what makes local scope work.
21
+ */
22
+ /** A stdio server entry. `type` is optional in Claude Code's own files. */
23
+ export interface StdioServerConfig {
24
+ type?: "stdio";
25
+ command: string;
26
+ args?: string[];
27
+ env?: Record<string, string>;
28
+ }
29
+ /** A remote server entry (streamable HTTP or legacy SSE). */
30
+ export interface RemoteServerConfig {
31
+ type: "http" | "sse";
32
+ url: string;
33
+ headers?: Record<string, string>;
34
+ }
35
+ export type McpServerConfig = StdioServerConfig | RemoteServerConfig;
36
+ export type Scope = "local" | "project" | "user" | "explicit";
37
+ export interface ResolvedServer {
38
+ name: string;
39
+ config: McpServerConfig;
40
+ /** The scope whose entry won. Writing to any lower one is a no-op. */
41
+ scope: Scope;
42
+ /** Scopes that also define this name and are shadowed by `scope`. */
43
+ shadowed: Scope[];
44
+ }
45
+ export interface ResolveOptions {
46
+ /** Override the `~/.claude.json` location (tests). */
47
+ claudeJsonPath?: string;
48
+ /** A single explicit `{ mcpServers }` file — no scope merging (`--config`). */
49
+ configPath?: string;
50
+ }
51
+ export declare function isRemote(config: McpServerConfig): config is RemoteServerConfig;
52
+ /** Parse a JSON file. `undefined` if absent; throws if present but malformed. */
53
+ export declare function parseJsonFile(path: string): unknown;
54
+ export declare function claudeJsonPath(override?: string): string;
55
+ /**
56
+ * The local scope's servers for `cwd`. The literal key is tried first, then
57
+ * every key is compared as a normalised path — and a key resolving to *no*
58
+ * servers does not end the search, because the empty twin is exactly the shape
59
+ * the Windows miss takes on disk.
60
+ */
61
+ export declare function projectScope(projects: Record<string, {
62
+ mcpServers?: Record<string, McpServerConfig>;
63
+ }> | undefined, cwd: string): Record<string, McpServerConfig>;
64
+ /** The key under which Claude Code stores (or would store) this cwd's local scope. */
65
+ export declare function projectKeyFor(projects: Record<string, unknown> | undefined, cwd: string): string;
66
+ /** Every MCP server that would be live for `cwd`, with the scope that won. */
67
+ export declare function resolveServers(cwd: string, opts?: ResolveOptions): ResolvedServer[];
68
+ //# sourceMappingURL=resolve.d.ts.map