@birdybeep/cli 0.2.0 → 0.4.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.
@@ -69,8 +69,11 @@ function parseGlobalFlags(argv) {
69
69
  }
70
70
  return { flags, rest };
71
71
  }
72
- function isUnknownFlag(token) {
73
- return token.startsWith("-") && !GLOBAL_FLAG_TOKENS.has(token);
72
+ function isUnknownFlag(token, allowed) {
73
+ if (!token.startsWith("-")) return false;
74
+ if (GLOBAL_FLAG_TOKENS.has(token)) return false;
75
+ const eq = token.indexOf("=");
76
+ return !allowed.has(eq >= 0 ? token.slice(0, eq) : token);
74
77
  }
75
78
  function renderRootHelp(version, commands) {
76
79
  const width = Math.max(...commands.map((c) => c.name.length));
@@ -91,6 +94,16 @@ function renderRootHelp(version, commands) {
91
94
  " -v, --version Show the CLI version"
92
95
  ].join("\n");
93
96
  }
97
+ function commandFlagTokens(...commands) {
98
+ const tokens = /* @__PURE__ */ new Set();
99
+ for (const command of commands) {
100
+ for (const option of command?.options ?? []) {
101
+ tokens.add(option.flag);
102
+ for (const alias of option.aliases ?? []) tokens.add(alias);
103
+ }
104
+ }
105
+ return tokens;
106
+ }
94
107
  function renderCommandHelp(path, command) {
95
108
  const lines = [
96
109
  `birdybeep ${path} \u2014 ${command.summary}`,
@@ -98,6 +111,17 @@ function renderCommandHelp(path, command) {
98
111
  "Usage:",
99
112
  ` ${command.usage ?? `birdybeep ${path} [options]`}`
100
113
  ];
114
+ if (command.options && command.options.length > 0) {
115
+ const labels = command.options.map(
116
+ (o) => `${[o.flag, ...o.aliases ?? []].join(", ")}${o.value ? ` ${o.value}` : ""}`
117
+ );
118
+ const width = Math.max(...labels.map((l) => l.length));
119
+ lines.push(
120
+ "",
121
+ "Options:",
122
+ ...command.options.map((o, i) => ` ${labels[i]?.padEnd(width)} ${o.summary}`)
123
+ );
124
+ }
101
125
  if (command.subcommands && command.subcommands.length > 0) {
102
126
  const width = Math.max(...command.subcommands.map((c) => c.name.length));
103
127
  lines.push(
@@ -122,6 +146,7 @@ async function dispatch(argv, deps) {
122
146
  return EXIT.OK;
123
147
  }
124
148
  let command = deps.commands.find((c) => c.name === rest[0]);
149
+ let parent;
125
150
  const pathParts = [];
126
151
  let argsStart = 1;
127
152
  if (command) {
@@ -129,6 +154,7 @@ async function dispatch(argv, deps) {
129
154
  if (command.subcommands && command.subcommands.length > 0) {
130
155
  const sub = command.subcommands.find((c) => c.name === rest[1]);
131
156
  if (sub) {
157
+ parent = command;
132
158
  command = sub;
133
159
  pathParts.push(sub.name);
134
160
  argsStart = 2;
@@ -152,6 +178,7 @@ async function dispatch(argv, deps) {
152
178
  name: path,
153
179
  summary: command.summary,
154
180
  usage: command.usage,
181
+ options: command.options,
155
182
  subcommands: command.subcommands?.map((c) => ({ name: c.name, summary: c.summary }))
156
183
  });
157
184
  return EXIT.OK;
@@ -161,7 +188,8 @@ async function dispatch(argv, deps) {
161
188
  return EXIT.USAGE;
162
189
  }
163
190
  const args = rest.slice(argsStart);
164
- const unknown = args.find(isUnknownFlag);
191
+ const allowed = commandFlagTokens(command, parent);
192
+ const unknown = args.find((token) => isUnknownFlag(token, allowed));
165
193
  if (unknown !== void 0) {
166
194
  io.errline(`birdybeep ${path}: unknown option "${unknown}".`);
167
195
  return EXIT.USAGE;
@@ -189,19 +217,36 @@ async function dispatch(argv, deps) {
189
217
  }
190
218
 
191
219
  // src/version.ts
192
- var CLI_VERSION = "0.2.0".length > 0 ? "0.2.0" : "0.0.0";
220
+ var CLI_VERSION = "0.4.0".length > 0 ? "0.4.0" : "0.0.0";
193
221
 
194
222
  // src/commands/agent.ts
195
223
  import { claudeCodeAdapter } from "@birdybeep/claude-code";
196
224
  import { codexAdapter } from "@birdybeep/codex";
225
+ import { copilotAdapter } from "@birdybeep/copilot";
226
+ import { cursorAdapter } from "@birdybeep/cursor";
197
227
  import { opencodeAdapter } from "@birdybeep/opencode";
198
- var DEFAULT_ADAPTERS = [claudeCodeAdapter, codexAdapter, opencodeAdapter];
228
+ var DEFAULT_ADAPTERS = [
229
+ claudeCodeAdapter,
230
+ codexAdapter,
231
+ opencodeAdapter,
232
+ cursorAdapter,
233
+ copilotAdapter
234
+ ];
199
235
  var TARGET_TO_ID = {
200
236
  claude: "claude_code",
201
237
  codex: "codex",
202
- opencode: "opencode"
238
+ opencode: "opencode",
239
+ cursor: "cursor",
240
+ copilot: "copilot"
203
241
  };
204
- var AGENT_TARGETS = ["all", "claude", "codex", "opencode"];
242
+ var AGENT_TARGETS = [
243
+ "all",
244
+ "claude",
245
+ "codex",
246
+ "opencode",
247
+ "cursor",
248
+ "copilot"
249
+ ];
205
250
  function selectAdapters(target, adapters) {
206
251
  if (target === "all") return adapters;
207
252
  const id = TARGET_TO_ID[target];
@@ -292,18 +337,18 @@ function createAgentCommand(deps = {}) {
292
337
  return {
293
338
  name: "agent",
294
339
  summary: "Install or uninstall harness adapters",
295
- usage: "birdybeep agent <install|uninstall> [all|claude|codex|opencode]",
340
+ usage: "birdybeep agent <install|uninstall> [all|claude|codex|opencode|cursor|copilot]",
296
341
  subcommands: [
297
342
  {
298
343
  name: "install",
299
- summary: "Install adapters (all | claude | codex | opencode)",
300
- usage: "birdybeep agent install [all|claude|codex|opencode]",
344
+ summary: "Install adapters (all | claude | codex | opencode | cursor | copilot)",
345
+ usage: "birdybeep agent install [all|claude|codex|opencode|cursor|copilot]",
301
346
  run: (ctx) => installSelected(adapters, ctx)
302
347
  },
303
348
  {
304
349
  name: "uninstall",
305
350
  summary: "Restore harness config to its pre-install state",
306
- usage: "birdybeep agent uninstall [all|claude|codex|opencode]",
351
+ usage: "birdybeep agent uninstall [all|claude|codex|opencode|cursor|copilot]",
307
352
  run: (ctx) => uninstallSelected(adapters, ctx)
308
353
  }
309
354
  ]
@@ -316,6 +361,8 @@ import {
316
361
  } from "@birdybeep/agent-core";
317
362
  import { claudeCodeAdapter as claudeCodeAdapter2 } from "@birdybeep/claude-code";
318
363
  import { codexAdapter as codexAdapter2 } from "@birdybeep/codex";
364
+ import { copilotAdapter as copilotAdapter2 } from "@birdybeep/copilot";
365
+ import { cursorAdapter as cursorAdapter2 } from "@birdybeep/cursor";
319
366
  import { opencodeAdapter as opencodeAdapter2 } from "@birdybeep/opencode";
320
367
 
321
368
  // src/config.ts
@@ -340,6 +387,8 @@ function writeCliConfig(patch) {
340
387
  const merged = {};
341
388
  const apiUrl = patch.apiUrl ?? current.apiUrl;
342
389
  if (apiUrl !== void 0) merged.apiUrl = apiUrl;
390
+ const expectEmail = patch.expectEmail ?? current.expectEmail;
391
+ if (expectEmail !== void 0) merged.expectEmail = expectEmail;
343
392
  mkdirSync2(birdyBeepConfigDir2(), { recursive: true, mode: 448 });
344
393
  writeFileSync(cliConfigPath(), `${JSON.stringify(merged, null, 2)}
345
394
  `, { mode: 384 });
@@ -382,7 +431,13 @@ function machineIdentity() {
382
431
  }
383
432
 
384
433
  // src/commands/doctor.ts
385
- var DEFAULT_ADAPTERS2 = [claudeCodeAdapter2, codexAdapter2, opencodeAdapter2];
434
+ var DEFAULT_ADAPTERS2 = [
435
+ claudeCodeAdapter2,
436
+ codexAdapter2,
437
+ opencodeAdapter2,
438
+ cursorAdapter2,
439
+ copilotAdapter2
440
+ ];
386
441
  async function defaultProbeNetwork(baseUrl) {
387
442
  try {
388
443
  const controller = new AbortController();
@@ -465,18 +520,36 @@ function createDoctorCommand(deps = {}) {
465
520
  }
466
521
 
467
522
  // src/commands/hook.ts
523
+ import { spawn } from "child_process";
524
+ import { randomBytes } from "crypto";
525
+ import { closeSync, openSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
526
+ import { tmpdir } from "os";
527
+ import { basename, dirname, join as join2 } from "path";
468
528
  import {
469
- createSender as defaultCreateSender2
529
+ createSender as defaultCreateSender2,
530
+ resolveOnPath
470
531
  } from "@birdybeep/agent-core";
471
- import { runClaudeHook } from "@birdybeep/claude-code";
532
+ import { isClaudeCodeHookPayload, runClaudeHook } from "@birdybeep/claude-code";
472
533
  import { runCodexHook } from "@birdybeep/codex";
534
+ import {
535
+ isCopilotHookEventName,
536
+ runCopilotHook
537
+ } from "@birdybeep/copilot";
538
+ import { isCursorHookEventName, isCursorHookPayload, runCursorHook } from "@birdybeep/cursor";
473
539
  import { runOpenCodeHook } from "@birdybeep/opencode";
474
540
  var RUNNERS = {
475
541
  claude: runClaudeHook,
476
542
  codex: runCodexHook,
477
- opencode: runOpenCodeHook
543
+ opencode: runOpenCodeHook,
544
+ cursor: runCursorHook
478
545
  };
479
- var HOOK_HARNESSES = ["claude", "codex", "opencode"];
546
+ var HOOK_HARNESSES = [
547
+ "claude",
548
+ "codex",
549
+ "opencode",
550
+ "cursor",
551
+ "copilot"
552
+ ];
480
553
  var STDIN_READ_TIMEOUT_MS = 3e3;
481
554
  function withTimeout(promise, ms, fallback) {
482
555
  return new Promise((resolve) => {
@@ -493,10 +566,31 @@ function withTimeout(promise, ms, fallback) {
493
566
  });
494
567
  }
495
568
  function isHarnessName(value) {
496
- return value === "claude" || value === "codex" || value === "opencode";
569
+ return value === "claude" || value === "codex" || value === "opencode" || value === "cursor" || value === "copilot";
497
570
  }
498
- function runHookCommand(harness, payload, sender) {
499
- return RUNNERS[harness](payload, { sender });
571
+ function resolveHookHarness(harness, payload) {
572
+ return harness === "claude" && isCursorHookPayload(payload) ? "cursor" : harness;
573
+ }
574
+ function recognizesPayload(harness, payload) {
575
+ if (harness === "claude") return isClaudeCodeHookPayload(payload);
576
+ if (harness === "cursor") return isCursorHookEventName(asRecord(payload)["hook_event_name"]);
577
+ return true;
578
+ }
579
+ function asRecord(value) {
580
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
581
+ }
582
+ function describeEventName(payload) {
583
+ const name = asRecord(payload)["hook_event_name"];
584
+ if (typeof name !== "string") return "(absent)";
585
+ return JSON.stringify(name.length > 64 ? `${name.slice(0, 63)}\u2026` : name);
586
+ }
587
+ function runHookCommand(harness, payload, sender, copilotEventName) {
588
+ const handler = resolveHookHarness(harness, payload);
589
+ if (handler === "copilot") {
590
+ if (copilotEventName === void 0) return Promise.resolve({ outcome: "skipped" });
591
+ return runCopilotHook(copilotEventName, payload, { sender });
592
+ }
593
+ return RUNNERS[handler](payload, { sender });
500
594
  }
501
595
  function readStdinDefault() {
502
596
  return new Promise((resolve) => {
@@ -511,24 +605,90 @@ function readStdinDefault() {
511
605
  process.stdin.on("error", () => resolve(""));
512
606
  });
513
607
  }
514
- async function readHookPayload(args, readStdin) {
515
- return args[1] ?? await readStdin();
608
+ async function readHookPayload(args, readStdin, stdinOnly = false) {
609
+ return stdinOnly ? readStdin() : args[1] ?? await readStdin();
610
+ }
611
+ var NOTIFY_STDIN_FILE_ENV = "BIRDYBEEP_CODEX_NOTIFY_STDIN_FILE";
612
+ function detachCodexNotifyWorker(payload) {
613
+ if (process.platform === "win32") return false;
614
+ let file;
615
+ let fd;
616
+ try {
617
+ const birdybeep = resolveOnPath("birdybeep");
618
+ if (birdybeep === null) return false;
619
+ const tmpFile = join2(tmpdir(), `birdybeep-notify-${randomBytes(16).toString("hex")}.json`);
620
+ file = tmpFile;
621
+ writeFileSync2(tmpFile, payload, { mode: 384 });
622
+ fd = openSync(tmpFile, "r");
623
+ const child = spawn(birdybeep, ["hook", "codex"], {
624
+ cwd: dirname(birdybeep),
625
+ // trusted dir, never the inherited/attacker cwd
626
+ detached: true,
627
+ // new session (setsid) → survives `codex exec` reaping the group
628
+ stdio: [fd, "ignore", "ignore"],
629
+ // stdin = the temp file; this process holds no pipe
630
+ env: { ...process.env, [NOTIFY_STDIN_FILE_ENV]: tmpFile },
631
+ // worker cleans it up post-read
632
+ windowsHide: true
633
+ });
634
+ child.on("error", () => {
635
+ try {
636
+ rmSync(tmpFile, { force: true });
637
+ } catch {
638
+ }
639
+ });
640
+ child.unref();
641
+ return true;
642
+ } catch {
643
+ if (file !== void 0) {
644
+ try {
645
+ rmSync(file, { force: true });
646
+ } catch {
647
+ }
648
+ }
649
+ return false;
650
+ } finally {
651
+ if (fd !== void 0) {
652
+ try {
653
+ closeSync(fd);
654
+ } catch {
655
+ }
656
+ }
657
+ }
516
658
  }
517
659
  function createHookCommand(deps = {}) {
518
660
  const makeSender = deps.createSender ?? ((baseUrl) => defaultCreateSender2({ baseUrl }));
519
661
  const readStdin = deps.readStdin ?? readStdinDefault;
520
662
  const stdinTimeoutMs = deps.stdinTimeoutMs ?? STDIN_READ_TIMEOUT_MS;
663
+ const detachCodexNotify = deps.detachCodexNotify ?? detachCodexNotifyWorker;
521
664
  return {
522
665
  name: "hook",
523
666
  summary: "Internal: normalize + send an event fired by a harness hook",
524
- usage: "birdybeep hook <claude|codex|opencode>",
667
+ usage: "birdybeep hook <claude|codex|opencode|cursor|copilot> [copilot-event]",
525
668
  run: async (ctx) => {
526
669
  const harness = ctx.args[0];
527
670
  if (!isHarnessName(harness)) {
528
671
  ctx.io.errline(`birdybeep hook: expected one of ${HOOK_HARNESSES.join("|")}`);
529
672
  return EXIT.USAGE;
530
673
  }
531
- const raw = await withTimeout(readHookPayload(ctx.args, readStdin), stdinTimeoutMs, "");
674
+ const notifyPayload = ctx.args[1];
675
+ if (harness === "codex" && notifyPayload !== void 0 && notifyPayload.length > 0 && detachCodexNotify(notifyPayload)) {
676
+ ctx.io.result({ harness, outcome: "detached" });
677
+ return EXIT.OK;
678
+ }
679
+ const copilotEventName = harness === "copilot" && isCopilotHookEventName(ctx.args[1]) ? ctx.args[1] : void 0;
680
+ const raw = await withTimeout(
681
+ readHookPayload(ctx.args, readStdin, harness === "copilot"),
682
+ stdinTimeoutMs,
683
+ ""
684
+ );
685
+ const notifyStdinFile = process.env[NOTIFY_STDIN_FILE_ENV];
686
+ if (notifyStdinFile !== void 0 && dirname(notifyStdinFile) === tmpdir() && basename(notifyStdinFile).startsWith("birdybeep-notify-")) {
687
+ try {
688
+ rmSync(notifyStdinFile, { force: true });
689
+ } catch {
690
+ }
691
+ }
532
692
  let payload;
533
693
  try {
534
694
  payload = JSON.parse(raw);
@@ -537,52 +697,87 @@ function createHookCommand(deps = {}) {
537
697
  return EXIT.OK;
538
698
  }
539
699
  const sender = makeSender(resolveApiUrl());
540
- const result = await runHookCommand(harness, payload, sender);
541
- ctx.io.result({ harness, outcome: result.outcome, eventType: result.eventType });
700
+ const handler = resolveHookHarness(harness, payload);
701
+ const result = await runHookCommand(harness, payload, sender, copilotEventName);
702
+ ctx.io.result({
703
+ harness: handler,
704
+ ...handler !== harness ? { routedFrom: harness } : {},
705
+ ...copilotEventName !== void 0 ? { event: copilotEventName } : {},
706
+ outcome: result.outcome,
707
+ eventType: result.eventType,
708
+ ...result.send?.decision ? { decision: result.send.decision } : {},
709
+ ...result.send?.status !== void 0 ? { status: result.send.status } : {}
710
+ });
711
+ if (result.outcome === "skipped" && !recognizesPayload(handler, payload)) {
712
+ ctx.io.errline(
713
+ `birdybeep hook ${harness}: hook_event_name ${describeEventName(payload)} is not a ${handler} hook event \u2014 nothing was sent. Check which tool is running this hook.`
714
+ );
715
+ return EXIT.ERROR;
716
+ }
542
717
  return EXIT.OK;
543
718
  }
544
719
  };
545
720
  }
546
721
 
547
722
  // src/commands/logout.ts
548
- import { clearToken } from "@birdybeep/agent-core";
549
- function createClearTokenCommand(spec, deps = {}) {
723
+ import { clearToken, getToken as getToken2 } from "@birdybeep/agent-core";
724
+ var base = (apiUrl) => apiUrl.replace(/\/$/, "");
725
+ function createLogoutCommand(deps = {}) {
550
726
  return {
551
- name: spec.name,
552
- summary: spec.summary,
553
- usage: `birdybeep ${spec.name}`,
727
+ name: "logout",
728
+ summary: "Remove the local machine token (does NOT revoke the machine server-side)",
729
+ usage: "birdybeep logout",
554
730
  run: async (ctx) => {
555
731
  await clearToken(deps.tokenOptions ?? {});
556
- ctx.io.emit(spec.humanMessage, { [spec.jsonKey]: true });
732
+ ctx.io.emit("Logged out \u2014 the machine token was removed.", { loggedOut: true });
557
733
  return EXIT.OK;
558
734
  }
559
735
  };
560
736
  }
561
- function createLogoutCommand(deps = {}) {
562
- return createClearTokenCommand(
563
- {
564
- name: "logout",
565
- summary: "Remove the local machine token (same as `unpair`)",
566
- humanMessage: "Logged out \u2014 the machine token was removed.",
567
- jsonKey: "loggedOut"
568
- },
569
- deps
570
- );
737
+ async function revokeSelf(token, fetchImpl, timeoutMs) {
738
+ const controller = new AbortController();
739
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
740
+ try {
741
+ const res = await fetchImpl(`${base(resolveApiUrl())}/v1/machine/revoke-self`, {
742
+ method: "POST",
743
+ headers: { authorization: `Bearer ${token}` },
744
+ signal: controller.signal
745
+ });
746
+ if (res.ok || res.status === 403) return "revoked";
747
+ return "rejected";
748
+ } catch {
749
+ return "unreachable";
750
+ } finally {
751
+ clearTimeout(timer);
752
+ }
571
753
  }
572
754
  function createUnpairCommand(deps = {}) {
573
- return createClearTokenCommand(
574
- {
575
- name: "unpair",
576
- summary: "Unpair this machine \u2014 remove the local machine token (same as `logout`)",
577
- humanMessage: "Unpaired \u2014 the machine token was removed.",
578
- jsonKey: "unpaired"
579
- },
580
- deps
581
- );
755
+ const fetchImpl = deps.fetchImpl ?? fetch;
756
+ const timeoutMs = deps.timeoutMs ?? 1e4;
757
+ return {
758
+ name: "unpair",
759
+ summary: "Unpair this machine \u2014 revoke it server-side and remove the local token",
760
+ usage: "birdybeep unpair",
761
+ run: async (ctx) => {
762
+ const token = await getToken2(deps.tokenOptions ?? {});
763
+ const outcome = token === null ? "no_token" : await revokeSelf(token, fetchImpl, timeoutMs);
764
+ await clearToken(deps.tokenOptions ?? {});
765
+ const serverRevoked = outcome === "revoked";
766
+ const human = outcome === "revoked" ? "Unpaired \u2014 the machine was revoked and removed from your account." : outcome === "no_token" ? "Already unpaired \u2014 there was no local token to remove." : outcome === "unreachable" ? "Unpaired locally, but the server was unreachable \u2014 the machine may still show in the app. Open BirdyBeep and revoke it there to fully remove it." : "Unpaired locally, but the server didn't confirm removal \u2014 if the machine still shows in the app, revoke it there.";
767
+ ctx.io.emit(human, { unpaired: true, serverRevoked });
768
+ return EXIT.OK;
769
+ }
770
+ };
582
771
  }
583
772
 
584
773
  // src/commands/pair.ts
585
- import { getMachineIdentity as getMachineIdentity2, setToken } from "@birdybeep/agent-core";
774
+ import { closeSync as closeSync2, openSync as openSync2 } from "fs";
775
+ import {
776
+ deriveCodeChallengeS256,
777
+ generateCodeVerifier,
778
+ getMachineIdentity as getMachineIdentity2,
779
+ setToken
780
+ } from "@birdybeep/agent-core";
586
781
  import { renderUnicodeCompact } from "uqr";
587
782
 
588
783
  // src/pairing.ts
@@ -591,16 +786,17 @@ import {
591
786
  pairStartResponseSchema,
592
787
  pairTokenResponseSchema
593
788
  } from "@birdybeep/agent-core";
594
- function base(apiUrl) {
789
+ function base2(apiUrl) {
595
790
  return apiUrl.replace(/\/$/, "");
596
791
  }
597
792
  async function pairStart(apiUrl, input, fetchImpl) {
598
793
  const body = {
599
794
  machine_label: input.machineLabel,
600
795
  ...input.os !== void 0 ? { os: input.os } : {},
601
- ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {}
796
+ ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {},
797
+ ...input.codeChallenge !== void 0 ? { code_challenge: input.codeChallenge } : {}
602
798
  };
603
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/start`, {
799
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/start`, {
604
800
  method: "POST",
605
801
  headers: { "content-type": "application/json" },
606
802
  body: JSON.stringify(body)
@@ -618,12 +814,13 @@ var TERMINAL_TOKEN_ERRORS = /* @__PURE__ */ new Set([
618
814
  "not_found",
619
815
  "payload_too_large"
620
816
  ]);
621
- async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint) {
817
+ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint, codeVerifier) {
622
818
  const body = {
623
819
  device_code: deviceCode,
624
- ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {}
820
+ ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {},
821
+ ...codeVerifier !== void 0 ? { code_verifier: codeVerifier } : {}
625
822
  };
626
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/token`, {
823
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/token`, {
627
824
  method: "POST",
628
825
  headers: { "content-type": "application/json" },
629
826
  body: JSON.stringify(body)
@@ -634,7 +831,10 @@ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint)
634
831
  return {
635
832
  status: "paired",
636
833
  machineToken: parsed.data.machine_token,
637
- machineId: parsed.data.machine_id
834
+ machineId: parsed.data.machine_id,
835
+ // Only surface the key when the server reported it (exactOptionalPropertyTypes: no explicit
836
+ // undefined). Older servers omit approved_by_email; newer ones (dgxd) include it.
837
+ ...parsed.data.approved_by_email !== void 0 ? { approvedByEmail: parsed.data.approved_by_email } : {}
638
838
  };
639
839
  }
640
840
  let errBody = null;
@@ -660,22 +860,172 @@ var HEARTBEAT_MS = 15e3;
660
860
  function renderQrMatrix(qrPayload) {
661
861
  return renderUnicodeCompact(qrPayload, { border: 2 });
662
862
  }
863
+ function parsePairFlags(args) {
864
+ const flags = { yes: false };
865
+ for (let i = 0; i < args.length; i += 1) {
866
+ const token = args[i] ?? "";
867
+ if (token === "--yes" || token === "-y") {
868
+ flags.yes = true;
869
+ } else if (token === "--expect-email" || token.startsWith("--expect-email=")) {
870
+ const inline = token.startsWith("--expect-email=") ? token.slice("--expect-email=".length) : void 0;
871
+ const value = inline ?? args[++i];
872
+ if (value === void 0 || value.length === 0 || value.startsWith("-")) {
873
+ return { ...flags, error: "--expect-email requires an email address" };
874
+ }
875
+ flags.expectEmail = value;
876
+ } else {
877
+ return { ...flags, error: `unexpected argument "${token}"` };
878
+ }
879
+ }
880
+ return flags;
881
+ }
882
+ function sameEmail(a, b) {
883
+ const fold = (v) => v.trim().toLowerCase();
884
+ const rawEqual = fold(a) === fold(b);
885
+ const nfkcEqual = fold(a.normalize("NFKC")) === fold(b.normalize("NFKC"));
886
+ return rawEqual && nfkcEqual;
887
+ }
888
+ function decidePairConfirmation(input) {
889
+ const { approvedByEmail, expectEmail } = input;
890
+ const platform = input.platform ?? process.platform;
891
+ if (expectEmail !== void 0) {
892
+ if (approvedByEmail === void 0) {
893
+ const remedy = input.expectEmailSource === "config" ? `Remove or correct the "expectEmail" key in ${input.configPath ?? "the BirdyBeep CLI config"} (or upgrade the backend to one that reports the approving account) and re-run.` : "Re-run without --expect-email (and confirm interactively) if that is expected.";
894
+ return {
895
+ action: "reject",
896
+ reason: "expected_email_unverifiable",
897
+ message: `Pairing refused: ${expectEmail} was pinned as the expected approving account, but the server did not report which account approved this machine, so the pin could not be verified. The machine token was NOT stored. ${remedy}`
898
+ };
899
+ }
900
+ if (sameEmail(approvedByEmail, expectEmail)) {
901
+ return { action: "approve", reason: "expected_email_match" };
902
+ }
903
+ return {
904
+ action: "reject",
905
+ reason: "expected_email_mismatch",
906
+ message: `Pairing refused: this machine was approved by ${approvedByEmail}, but ${expectEmail} was expected. The machine token was NOT stored. If you did not expect that account to approve it, open BirdyBeep and revoke the machine, then re-run \`birdybeep pair\`.`
907
+ };
908
+ }
909
+ if (input.yes) return { action: "approve", reason: "yes_flag" };
910
+ const question = approvedByEmail !== void 0 ? `Pair this machine to ${approvedByEmail}? [y/N] ` : "The server did not report which account approved this machine. Pair anyway? [y/N] ";
911
+ if (!input.nonInteractive) {
912
+ if (input.stdinIsTTY) return { action: "prompt", question, on: "stdin" };
913
+ if (input.controllingTerminalAvailable) {
914
+ return { action: "prompt", question, on: "controlling-terminal" };
915
+ }
916
+ }
917
+ const who = approvedByEmail !== void 0 ? ` (approved by ${approvedByEmail})` : "";
918
+ const winptyHint = platform === "win32" && !input.nonInteractive ? " In Git Bash / MSYS, `winpty birdybeep pair` attaches a real console so the prompt can appear." : "";
919
+ return {
920
+ action: "reject",
921
+ reason: "non_interactive",
922
+ message: `Pairing needs confirmation${who}, but there is no terminal to ask on, so the machine token was NOT stored. Re-run with \`--expect-email <addr>\` to pin the approving account (recommended for CI), or \`--yes\` to accept whichever account approved it.` + winptyHint
923
+ };
924
+ }
925
+ function isAffirmative(answer) {
926
+ return /^(y|yes)$/i.test(answer.trim());
927
+ }
928
+ function controllingTerminalPath() {
929
+ return "/dev/tty";
930
+ }
931
+ function canOpenControllingTerminal(path = controllingTerminalPath(), platform = process.platform) {
932
+ if (platform === "win32") return false;
933
+ let fd;
934
+ try {
935
+ fd = openSync2(path, "r");
936
+ return true;
937
+ } catch {
938
+ return false;
939
+ } finally {
940
+ if (fd !== void 0) {
941
+ try {
942
+ closeSync2(fd);
943
+ } catch {
944
+ }
945
+ }
946
+ }
947
+ }
948
+ async function promptForAnswer(question, on) {
949
+ const { createInterface } = await import("readline/promises");
950
+ let ttyFd;
951
+ let input;
952
+ if (on === "stdin") {
953
+ input = process.stdin;
954
+ } else {
955
+ const { ReadStream } = await import("tty");
956
+ ttyFd = openSync2(controllingTerminalPath(), "r");
957
+ input = new ReadStream(ttyFd);
958
+ }
959
+ return new Promise((resolve) => {
960
+ const rl = createInterface({ input, output: process.stderr });
961
+ let settled = false;
962
+ const done = (value) => {
963
+ if (settled) return;
964
+ settled = true;
965
+ rl.close();
966
+ if (on === "stdin") {
967
+ process.stdin.unref?.();
968
+ } else {
969
+ try {
970
+ input.unref?.();
971
+ input.destroy?.();
972
+ } catch {
973
+ }
974
+ if (ttyFd !== void 0) {
975
+ try {
976
+ closeSync2(ttyFd);
977
+ } catch {
978
+ }
979
+ }
980
+ }
981
+ resolve(value);
982
+ };
983
+ rl.question(question).then(done, () => done(""));
984
+ rl.once("close", () => done(""));
985
+ input.once?.("error", () => done(""));
986
+ });
987
+ }
663
988
  function createPairCommand(deps = {}) {
664
989
  const fetchImpl = deps.fetchImpl ?? fetch;
665
990
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
666
991
  const clock = deps.now ?? (() => Date.now());
667
992
  const renderQr = deps.renderQr ?? renderQrMatrix;
668
993
  const intervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
994
+ const promptLine = deps.promptLine ?? promptForAnswer;
995
+ const hasControllingTerminal = deps.hasControllingTerminal ?? (() => canOpenControllingTerminal());
996
+ const configuredExpectEmail = deps.configuredExpectEmail ?? (() => {
997
+ const pinned = readCliConfig().expectEmail;
998
+ return typeof pinned === "string" && pinned.trim().length > 0 ? pinned : void 0;
999
+ });
669
1000
  return {
670
1001
  name: "pair",
671
1002
  summary: "Pair this machine with your BirdyBeep account (QR or manual)",
672
- usage: "birdybeep pair [--json]",
1003
+ usage: "birdybeep pair [--yes] [--expect-email <addr>] [--json]",
1004
+ options: [
1005
+ {
1006
+ flag: "--yes",
1007
+ aliases: ["-y"],
1008
+ summary: "Skip the approving-account confirmation (headless/CI)"
1009
+ },
1010
+ {
1011
+ flag: "--expect-email",
1012
+ value: "<addr>",
1013
+ summary: "Only trust the pairing if this account approved it (else fail)"
1014
+ }
1015
+ ],
673
1016
  run: async (ctx) => {
1017
+ const pairFlags = parsePairFlags(ctx.args);
1018
+ if (pairFlags.error !== void 0) {
1019
+ ctx.io.errline(`birdybeep pair: ${pairFlags.error}.`);
1020
+ return EXIT.USAGE;
1021
+ }
674
1022
  const apiUrl = resolveApiUrl();
675
1023
  const identity = getMachineIdentity2();
1024
+ const codeVerifier = generateCodeVerifier();
1025
+ const codeChallenge = deriveCodeChallengeS256(codeVerifier);
676
1026
  const start = await pairStart(
677
1027
  apiUrl,
678
- { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION },
1028
+ { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION, codeChallenge },
679
1029
  fetchImpl
680
1030
  );
681
1031
  if (ctx.flags.json) {
@@ -687,12 +1037,14 @@ function createPairCommand(deps = {}) {
687
1037
  });
688
1038
  } else {
689
1039
  ctx.io.line(
690
- "To pair this machine, open the BirdyBeep app, tap \u201Cpair a machine\u201D, and scan this QR (or enter the code):"
1040
+ "To pair this machine, open the BirdyBeep app, tap \u201Cpair a machine\u201D, and scan this QR or open the complete link:"
691
1041
  );
692
1042
  const isTTY = deps.isTTY ?? process.stdout.isTTY === true;
693
1043
  if (isTTY) ctx.io.line(renderQr(start.qr_payload));
694
1044
  ctx.io.line(` Scan or open: ${start.qr_payload}`);
695
- ctx.io.line(` Code: ${start.user_code}`);
1045
+ ctx.io.line(
1046
+ ` Session code (display only; cannot approve by itself): ${start.user_code}`
1047
+ );
696
1048
  ctx.io.line("Waiting for you to approve this machine in the app\u2026");
697
1049
  }
698
1050
  const deadline = Date.parse(start.expires_at);
@@ -708,7 +1060,9 @@ function createPairCommand(deps = {}) {
708
1060
  apiUrl,
709
1061
  start.device_code,
710
1062
  fetchImpl,
711
- identity.fingerprintHash
1063
+ identity.fingerprintHash,
1064
+ codeVerifier
1065
+ // PKCE proof-of-possession (dgxd) — sent on every poll
712
1066
  );
713
1067
  if (poll.status === "paired") {
714
1068
  paired = poll;
@@ -733,15 +1087,44 @@ function createPairCommand(deps = {}) {
733
1087
  if (paired === void 0 || paired.status !== "paired") {
734
1088
  ctx.io.result({ paired: false, reason: "timeout" });
735
1089
  ctx.io.errline(
736
- "Pairing timed out before you approved it. In the BirdyBeep app, tap \u201Cpair a machine\u201D, scan the QR (or enter the code), then run `birdybeep pair` again."
1090
+ "Pairing timed out before you approved it. In the BirdyBeep app, tap \u201Cpair a machine\u201D, scan a fresh QR or open its complete link, then run `birdybeep pair` again."
1091
+ );
1092
+ return EXIT.ERROR;
1093
+ }
1094
+ const approvedBy = paired.approvedByEmail;
1095
+ const expectEmail = pairFlags.expectEmail ?? configuredExpectEmail();
1096
+ const stdinIsTTY = deps.isStdinTTY ?? process.stdin.isTTY === true;
1097
+ const decision = decidePairConfirmation({
1098
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {},
1099
+ ...expectEmail !== void 0 ? { expectEmail } : {},
1100
+ ...expectEmail !== void 0 ? { expectEmailSource: pairFlags.expectEmail !== void 0 ? "flag" : "config" } : {},
1101
+ yes: pairFlags.yes,
1102
+ nonInteractive: ctx.flags.nonInteractive,
1103
+ stdinIsTTY,
1104
+ // Probed ONLY when stdin can't answer — opening /dev/tty is a syscall, and when stdin is
1105
+ // already a terminal the answer is irrelevant.
1106
+ controllingTerminalAvailable: stdinIsTTY ? false : hasControllingTerminal(),
1107
+ configPath: cliConfigPath()
1108
+ });
1109
+ if (decision.action === "reject") {
1110
+ ctx.io.result({ paired: false, reason: decision.reason });
1111
+ ctx.io.errline(decision.message);
1112
+ return EXIT.ERROR;
1113
+ }
1114
+ if (decision.action === "prompt" && !isAffirmative(await promptLine(decision.question, decision.on))) {
1115
+ ctx.io.result({ paired: false, reason: "declined" });
1116
+ ctx.io.errline(
1117
+ "Pairing declined \u2014 the machine token was NOT stored, and this machine will send no events. The machine may still appear in the BirdyBeep app; revoke it there if you did not intend to pair it."
737
1118
  );
738
1119
  return EXIT.ERROR;
739
1120
  }
740
1121
  await setToken(paired.machineToken, deps.tokenOptions ?? {});
741
1122
  writeCliConfig({ apiUrl });
742
- ctx.io.emit(`\u2713 Paired. Run \`birdybeep test\` to send a test Beep.`, {
1123
+ const humanSuffix = approvedBy !== void 0 ? ` to ${approvedBy}` : "";
1124
+ ctx.io.emit(`\u2713 Paired${humanSuffix}. Run \`birdybeep test\` to send a test Beep.`, {
743
1125
  paired: true,
744
- machineId: paired.machineId
1126
+ machineId: paired.machineId,
1127
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {}
745
1128
  });
746
1129
  return EXIT.OK;
747
1130
  }
@@ -773,19 +1156,29 @@ function createQueueCommand() {
773
1156
  // src/commands/report-status.ts
774
1157
  import {
775
1158
  errorEnvelopeSchema as errorEnvelopeSchema2,
776
- getToken as getToken2,
1159
+ getToken as getToken3,
777
1160
  integrationStatusResponseSchema
778
1161
  } from "@birdybeep/agent-core";
779
1162
  import { CLAUDE_CODE_ADAPTER_VERSION, claudeCodeAdapter as claudeCodeAdapter3 } from "@birdybeep/claude-code";
780
1163
  import { CODEX_ADAPTER_VERSION, codexAdapter as codexAdapter3 } from "@birdybeep/codex";
1164
+ import { COPILOT_ADAPTER_VERSION, copilotAdapter as copilotAdapter3 } from "@birdybeep/copilot";
1165
+ import { CURSOR_ADAPTER_VERSION, cursorAdapter as cursorAdapter3 } from "@birdybeep/cursor";
781
1166
  import { OPENCODE_ADAPTER_VERSION, opencodeAdapter as opencodeAdapter3 } from "@birdybeep/opencode";
782
- var DEFAULT_ADAPTERS3 = [claudeCodeAdapter3, codexAdapter3, opencodeAdapter3];
1167
+ var DEFAULT_ADAPTERS3 = [
1168
+ claudeCodeAdapter3,
1169
+ codexAdapter3,
1170
+ opencodeAdapter3,
1171
+ cursorAdapter3,
1172
+ copilotAdapter3
1173
+ ];
783
1174
  var ADAPTER_VERSIONS = {
784
1175
  claude_code: CLAUDE_CODE_ADAPTER_VERSION,
785
1176
  codex: CODEX_ADAPTER_VERSION,
786
- opencode: OPENCODE_ADAPTER_VERSION
1177
+ opencode: OPENCODE_ADAPTER_VERSION,
1178
+ cursor: CURSOR_ADAPTER_VERSION,
1179
+ copilot: COPILOT_ADAPTER_VERSION
787
1180
  };
788
- var base2 = (apiUrl) => apiUrl.replace(/\/$/, "");
1181
+ var base3 = (apiUrl) => apiUrl.replace(/\/$/, "");
789
1182
  async function gatherItems(adapters) {
790
1183
  return Promise.all(
791
1184
  adapters.map(async (a) => {
@@ -806,7 +1199,7 @@ function createReportStatusCommand(deps = {}) {
806
1199
  summary: "Internal: report integration status to the backend",
807
1200
  usage: "birdybeep report-status [--json]",
808
1201
  run: async (ctx) => {
809
- const token = await getToken2(deps.tokenOptions ?? {});
1202
+ const token = await getToken3(deps.tokenOptions ?? {});
810
1203
  if (token === null) {
811
1204
  ctx.io.errline("No machine token \u2014 run `birdybeep pair` first.");
812
1205
  return EXIT.ERROR;
@@ -820,7 +1213,7 @@ function createReportStatusCommand(deps = {}) {
820
1213
  let outcome = "deferred";
821
1214
  let errorCode;
822
1215
  try {
823
- const res = await fetchImpl(`${base2(resolveApiUrl())}/v1/integrations/status`, {
1216
+ const res = await fetchImpl(`${base3(resolveApiUrl())}/v1/integrations/status`, {
824
1217
  method: "POST",
825
1218
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
826
1219
  body: JSON.stringify({ integrations: items })
@@ -873,8 +1266,16 @@ import {
873
1266
  } from "@birdybeep/agent-core";
874
1267
  import { claudeCodeAdapter as claudeCodeAdapter4 } from "@birdybeep/claude-code";
875
1268
  import { codexAdapter as codexAdapter4 } from "@birdybeep/codex";
1269
+ import { copilotAdapter as copilotAdapter4 } from "@birdybeep/copilot";
1270
+ import { cursorAdapter as cursorAdapter4 } from "@birdybeep/cursor";
876
1271
  import { opencodeAdapter as opencodeAdapter4 } from "@birdybeep/opencode";
877
- var DEFAULT_ADAPTERS4 = [claudeCodeAdapter4, codexAdapter4, opencodeAdapter4];
1272
+ var DEFAULT_ADAPTERS4 = [
1273
+ claudeCodeAdapter4,
1274
+ codexAdapter4,
1275
+ opencodeAdapter4,
1276
+ cursorAdapter4,
1277
+ copilotAdapter4
1278
+ ];
878
1279
  function createStatusCommand(deps = {}) {
879
1280
  const adapters = deps.adapters ?? DEFAULT_ADAPTERS4;
880
1281
  const makeSender = deps.createSender ?? ((baseUrl) => defaultCreateSender3(
@@ -1000,8 +1401,8 @@ function buildCommands() {
1000
1401
  }
1001
1402
 
1002
1403
  // src/update-check.ts
1003
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1004
- import { join as join2 } from "path";
1404
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
1405
+ import { join as join3 } from "path";
1005
1406
  import { birdyBeepConfigDir as birdyBeepConfigDir3 } from "@birdybeep/agent-core";
1006
1407
  var PACKAGE_NAME = "@birdybeep/cli";
1007
1408
  var PACKAGE_PATH = "@birdybeep%2Fcli";
@@ -1056,7 +1457,7 @@ function isNewer(current, latest) {
1056
1457
  return cur !== null && lat !== null && compareSemver(cur, lat) < 0;
1057
1458
  }
1058
1459
  function updateCachePath() {
1059
- return join2(birdyBeepConfigDir3(), UPDATE_CACHE_FILE);
1460
+ return join3(birdyBeepConfigDir3(), UPDATE_CACHE_FILE);
1060
1461
  }
1061
1462
  function readUpdateCache() {
1062
1463
  try {
@@ -1072,7 +1473,7 @@ function readUpdateCache() {
1072
1473
  }
1073
1474
  function writeUpdateCache(cache) {
1074
1475
  mkdirSync3(birdyBeepConfigDir3(), { recursive: true, mode: 448 });
1075
- writeFileSync2(updateCachePath(), `${JSON.stringify(cache)}
1476
+ writeFileSync3(updateCachePath(), `${JSON.stringify(cache)}
1076
1477
  `, { mode: 384 });
1077
1478
  }
1078
1479
  async function fetchLatestVersion(registryUrl, fetchImpl, timeoutMs) {
@@ -1160,4 +1561,4 @@ export {
1160
1561
  buildCommands,
1161
1562
  runCli
1162
1563
  };
1163
- //# sourceMappingURL=chunk-OCS5IDYI.js.map
1564
+ //# sourceMappingURL=chunk-ZYFMLHY4.js.map