@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.
package/dist/bin.cjs CHANGED
@@ -1,9 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
3
25
 
4
26
  // src/commands/agent.ts
5
27
  var import_claude_code = require("@birdybeep/claude-code");
6
28
  var import_codex = require("@birdybeep/codex");
29
+ var import_copilot = require("@birdybeep/copilot");
30
+ var import_cursor = require("@birdybeep/cursor");
7
31
  var import_opencode = require("@birdybeep/opencode");
8
32
 
9
33
  // src/framework.ts
@@ -72,8 +96,11 @@ function parseGlobalFlags(argv) {
72
96
  }
73
97
  return { flags, rest };
74
98
  }
75
- function isUnknownFlag(token) {
76
- return token.startsWith("-") && !GLOBAL_FLAG_TOKENS.has(token);
99
+ function isUnknownFlag(token, allowed) {
100
+ if (!token.startsWith("-")) return false;
101
+ if (GLOBAL_FLAG_TOKENS.has(token)) return false;
102
+ const eq = token.indexOf("=");
103
+ return !allowed.has(eq >= 0 ? token.slice(0, eq) : token);
77
104
  }
78
105
  function renderRootHelp(version, commands) {
79
106
  const width = Math.max(...commands.map((c) => c.name.length));
@@ -94,6 +121,16 @@ function renderRootHelp(version, commands) {
94
121
  " -v, --version Show the CLI version"
95
122
  ].join("\n");
96
123
  }
124
+ function commandFlagTokens(...commands) {
125
+ const tokens = /* @__PURE__ */ new Set();
126
+ for (const command of commands) {
127
+ for (const option of command?.options ?? []) {
128
+ tokens.add(option.flag);
129
+ for (const alias of option.aliases ?? []) tokens.add(alias);
130
+ }
131
+ }
132
+ return tokens;
133
+ }
97
134
  function renderCommandHelp(path, command) {
98
135
  const lines = [
99
136
  `birdybeep ${path} \u2014 ${command.summary}`,
@@ -101,6 +138,17 @@ function renderCommandHelp(path, command) {
101
138
  "Usage:",
102
139
  ` ${command.usage ?? `birdybeep ${path} [options]`}`
103
140
  ];
141
+ if (command.options && command.options.length > 0) {
142
+ const labels = command.options.map(
143
+ (o) => `${[o.flag, ...o.aliases ?? []].join(", ")}${o.value ? ` ${o.value}` : ""}`
144
+ );
145
+ const width = Math.max(...labels.map((l) => l.length));
146
+ lines.push(
147
+ "",
148
+ "Options:",
149
+ ...command.options.map((o, i) => ` ${labels[i]?.padEnd(width)} ${o.summary}`)
150
+ );
151
+ }
104
152
  if (command.subcommands && command.subcommands.length > 0) {
105
153
  const width = Math.max(...command.subcommands.map((c) => c.name.length));
106
154
  lines.push(
@@ -125,6 +173,7 @@ async function dispatch(argv, deps) {
125
173
  return EXIT.OK;
126
174
  }
127
175
  let command = deps.commands.find((c) => c.name === rest[0]);
176
+ let parent;
128
177
  const pathParts = [];
129
178
  let argsStart = 1;
130
179
  if (command) {
@@ -132,6 +181,7 @@ async function dispatch(argv, deps) {
132
181
  if (command.subcommands && command.subcommands.length > 0) {
133
182
  const sub = command.subcommands.find((c) => c.name === rest[1]);
134
183
  if (sub) {
184
+ parent = command;
135
185
  command = sub;
136
186
  pathParts.push(sub.name);
137
187
  argsStart = 2;
@@ -155,6 +205,7 @@ async function dispatch(argv, deps) {
155
205
  name: path,
156
206
  summary: command.summary,
157
207
  usage: command.usage,
208
+ options: command.options,
158
209
  subcommands: command.subcommands?.map((c) => ({ name: c.name, summary: c.summary }))
159
210
  });
160
211
  return EXIT.OK;
@@ -164,7 +215,8 @@ async function dispatch(argv, deps) {
164
215
  return EXIT.USAGE;
165
216
  }
166
217
  const args = rest.slice(argsStart);
167
- const unknown = args.find(isUnknownFlag);
218
+ const allowed = commandFlagTokens(command, parent);
219
+ const unknown = args.find((token) => isUnknownFlag(token, allowed));
168
220
  if (unknown !== void 0) {
169
221
  io.errline(`birdybeep ${path}: unknown option "${unknown}".`);
170
222
  return EXIT.USAGE;
@@ -192,13 +244,28 @@ async function dispatch(argv, deps) {
192
244
  }
193
245
 
194
246
  // src/commands/agent.ts
195
- var DEFAULT_ADAPTERS = [import_claude_code.claudeCodeAdapter, import_codex.codexAdapter, import_opencode.opencodeAdapter];
247
+ var DEFAULT_ADAPTERS = [
248
+ import_claude_code.claudeCodeAdapter,
249
+ import_codex.codexAdapter,
250
+ import_opencode.opencodeAdapter,
251
+ import_cursor.cursorAdapter,
252
+ import_copilot.copilotAdapter
253
+ ];
196
254
  var TARGET_TO_ID = {
197
255
  claude: "claude_code",
198
256
  codex: "codex",
199
- opencode: "opencode"
257
+ opencode: "opencode",
258
+ cursor: "cursor",
259
+ copilot: "copilot"
200
260
  };
201
- var AGENT_TARGETS = ["all", "claude", "codex", "opencode"];
261
+ var AGENT_TARGETS = [
262
+ "all",
263
+ "claude",
264
+ "codex",
265
+ "opencode",
266
+ "cursor",
267
+ "copilot"
268
+ ];
202
269
  function selectAdapters(target, adapters) {
203
270
  if (target === "all") return adapters;
204
271
  const id = TARGET_TO_ID[target];
@@ -289,18 +356,18 @@ function createAgentCommand(deps = {}) {
289
356
  return {
290
357
  name: "agent",
291
358
  summary: "Install or uninstall harness adapters",
292
- usage: "birdybeep agent <install|uninstall> [all|claude|codex|opencode]",
359
+ usage: "birdybeep agent <install|uninstall> [all|claude|codex|opencode|cursor|copilot]",
293
360
  subcommands: [
294
361
  {
295
362
  name: "install",
296
- summary: "Install adapters (all | claude | codex | opencode)",
297
- usage: "birdybeep agent install [all|claude|codex|opencode]",
363
+ summary: "Install adapters (all | claude | codex | opencode | cursor | copilot)",
364
+ usage: "birdybeep agent install [all|claude|codex|opencode|cursor|copilot]",
298
365
  run: (ctx) => installSelected(adapters, ctx)
299
366
  },
300
367
  {
301
368
  name: "uninstall",
302
369
  summary: "Restore harness config to its pre-install state",
303
- usage: "birdybeep agent uninstall [all|claude|codex|opencode]",
370
+ usage: "birdybeep agent uninstall [all|claude|codex|opencode|cursor|copilot]",
304
371
  run: (ctx) => uninstallSelected(adapters, ctx)
305
372
  }
306
373
  ]
@@ -311,6 +378,8 @@ function createAgentCommand(deps = {}) {
311
378
  var import_agent_core4 = require("@birdybeep/agent-core");
312
379
  var import_claude_code2 = require("@birdybeep/claude-code");
313
380
  var import_codex2 = require("@birdybeep/codex");
381
+ var import_copilot2 = require("@birdybeep/copilot");
382
+ var import_cursor2 = require("@birdybeep/cursor");
314
383
  var import_opencode2 = require("@birdybeep/opencode");
315
384
 
316
385
  // src/config.ts
@@ -335,6 +404,8 @@ function writeCliConfig(patch) {
335
404
  const merged = {};
336
405
  const apiUrl = patch.apiUrl ?? current.apiUrl;
337
406
  if (apiUrl !== void 0) merged.apiUrl = apiUrl;
407
+ const expectEmail = patch.expectEmail ?? current.expectEmail;
408
+ if (expectEmail !== void 0) merged.expectEmail = expectEmail;
338
409
  (0, import_node_fs2.mkdirSync)((0, import_agent_core2.birdyBeepConfigDir)(), { recursive: true, mode: 448 });
339
410
  (0, import_node_fs2.writeFileSync)(cliConfigPath(), `${JSON.stringify(merged, null, 2)}
340
411
  `, { mode: 384 });
@@ -373,7 +444,13 @@ function machineIdentity() {
373
444
  }
374
445
 
375
446
  // src/commands/doctor.ts
376
- var DEFAULT_ADAPTERS2 = [import_claude_code2.claudeCodeAdapter, import_codex2.codexAdapter, import_opencode2.opencodeAdapter];
447
+ var DEFAULT_ADAPTERS2 = [
448
+ import_claude_code2.claudeCodeAdapter,
449
+ import_codex2.codexAdapter,
450
+ import_opencode2.opencodeAdapter,
451
+ import_cursor2.cursorAdapter,
452
+ import_copilot2.copilotAdapter
453
+ ];
377
454
  async function defaultProbeNetwork(baseUrl) {
378
455
  try {
379
456
  const controller = new AbortController();
@@ -456,16 +533,30 @@ function createDoctorCommand(deps = {}) {
456
533
  }
457
534
 
458
535
  // src/commands/hook.ts
536
+ var import_node_child_process = require("child_process");
537
+ var import_node_crypto = require("crypto");
538
+ var import_node_fs3 = require("fs");
539
+ var import_node_os = require("os");
540
+ var import_node_path2 = require("path");
459
541
  var import_agent_core5 = require("@birdybeep/agent-core");
460
542
  var import_claude_code3 = require("@birdybeep/claude-code");
461
543
  var import_codex3 = require("@birdybeep/codex");
544
+ var import_copilot3 = require("@birdybeep/copilot");
545
+ var import_cursor3 = require("@birdybeep/cursor");
462
546
  var import_opencode3 = require("@birdybeep/opencode");
463
547
  var RUNNERS = {
464
548
  claude: import_claude_code3.runClaudeHook,
465
549
  codex: import_codex3.runCodexHook,
466
- opencode: import_opencode3.runOpenCodeHook
550
+ opencode: import_opencode3.runOpenCodeHook,
551
+ cursor: import_cursor3.runCursorHook
467
552
  };
468
- var HOOK_HARNESSES = ["claude", "codex", "opencode"];
553
+ var HOOK_HARNESSES = [
554
+ "claude",
555
+ "codex",
556
+ "opencode",
557
+ "cursor",
558
+ "copilot"
559
+ ];
469
560
  var STDIN_READ_TIMEOUT_MS = 3e3;
470
561
  function withTimeout(promise, ms, fallback) {
471
562
  return new Promise((resolve) => {
@@ -482,9 +573,13 @@ function withTimeout(promise, ms, fallback) {
482
573
  });
483
574
  }
484
575
  function isHarnessName(value) {
485
- return value === "claude" || value === "codex" || value === "opencode";
576
+ return value === "claude" || value === "codex" || value === "opencode" || value === "cursor" || value === "copilot";
486
577
  }
487
- function runHookCommand(harness, payload, sender) {
578
+ function runHookCommand(harness, payload, sender, copilotEventName) {
579
+ if (harness === "copilot") {
580
+ if (copilotEventName === void 0) return Promise.resolve({ outcome: "skipped" });
581
+ return (0, import_copilot3.runCopilotHook)(copilotEventName, payload, { sender });
582
+ }
488
583
  return RUNNERS[harness](payload, { sender });
489
584
  }
490
585
  function readStdinDefault() {
@@ -500,24 +595,90 @@ function readStdinDefault() {
500
595
  process.stdin.on("error", () => resolve(""));
501
596
  });
502
597
  }
503
- async function readHookPayload(args, readStdin) {
504
- return args[1] ?? await readStdin();
598
+ async function readHookPayload(args, readStdin, stdinOnly = false) {
599
+ return stdinOnly ? readStdin() : args[1] ?? await readStdin();
600
+ }
601
+ var NOTIFY_STDIN_FILE_ENV = "BIRDYBEEP_CODEX_NOTIFY_STDIN_FILE";
602
+ function detachCodexNotifyWorker(payload) {
603
+ if (process.platform === "win32") return false;
604
+ let file;
605
+ let fd;
606
+ try {
607
+ const birdybeep = (0, import_agent_core5.resolveOnPath)("birdybeep");
608
+ if (birdybeep === null) return false;
609
+ const tmpFile = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `birdybeep-notify-${(0, import_node_crypto.randomBytes)(16).toString("hex")}.json`);
610
+ file = tmpFile;
611
+ (0, import_node_fs3.writeFileSync)(tmpFile, payload, { mode: 384 });
612
+ fd = (0, import_node_fs3.openSync)(tmpFile, "r");
613
+ const child = (0, import_node_child_process.spawn)(birdybeep, ["hook", "codex"], {
614
+ cwd: (0, import_node_path2.dirname)(birdybeep),
615
+ // trusted dir, never the inherited/attacker cwd
616
+ detached: true,
617
+ // new session (setsid) → survives `codex exec` reaping the group
618
+ stdio: [fd, "ignore", "ignore"],
619
+ // stdin = the temp file; this process holds no pipe
620
+ env: { ...process.env, [NOTIFY_STDIN_FILE_ENV]: tmpFile },
621
+ // worker cleans it up post-read
622
+ windowsHide: true
623
+ });
624
+ child.on("error", () => {
625
+ try {
626
+ (0, import_node_fs3.rmSync)(tmpFile, { force: true });
627
+ } catch {
628
+ }
629
+ });
630
+ child.unref();
631
+ return true;
632
+ } catch {
633
+ if (file !== void 0) {
634
+ try {
635
+ (0, import_node_fs3.rmSync)(file, { force: true });
636
+ } catch {
637
+ }
638
+ }
639
+ return false;
640
+ } finally {
641
+ if (fd !== void 0) {
642
+ try {
643
+ (0, import_node_fs3.closeSync)(fd);
644
+ } catch {
645
+ }
646
+ }
647
+ }
505
648
  }
506
649
  function createHookCommand(deps = {}) {
507
650
  const makeSender = deps.createSender ?? ((baseUrl) => (0, import_agent_core5.createSender)({ baseUrl }));
508
651
  const readStdin = deps.readStdin ?? readStdinDefault;
509
652
  const stdinTimeoutMs = deps.stdinTimeoutMs ?? STDIN_READ_TIMEOUT_MS;
653
+ const detachCodexNotify = deps.detachCodexNotify ?? detachCodexNotifyWorker;
510
654
  return {
511
655
  name: "hook",
512
656
  summary: "Internal: normalize + send an event fired by a harness hook",
513
- usage: "birdybeep hook <claude|codex|opencode>",
657
+ usage: "birdybeep hook <claude|codex|opencode|cursor|copilot> [copilot-event]",
514
658
  run: async (ctx) => {
515
659
  const harness = ctx.args[0];
516
660
  if (!isHarnessName(harness)) {
517
661
  ctx.io.errline(`birdybeep hook: expected one of ${HOOK_HARNESSES.join("|")}`);
518
662
  return EXIT.USAGE;
519
663
  }
520
- const raw = await withTimeout(readHookPayload(ctx.args, readStdin), stdinTimeoutMs, "");
664
+ const notifyPayload = ctx.args[1];
665
+ if (harness === "codex" && notifyPayload !== void 0 && notifyPayload.length > 0 && detachCodexNotify(notifyPayload)) {
666
+ ctx.io.result({ harness, outcome: "detached" });
667
+ return EXIT.OK;
668
+ }
669
+ const copilotEventName = harness === "copilot" && (0, import_copilot3.isCopilotHookEventName)(ctx.args[1]) ? ctx.args[1] : void 0;
670
+ const raw = await withTimeout(
671
+ readHookPayload(ctx.args, readStdin, harness === "copilot"),
672
+ stdinTimeoutMs,
673
+ ""
674
+ );
675
+ const notifyStdinFile = process.env[NOTIFY_STDIN_FILE_ENV];
676
+ if (notifyStdinFile !== void 0 && (0, import_node_path2.dirname)(notifyStdinFile) === (0, import_node_os.tmpdir)() && (0, import_node_path2.basename)(notifyStdinFile).startsWith("birdybeep-notify-")) {
677
+ try {
678
+ (0, import_node_fs3.rmSync)(notifyStdinFile, { force: true });
679
+ } catch {
680
+ }
681
+ }
521
682
  let payload;
522
683
  try {
523
684
  payload = JSON.parse(raw);
@@ -526,8 +687,15 @@ function createHookCommand(deps = {}) {
526
687
  return EXIT.OK;
527
688
  }
528
689
  const sender = makeSender(resolveApiUrl());
529
- const result = await runHookCommand(harness, payload, sender);
530
- ctx.io.result({ harness, outcome: result.outcome, eventType: result.eventType });
690
+ const result = await runHookCommand(harness, payload, sender, copilotEventName);
691
+ ctx.io.result({
692
+ harness,
693
+ ...copilotEventName !== void 0 ? { event: copilotEventName } : {},
694
+ outcome: result.outcome,
695
+ eventType: result.eventType,
696
+ ...result.send?.decision ? { decision: result.send.decision } : {},
697
+ ...result.send?.status !== void 0 ? { status: result.send.status } : {}
698
+ });
531
699
  return EXIT.OK;
532
700
  }
533
701
  };
@@ -535,57 +703,73 @@ function createHookCommand(deps = {}) {
535
703
 
536
704
  // src/commands/logout.ts
537
705
  var import_agent_core6 = require("@birdybeep/agent-core");
538
- function createClearTokenCommand(spec, deps = {}) {
706
+ var base = (apiUrl) => apiUrl.replace(/\/$/, "");
707
+ function createLogoutCommand(deps = {}) {
539
708
  return {
540
- name: spec.name,
541
- summary: spec.summary,
542
- usage: `birdybeep ${spec.name}`,
709
+ name: "logout",
710
+ summary: "Remove the local machine token (does NOT revoke the machine server-side)",
711
+ usage: "birdybeep logout",
543
712
  run: async (ctx) => {
544
713
  await (0, import_agent_core6.clearToken)(deps.tokenOptions ?? {});
545
- ctx.io.emit(spec.humanMessage, { [spec.jsonKey]: true });
714
+ ctx.io.emit("Logged out \u2014 the machine token was removed.", { loggedOut: true });
546
715
  return EXIT.OK;
547
716
  }
548
717
  };
549
718
  }
550
- function createLogoutCommand(deps = {}) {
551
- return createClearTokenCommand(
552
- {
553
- name: "logout",
554
- summary: "Remove the local machine token (same as `unpair`)",
555
- humanMessage: "Logged out \u2014 the machine token was removed.",
556
- jsonKey: "loggedOut"
557
- },
558
- deps
559
- );
719
+ async function revokeSelf(token, fetchImpl, timeoutMs) {
720
+ const controller = new AbortController();
721
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
722
+ try {
723
+ const res = await fetchImpl(`${base(resolveApiUrl())}/v1/machine/revoke-self`, {
724
+ method: "POST",
725
+ headers: { authorization: `Bearer ${token}` },
726
+ signal: controller.signal
727
+ });
728
+ if (res.ok || res.status === 403) return "revoked";
729
+ return "rejected";
730
+ } catch {
731
+ return "unreachable";
732
+ } finally {
733
+ clearTimeout(timer);
734
+ }
560
735
  }
561
736
  function createUnpairCommand(deps = {}) {
562
- return createClearTokenCommand(
563
- {
564
- name: "unpair",
565
- summary: "Unpair this machine \u2014 remove the local machine token (same as `logout`)",
566
- humanMessage: "Unpaired \u2014 the machine token was removed.",
567
- jsonKey: "unpaired"
568
- },
569
- deps
570
- );
737
+ const fetchImpl = deps.fetchImpl ?? fetch;
738
+ const timeoutMs = deps.timeoutMs ?? 1e4;
739
+ return {
740
+ name: "unpair",
741
+ summary: "Unpair this machine \u2014 revoke it server-side and remove the local token",
742
+ usage: "birdybeep unpair",
743
+ run: async (ctx) => {
744
+ const token = await (0, import_agent_core6.getToken)(deps.tokenOptions ?? {});
745
+ const outcome = token === null ? "no_token" : await revokeSelf(token, fetchImpl, timeoutMs);
746
+ await (0, import_agent_core6.clearToken)(deps.tokenOptions ?? {});
747
+ const serverRevoked = outcome === "revoked";
748
+ 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.";
749
+ ctx.io.emit(human, { unpaired: true, serverRevoked });
750
+ return EXIT.OK;
751
+ }
752
+ };
571
753
  }
572
754
 
573
755
  // src/commands/pair.ts
756
+ var import_node_fs4 = require("fs");
574
757
  var import_agent_core8 = require("@birdybeep/agent-core");
575
758
  var import_uqr = require("uqr");
576
759
 
577
760
  // src/pairing.ts
578
761
  var import_agent_core7 = require("@birdybeep/agent-core");
579
- function base(apiUrl) {
762
+ function base2(apiUrl) {
580
763
  return apiUrl.replace(/\/$/, "");
581
764
  }
582
765
  async function pairStart(apiUrl, input, fetchImpl) {
583
766
  const body = {
584
767
  machine_label: input.machineLabel,
585
768
  ...input.os !== void 0 ? { os: input.os } : {},
586
- ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {}
769
+ ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {},
770
+ ...input.codeChallenge !== void 0 ? { code_challenge: input.codeChallenge } : {}
587
771
  };
588
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/start`, {
772
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/start`, {
589
773
  method: "POST",
590
774
  headers: { "content-type": "application/json" },
591
775
  body: JSON.stringify(body)
@@ -603,12 +787,13 @@ var TERMINAL_TOKEN_ERRORS = /* @__PURE__ */ new Set([
603
787
  "not_found",
604
788
  "payload_too_large"
605
789
  ]);
606
- async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint) {
790
+ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint, codeVerifier) {
607
791
  const body = {
608
792
  device_code: deviceCode,
609
- ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {}
793
+ ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {},
794
+ ...codeVerifier !== void 0 ? { code_verifier: codeVerifier } : {}
610
795
  };
611
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/token`, {
796
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/token`, {
612
797
  method: "POST",
613
798
  headers: { "content-type": "application/json" },
614
799
  body: JSON.stringify(body)
@@ -619,7 +804,10 @@ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint)
619
804
  return {
620
805
  status: "paired",
621
806
  machineToken: parsed.data.machine_token,
622
- machineId: parsed.data.machine_id
807
+ machineId: parsed.data.machine_id,
808
+ // Only surface the key when the server reported it (exactOptionalPropertyTypes: no explicit
809
+ // undefined). Older servers omit approved_by_email; newer ones (dgxd) include it.
810
+ ...parsed.data.approved_by_email !== void 0 ? { approvedByEmail: parsed.data.approved_by_email } : {}
623
811
  };
624
812
  }
625
813
  let errBody = null;
@@ -640,7 +828,7 @@ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint)
640
828
  }
641
829
 
642
830
  // src/version.ts
643
- var CLI_VERSION = "0.2.0".length > 0 ? "0.2.0" : "0.0.0";
831
+ var CLI_VERSION = "0.3.0".length > 0 ? "0.3.0" : "0.0.0";
644
832
 
645
833
  // src/commands/pair.ts
646
834
  var DEFAULT_POLL_INTERVAL_MS = 2e3;
@@ -648,22 +836,172 @@ var HEARTBEAT_MS = 15e3;
648
836
  function renderQrMatrix(qrPayload) {
649
837
  return (0, import_uqr.renderUnicodeCompact)(qrPayload, { border: 2 });
650
838
  }
839
+ function parsePairFlags(args) {
840
+ const flags = { yes: false };
841
+ for (let i = 0; i < args.length; i += 1) {
842
+ const token = args[i] ?? "";
843
+ if (token === "--yes" || token === "-y") {
844
+ flags.yes = true;
845
+ } else if (token === "--expect-email" || token.startsWith("--expect-email=")) {
846
+ const inline = token.startsWith("--expect-email=") ? token.slice("--expect-email=".length) : void 0;
847
+ const value = inline ?? args[++i];
848
+ if (value === void 0 || value.length === 0 || value.startsWith("-")) {
849
+ return { ...flags, error: "--expect-email requires an email address" };
850
+ }
851
+ flags.expectEmail = value;
852
+ } else {
853
+ return { ...flags, error: `unexpected argument "${token}"` };
854
+ }
855
+ }
856
+ return flags;
857
+ }
858
+ function sameEmail(a, b) {
859
+ const fold = (v) => v.trim().toLowerCase();
860
+ const rawEqual = fold(a) === fold(b);
861
+ const nfkcEqual = fold(a.normalize("NFKC")) === fold(b.normalize("NFKC"));
862
+ return rawEqual && nfkcEqual;
863
+ }
864
+ function decidePairConfirmation(input) {
865
+ const { approvedByEmail, expectEmail } = input;
866
+ const platform = input.platform ?? process.platform;
867
+ if (expectEmail !== void 0) {
868
+ if (approvedByEmail === void 0) {
869
+ 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.";
870
+ return {
871
+ action: "reject",
872
+ reason: "expected_email_unverifiable",
873
+ 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}`
874
+ };
875
+ }
876
+ if (sameEmail(approvedByEmail, expectEmail)) {
877
+ return { action: "approve", reason: "expected_email_match" };
878
+ }
879
+ return {
880
+ action: "reject",
881
+ reason: "expected_email_mismatch",
882
+ 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\`.`
883
+ };
884
+ }
885
+ if (input.yes) return { action: "approve", reason: "yes_flag" };
886
+ 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] ";
887
+ if (!input.nonInteractive) {
888
+ if (input.stdinIsTTY) return { action: "prompt", question, on: "stdin" };
889
+ if (input.controllingTerminalAvailable) {
890
+ return { action: "prompt", question, on: "controlling-terminal" };
891
+ }
892
+ }
893
+ const who = approvedByEmail !== void 0 ? ` (approved by ${approvedByEmail})` : "";
894
+ const winptyHint = platform === "win32" && !input.nonInteractive ? " In Git Bash / MSYS, `winpty birdybeep pair` attaches a real console so the prompt can appear." : "";
895
+ return {
896
+ action: "reject",
897
+ reason: "non_interactive",
898
+ 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
899
+ };
900
+ }
901
+ function isAffirmative(answer) {
902
+ return /^(y|yes)$/i.test(answer.trim());
903
+ }
904
+ function controllingTerminalPath() {
905
+ return "/dev/tty";
906
+ }
907
+ function canOpenControllingTerminal(path = controllingTerminalPath(), platform = process.platform) {
908
+ if (platform === "win32") return false;
909
+ let fd;
910
+ try {
911
+ fd = (0, import_node_fs4.openSync)(path, "r");
912
+ return true;
913
+ } catch {
914
+ return false;
915
+ } finally {
916
+ if (fd !== void 0) {
917
+ try {
918
+ (0, import_node_fs4.closeSync)(fd);
919
+ } catch {
920
+ }
921
+ }
922
+ }
923
+ }
924
+ async function promptForAnswer(question, on) {
925
+ const { createInterface } = await import("readline/promises");
926
+ let ttyFd;
927
+ let input;
928
+ if (on === "stdin") {
929
+ input = process.stdin;
930
+ } else {
931
+ const { ReadStream } = await import("tty");
932
+ ttyFd = (0, import_node_fs4.openSync)(controllingTerminalPath(), "r");
933
+ input = new ReadStream(ttyFd);
934
+ }
935
+ return new Promise((resolve) => {
936
+ const rl = createInterface({ input, output: process.stderr });
937
+ let settled = false;
938
+ const done = (value) => {
939
+ if (settled) return;
940
+ settled = true;
941
+ rl.close();
942
+ if (on === "stdin") {
943
+ process.stdin.unref?.();
944
+ } else {
945
+ try {
946
+ input.unref?.();
947
+ input.destroy?.();
948
+ } catch {
949
+ }
950
+ if (ttyFd !== void 0) {
951
+ try {
952
+ (0, import_node_fs4.closeSync)(ttyFd);
953
+ } catch {
954
+ }
955
+ }
956
+ }
957
+ resolve(value);
958
+ };
959
+ rl.question(question).then(done, () => done(""));
960
+ rl.once("close", () => done(""));
961
+ input.once?.("error", () => done(""));
962
+ });
963
+ }
651
964
  function createPairCommand(deps = {}) {
652
965
  const fetchImpl = deps.fetchImpl ?? fetch;
653
966
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
654
967
  const clock = deps.now ?? (() => Date.now());
655
968
  const renderQr = deps.renderQr ?? renderQrMatrix;
656
969
  const intervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
970
+ const promptLine = deps.promptLine ?? promptForAnswer;
971
+ const hasControllingTerminal = deps.hasControllingTerminal ?? (() => canOpenControllingTerminal());
972
+ const configuredExpectEmail = deps.configuredExpectEmail ?? (() => {
973
+ const pinned = readCliConfig().expectEmail;
974
+ return typeof pinned === "string" && pinned.trim().length > 0 ? pinned : void 0;
975
+ });
657
976
  return {
658
977
  name: "pair",
659
978
  summary: "Pair this machine with your BirdyBeep account (QR or manual)",
660
- usage: "birdybeep pair [--json]",
979
+ usage: "birdybeep pair [--yes] [--expect-email <addr>] [--json]",
980
+ options: [
981
+ {
982
+ flag: "--yes",
983
+ aliases: ["-y"],
984
+ summary: "Skip the approving-account confirmation (headless/CI)"
985
+ },
986
+ {
987
+ flag: "--expect-email",
988
+ value: "<addr>",
989
+ summary: "Only trust the pairing if this account approved it (else fail)"
990
+ }
991
+ ],
661
992
  run: async (ctx) => {
993
+ const pairFlags = parsePairFlags(ctx.args);
994
+ if (pairFlags.error !== void 0) {
995
+ ctx.io.errline(`birdybeep pair: ${pairFlags.error}.`);
996
+ return EXIT.USAGE;
997
+ }
662
998
  const apiUrl = resolveApiUrl();
663
999
  const identity = (0, import_agent_core8.getMachineIdentity)();
1000
+ const codeVerifier = (0, import_agent_core8.generateCodeVerifier)();
1001
+ const codeChallenge = (0, import_agent_core8.deriveCodeChallengeS256)(codeVerifier);
664
1002
  const start = await pairStart(
665
1003
  apiUrl,
666
- { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION },
1004
+ { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION, codeChallenge },
667
1005
  fetchImpl
668
1006
  );
669
1007
  if (ctx.flags.json) {
@@ -675,12 +1013,14 @@ function createPairCommand(deps = {}) {
675
1013
  });
676
1014
  } else {
677
1015
  ctx.io.line(
678
- "To pair this machine, open the BirdyBeep app, tap \u201Cpair a machine\u201D, and scan this QR (or enter the code):"
1016
+ "To pair this machine, open the BirdyBeep app, tap \u201Cpair a machine\u201D, and scan this QR or open the complete link:"
679
1017
  );
680
1018
  const isTTY = deps.isTTY ?? process.stdout.isTTY === true;
681
1019
  if (isTTY) ctx.io.line(renderQr(start.qr_payload));
682
1020
  ctx.io.line(` Scan or open: ${start.qr_payload}`);
683
- ctx.io.line(` Code: ${start.user_code}`);
1021
+ ctx.io.line(
1022
+ ` Session code (display only; cannot approve by itself): ${start.user_code}`
1023
+ );
684
1024
  ctx.io.line("Waiting for you to approve this machine in the app\u2026");
685
1025
  }
686
1026
  const deadline = Date.parse(start.expires_at);
@@ -696,7 +1036,9 @@ function createPairCommand(deps = {}) {
696
1036
  apiUrl,
697
1037
  start.device_code,
698
1038
  fetchImpl,
699
- identity.fingerprintHash
1039
+ identity.fingerprintHash,
1040
+ codeVerifier
1041
+ // PKCE proof-of-possession (dgxd) — sent on every poll
700
1042
  );
701
1043
  if (poll.status === "paired") {
702
1044
  paired = poll;
@@ -721,15 +1063,44 @@ function createPairCommand(deps = {}) {
721
1063
  if (paired === void 0 || paired.status !== "paired") {
722
1064
  ctx.io.result({ paired: false, reason: "timeout" });
723
1065
  ctx.io.errline(
724
- "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."
1066
+ "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."
1067
+ );
1068
+ return EXIT.ERROR;
1069
+ }
1070
+ const approvedBy = paired.approvedByEmail;
1071
+ const expectEmail = pairFlags.expectEmail ?? configuredExpectEmail();
1072
+ const stdinIsTTY = deps.isStdinTTY ?? process.stdin.isTTY === true;
1073
+ const decision = decidePairConfirmation({
1074
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {},
1075
+ ...expectEmail !== void 0 ? { expectEmail } : {},
1076
+ ...expectEmail !== void 0 ? { expectEmailSource: pairFlags.expectEmail !== void 0 ? "flag" : "config" } : {},
1077
+ yes: pairFlags.yes,
1078
+ nonInteractive: ctx.flags.nonInteractive,
1079
+ stdinIsTTY,
1080
+ // Probed ONLY when stdin can't answer — opening /dev/tty is a syscall, and when stdin is
1081
+ // already a terminal the answer is irrelevant.
1082
+ controllingTerminalAvailable: stdinIsTTY ? false : hasControllingTerminal(),
1083
+ configPath: cliConfigPath()
1084
+ });
1085
+ if (decision.action === "reject") {
1086
+ ctx.io.result({ paired: false, reason: decision.reason });
1087
+ ctx.io.errline(decision.message);
1088
+ return EXIT.ERROR;
1089
+ }
1090
+ if (decision.action === "prompt" && !isAffirmative(await promptLine(decision.question, decision.on))) {
1091
+ ctx.io.result({ paired: false, reason: "declined" });
1092
+ ctx.io.errline(
1093
+ "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."
725
1094
  );
726
1095
  return EXIT.ERROR;
727
1096
  }
728
1097
  await (0, import_agent_core8.setToken)(paired.machineToken, deps.tokenOptions ?? {});
729
1098
  writeCliConfig({ apiUrl });
730
- ctx.io.emit(`\u2713 Paired. Run \`birdybeep test\` to send a test Beep.`, {
1099
+ const humanSuffix = approvedBy !== void 0 ? ` to ${approvedBy}` : "";
1100
+ ctx.io.emit(`\u2713 Paired${humanSuffix}. Run \`birdybeep test\` to send a test Beep.`, {
731
1101
  paired: true,
732
- machineId: paired.machineId
1102
+ machineId: paired.machineId,
1103
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {}
733
1104
  });
734
1105
  return EXIT.OK;
735
1106
  }
@@ -762,14 +1133,24 @@ function createQueueCommand() {
762
1133
  var import_agent_core10 = require("@birdybeep/agent-core");
763
1134
  var import_claude_code4 = require("@birdybeep/claude-code");
764
1135
  var import_codex4 = require("@birdybeep/codex");
1136
+ var import_copilot4 = require("@birdybeep/copilot");
1137
+ var import_cursor4 = require("@birdybeep/cursor");
765
1138
  var import_opencode4 = require("@birdybeep/opencode");
766
- var DEFAULT_ADAPTERS3 = [import_claude_code4.claudeCodeAdapter, import_codex4.codexAdapter, import_opencode4.opencodeAdapter];
1139
+ var DEFAULT_ADAPTERS3 = [
1140
+ import_claude_code4.claudeCodeAdapter,
1141
+ import_codex4.codexAdapter,
1142
+ import_opencode4.opencodeAdapter,
1143
+ import_cursor4.cursorAdapter,
1144
+ import_copilot4.copilotAdapter
1145
+ ];
767
1146
  var ADAPTER_VERSIONS = {
768
1147
  claude_code: import_claude_code4.CLAUDE_CODE_ADAPTER_VERSION,
769
1148
  codex: import_codex4.CODEX_ADAPTER_VERSION,
770
- opencode: import_opencode4.OPENCODE_ADAPTER_VERSION
1149
+ opencode: import_opencode4.OPENCODE_ADAPTER_VERSION,
1150
+ cursor: import_cursor4.CURSOR_ADAPTER_VERSION,
1151
+ copilot: import_copilot4.COPILOT_ADAPTER_VERSION
771
1152
  };
772
- var base2 = (apiUrl) => apiUrl.replace(/\/$/, "");
1153
+ var base3 = (apiUrl) => apiUrl.replace(/\/$/, "");
773
1154
  async function gatherItems(adapters) {
774
1155
  return Promise.all(
775
1156
  adapters.map(async (a) => {
@@ -804,7 +1185,7 @@ function createReportStatusCommand(deps = {}) {
804
1185
  let outcome = "deferred";
805
1186
  let errorCode;
806
1187
  try {
807
- const res = await fetchImpl(`${base2(resolveApiUrl())}/v1/integrations/status`, {
1188
+ const res = await fetchImpl(`${base3(resolveApiUrl())}/v1/integrations/status`, {
808
1189
  method: "POST",
809
1190
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
810
1191
  body: JSON.stringify({ integrations: items })
@@ -855,8 +1236,16 @@ function createReportStatusCommand(deps = {}) {
855
1236
  var import_agent_core11 = require("@birdybeep/agent-core");
856
1237
  var import_claude_code5 = require("@birdybeep/claude-code");
857
1238
  var import_codex5 = require("@birdybeep/codex");
1239
+ var import_copilot5 = require("@birdybeep/copilot");
1240
+ var import_cursor5 = require("@birdybeep/cursor");
858
1241
  var import_opencode5 = require("@birdybeep/opencode");
859
- var DEFAULT_ADAPTERS4 = [import_claude_code5.claudeCodeAdapter, import_codex5.codexAdapter, import_opencode5.opencodeAdapter];
1242
+ var DEFAULT_ADAPTERS4 = [
1243
+ import_claude_code5.claudeCodeAdapter,
1244
+ import_codex5.codexAdapter,
1245
+ import_opencode5.opencodeAdapter,
1246
+ import_cursor5.cursorAdapter,
1247
+ import_copilot5.copilotAdapter
1248
+ ];
860
1249
  function createStatusCommand(deps = {}) {
861
1250
  const adapters = deps.adapters ?? DEFAULT_ADAPTERS4;
862
1251
  const makeSender = deps.createSender ?? ((baseUrl) => (0, import_agent_core11.createSender)(
@@ -896,7 +1285,7 @@ function createStatusCommand(deps = {}) {
896
1285
  }
897
1286
 
898
1287
  // src/commands/test.ts
899
- var import_node_crypto = require("crypto");
1288
+ var import_node_crypto2 = require("crypto");
900
1289
  var import_agent_core12 = require("@birdybeep/agent-core");
901
1290
  function buildTestEvent(opts = {}) {
902
1291
  const machine = (0, import_agent_core12.getMachineIdentity)();
@@ -908,7 +1297,7 @@ function buildTestEvent(opts = {}) {
908
1297
  // schema requires a harness; the "test" type distinguishes it
909
1298
  // Unique per run: a repeat `birdybeep test` inside the backend's dedupe window must
910
1299
  // still beep — a constant id made the second test silently "deduped" (9fh).
911
- source_session_id: `birdybeep-cli-test-${(0, import_node_crypto.randomUUID)()}`,
1300
+ source_session_id: `birdybeep-cli-test-${(0, import_node_crypto2.randomUUID)()}`,
912
1301
  machine: { label: machine.label, os: machine.os },
913
1302
  workspace: { cwd: process.cwd() },
914
1303
  title: "BirdyBeep test event",
@@ -978,8 +1367,8 @@ function buildCommands() {
978
1367
  }
979
1368
 
980
1369
  // src/update-check.ts
981
- var import_node_fs3 = require("fs");
982
- var import_node_path2 = require("path");
1370
+ var import_node_fs5 = require("fs");
1371
+ var import_node_path3 = require("path");
983
1372
  var import_agent_core13 = require("@birdybeep/agent-core");
984
1373
  var PACKAGE_NAME = "@birdybeep/cli";
985
1374
  var PACKAGE_PATH = "@birdybeep%2Fcli";
@@ -1034,11 +1423,11 @@ function isNewer(current, latest) {
1034
1423
  return cur !== null && lat !== null && compareSemver(cur, lat) < 0;
1035
1424
  }
1036
1425
  function updateCachePath() {
1037
- return (0, import_node_path2.join)((0, import_agent_core13.birdyBeepConfigDir)(), UPDATE_CACHE_FILE);
1426
+ return (0, import_node_path3.join)((0, import_agent_core13.birdyBeepConfigDir)(), UPDATE_CACHE_FILE);
1038
1427
  }
1039
1428
  function readUpdateCache() {
1040
1429
  try {
1041
- const parsed = JSON.parse((0, import_node_fs3.readFileSync)(updateCachePath(), "utf8"));
1430
+ const parsed = JSON.parse((0, import_node_fs5.readFileSync)(updateCachePath(), "utf8"));
1042
1431
  if (typeof parsed !== "object" || parsed === null) return null;
1043
1432
  const { checkedAt, latest } = parsed;
1044
1433
  if (typeof checkedAt !== "number") return null;
@@ -1049,8 +1438,8 @@ function readUpdateCache() {
1049
1438
  }
1050
1439
  }
1051
1440
  function writeUpdateCache(cache) {
1052
- (0, import_node_fs3.mkdirSync)((0, import_agent_core13.birdyBeepConfigDir)(), { recursive: true, mode: 448 });
1053
- (0, import_node_fs3.writeFileSync)(updateCachePath(), `${JSON.stringify(cache)}
1441
+ (0, import_node_fs5.mkdirSync)((0, import_agent_core13.birdyBeepConfigDir)(), { recursive: true, mode: 448 });
1442
+ (0, import_node_fs5.writeFileSync)(updateCachePath(), `${JSON.stringify(cache)}
1054
1443
  `, { mode: 384 });
1055
1444
  }
1056
1445
  async function fetchLatestVersion(registryUrl, fetchImpl, timeoutMs) {