@basein/runner 0.2.8 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +86 -22
  2. package/dist/auth/client.d.ts +40 -1
  3. package/dist/auth/client.js +77 -9
  4. package/dist/bin/bir-hooks.d.ts +18 -3
  5. package/dist/bin/bir-hooks.js +124 -38
  6. package/dist/bin/bir-scenario.d.ts +18 -2
  7. package/dist/bin/bir-scenario.js +374 -4
  8. package/dist/bin/bir.d.ts +12 -0
  9. package/dist/bin/bir.js +501 -81
  10. package/dist/bin/investigate.js +1 -1
  11. package/dist/bin/scenario-edit.d.ts +173 -0
  12. package/dist/bin/scenario-edit.js +771 -0
  13. package/dist/bin/setup.d.ts +72 -0
  14. package/dist/bin/setup.js +286 -0
  15. package/dist/config/adapters/claude-code.d.ts +90 -4
  16. package/dist/config/adapters/claude-code.js +164 -16
  17. package/dist/config/generate.d.ts +114 -1
  18. package/dist/config/generate.js +106 -3
  19. package/dist/control/client.d.ts +5 -0
  20. package/dist/control/client.js +8 -0
  21. package/dist/control/daemon.d.ts +116 -0
  22. package/dist/control/daemon.js +339 -0
  23. package/dist/control/discovery.d.ts +26 -0
  24. package/dist/control/discovery.js +41 -9
  25. package/dist/control/ensure-hook.d.ts +39 -0
  26. package/dist/control/ensure-hook.js +98 -0
  27. package/dist/control/paths.d.ts +14 -0
  28. package/dist/control/paths.js +20 -0
  29. package/dist/control/server.d.ts +28 -0
  30. package/dist/control/server.js +15 -2
  31. package/dist/proxy/session.d.ts +8 -1
  32. package/dist/proxy/session.js +28 -6
  33. package/docs/calculatedReplay.md +51 -0
  34. package/docs/calculatedReplayGuide.md +471 -74
  35. package/docs/installRun.md +457 -111
  36. package/docs/loginWeb.md +1 -1
  37. package/docs/quickstart.md +195 -158
  38. package/package.json +2 -1
  39. package/scripts/install.ps1 +669 -0
  40. package/scripts/install.sh +586 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `bir setup` — from an installed package to a recording, replaying project,
3
+ * in one command and with nothing left open.
4
+ *
5
+ * It is the sequence the guides used to ask a person to type, done in order and
6
+ * checked at each step:
7
+ *
8
+ * 1. the service address, verified to be the API and not a website, then
9
+ * stored in ~/.baseinstrunner/config.json so no terminal needs BIR_AUTH_URL;
10
+ * 2. the sign-in — a setup token from the console, a cached session, or the
11
+ * browser flow — stored the way `bir login` stores it;
12
+ * 3. the project: MCP servers wrapped, hooks wired, the scenario server added
13
+ * and pre-approved (`bir install --global --replay`);
14
+ * 4. the recorder, started in the background and from now on started by the
15
+ * SessionStart hook whenever it is missing;
16
+ * 5. a summary that names the account, the servers, the recorder's log, and
17
+ * the one thing left to do: start Claude Code.
18
+ *
19
+ * Idempotent, so it is also the upgrade path and the "is it still right?" path.
20
+ * It never prompts: under `curl | sh` stdin is the script itself, and a script
21
+ * that stops to ask is a script that hangs. Everything it needs is an argument
22
+ * or an environment variable, and everything it decides is printed.
23
+ *
24
+ * THE WRONG FOLDER. A terminal opened from the Start menu begins in the home
25
+ * directory, and that is where a first paste often lands. Refusing would throw
26
+ * away the two things that already went right — the install and the sign-in —
27
+ * so instead the sign-in is kept, and the summary names the two commands left:
28
+ * `cd <project>` and `bir setup`. The second needs no token; the session is
29
+ * cached.
30
+ */
31
+ export interface SetupArgs {
32
+ /** `--auth-url`; else BIR_AUTH_URL; else the stored address. */
33
+ authUrl?: string;
34
+ /** `--token`; else the BIR_SETUP_TOKEN environment variable. Never echoed. */
35
+ token?: string;
36
+ /** `--project`: the directory to record. Default: the current one. */
37
+ project?: string;
38
+ /** `--no-browser` → false. Undefined lets `deviceLogin` decide. */
39
+ browser?: boolean;
40
+ /** `--no-replay` → false: wrap and record, but add no scenario server. */
41
+ replay: boolean;
42
+ /** `--no-daemon` → false: wire everything, start nothing. */
43
+ daemon: boolean;
44
+ /** `--allow-servers a,b`: the servers replay may call unattended, stored for this project. */
45
+ allowServers?: string;
46
+ }
47
+ export interface SetupDeps {
48
+ out: (line?: string) => void;
49
+ err: (line: string) => void;
50
+ /**
51
+ * Wrap the current directory: `bir install --replay` (or without), with the
52
+ * invocation the caller decided. Returns its exit code.
53
+ */
54
+ install: (opts: {
55
+ replay: boolean;
56
+ }) => Promise<number>;
57
+ /** Absolute path of `dist/bin/bir-hooks.js`, for the daemon. */
58
+ hooksScript: string;
59
+ env?: NodeJS.ProcessEnv;
60
+ /** Injected by tests; defaults to `process.chdir`. */
61
+ chdir?: (dir: string) => void;
62
+ }
63
+ /** True when `claude` (Claude Code) is on PATH or in its native install location. */
64
+ export declare function claudeOnPath(env?: NodeJS.ProcessEnv): boolean;
65
+ /**
66
+ * The console for an API address, when the deployment follows the
67
+ * `api.<domain>` convention this project uses. Undefined otherwise — a guessed
68
+ * link is worse than none.
69
+ */
70
+ export declare function consoleUrlFor(apiUrl: string): string | undefined;
71
+ export declare function runSetup(args: SetupArgs, deps: SetupDeps): Promise<number>;
72
+ //# sourceMappingURL=setup.d.ts.map
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `bir setup` — from an installed package to a recording, replaying project,
3
+ * in one command and with nothing left open.
4
+ *
5
+ * It is the sequence the guides used to ask a person to type, done in order and
6
+ * checked at each step:
7
+ *
8
+ * 1. the service address, verified to be the API and not a website, then
9
+ * stored in ~/.baseinstrunner/config.json so no terminal needs BIR_AUTH_URL;
10
+ * 2. the sign-in — a setup token from the console, a cached session, or the
11
+ * browser flow — stored the way `bir login` stores it;
12
+ * 3. the project: MCP servers wrapped, hooks wired, the scenario server added
13
+ * and pre-approved (`bir install --global --replay`);
14
+ * 4. the recorder, started in the background and from now on started by the
15
+ * SessionStart hook whenever it is missing;
16
+ * 5. a summary that names the account, the servers, the recorder's log, and
17
+ * the one thing left to do: start Claude Code.
18
+ *
19
+ * Idempotent, so it is also the upgrade path and the "is it still right?" path.
20
+ * It never prompts: under `curl | sh` stdin is the script itself, and a script
21
+ * that stops to ask is a script that hangs. Everything it needs is an argument
22
+ * or an environment variable, and everything it decides is printed.
23
+ *
24
+ * THE WRONG FOLDER. A terminal opened from the Start menu begins in the home
25
+ * directory, and that is where a first paste often lands. Refusing would throw
26
+ * away the two things that already went right — the install and the sign-in —
27
+ * so instead the sign-in is kept, and the summary names the two commands left:
28
+ * `cd <project>` and `bir setup`. The second needs no token; the session is
29
+ * cached.
30
+ */
31
+ import { existsSync, statSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { delimiter, join, parse as parsePath, resolve as resolvePath } from "node:path";
34
+ import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, describeAuthService, deviceLogin, loadStoredConfig, normalizeAuthUrl, rememberAuthUrl, tokenLogin, } from "../auth/client.js";
35
+ import { activeSessions, ensureDaemon, SETUP_TOKEN_ENV, stopDaemon, } from "../control/daemon.js";
36
+ import { normalizePath } from "../control/paths.js";
37
+ import { isWrapped, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
38
+ import { resolveServers } from "../config/resolve.js";
39
+ import { errText } from "../util/log.js";
40
+ import { packageVersion } from "../util/version.js";
41
+ /** True when `claude` (Claude Code) is on PATH or in its native install location. */
42
+ export function claudeOnPath(env = process.env) {
43
+ const names = process.platform === "win32" ? ["claude.exe", "claude.cmd", "claude"] : ["claude"];
44
+ const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
45
+ dirs.push(join(homedir(), ".local", "bin"));
46
+ return dirs.some((dir) => names.some((name) => existsSync(join(dir, name))));
47
+ }
48
+ /** A directory nobody means to record from: the home folder or a drive root. */
49
+ function looksLikeNotAProject(dir) {
50
+ const norm = normalizePath(dir);
51
+ if (norm === normalizePath(homedir()))
52
+ return "your home folder";
53
+ if (normalizePath(parsePath(dir).root) === norm)
54
+ return "the root of a drive";
55
+ return undefined;
56
+ }
57
+ /**
58
+ * The console for an API address, when the deployment follows the
59
+ * `api.<domain>` convention this project uses. Undefined otherwise — a guessed
60
+ * link is worse than none.
61
+ */
62
+ export function consoleUrlFor(apiUrl) {
63
+ try {
64
+ const u = new URL(apiUrl);
65
+ if (!u.hostname.startsWith("api."))
66
+ return undefined;
67
+ return `${u.protocol}//${u.hostname.slice("api.".length)}`;
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ export async function runSetup(args, deps) {
74
+ const { out, err } = deps;
75
+ const env = deps.env ?? process.env;
76
+ const chdir = deps.chdir ?? ((dir) => process.chdir(dir));
77
+ // The token is read once and then removed from this process's environment,
78
+ // so nothing started from here — the recorder above all — inherits it.
79
+ const token = args.token ?? env[SETUP_TOKEN_ENV];
80
+ delete env[SETUP_TOKEN_ENV];
81
+ if (env === process.env)
82
+ delete process.env[SETUP_TOKEN_ENV];
83
+ // ── 1. the service ───────────────────────────────────────────────────────
84
+ // `||`, not `??`: an environment that *sets* BIR_AUTH_URL to nothing (a
85
+ // wrapper script, a CI job) means "not set", not "the empty address".
86
+ const configured = (args.authUrl || env.BIR_AUTH_URL || loadStoredConfig().authUrl || "").trim();
87
+ if (!configured) {
88
+ err("No service address. Pass --auth-url https://api.your-service, or set BIR_AUTH_URL.");
89
+ err(AUTH_URL_HINT);
90
+ return 1;
91
+ }
92
+ const baseUrl = await normalizeAuthUrl(configured);
93
+ const problem = await describeAuthService(baseUrl);
94
+ if (problem) {
95
+ err(`${baseUrl} is not a BaseIn service: ${problem}.`);
96
+ err(AUTH_URL_HINT);
97
+ return 1;
98
+ }
99
+ const envUrl = (env.BIR_AUTH_URL ?? "").replace(/\/+$/, "");
100
+ const storedUrl = (loadStoredConfig().authUrl ?? "").replace(/\/+$/, "");
101
+ if (envUrl && ((args.authUrl && envUrl !== baseUrl) || (!args.authUrl && storedUrl && envUrl !== storedUrl))) {
102
+ // Every process reads BIR_AUTH_URL before the stored address, so a stale
103
+ // value in a profile file (the pre-0.2.9 installers wrote one) would send
104
+ // tomorrow's recordings elsewhere — or, now that sessions are bound to
105
+ // their service, nowhere. Said whether the address came from --auth-url or
106
+ // from the store; a bare `bir setup` in a second project is the usual case.
107
+ err(`BIR_AUTH_URL in this environment is ${envUrl}, but ${args.authUrl ? "this sets up" : "the stored address is"} ${args.authUrl ? baseUrl : storedUrl}.`);
108
+ err("Remove that variable (check your shell profile), or the recorder will not record.");
109
+ }
110
+ const project = resolvePath(args.project ?? process.cwd());
111
+ if (!existsSync(project) || !statSync(project).isDirectory()) {
112
+ err(`Project directory not found: ${project}`);
113
+ return 1;
114
+ }
115
+ out(`==> Setting up the runner (${packageVersion()})`);
116
+ out(` service ${baseUrl}`);
117
+ // ── 2. the sign-in ───────────────────────────────────────────────────────
118
+ let session;
119
+ try {
120
+ if (token !== undefined) {
121
+ if (!token.trim()) {
122
+ err("--token needs the token itself: bir setup --token <token>");
123
+ return 1;
124
+ }
125
+ try {
126
+ session = await tokenLogin(baseUrl, token);
127
+ }
128
+ catch (e) {
129
+ // The same line, pasted twice: a setup token is single-use, and the
130
+ // second paste — after a failure past the sign-in, or as the upgrade
131
+ // path — carries a token the first paste already redeemed. The sign-in
132
+ // that first paste made is still on disk; that is the one to keep.
133
+ const cached = e instanceof DeviceFlowAborted && e.reason === "expired_token"
134
+ ? await authenticate({ authUrl: baseUrl })
135
+ : undefined;
136
+ if (!cached)
137
+ throw e;
138
+ session = cached;
139
+ out(` the setup token was already used; keeping the sign-in from before (${cached.user.email})`);
140
+ }
141
+ }
142
+ else {
143
+ // A cached session is bound to the service that issued it (auth/client.ts);
144
+ // `authenticate` answers nothing for another one, and the browser flow runs.
145
+ session = await authenticate({ authUrl: baseUrl });
146
+ if (!session) {
147
+ out(" signing in through the browser (a setup token from the console skips this)");
148
+ // The install scripts run this with stderr on a pipe, and `deviceLogin`
149
+ // would read that as "nobody is watching" and open nothing. Somebody is:
150
+ // they just pasted the line. Only SSH, where the browser would open on
151
+ // the wrong machine, still keeps the link on paper.
152
+ const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY);
153
+ session = await deviceLogin(baseUrl, { openBrowser: args.browser ?? !overSsh });
154
+ }
155
+ }
156
+ }
157
+ catch (e) {
158
+ if (e instanceof DeviceFlowAborted) {
159
+ err(e.message);
160
+ return 1;
161
+ }
162
+ if (e instanceof DeviceFlowUnsupported) {
163
+ err("this service does not offer browser sign-in or setup tokens; run `bir login --password`.");
164
+ return 1;
165
+ }
166
+ err(`sign-in failed: ${errText(e)}`);
167
+ return 1;
168
+ }
169
+ if (!session) {
170
+ err("not signed in — run `bir setup` again (or `bir login`).");
171
+ return 1;
172
+ }
173
+ rememberAuthUrl(baseUrl);
174
+ out(` account ${session.user.email}`);
175
+ // ── 3. the wrong folder ──────────────────────────────────────────────────
176
+ const notAProject = looksLikeNotAProject(project);
177
+ if (notAProject) {
178
+ out();
179
+ out(`Signed in as ${session.user.email}. This is ${notAProject}, so nothing was wired here.`);
180
+ out("Two commands left. Open a terminal in the project you start Claude Code in, and run:");
181
+ out(` cd ${process.platform === "win32" ? "C:\\path\\to\\your\\project" : "~/path/to/your/project"}`);
182
+ out(" bir setup");
183
+ out("No token is needed the second time; the sign-in is kept on this computer.");
184
+ return 0;
185
+ }
186
+ // ── 4. the project ───────────────────────────────────────────────────────
187
+ chdir(project);
188
+ if (args.allowServers !== undefined) {
189
+ // Stored before install so the recorder started below reads it at start.
190
+ const sidecar = readSidecar();
191
+ const port = projectRecord(sidecar, project)?.port ?? (await allocateFreeProjectPort(sidecar, project));
192
+ const list = args.allowServers.split(",").map((s) => s.trim()).filter(Boolean);
193
+ setProjectRecord(sidecar, project, { port, replay: { ...(projectRecord(sidecar, project)?.replay ?? {}), allowServers: list.length ? list : undefined } });
194
+ writeSidecar(sidecar);
195
+ }
196
+ out("==> Wiring the project");
197
+ const code = await deps.install({ replay: args.replay });
198
+ if (code !== 0)
199
+ return code;
200
+ // ── 5. the recorder ──────────────────────────────────────────────────────
201
+ let ensured;
202
+ if (args.daemon) {
203
+ out("==> Starting the recorder in the background");
204
+ try {
205
+ ensured = await ensureDaemon(project, {
206
+ hooksScript: deps.hooksScript,
207
+ env,
208
+ restartOnSkew: true,
209
+ });
210
+ if (!ensured.started &&
211
+ ensured.status.health.recording === false &&
212
+ activeSessions(ensured.status.health) === 0) {
213
+ // A recorder that came up before there was a sign-in (a `bir up` on a
214
+ // fresh machine, a session that expired) has nowhere to send steps.
215
+ // There is one now, and nothing is mid-run: replace it rather than
216
+ // tell the person to.
217
+ out(" the running recorder has no sign-in; replacing it");
218
+ await stopDaemon(project);
219
+ ensured = await ensureDaemon(project, { hooksScript: deps.hooksScript, env });
220
+ ensured = { ...ensured, restarted: true };
221
+ }
222
+ }
223
+ catch (e) {
224
+ err(`the recorder did not start: ${errText(e)}`);
225
+ err("Everything else is in place. Fix that, then run `bir up` here.");
226
+ return 1;
227
+ }
228
+ }
229
+ // ── 6. the summary ───────────────────────────────────────────────────────
230
+ const wrapped = resolveServers(project)
231
+ .filter((s) => s.name !== SCENARIO_SERVER_KEY && isWrapped(s.config))
232
+ .map((s) => s.name);
233
+ const health = ensured?.status.health;
234
+ const replay = health?.replay;
235
+ const recording = health ? health.recording !== false : undefined;
236
+ const policy = projectRecord(readSidecar(), project)?.replay;
237
+ const allow = replay?.allowServers ?? policy?.allowServers ?? null;
238
+ out();
239
+ out(`Done. Recording${args.replay ? " and replay are" : " is"} on for ${project}`);
240
+ out(` account ${session.user.email} (${baseUrl})`);
241
+ out(` wrapped ${wrapped.length ? wrapped.join(", ") : "(no MCP servers here)"}`);
242
+ if (!args.replay) {
243
+ out(" replay not installed (--no-replay)");
244
+ }
245
+ else if (wrapped.length === 0) {
246
+ out(" replay steer mode only — no MCP servers wrapped here; add a .mcp.json and run `bir setup` again for direct replay");
247
+ }
248
+ else {
249
+ const on = replay ? replay.enabled !== false : policy?.enabled !== false;
250
+ out(` replay ${on ? "on" : "off"} — direct execution allowed for: ${allow?.length ? allow.join(", ") : `all wrapped (narrow it with \`bir replay allow ${wrapped[0]}\`)`}`);
251
+ }
252
+ if (ensured) {
253
+ const { info, health: h } = ensured.status;
254
+ const theirs = String(h.hooksVersion ?? info.version ?? "");
255
+ out(` recorder ${ensured.started ? (ensured.restarted ? "restarted" : "started") : "already running"} in the background (pid ${info.pid})` +
256
+ " — it starts itself with every Claude Code session");
257
+ if (theirs && theirs !== packageVersion()) {
258
+ out(` recorder ${theirs} is still serving an open Claude Code session; it restarts to ${packageVersion()} with the next session`);
259
+ }
260
+ if (info.logFile)
261
+ out(` log ${info.logFile}`);
262
+ if (recording === false) {
263
+ out(" !! the recorder has no session to record with — run `bir login`, then `bir up --restart`");
264
+ }
265
+ }
266
+ else {
267
+ out(" recorder not started (--no-daemon); the SessionStart hook starts it, or run `bir up`");
268
+ }
269
+ out();
270
+ const console_ = consoleUrlFor(baseUrl);
271
+ if (claudeOnPath(env)) {
272
+ out("Now: claude");
273
+ }
274
+ else {
275
+ out("Now: install Claude Code, then start it here: claude");
276
+ out(process.platform === "win32"
277
+ ? " irm https://claude.ai/install.ps1 | iex"
278
+ : " curl -fsSL https://claude.ai/install.sh | bash");
279
+ }
280
+ out(' first time in this folder: say Yes to "trust this folder"');
281
+ out(" just installed Claude Code? it asks you to sign in to Claude (it needs a Claude subscription)");
282
+ out(` give it a task that takes 4+ tool calls — it appears ${console_ ? `at ${console_}/recordings` : "in the console's Recordings"} within a minute of finishing`);
283
+ out("Any time: bir doctor bir investigate bir down (stop) bir uninstall (undo)");
284
+ return recording === false ? 1 : 0;
285
+ }
286
+ //# sourceMappingURL=setup.js.map
@@ -19,10 +19,50 @@ export interface ClaudeCodePaths {
19
19
  claudeJson: string;
20
20
  /** `<cwd>/.mcp.json` (project scope). */
21
21
  mcpJson: string;
22
- /** `<cwd>/.claude/settings.json` (hooks). */
22
+ /**
23
+ * `<cwd>/.claude/settings.json` — the file a team commits. Hooks used to go
24
+ * here; they are only ever *removed* from it now (see {@link ClaudeCodePaths.settingsLocal}).
25
+ */
23
26
  settings: string;
27
+ /**
28
+ * `<cwd>/.claude/settings.local.json` — "you, in this project", the file
29
+ * Claude Code itself keeps out of git. Where the hooks go: they carry a
30
+ * bearer token and an absolute path to this machine's node, neither of which
31
+ * belongs in a teammate's clone.
32
+ */
33
+ settingsLocal: string;
24
34
  }
25
35
  export declare function claudeCodePaths(cwd: string, claudeJsonOverride?: string): ClaudeCodePaths;
36
+ /** The line `bir` adds to a repository's exclude file. */
37
+ export declare const LOCAL_SETTINGS_EXCLUDE = ".claude/settings.local.json";
38
+ /**
39
+ * Keep `.claude/settings.local.json` out of version control, the way Claude
40
+ * Code does when *it* creates the file — it only does so for files it wrote.
41
+ *
42
+ * `.git/info/exclude`, never `.gitignore`: the first is the person's own,
43
+ * untracked list; the second is a file the team owns and would see in a diff.
44
+ * Walks up from `dir` to find the repository, follows a worktree's `gitdir:`
45
+ * pointer, and appends the line once. Returns the exclude file's path when the
46
+ * line is there (added or already present), undefined outside a repository or
47
+ * when the file could not be written — an exclude that fails is a note, not a
48
+ * failed install.
49
+ */
50
+ export declare function ensureGitExclude(dir: string, line?: string): string | undefined;
51
+ /** Undo {@link ensureGitExclude}: drop the line, leave everything else. */
52
+ export declare function removeGitExclude(dir: string, line?: string): string | undefined;
53
+ /**
54
+ * Pre-approve project-scoped MCP servers by name (`enabledMcpjsonServers`).
55
+ *
56
+ * Claude Code asks before it uses a server from `.mcp.json`, and a "No" there
57
+ * silently leaves a direct replay with nowhere to deliver its results. The
58
+ * runner's own scenario server is approved here, in the person's local
59
+ * settings, so the question is never asked. Returns the file's new text.
60
+ */
61
+ export declare function enableMcpjsonServers(settingsPath: string, names: string[]): string;
62
+ /** Undo {@link enableMcpjsonServers} for `names`; drops the key when empty. */
63
+ export declare function disableMcpjsonServers(settingsPath: string, names: string[]): string | undefined;
64
+ /** True when `settingsPath` holds any hook of ours. */
65
+ export declare function hasBirHooks(settingsPath: string): boolean;
26
66
  /** Which file holds a given scope's `mcpServers` for this cwd. */
27
67
  export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): string;
28
68
  export declare function sha256(text: string): string;
@@ -52,19 +92,65 @@ export declare const HOOK_ROUTES: Array<{
52
92
  timeout: number;
53
93
  async?: boolean;
54
94
  }>;
55
- export interface HookEntry {
95
+ export interface HttpHookEntry {
56
96
  type: "http";
57
97
  url: string;
58
98
  timeout: number;
59
99
  headers?: Record<string, string>;
60
100
  async?: boolean;
61
101
  }
102
+ /**
103
+ * A command hook in Claude Code's **exec form**: 'command' is spawned directly
104
+ * with 'args' as its argument vector and no shell in between. That is the only
105
+ * form that survives every host: a path with a space in it needs no quoting,
106
+ * and it runs the same under Git Bash, PowerShell and cmd.exe.
107
+ */
108
+ export interface CommandHookEntry {
109
+ type: "command";
110
+ command: string;
111
+ args: string[];
112
+ timeout: number;
113
+ }
114
+ export type HookEntry = HttpHookEntry | CommandHookEntry;
62
115
  export type HooksBlock = Record<string, Array<{
63
116
  matcher?: string;
64
117
  hooks: HookEntry[];
65
118
  }>>;
66
- /** Build the `hooks` block pointing at a control server. */
67
- export declare function buildHooksBlock(controlUrl: string, token: string): HooksBlock;
119
+ /**
120
+ * The argument that makes a 'bir-hooks' invocation the SessionStart hook.
121
+ * Exported so the binary and the adapter cannot drift apart on the spelling.
122
+ */
123
+ export declare const ENSURE_ARG = "ensure";
124
+ /** How long SessionStart may wait for a background recorder to come up. */
125
+ export declare const ENSURE_TIMEOUT_S = 30;
126
+ export interface HooksBlockOptions {
127
+ /**
128
+ * Make SessionStart start the recorder itself when none is running.
129
+ *
130
+ * 'node' is the interpreter and 'hooksScript' the absolute path of
131
+ * dist/bin/bir-hooks.js. The hook then reads the SessionStart payload, starts
132
+ * 'bir-hooks' in the background if this directory has no live control
133
+ * server, and relays the payload to it — so the HTTP SessionStart hook is
134
+ * **replaced**, not added to. Hooks for one event run in parallel, and an
135
+ * HTTP hook racing the daemon it depends on would fail every first session.
136
+ *
137
+ * Omitted for an npx invocation: resolving a package inside a hook is too
138
+ * slow to start a recorder from, so those installs keep the HTTP hook and a
139
+ * recorder someone started.
140
+ */
141
+ ensure?: {
142
+ node: string;
143
+ hooksScript: string;
144
+ nodeFlags?: string[];
145
+ };
146
+ }
147
+ /** Build the 'hooks' block pointing at a control server. */
148
+ export declare function buildHooksBlock(controlUrl: string, token: string, opts?: HooksBlockOptions): HooksBlock;
149
+ /**
150
+ * True when a hook entry is one of ours: an HTTP hook aimed at a control-server
151
+ * route, or the exec-form command hook that starts the recorder.
152
+ */
153
+ export declare function isBirHook(entry: unknown): boolean;
68
154
  /**
69
155
  * Merge our hooks into a settings file, leaving every other hook alone. Returns
70
156
  * the new text. Our own entries are replaced rather than appended, so repeated