@birdybeep/cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.3.0".length > 0 ? "0.3.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
532
  import { runClaudeHook } from "@birdybeep/claude-code";
472
533
  import { runCodexHook } from "@birdybeep/codex";
534
+ import {
535
+ isCopilotHookEventName,
536
+ runCopilotHook
537
+ } from "@birdybeep/copilot";
538
+ import { 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,9 +566,13 @@ 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) {
571
+ function runHookCommand(harness, payload, sender, copilotEventName) {
572
+ if (harness === "copilot") {
573
+ if (copilotEventName === void 0) return Promise.resolve({ outcome: "skipped" });
574
+ return runCopilotHook(copilotEventName, payload, { sender });
575
+ }
499
576
  return RUNNERS[harness](payload, { sender });
500
577
  }
501
578
  function readStdinDefault() {
@@ -511,24 +588,90 @@ function readStdinDefault() {
511
588
  process.stdin.on("error", () => resolve(""));
512
589
  });
513
590
  }
514
- async function readHookPayload(args, readStdin) {
515
- return args[1] ?? await readStdin();
591
+ async function readHookPayload(args, readStdin, stdinOnly = false) {
592
+ return stdinOnly ? readStdin() : args[1] ?? await readStdin();
593
+ }
594
+ var NOTIFY_STDIN_FILE_ENV = "BIRDYBEEP_CODEX_NOTIFY_STDIN_FILE";
595
+ function detachCodexNotifyWorker(payload) {
596
+ if (process.platform === "win32") return false;
597
+ let file;
598
+ let fd;
599
+ try {
600
+ const birdybeep = resolveOnPath("birdybeep");
601
+ if (birdybeep === null) return false;
602
+ const tmpFile = join2(tmpdir(), `birdybeep-notify-${randomBytes(16).toString("hex")}.json`);
603
+ file = tmpFile;
604
+ writeFileSync2(tmpFile, payload, { mode: 384 });
605
+ fd = openSync(tmpFile, "r");
606
+ const child = spawn(birdybeep, ["hook", "codex"], {
607
+ cwd: dirname(birdybeep),
608
+ // trusted dir, never the inherited/attacker cwd
609
+ detached: true,
610
+ // new session (setsid) → survives `codex exec` reaping the group
611
+ stdio: [fd, "ignore", "ignore"],
612
+ // stdin = the temp file; this process holds no pipe
613
+ env: { ...process.env, [NOTIFY_STDIN_FILE_ENV]: tmpFile },
614
+ // worker cleans it up post-read
615
+ windowsHide: true
616
+ });
617
+ child.on("error", () => {
618
+ try {
619
+ rmSync(tmpFile, { force: true });
620
+ } catch {
621
+ }
622
+ });
623
+ child.unref();
624
+ return true;
625
+ } catch {
626
+ if (file !== void 0) {
627
+ try {
628
+ rmSync(file, { force: true });
629
+ } catch {
630
+ }
631
+ }
632
+ return false;
633
+ } finally {
634
+ if (fd !== void 0) {
635
+ try {
636
+ closeSync(fd);
637
+ } catch {
638
+ }
639
+ }
640
+ }
516
641
  }
517
642
  function createHookCommand(deps = {}) {
518
643
  const makeSender = deps.createSender ?? ((baseUrl) => defaultCreateSender2({ baseUrl }));
519
644
  const readStdin = deps.readStdin ?? readStdinDefault;
520
645
  const stdinTimeoutMs = deps.stdinTimeoutMs ?? STDIN_READ_TIMEOUT_MS;
646
+ const detachCodexNotify = deps.detachCodexNotify ?? detachCodexNotifyWorker;
521
647
  return {
522
648
  name: "hook",
523
649
  summary: "Internal: normalize + send an event fired by a harness hook",
524
- usage: "birdybeep hook <claude|codex|opencode>",
650
+ usage: "birdybeep hook <claude|codex|opencode|cursor|copilot> [copilot-event]",
525
651
  run: async (ctx) => {
526
652
  const harness = ctx.args[0];
527
653
  if (!isHarnessName(harness)) {
528
654
  ctx.io.errline(`birdybeep hook: expected one of ${HOOK_HARNESSES.join("|")}`);
529
655
  return EXIT.USAGE;
530
656
  }
531
- const raw = await withTimeout(readHookPayload(ctx.args, readStdin), stdinTimeoutMs, "");
657
+ const notifyPayload = ctx.args[1];
658
+ if (harness === "codex" && notifyPayload !== void 0 && notifyPayload.length > 0 && detachCodexNotify(notifyPayload)) {
659
+ ctx.io.result({ harness, outcome: "detached" });
660
+ return EXIT.OK;
661
+ }
662
+ const copilotEventName = harness === "copilot" && isCopilotHookEventName(ctx.args[1]) ? ctx.args[1] : void 0;
663
+ const raw = await withTimeout(
664
+ readHookPayload(ctx.args, readStdin, harness === "copilot"),
665
+ stdinTimeoutMs,
666
+ ""
667
+ );
668
+ const notifyStdinFile = process.env[NOTIFY_STDIN_FILE_ENV];
669
+ if (notifyStdinFile !== void 0 && dirname(notifyStdinFile) === tmpdir() && basename(notifyStdinFile).startsWith("birdybeep-notify-")) {
670
+ try {
671
+ rmSync(notifyStdinFile, { force: true });
672
+ } catch {
673
+ }
674
+ }
532
675
  let payload;
533
676
  try {
534
677
  payload = JSON.parse(raw);
@@ -537,52 +680,79 @@ function createHookCommand(deps = {}) {
537
680
  return EXIT.OK;
538
681
  }
539
682
  const sender = makeSender(resolveApiUrl());
540
- const result = await runHookCommand(harness, payload, sender);
541
- ctx.io.result({ harness, outcome: result.outcome, eventType: result.eventType });
683
+ const result = await runHookCommand(harness, payload, sender, copilotEventName);
684
+ ctx.io.result({
685
+ harness,
686
+ ...copilotEventName !== void 0 ? { event: copilotEventName } : {},
687
+ outcome: result.outcome,
688
+ eventType: result.eventType,
689
+ ...result.send?.decision ? { decision: result.send.decision } : {},
690
+ ...result.send?.status !== void 0 ? { status: result.send.status } : {}
691
+ });
542
692
  return EXIT.OK;
543
693
  }
544
694
  };
545
695
  }
546
696
 
547
697
  // src/commands/logout.ts
548
- import { clearToken } from "@birdybeep/agent-core";
549
- function createClearTokenCommand(spec, deps = {}) {
698
+ import { clearToken, getToken as getToken2 } from "@birdybeep/agent-core";
699
+ var base = (apiUrl) => apiUrl.replace(/\/$/, "");
700
+ function createLogoutCommand(deps = {}) {
550
701
  return {
551
- name: spec.name,
552
- summary: spec.summary,
553
- usage: `birdybeep ${spec.name}`,
702
+ name: "logout",
703
+ summary: "Remove the local machine token (does NOT revoke the machine server-side)",
704
+ usage: "birdybeep logout",
554
705
  run: async (ctx) => {
555
706
  await clearToken(deps.tokenOptions ?? {});
556
- ctx.io.emit(spec.humanMessage, { [spec.jsonKey]: true });
707
+ ctx.io.emit("Logged out \u2014 the machine token was removed.", { loggedOut: true });
557
708
  return EXIT.OK;
558
709
  }
559
710
  };
560
711
  }
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
- );
712
+ async function revokeSelf(token, fetchImpl, timeoutMs) {
713
+ const controller = new AbortController();
714
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
715
+ try {
716
+ const res = await fetchImpl(`${base(resolveApiUrl())}/v1/machine/revoke-self`, {
717
+ method: "POST",
718
+ headers: { authorization: `Bearer ${token}` },
719
+ signal: controller.signal
720
+ });
721
+ if (res.ok || res.status === 403) return "revoked";
722
+ return "rejected";
723
+ } catch {
724
+ return "unreachable";
725
+ } finally {
726
+ clearTimeout(timer);
727
+ }
571
728
  }
572
729
  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
- );
730
+ const fetchImpl = deps.fetchImpl ?? fetch;
731
+ const timeoutMs = deps.timeoutMs ?? 1e4;
732
+ return {
733
+ name: "unpair",
734
+ summary: "Unpair this machine \u2014 revoke it server-side and remove the local token",
735
+ usage: "birdybeep unpair",
736
+ run: async (ctx) => {
737
+ const token = await getToken2(deps.tokenOptions ?? {});
738
+ const outcome = token === null ? "no_token" : await revokeSelf(token, fetchImpl, timeoutMs);
739
+ await clearToken(deps.tokenOptions ?? {});
740
+ const serverRevoked = outcome === "revoked";
741
+ 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.";
742
+ ctx.io.emit(human, { unpaired: true, serverRevoked });
743
+ return EXIT.OK;
744
+ }
745
+ };
582
746
  }
583
747
 
584
748
  // src/commands/pair.ts
585
- import { getMachineIdentity as getMachineIdentity2, setToken } from "@birdybeep/agent-core";
749
+ import { closeSync as closeSync2, openSync as openSync2 } from "fs";
750
+ import {
751
+ deriveCodeChallengeS256,
752
+ generateCodeVerifier,
753
+ getMachineIdentity as getMachineIdentity2,
754
+ setToken
755
+ } from "@birdybeep/agent-core";
586
756
  import { renderUnicodeCompact } from "uqr";
587
757
 
588
758
  // src/pairing.ts
@@ -591,16 +761,17 @@ import {
591
761
  pairStartResponseSchema,
592
762
  pairTokenResponseSchema
593
763
  } from "@birdybeep/agent-core";
594
- function base(apiUrl) {
764
+ function base2(apiUrl) {
595
765
  return apiUrl.replace(/\/$/, "");
596
766
  }
597
767
  async function pairStart(apiUrl, input, fetchImpl) {
598
768
  const body = {
599
769
  machine_label: input.machineLabel,
600
770
  ...input.os !== void 0 ? { os: input.os } : {},
601
- ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {}
771
+ ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {},
772
+ ...input.codeChallenge !== void 0 ? { code_challenge: input.codeChallenge } : {}
602
773
  };
603
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/start`, {
774
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/start`, {
604
775
  method: "POST",
605
776
  headers: { "content-type": "application/json" },
606
777
  body: JSON.stringify(body)
@@ -618,12 +789,13 @@ var TERMINAL_TOKEN_ERRORS = /* @__PURE__ */ new Set([
618
789
  "not_found",
619
790
  "payload_too_large"
620
791
  ]);
621
- async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint) {
792
+ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint, codeVerifier) {
622
793
  const body = {
623
794
  device_code: deviceCode,
624
- ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {}
795
+ ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {},
796
+ ...codeVerifier !== void 0 ? { code_verifier: codeVerifier } : {}
625
797
  };
626
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/token`, {
798
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/token`, {
627
799
  method: "POST",
628
800
  headers: { "content-type": "application/json" },
629
801
  body: JSON.stringify(body)
@@ -634,7 +806,10 @@ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint)
634
806
  return {
635
807
  status: "paired",
636
808
  machineToken: parsed.data.machine_token,
637
- machineId: parsed.data.machine_id
809
+ machineId: parsed.data.machine_id,
810
+ // Only surface the key when the server reported it (exactOptionalPropertyTypes: no explicit
811
+ // undefined). Older servers omit approved_by_email; newer ones (dgxd) include it.
812
+ ...parsed.data.approved_by_email !== void 0 ? { approvedByEmail: parsed.data.approved_by_email } : {}
638
813
  };
639
814
  }
640
815
  let errBody = null;
@@ -660,22 +835,172 @@ var HEARTBEAT_MS = 15e3;
660
835
  function renderQrMatrix(qrPayload) {
661
836
  return renderUnicodeCompact(qrPayload, { border: 2 });
662
837
  }
838
+ function parsePairFlags(args) {
839
+ const flags = { yes: false };
840
+ for (let i = 0; i < args.length; i += 1) {
841
+ const token = args[i] ?? "";
842
+ if (token === "--yes" || token === "-y") {
843
+ flags.yes = true;
844
+ } else if (token === "--expect-email" || token.startsWith("--expect-email=")) {
845
+ const inline = token.startsWith("--expect-email=") ? token.slice("--expect-email=".length) : void 0;
846
+ const value = inline ?? args[++i];
847
+ if (value === void 0 || value.length === 0 || value.startsWith("-")) {
848
+ return { ...flags, error: "--expect-email requires an email address" };
849
+ }
850
+ flags.expectEmail = value;
851
+ } else {
852
+ return { ...flags, error: `unexpected argument "${token}"` };
853
+ }
854
+ }
855
+ return flags;
856
+ }
857
+ function sameEmail(a, b) {
858
+ const fold = (v) => v.trim().toLowerCase();
859
+ const rawEqual = fold(a) === fold(b);
860
+ const nfkcEqual = fold(a.normalize("NFKC")) === fold(b.normalize("NFKC"));
861
+ return rawEqual && nfkcEqual;
862
+ }
863
+ function decidePairConfirmation(input) {
864
+ const { approvedByEmail, expectEmail } = input;
865
+ const platform = input.platform ?? process.platform;
866
+ if (expectEmail !== void 0) {
867
+ if (approvedByEmail === void 0) {
868
+ 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.";
869
+ return {
870
+ action: "reject",
871
+ reason: "expected_email_unverifiable",
872
+ 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}`
873
+ };
874
+ }
875
+ if (sameEmail(approvedByEmail, expectEmail)) {
876
+ return { action: "approve", reason: "expected_email_match" };
877
+ }
878
+ return {
879
+ action: "reject",
880
+ reason: "expected_email_mismatch",
881
+ 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\`.`
882
+ };
883
+ }
884
+ if (input.yes) return { action: "approve", reason: "yes_flag" };
885
+ 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] ";
886
+ if (!input.nonInteractive) {
887
+ if (input.stdinIsTTY) return { action: "prompt", question, on: "stdin" };
888
+ if (input.controllingTerminalAvailable) {
889
+ return { action: "prompt", question, on: "controlling-terminal" };
890
+ }
891
+ }
892
+ const who = approvedByEmail !== void 0 ? ` (approved by ${approvedByEmail})` : "";
893
+ const winptyHint = platform === "win32" && !input.nonInteractive ? " In Git Bash / MSYS, `winpty birdybeep pair` attaches a real console so the prompt can appear." : "";
894
+ return {
895
+ action: "reject",
896
+ reason: "non_interactive",
897
+ 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
898
+ };
899
+ }
900
+ function isAffirmative(answer) {
901
+ return /^(y|yes)$/i.test(answer.trim());
902
+ }
903
+ function controllingTerminalPath() {
904
+ return "/dev/tty";
905
+ }
906
+ function canOpenControllingTerminal(path = controllingTerminalPath(), platform = process.platform) {
907
+ if (platform === "win32") return false;
908
+ let fd;
909
+ try {
910
+ fd = openSync2(path, "r");
911
+ return true;
912
+ } catch {
913
+ return false;
914
+ } finally {
915
+ if (fd !== void 0) {
916
+ try {
917
+ closeSync2(fd);
918
+ } catch {
919
+ }
920
+ }
921
+ }
922
+ }
923
+ async function promptForAnswer(question, on) {
924
+ const { createInterface } = await import("readline/promises");
925
+ let ttyFd;
926
+ let input;
927
+ if (on === "stdin") {
928
+ input = process.stdin;
929
+ } else {
930
+ const { ReadStream } = await import("tty");
931
+ ttyFd = openSync2(controllingTerminalPath(), "r");
932
+ input = new ReadStream(ttyFd);
933
+ }
934
+ return new Promise((resolve) => {
935
+ const rl = createInterface({ input, output: process.stderr });
936
+ let settled = false;
937
+ const done = (value) => {
938
+ if (settled) return;
939
+ settled = true;
940
+ rl.close();
941
+ if (on === "stdin") {
942
+ process.stdin.unref?.();
943
+ } else {
944
+ try {
945
+ input.unref?.();
946
+ input.destroy?.();
947
+ } catch {
948
+ }
949
+ if (ttyFd !== void 0) {
950
+ try {
951
+ closeSync2(ttyFd);
952
+ } catch {
953
+ }
954
+ }
955
+ }
956
+ resolve(value);
957
+ };
958
+ rl.question(question).then(done, () => done(""));
959
+ rl.once("close", () => done(""));
960
+ input.once?.("error", () => done(""));
961
+ });
962
+ }
663
963
  function createPairCommand(deps = {}) {
664
964
  const fetchImpl = deps.fetchImpl ?? fetch;
665
965
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
666
966
  const clock = deps.now ?? (() => Date.now());
667
967
  const renderQr = deps.renderQr ?? renderQrMatrix;
668
968
  const intervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
969
+ const promptLine = deps.promptLine ?? promptForAnswer;
970
+ const hasControllingTerminal = deps.hasControllingTerminal ?? (() => canOpenControllingTerminal());
971
+ const configuredExpectEmail = deps.configuredExpectEmail ?? (() => {
972
+ const pinned = readCliConfig().expectEmail;
973
+ return typeof pinned === "string" && pinned.trim().length > 0 ? pinned : void 0;
974
+ });
669
975
  return {
670
976
  name: "pair",
671
977
  summary: "Pair this machine with your BirdyBeep account (QR or manual)",
672
- usage: "birdybeep pair [--json]",
978
+ usage: "birdybeep pair [--yes] [--expect-email <addr>] [--json]",
979
+ options: [
980
+ {
981
+ flag: "--yes",
982
+ aliases: ["-y"],
983
+ summary: "Skip the approving-account confirmation (headless/CI)"
984
+ },
985
+ {
986
+ flag: "--expect-email",
987
+ value: "<addr>",
988
+ summary: "Only trust the pairing if this account approved it (else fail)"
989
+ }
990
+ ],
673
991
  run: async (ctx) => {
992
+ const pairFlags = parsePairFlags(ctx.args);
993
+ if (pairFlags.error !== void 0) {
994
+ ctx.io.errline(`birdybeep pair: ${pairFlags.error}.`);
995
+ return EXIT.USAGE;
996
+ }
674
997
  const apiUrl = resolveApiUrl();
675
998
  const identity = getMachineIdentity2();
999
+ const codeVerifier = generateCodeVerifier();
1000
+ const codeChallenge = deriveCodeChallengeS256(codeVerifier);
676
1001
  const start = await pairStart(
677
1002
  apiUrl,
678
- { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION },
1003
+ { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION, codeChallenge },
679
1004
  fetchImpl
680
1005
  );
681
1006
  if (ctx.flags.json) {
@@ -687,12 +1012,14 @@ function createPairCommand(deps = {}) {
687
1012
  });
688
1013
  } else {
689
1014
  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):"
1015
+ "To pair this machine, open the BirdyBeep app, tap \u201Cpair a machine\u201D, and scan this QR or open the complete link:"
691
1016
  );
692
1017
  const isTTY = deps.isTTY ?? process.stdout.isTTY === true;
693
1018
  if (isTTY) ctx.io.line(renderQr(start.qr_payload));
694
1019
  ctx.io.line(` Scan or open: ${start.qr_payload}`);
695
- ctx.io.line(` Code: ${start.user_code}`);
1020
+ ctx.io.line(
1021
+ ` Session code (display only; cannot approve by itself): ${start.user_code}`
1022
+ );
696
1023
  ctx.io.line("Waiting for you to approve this machine in the app\u2026");
697
1024
  }
698
1025
  const deadline = Date.parse(start.expires_at);
@@ -708,7 +1035,9 @@ function createPairCommand(deps = {}) {
708
1035
  apiUrl,
709
1036
  start.device_code,
710
1037
  fetchImpl,
711
- identity.fingerprintHash
1038
+ identity.fingerprintHash,
1039
+ codeVerifier
1040
+ // PKCE proof-of-possession (dgxd) — sent on every poll
712
1041
  );
713
1042
  if (poll.status === "paired") {
714
1043
  paired = poll;
@@ -733,15 +1062,44 @@ function createPairCommand(deps = {}) {
733
1062
  if (paired === void 0 || paired.status !== "paired") {
734
1063
  ctx.io.result({ paired: false, reason: "timeout" });
735
1064
  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."
1065
+ "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."
1066
+ );
1067
+ return EXIT.ERROR;
1068
+ }
1069
+ const approvedBy = paired.approvedByEmail;
1070
+ const expectEmail = pairFlags.expectEmail ?? configuredExpectEmail();
1071
+ const stdinIsTTY = deps.isStdinTTY ?? process.stdin.isTTY === true;
1072
+ const decision = decidePairConfirmation({
1073
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {},
1074
+ ...expectEmail !== void 0 ? { expectEmail } : {},
1075
+ ...expectEmail !== void 0 ? { expectEmailSource: pairFlags.expectEmail !== void 0 ? "flag" : "config" } : {},
1076
+ yes: pairFlags.yes,
1077
+ nonInteractive: ctx.flags.nonInteractive,
1078
+ stdinIsTTY,
1079
+ // Probed ONLY when stdin can't answer — opening /dev/tty is a syscall, and when stdin is
1080
+ // already a terminal the answer is irrelevant.
1081
+ controllingTerminalAvailable: stdinIsTTY ? false : hasControllingTerminal(),
1082
+ configPath: cliConfigPath()
1083
+ });
1084
+ if (decision.action === "reject") {
1085
+ ctx.io.result({ paired: false, reason: decision.reason });
1086
+ ctx.io.errline(decision.message);
1087
+ return EXIT.ERROR;
1088
+ }
1089
+ if (decision.action === "prompt" && !isAffirmative(await promptLine(decision.question, decision.on))) {
1090
+ ctx.io.result({ paired: false, reason: "declined" });
1091
+ ctx.io.errline(
1092
+ "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
1093
  );
738
1094
  return EXIT.ERROR;
739
1095
  }
740
1096
  await setToken(paired.machineToken, deps.tokenOptions ?? {});
741
1097
  writeCliConfig({ apiUrl });
742
- ctx.io.emit(`\u2713 Paired. Run \`birdybeep test\` to send a test Beep.`, {
1098
+ const humanSuffix = approvedBy !== void 0 ? ` to ${approvedBy}` : "";
1099
+ ctx.io.emit(`\u2713 Paired${humanSuffix}. Run \`birdybeep test\` to send a test Beep.`, {
743
1100
  paired: true,
744
- machineId: paired.machineId
1101
+ machineId: paired.machineId,
1102
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {}
745
1103
  });
746
1104
  return EXIT.OK;
747
1105
  }
@@ -773,19 +1131,29 @@ function createQueueCommand() {
773
1131
  // src/commands/report-status.ts
774
1132
  import {
775
1133
  errorEnvelopeSchema as errorEnvelopeSchema2,
776
- getToken as getToken2,
1134
+ getToken as getToken3,
777
1135
  integrationStatusResponseSchema
778
1136
  } from "@birdybeep/agent-core";
779
1137
  import { CLAUDE_CODE_ADAPTER_VERSION, claudeCodeAdapter as claudeCodeAdapter3 } from "@birdybeep/claude-code";
780
1138
  import { CODEX_ADAPTER_VERSION, codexAdapter as codexAdapter3 } from "@birdybeep/codex";
1139
+ import { COPILOT_ADAPTER_VERSION, copilotAdapter as copilotAdapter3 } from "@birdybeep/copilot";
1140
+ import { CURSOR_ADAPTER_VERSION, cursorAdapter as cursorAdapter3 } from "@birdybeep/cursor";
781
1141
  import { OPENCODE_ADAPTER_VERSION, opencodeAdapter as opencodeAdapter3 } from "@birdybeep/opencode";
782
- var DEFAULT_ADAPTERS3 = [claudeCodeAdapter3, codexAdapter3, opencodeAdapter3];
1142
+ var DEFAULT_ADAPTERS3 = [
1143
+ claudeCodeAdapter3,
1144
+ codexAdapter3,
1145
+ opencodeAdapter3,
1146
+ cursorAdapter3,
1147
+ copilotAdapter3
1148
+ ];
783
1149
  var ADAPTER_VERSIONS = {
784
1150
  claude_code: CLAUDE_CODE_ADAPTER_VERSION,
785
1151
  codex: CODEX_ADAPTER_VERSION,
786
- opencode: OPENCODE_ADAPTER_VERSION
1152
+ opencode: OPENCODE_ADAPTER_VERSION,
1153
+ cursor: CURSOR_ADAPTER_VERSION,
1154
+ copilot: COPILOT_ADAPTER_VERSION
787
1155
  };
788
- var base2 = (apiUrl) => apiUrl.replace(/\/$/, "");
1156
+ var base3 = (apiUrl) => apiUrl.replace(/\/$/, "");
789
1157
  async function gatherItems(adapters) {
790
1158
  return Promise.all(
791
1159
  adapters.map(async (a) => {
@@ -806,7 +1174,7 @@ function createReportStatusCommand(deps = {}) {
806
1174
  summary: "Internal: report integration status to the backend",
807
1175
  usage: "birdybeep report-status [--json]",
808
1176
  run: async (ctx) => {
809
- const token = await getToken2(deps.tokenOptions ?? {});
1177
+ const token = await getToken3(deps.tokenOptions ?? {});
810
1178
  if (token === null) {
811
1179
  ctx.io.errline("No machine token \u2014 run `birdybeep pair` first.");
812
1180
  return EXIT.ERROR;
@@ -820,7 +1188,7 @@ function createReportStatusCommand(deps = {}) {
820
1188
  let outcome = "deferred";
821
1189
  let errorCode;
822
1190
  try {
823
- const res = await fetchImpl(`${base2(resolveApiUrl())}/v1/integrations/status`, {
1191
+ const res = await fetchImpl(`${base3(resolveApiUrl())}/v1/integrations/status`, {
824
1192
  method: "POST",
825
1193
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
826
1194
  body: JSON.stringify({ integrations: items })
@@ -873,8 +1241,16 @@ import {
873
1241
  } from "@birdybeep/agent-core";
874
1242
  import { claudeCodeAdapter as claudeCodeAdapter4 } from "@birdybeep/claude-code";
875
1243
  import { codexAdapter as codexAdapter4 } from "@birdybeep/codex";
1244
+ import { copilotAdapter as copilotAdapter4 } from "@birdybeep/copilot";
1245
+ import { cursorAdapter as cursorAdapter4 } from "@birdybeep/cursor";
876
1246
  import { opencodeAdapter as opencodeAdapter4 } from "@birdybeep/opencode";
877
- var DEFAULT_ADAPTERS4 = [claudeCodeAdapter4, codexAdapter4, opencodeAdapter4];
1247
+ var DEFAULT_ADAPTERS4 = [
1248
+ claudeCodeAdapter4,
1249
+ codexAdapter4,
1250
+ opencodeAdapter4,
1251
+ cursorAdapter4,
1252
+ copilotAdapter4
1253
+ ];
878
1254
  function createStatusCommand(deps = {}) {
879
1255
  const adapters = deps.adapters ?? DEFAULT_ADAPTERS4;
880
1256
  const makeSender = deps.createSender ?? ((baseUrl) => defaultCreateSender3(
@@ -1000,8 +1376,8 @@ function buildCommands() {
1000
1376
  }
1001
1377
 
1002
1378
  // src/update-check.ts
1003
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1004
- import { join as join2 } from "path";
1379
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
1380
+ import { join as join3 } from "path";
1005
1381
  import { birdyBeepConfigDir as birdyBeepConfigDir3 } from "@birdybeep/agent-core";
1006
1382
  var PACKAGE_NAME = "@birdybeep/cli";
1007
1383
  var PACKAGE_PATH = "@birdybeep%2Fcli";
@@ -1056,7 +1432,7 @@ function isNewer(current, latest) {
1056
1432
  return cur !== null && lat !== null && compareSemver(cur, lat) < 0;
1057
1433
  }
1058
1434
  function updateCachePath() {
1059
- return join2(birdyBeepConfigDir3(), UPDATE_CACHE_FILE);
1435
+ return join3(birdyBeepConfigDir3(), UPDATE_CACHE_FILE);
1060
1436
  }
1061
1437
  function readUpdateCache() {
1062
1438
  try {
@@ -1072,7 +1448,7 @@ function readUpdateCache() {
1072
1448
  }
1073
1449
  function writeUpdateCache(cache) {
1074
1450
  mkdirSync3(birdyBeepConfigDir3(), { recursive: true, mode: 448 });
1075
- writeFileSync2(updateCachePath(), `${JSON.stringify(cache)}
1451
+ writeFileSync3(updateCachePath(), `${JSON.stringify(cache)}
1076
1452
  `, { mode: 384 });
1077
1453
  }
1078
1454
  async function fetchLatestVersion(registryUrl, fetchImpl, timeoutMs) {
@@ -1160,4 +1536,4 @@ export {
1160
1536
  buildCommands,
1161
1537
  runCli
1162
1538
  };
1163
- //# sourceMappingURL=chunk-OCS5IDYI.js.map
1539
+ //# sourceMappingURL=chunk-U4EIHC5C.js.map