@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.
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,10 +573,31 @@ 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) {
488
- return RUNNERS[harness](payload, { sender });
578
+ function resolveHookHarness(harness, payload) {
579
+ return harness === "claude" && (0, import_cursor3.isCursorHookPayload)(payload) ? "cursor" : harness;
580
+ }
581
+ function recognizesPayload(harness, payload) {
582
+ if (harness === "claude") return (0, import_claude_code3.isClaudeCodeHookPayload)(payload);
583
+ if (harness === "cursor") return (0, import_cursor3.isCursorHookEventName)(asRecord(payload)["hook_event_name"]);
584
+ return true;
585
+ }
586
+ function asRecord(value) {
587
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
588
+ }
589
+ function describeEventName(payload) {
590
+ const name = asRecord(payload)["hook_event_name"];
591
+ if (typeof name !== "string") return "(absent)";
592
+ return JSON.stringify(name.length > 64 ? `${name.slice(0, 63)}\u2026` : name);
593
+ }
594
+ function runHookCommand(harness, payload, sender, copilotEventName) {
595
+ const handler = resolveHookHarness(harness, payload);
596
+ if (handler === "copilot") {
597
+ if (copilotEventName === void 0) return Promise.resolve({ outcome: "skipped" });
598
+ return (0, import_copilot3.runCopilotHook)(copilotEventName, payload, { sender });
599
+ }
600
+ return RUNNERS[handler](payload, { sender });
489
601
  }
490
602
  function readStdinDefault() {
491
603
  return new Promise((resolve) => {
@@ -500,24 +612,90 @@ function readStdinDefault() {
500
612
  process.stdin.on("error", () => resolve(""));
501
613
  });
502
614
  }
503
- async function readHookPayload(args, readStdin) {
504
- return args[1] ?? await readStdin();
615
+ async function readHookPayload(args, readStdin, stdinOnly = false) {
616
+ return stdinOnly ? readStdin() : args[1] ?? await readStdin();
617
+ }
618
+ var NOTIFY_STDIN_FILE_ENV = "BIRDYBEEP_CODEX_NOTIFY_STDIN_FILE";
619
+ function detachCodexNotifyWorker(payload) {
620
+ if (process.platform === "win32") return false;
621
+ let file;
622
+ let fd;
623
+ try {
624
+ const birdybeep = (0, import_agent_core5.resolveOnPath)("birdybeep");
625
+ if (birdybeep === null) return false;
626
+ const tmpFile = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `birdybeep-notify-${(0, import_node_crypto.randomBytes)(16).toString("hex")}.json`);
627
+ file = tmpFile;
628
+ (0, import_node_fs3.writeFileSync)(tmpFile, payload, { mode: 384 });
629
+ fd = (0, import_node_fs3.openSync)(tmpFile, "r");
630
+ const child = (0, import_node_child_process.spawn)(birdybeep, ["hook", "codex"], {
631
+ cwd: (0, import_node_path2.dirname)(birdybeep),
632
+ // trusted dir, never the inherited/attacker cwd
633
+ detached: true,
634
+ // new session (setsid) → survives `codex exec` reaping the group
635
+ stdio: [fd, "ignore", "ignore"],
636
+ // stdin = the temp file; this process holds no pipe
637
+ env: { ...process.env, [NOTIFY_STDIN_FILE_ENV]: tmpFile },
638
+ // worker cleans it up post-read
639
+ windowsHide: true
640
+ });
641
+ child.on("error", () => {
642
+ try {
643
+ (0, import_node_fs3.rmSync)(tmpFile, { force: true });
644
+ } catch {
645
+ }
646
+ });
647
+ child.unref();
648
+ return true;
649
+ } catch {
650
+ if (file !== void 0) {
651
+ try {
652
+ (0, import_node_fs3.rmSync)(file, { force: true });
653
+ } catch {
654
+ }
655
+ }
656
+ return false;
657
+ } finally {
658
+ if (fd !== void 0) {
659
+ try {
660
+ (0, import_node_fs3.closeSync)(fd);
661
+ } catch {
662
+ }
663
+ }
664
+ }
505
665
  }
506
666
  function createHookCommand(deps = {}) {
507
667
  const makeSender = deps.createSender ?? ((baseUrl) => (0, import_agent_core5.createSender)({ baseUrl }));
508
668
  const readStdin = deps.readStdin ?? readStdinDefault;
509
669
  const stdinTimeoutMs = deps.stdinTimeoutMs ?? STDIN_READ_TIMEOUT_MS;
670
+ const detachCodexNotify = deps.detachCodexNotify ?? detachCodexNotifyWorker;
510
671
  return {
511
672
  name: "hook",
512
673
  summary: "Internal: normalize + send an event fired by a harness hook",
513
- usage: "birdybeep hook <claude|codex|opencode>",
674
+ usage: "birdybeep hook <claude|codex|opencode|cursor|copilot> [copilot-event]",
514
675
  run: async (ctx) => {
515
676
  const harness = ctx.args[0];
516
677
  if (!isHarnessName(harness)) {
517
678
  ctx.io.errline(`birdybeep hook: expected one of ${HOOK_HARNESSES.join("|")}`);
518
679
  return EXIT.USAGE;
519
680
  }
520
- const raw = await withTimeout(readHookPayload(ctx.args, readStdin), stdinTimeoutMs, "");
681
+ const notifyPayload = ctx.args[1];
682
+ if (harness === "codex" && notifyPayload !== void 0 && notifyPayload.length > 0 && detachCodexNotify(notifyPayload)) {
683
+ ctx.io.result({ harness, outcome: "detached" });
684
+ return EXIT.OK;
685
+ }
686
+ const copilotEventName = harness === "copilot" && (0, import_copilot3.isCopilotHookEventName)(ctx.args[1]) ? ctx.args[1] : void 0;
687
+ const raw = await withTimeout(
688
+ readHookPayload(ctx.args, readStdin, harness === "copilot"),
689
+ stdinTimeoutMs,
690
+ ""
691
+ );
692
+ const notifyStdinFile = process.env[NOTIFY_STDIN_FILE_ENV];
693
+ if (notifyStdinFile !== void 0 && (0, import_node_path2.dirname)(notifyStdinFile) === (0, import_node_os.tmpdir)() && (0, import_node_path2.basename)(notifyStdinFile).startsWith("birdybeep-notify-")) {
694
+ try {
695
+ (0, import_node_fs3.rmSync)(notifyStdinFile, { force: true });
696
+ } catch {
697
+ }
698
+ }
521
699
  let payload;
522
700
  try {
523
701
  payload = JSON.parse(raw);
@@ -526,8 +704,23 @@ function createHookCommand(deps = {}) {
526
704
  return EXIT.OK;
527
705
  }
528
706
  const sender = makeSender(resolveApiUrl());
529
- const result = await runHookCommand(harness, payload, sender);
530
- ctx.io.result({ harness, outcome: result.outcome, eventType: result.eventType });
707
+ const handler = resolveHookHarness(harness, payload);
708
+ const result = await runHookCommand(harness, payload, sender, copilotEventName);
709
+ ctx.io.result({
710
+ harness: handler,
711
+ ...handler !== harness ? { routedFrom: harness } : {},
712
+ ...copilotEventName !== void 0 ? { event: copilotEventName } : {},
713
+ outcome: result.outcome,
714
+ eventType: result.eventType,
715
+ ...result.send?.decision ? { decision: result.send.decision } : {},
716
+ ...result.send?.status !== void 0 ? { status: result.send.status } : {}
717
+ });
718
+ if (result.outcome === "skipped" && !recognizesPayload(handler, payload)) {
719
+ ctx.io.errline(
720
+ `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.`
721
+ );
722
+ return EXIT.ERROR;
723
+ }
531
724
  return EXIT.OK;
532
725
  }
533
726
  };
@@ -535,57 +728,73 @@ function createHookCommand(deps = {}) {
535
728
 
536
729
  // src/commands/logout.ts
537
730
  var import_agent_core6 = require("@birdybeep/agent-core");
538
- function createClearTokenCommand(spec, deps = {}) {
731
+ var base = (apiUrl) => apiUrl.replace(/\/$/, "");
732
+ function createLogoutCommand(deps = {}) {
539
733
  return {
540
- name: spec.name,
541
- summary: spec.summary,
542
- usage: `birdybeep ${spec.name}`,
734
+ name: "logout",
735
+ summary: "Remove the local machine token (does NOT revoke the machine server-side)",
736
+ usage: "birdybeep logout",
543
737
  run: async (ctx) => {
544
738
  await (0, import_agent_core6.clearToken)(deps.tokenOptions ?? {});
545
- ctx.io.emit(spec.humanMessage, { [spec.jsonKey]: true });
739
+ ctx.io.emit("Logged out \u2014 the machine token was removed.", { loggedOut: true });
546
740
  return EXIT.OK;
547
741
  }
548
742
  };
549
743
  }
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
- );
744
+ async function revokeSelf(token, fetchImpl, timeoutMs) {
745
+ const controller = new AbortController();
746
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
747
+ try {
748
+ const res = await fetchImpl(`${base(resolveApiUrl())}/v1/machine/revoke-self`, {
749
+ method: "POST",
750
+ headers: { authorization: `Bearer ${token}` },
751
+ signal: controller.signal
752
+ });
753
+ if (res.ok || res.status === 403) return "revoked";
754
+ return "rejected";
755
+ } catch {
756
+ return "unreachable";
757
+ } finally {
758
+ clearTimeout(timer);
759
+ }
560
760
  }
561
761
  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
- );
762
+ const fetchImpl = deps.fetchImpl ?? fetch;
763
+ const timeoutMs = deps.timeoutMs ?? 1e4;
764
+ return {
765
+ name: "unpair",
766
+ summary: "Unpair this machine \u2014 revoke it server-side and remove the local token",
767
+ usage: "birdybeep unpair",
768
+ run: async (ctx) => {
769
+ const token = await (0, import_agent_core6.getToken)(deps.tokenOptions ?? {});
770
+ const outcome = token === null ? "no_token" : await revokeSelf(token, fetchImpl, timeoutMs);
771
+ await (0, import_agent_core6.clearToken)(deps.tokenOptions ?? {});
772
+ const serverRevoked = outcome === "revoked";
773
+ 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.";
774
+ ctx.io.emit(human, { unpaired: true, serverRevoked });
775
+ return EXIT.OK;
776
+ }
777
+ };
571
778
  }
572
779
 
573
780
  // src/commands/pair.ts
781
+ var import_node_fs4 = require("fs");
574
782
  var import_agent_core8 = require("@birdybeep/agent-core");
575
783
  var import_uqr = require("uqr");
576
784
 
577
785
  // src/pairing.ts
578
786
  var import_agent_core7 = require("@birdybeep/agent-core");
579
- function base(apiUrl) {
787
+ function base2(apiUrl) {
580
788
  return apiUrl.replace(/\/$/, "");
581
789
  }
582
790
  async function pairStart(apiUrl, input, fetchImpl) {
583
791
  const body = {
584
792
  machine_label: input.machineLabel,
585
793
  ...input.os !== void 0 ? { os: input.os } : {},
586
- ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {}
794
+ ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {},
795
+ ...input.codeChallenge !== void 0 ? { code_challenge: input.codeChallenge } : {}
587
796
  };
588
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/start`, {
797
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/start`, {
589
798
  method: "POST",
590
799
  headers: { "content-type": "application/json" },
591
800
  body: JSON.stringify(body)
@@ -603,12 +812,13 @@ var TERMINAL_TOKEN_ERRORS = /* @__PURE__ */ new Set([
603
812
  "not_found",
604
813
  "payload_too_large"
605
814
  ]);
606
- async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint) {
815
+ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint, codeVerifier) {
607
816
  const body = {
608
817
  device_code: deviceCode,
609
- ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {}
818
+ ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {},
819
+ ...codeVerifier !== void 0 ? { code_verifier: codeVerifier } : {}
610
820
  };
611
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/token`, {
821
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/token`, {
612
822
  method: "POST",
613
823
  headers: { "content-type": "application/json" },
614
824
  body: JSON.stringify(body)
@@ -619,7 +829,10 @@ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint)
619
829
  return {
620
830
  status: "paired",
621
831
  machineToken: parsed.data.machine_token,
622
- machineId: parsed.data.machine_id
832
+ machineId: parsed.data.machine_id,
833
+ // Only surface the key when the server reported it (exactOptionalPropertyTypes: no explicit
834
+ // undefined). Older servers omit approved_by_email; newer ones (dgxd) include it.
835
+ ...parsed.data.approved_by_email !== void 0 ? { approvedByEmail: parsed.data.approved_by_email } : {}
623
836
  };
624
837
  }
625
838
  let errBody = null;
@@ -640,7 +853,7 @@ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint)
640
853
  }
641
854
 
642
855
  // src/version.ts
643
- var CLI_VERSION = "0.2.0".length > 0 ? "0.2.0" : "0.0.0";
856
+ var CLI_VERSION = "0.4.0".length > 0 ? "0.4.0" : "0.0.0";
644
857
 
645
858
  // src/commands/pair.ts
646
859
  var DEFAULT_POLL_INTERVAL_MS = 2e3;
@@ -648,22 +861,172 @@ var HEARTBEAT_MS = 15e3;
648
861
  function renderQrMatrix(qrPayload) {
649
862
  return (0, import_uqr.renderUnicodeCompact)(qrPayload, { border: 2 });
650
863
  }
864
+ function parsePairFlags(args) {
865
+ const flags = { yes: false };
866
+ for (let i = 0; i < args.length; i += 1) {
867
+ const token = args[i] ?? "";
868
+ if (token === "--yes" || token === "-y") {
869
+ flags.yes = true;
870
+ } else if (token === "--expect-email" || token.startsWith("--expect-email=")) {
871
+ const inline = token.startsWith("--expect-email=") ? token.slice("--expect-email=".length) : void 0;
872
+ const value = inline ?? args[++i];
873
+ if (value === void 0 || value.length === 0 || value.startsWith("-")) {
874
+ return { ...flags, error: "--expect-email requires an email address" };
875
+ }
876
+ flags.expectEmail = value;
877
+ } else {
878
+ return { ...flags, error: `unexpected argument "${token}"` };
879
+ }
880
+ }
881
+ return flags;
882
+ }
883
+ function sameEmail(a, b) {
884
+ const fold = (v) => v.trim().toLowerCase();
885
+ const rawEqual = fold(a) === fold(b);
886
+ const nfkcEqual = fold(a.normalize("NFKC")) === fold(b.normalize("NFKC"));
887
+ return rawEqual && nfkcEqual;
888
+ }
889
+ function decidePairConfirmation(input) {
890
+ const { approvedByEmail, expectEmail } = input;
891
+ const platform = input.platform ?? process.platform;
892
+ if (expectEmail !== void 0) {
893
+ if (approvedByEmail === void 0) {
894
+ 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.";
895
+ return {
896
+ action: "reject",
897
+ reason: "expected_email_unverifiable",
898
+ 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}`
899
+ };
900
+ }
901
+ if (sameEmail(approvedByEmail, expectEmail)) {
902
+ return { action: "approve", reason: "expected_email_match" };
903
+ }
904
+ return {
905
+ action: "reject",
906
+ reason: "expected_email_mismatch",
907
+ 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\`.`
908
+ };
909
+ }
910
+ if (input.yes) return { action: "approve", reason: "yes_flag" };
911
+ 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] ";
912
+ if (!input.nonInteractive) {
913
+ if (input.stdinIsTTY) return { action: "prompt", question, on: "stdin" };
914
+ if (input.controllingTerminalAvailable) {
915
+ return { action: "prompt", question, on: "controlling-terminal" };
916
+ }
917
+ }
918
+ const who = approvedByEmail !== void 0 ? ` (approved by ${approvedByEmail})` : "";
919
+ const winptyHint = platform === "win32" && !input.nonInteractive ? " In Git Bash / MSYS, `winpty birdybeep pair` attaches a real console so the prompt can appear." : "";
920
+ return {
921
+ action: "reject",
922
+ reason: "non_interactive",
923
+ 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
924
+ };
925
+ }
926
+ function isAffirmative(answer) {
927
+ return /^(y|yes)$/i.test(answer.trim());
928
+ }
929
+ function controllingTerminalPath() {
930
+ return "/dev/tty";
931
+ }
932
+ function canOpenControllingTerminal(path = controllingTerminalPath(), platform = process.platform) {
933
+ if (platform === "win32") return false;
934
+ let fd;
935
+ try {
936
+ fd = (0, import_node_fs4.openSync)(path, "r");
937
+ return true;
938
+ } catch {
939
+ return false;
940
+ } finally {
941
+ if (fd !== void 0) {
942
+ try {
943
+ (0, import_node_fs4.closeSync)(fd);
944
+ } catch {
945
+ }
946
+ }
947
+ }
948
+ }
949
+ async function promptForAnswer(question, on) {
950
+ const { createInterface } = await import("readline/promises");
951
+ let ttyFd;
952
+ let input;
953
+ if (on === "stdin") {
954
+ input = process.stdin;
955
+ } else {
956
+ const { ReadStream } = await import("tty");
957
+ ttyFd = (0, import_node_fs4.openSync)(controllingTerminalPath(), "r");
958
+ input = new ReadStream(ttyFd);
959
+ }
960
+ return new Promise((resolve) => {
961
+ const rl = createInterface({ input, output: process.stderr });
962
+ let settled = false;
963
+ const done = (value) => {
964
+ if (settled) return;
965
+ settled = true;
966
+ rl.close();
967
+ if (on === "stdin") {
968
+ process.stdin.unref?.();
969
+ } else {
970
+ try {
971
+ input.unref?.();
972
+ input.destroy?.();
973
+ } catch {
974
+ }
975
+ if (ttyFd !== void 0) {
976
+ try {
977
+ (0, import_node_fs4.closeSync)(ttyFd);
978
+ } catch {
979
+ }
980
+ }
981
+ }
982
+ resolve(value);
983
+ };
984
+ rl.question(question).then(done, () => done(""));
985
+ rl.once("close", () => done(""));
986
+ input.once?.("error", () => done(""));
987
+ });
988
+ }
651
989
  function createPairCommand(deps = {}) {
652
990
  const fetchImpl = deps.fetchImpl ?? fetch;
653
991
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
654
992
  const clock = deps.now ?? (() => Date.now());
655
993
  const renderQr = deps.renderQr ?? renderQrMatrix;
656
994
  const intervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
995
+ const promptLine = deps.promptLine ?? promptForAnswer;
996
+ const hasControllingTerminal = deps.hasControllingTerminal ?? (() => canOpenControllingTerminal());
997
+ const configuredExpectEmail = deps.configuredExpectEmail ?? (() => {
998
+ const pinned = readCliConfig().expectEmail;
999
+ return typeof pinned === "string" && pinned.trim().length > 0 ? pinned : void 0;
1000
+ });
657
1001
  return {
658
1002
  name: "pair",
659
1003
  summary: "Pair this machine with your BirdyBeep account (QR or manual)",
660
- usage: "birdybeep pair [--json]",
1004
+ usage: "birdybeep pair [--yes] [--expect-email <addr>] [--json]",
1005
+ options: [
1006
+ {
1007
+ flag: "--yes",
1008
+ aliases: ["-y"],
1009
+ summary: "Skip the approving-account confirmation (headless/CI)"
1010
+ },
1011
+ {
1012
+ flag: "--expect-email",
1013
+ value: "<addr>",
1014
+ summary: "Only trust the pairing if this account approved it (else fail)"
1015
+ }
1016
+ ],
661
1017
  run: async (ctx) => {
1018
+ const pairFlags = parsePairFlags(ctx.args);
1019
+ if (pairFlags.error !== void 0) {
1020
+ ctx.io.errline(`birdybeep pair: ${pairFlags.error}.`);
1021
+ return EXIT.USAGE;
1022
+ }
662
1023
  const apiUrl = resolveApiUrl();
663
1024
  const identity = (0, import_agent_core8.getMachineIdentity)();
1025
+ const codeVerifier = (0, import_agent_core8.generateCodeVerifier)();
1026
+ const codeChallenge = (0, import_agent_core8.deriveCodeChallengeS256)(codeVerifier);
664
1027
  const start = await pairStart(
665
1028
  apiUrl,
666
- { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION },
1029
+ { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION, codeChallenge },
667
1030
  fetchImpl
668
1031
  );
669
1032
  if (ctx.flags.json) {
@@ -675,12 +1038,14 @@ function createPairCommand(deps = {}) {
675
1038
  });
676
1039
  } else {
677
1040
  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):"
1041
+ "To pair this machine, open the BirdyBeep app, tap \u201Cpair a machine\u201D, and scan this QR or open the complete link:"
679
1042
  );
680
1043
  const isTTY = deps.isTTY ?? process.stdout.isTTY === true;
681
1044
  if (isTTY) ctx.io.line(renderQr(start.qr_payload));
682
1045
  ctx.io.line(` Scan or open: ${start.qr_payload}`);
683
- ctx.io.line(` Code: ${start.user_code}`);
1046
+ ctx.io.line(
1047
+ ` Session code (display only; cannot approve by itself): ${start.user_code}`
1048
+ );
684
1049
  ctx.io.line("Waiting for you to approve this machine in the app\u2026");
685
1050
  }
686
1051
  const deadline = Date.parse(start.expires_at);
@@ -696,7 +1061,9 @@ function createPairCommand(deps = {}) {
696
1061
  apiUrl,
697
1062
  start.device_code,
698
1063
  fetchImpl,
699
- identity.fingerprintHash
1064
+ identity.fingerprintHash,
1065
+ codeVerifier
1066
+ // PKCE proof-of-possession (dgxd) — sent on every poll
700
1067
  );
701
1068
  if (poll.status === "paired") {
702
1069
  paired = poll;
@@ -721,15 +1088,44 @@ function createPairCommand(deps = {}) {
721
1088
  if (paired === void 0 || paired.status !== "paired") {
722
1089
  ctx.io.result({ paired: false, reason: "timeout" });
723
1090
  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."
1091
+ "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."
1092
+ );
1093
+ return EXIT.ERROR;
1094
+ }
1095
+ const approvedBy = paired.approvedByEmail;
1096
+ const expectEmail = pairFlags.expectEmail ?? configuredExpectEmail();
1097
+ const stdinIsTTY = deps.isStdinTTY ?? process.stdin.isTTY === true;
1098
+ const decision = decidePairConfirmation({
1099
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {},
1100
+ ...expectEmail !== void 0 ? { expectEmail } : {},
1101
+ ...expectEmail !== void 0 ? { expectEmailSource: pairFlags.expectEmail !== void 0 ? "flag" : "config" } : {},
1102
+ yes: pairFlags.yes,
1103
+ nonInteractive: ctx.flags.nonInteractive,
1104
+ stdinIsTTY,
1105
+ // Probed ONLY when stdin can't answer — opening /dev/tty is a syscall, and when stdin is
1106
+ // already a terminal the answer is irrelevant.
1107
+ controllingTerminalAvailable: stdinIsTTY ? false : hasControllingTerminal(),
1108
+ configPath: cliConfigPath()
1109
+ });
1110
+ if (decision.action === "reject") {
1111
+ ctx.io.result({ paired: false, reason: decision.reason });
1112
+ ctx.io.errline(decision.message);
1113
+ return EXIT.ERROR;
1114
+ }
1115
+ if (decision.action === "prompt" && !isAffirmative(await promptLine(decision.question, decision.on))) {
1116
+ ctx.io.result({ paired: false, reason: "declined" });
1117
+ ctx.io.errline(
1118
+ "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
1119
  );
726
1120
  return EXIT.ERROR;
727
1121
  }
728
1122
  await (0, import_agent_core8.setToken)(paired.machineToken, deps.tokenOptions ?? {});
729
1123
  writeCliConfig({ apiUrl });
730
- ctx.io.emit(`\u2713 Paired. Run \`birdybeep test\` to send a test Beep.`, {
1124
+ const humanSuffix = approvedBy !== void 0 ? ` to ${approvedBy}` : "";
1125
+ ctx.io.emit(`\u2713 Paired${humanSuffix}. Run \`birdybeep test\` to send a test Beep.`, {
731
1126
  paired: true,
732
- machineId: paired.machineId
1127
+ machineId: paired.machineId,
1128
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {}
733
1129
  });
734
1130
  return EXIT.OK;
735
1131
  }
@@ -762,14 +1158,24 @@ function createQueueCommand() {
762
1158
  var import_agent_core10 = require("@birdybeep/agent-core");
763
1159
  var import_claude_code4 = require("@birdybeep/claude-code");
764
1160
  var import_codex4 = require("@birdybeep/codex");
1161
+ var import_copilot4 = require("@birdybeep/copilot");
1162
+ var import_cursor4 = require("@birdybeep/cursor");
765
1163
  var import_opencode4 = require("@birdybeep/opencode");
766
- var DEFAULT_ADAPTERS3 = [import_claude_code4.claudeCodeAdapter, import_codex4.codexAdapter, import_opencode4.opencodeAdapter];
1164
+ var DEFAULT_ADAPTERS3 = [
1165
+ import_claude_code4.claudeCodeAdapter,
1166
+ import_codex4.codexAdapter,
1167
+ import_opencode4.opencodeAdapter,
1168
+ import_cursor4.cursorAdapter,
1169
+ import_copilot4.copilotAdapter
1170
+ ];
767
1171
  var ADAPTER_VERSIONS = {
768
1172
  claude_code: import_claude_code4.CLAUDE_CODE_ADAPTER_VERSION,
769
1173
  codex: import_codex4.CODEX_ADAPTER_VERSION,
770
- opencode: import_opencode4.OPENCODE_ADAPTER_VERSION
1174
+ opencode: import_opencode4.OPENCODE_ADAPTER_VERSION,
1175
+ cursor: import_cursor4.CURSOR_ADAPTER_VERSION,
1176
+ copilot: import_copilot4.COPILOT_ADAPTER_VERSION
771
1177
  };
772
- var base2 = (apiUrl) => apiUrl.replace(/\/$/, "");
1178
+ var base3 = (apiUrl) => apiUrl.replace(/\/$/, "");
773
1179
  async function gatherItems(adapters) {
774
1180
  return Promise.all(
775
1181
  adapters.map(async (a) => {
@@ -804,7 +1210,7 @@ function createReportStatusCommand(deps = {}) {
804
1210
  let outcome = "deferred";
805
1211
  let errorCode;
806
1212
  try {
807
- const res = await fetchImpl(`${base2(resolveApiUrl())}/v1/integrations/status`, {
1213
+ const res = await fetchImpl(`${base3(resolveApiUrl())}/v1/integrations/status`, {
808
1214
  method: "POST",
809
1215
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
810
1216
  body: JSON.stringify({ integrations: items })
@@ -855,8 +1261,16 @@ function createReportStatusCommand(deps = {}) {
855
1261
  var import_agent_core11 = require("@birdybeep/agent-core");
856
1262
  var import_claude_code5 = require("@birdybeep/claude-code");
857
1263
  var import_codex5 = require("@birdybeep/codex");
1264
+ var import_copilot5 = require("@birdybeep/copilot");
1265
+ var import_cursor5 = require("@birdybeep/cursor");
858
1266
  var import_opencode5 = require("@birdybeep/opencode");
859
- var DEFAULT_ADAPTERS4 = [import_claude_code5.claudeCodeAdapter, import_codex5.codexAdapter, import_opencode5.opencodeAdapter];
1267
+ var DEFAULT_ADAPTERS4 = [
1268
+ import_claude_code5.claudeCodeAdapter,
1269
+ import_codex5.codexAdapter,
1270
+ import_opencode5.opencodeAdapter,
1271
+ import_cursor5.cursorAdapter,
1272
+ import_copilot5.copilotAdapter
1273
+ ];
860
1274
  function createStatusCommand(deps = {}) {
861
1275
  const adapters = deps.adapters ?? DEFAULT_ADAPTERS4;
862
1276
  const makeSender = deps.createSender ?? ((baseUrl) => (0, import_agent_core11.createSender)(
@@ -896,7 +1310,7 @@ function createStatusCommand(deps = {}) {
896
1310
  }
897
1311
 
898
1312
  // src/commands/test.ts
899
- var import_node_crypto = require("crypto");
1313
+ var import_node_crypto2 = require("crypto");
900
1314
  var import_agent_core12 = require("@birdybeep/agent-core");
901
1315
  function buildTestEvent(opts = {}) {
902
1316
  const machine = (0, import_agent_core12.getMachineIdentity)();
@@ -908,7 +1322,7 @@ function buildTestEvent(opts = {}) {
908
1322
  // schema requires a harness; the "test" type distinguishes it
909
1323
  // Unique per run: a repeat `birdybeep test` inside the backend's dedupe window must
910
1324
  // still beep — a constant id made the second test silently "deduped" (9fh).
911
- source_session_id: `birdybeep-cli-test-${(0, import_node_crypto.randomUUID)()}`,
1325
+ source_session_id: `birdybeep-cli-test-${(0, import_node_crypto2.randomUUID)()}`,
912
1326
  machine: { label: machine.label, os: machine.os },
913
1327
  workspace: { cwd: process.cwd() },
914
1328
  title: "BirdyBeep test event",
@@ -978,8 +1392,8 @@ function buildCommands() {
978
1392
  }
979
1393
 
980
1394
  // src/update-check.ts
981
- var import_node_fs3 = require("fs");
982
- var import_node_path2 = require("path");
1395
+ var import_node_fs5 = require("fs");
1396
+ var import_node_path3 = require("path");
983
1397
  var import_agent_core13 = require("@birdybeep/agent-core");
984
1398
  var PACKAGE_NAME = "@birdybeep/cli";
985
1399
  var PACKAGE_PATH = "@birdybeep%2Fcli";
@@ -1034,11 +1448,11 @@ function isNewer(current, latest) {
1034
1448
  return cur !== null && lat !== null && compareSemver(cur, lat) < 0;
1035
1449
  }
1036
1450
  function updateCachePath() {
1037
- return (0, import_node_path2.join)((0, import_agent_core13.birdyBeepConfigDir)(), UPDATE_CACHE_FILE);
1451
+ return (0, import_node_path3.join)((0, import_agent_core13.birdyBeepConfigDir)(), UPDATE_CACHE_FILE);
1038
1452
  }
1039
1453
  function readUpdateCache() {
1040
1454
  try {
1041
- const parsed = JSON.parse((0, import_node_fs3.readFileSync)(updateCachePath(), "utf8"));
1455
+ const parsed = JSON.parse((0, import_node_fs5.readFileSync)(updateCachePath(), "utf8"));
1042
1456
  if (typeof parsed !== "object" || parsed === null) return null;
1043
1457
  const { checkedAt, latest } = parsed;
1044
1458
  if (typeof checkedAt !== "number") return null;
@@ -1049,8 +1463,8 @@ function readUpdateCache() {
1049
1463
  }
1050
1464
  }
1051
1465
  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)}
1466
+ (0, import_node_fs5.mkdirSync)((0, import_agent_core13.birdyBeepConfigDir)(), { recursive: true, mode: 448 });
1467
+ (0, import_node_fs5.writeFileSync)(updateCachePath(), `${JSON.stringify(cache)}
1054
1468
  `, { mode: 384 });
1055
1469
  }
1056
1470
  async function fetchLatestVersion(registryUrl, fetchImpl, timeoutMs) {