@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/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -35,6 +45,8 @@ module.exports = __toCommonJS(index_exports);
35
45
  // src/commands/agent.ts
36
46
  var import_claude_code = require("@birdybeep/claude-code");
37
47
  var import_codex = require("@birdybeep/codex");
48
+ var import_copilot = require("@birdybeep/copilot");
49
+ var import_cursor = require("@birdybeep/cursor");
38
50
  var import_opencode = require("@birdybeep/opencode");
39
51
 
40
52
  // src/framework.ts
@@ -108,8 +120,11 @@ function parseGlobalFlags(argv) {
108
120
  }
109
121
  return { flags, rest };
110
122
  }
111
- function isUnknownFlag(token) {
112
- return token.startsWith("-") && !GLOBAL_FLAG_TOKENS.has(token);
123
+ function isUnknownFlag(token, allowed) {
124
+ if (!token.startsWith("-")) return false;
125
+ if (GLOBAL_FLAG_TOKENS.has(token)) return false;
126
+ const eq = token.indexOf("=");
127
+ return !allowed.has(eq >= 0 ? token.slice(0, eq) : token);
113
128
  }
114
129
  function renderRootHelp(version, commands) {
115
130
  const width = Math.max(...commands.map((c) => c.name.length));
@@ -130,6 +145,16 @@ function renderRootHelp(version, commands) {
130
145
  " -v, --version Show the CLI version"
131
146
  ].join("\n");
132
147
  }
148
+ function commandFlagTokens(...commands) {
149
+ const tokens = /* @__PURE__ */ new Set();
150
+ for (const command of commands) {
151
+ for (const option of command?.options ?? []) {
152
+ tokens.add(option.flag);
153
+ for (const alias of option.aliases ?? []) tokens.add(alias);
154
+ }
155
+ }
156
+ return tokens;
157
+ }
133
158
  function renderCommandHelp(path, command) {
134
159
  const lines = [
135
160
  `birdybeep ${path} \u2014 ${command.summary}`,
@@ -137,6 +162,17 @@ function renderCommandHelp(path, command) {
137
162
  "Usage:",
138
163
  ` ${command.usage ?? `birdybeep ${path} [options]`}`
139
164
  ];
165
+ if (command.options && command.options.length > 0) {
166
+ const labels = command.options.map(
167
+ (o) => `${[o.flag, ...o.aliases ?? []].join(", ")}${o.value ? ` ${o.value}` : ""}`
168
+ );
169
+ const width = Math.max(...labels.map((l) => l.length));
170
+ lines.push(
171
+ "",
172
+ "Options:",
173
+ ...command.options.map((o, i) => ` ${labels[i]?.padEnd(width)} ${o.summary}`)
174
+ );
175
+ }
140
176
  if (command.subcommands && command.subcommands.length > 0) {
141
177
  const width = Math.max(...command.subcommands.map((c) => c.name.length));
142
178
  lines.push(
@@ -161,6 +197,7 @@ async function dispatch(argv, deps) {
161
197
  return EXIT.OK;
162
198
  }
163
199
  let command = deps.commands.find((c) => c.name === rest[0]);
200
+ let parent;
164
201
  const pathParts = [];
165
202
  let argsStart = 1;
166
203
  if (command) {
@@ -168,6 +205,7 @@ async function dispatch(argv, deps) {
168
205
  if (command.subcommands && command.subcommands.length > 0) {
169
206
  const sub = command.subcommands.find((c) => c.name === rest[1]);
170
207
  if (sub) {
208
+ parent = command;
171
209
  command = sub;
172
210
  pathParts.push(sub.name);
173
211
  argsStart = 2;
@@ -191,6 +229,7 @@ async function dispatch(argv, deps) {
191
229
  name: path,
192
230
  summary: command.summary,
193
231
  usage: command.usage,
232
+ options: command.options,
194
233
  subcommands: command.subcommands?.map((c) => ({ name: c.name, summary: c.summary }))
195
234
  });
196
235
  return EXIT.OK;
@@ -200,7 +239,8 @@ async function dispatch(argv, deps) {
200
239
  return EXIT.USAGE;
201
240
  }
202
241
  const args = rest.slice(argsStart);
203
- const unknown = args.find(isUnknownFlag);
242
+ const allowed = commandFlagTokens(command, parent);
243
+ const unknown = args.find((token) => isUnknownFlag(token, allowed));
204
244
  if (unknown !== void 0) {
205
245
  io.errline(`birdybeep ${path}: unknown option "${unknown}".`);
206
246
  return EXIT.USAGE;
@@ -228,13 +268,28 @@ async function dispatch(argv, deps) {
228
268
  }
229
269
 
230
270
  // src/commands/agent.ts
231
- var DEFAULT_ADAPTERS = [import_claude_code.claudeCodeAdapter, import_codex.codexAdapter, import_opencode.opencodeAdapter];
271
+ var DEFAULT_ADAPTERS = [
272
+ import_claude_code.claudeCodeAdapter,
273
+ import_codex.codexAdapter,
274
+ import_opencode.opencodeAdapter,
275
+ import_cursor.cursorAdapter,
276
+ import_copilot.copilotAdapter
277
+ ];
232
278
  var TARGET_TO_ID = {
233
279
  claude: "claude_code",
234
280
  codex: "codex",
235
- opencode: "opencode"
281
+ opencode: "opencode",
282
+ cursor: "cursor",
283
+ copilot: "copilot"
236
284
  };
237
- var AGENT_TARGETS = ["all", "claude", "codex", "opencode"];
285
+ var AGENT_TARGETS = [
286
+ "all",
287
+ "claude",
288
+ "codex",
289
+ "opencode",
290
+ "cursor",
291
+ "copilot"
292
+ ];
238
293
  function selectAdapters(target, adapters) {
239
294
  if (target === "all") return adapters;
240
295
  const id = TARGET_TO_ID[target];
@@ -325,18 +380,18 @@ function createAgentCommand(deps = {}) {
325
380
  return {
326
381
  name: "agent",
327
382
  summary: "Install or uninstall harness adapters",
328
- usage: "birdybeep agent <install|uninstall> [all|claude|codex|opencode]",
383
+ usage: "birdybeep agent <install|uninstall> [all|claude|codex|opencode|cursor|copilot]",
329
384
  subcommands: [
330
385
  {
331
386
  name: "install",
332
- summary: "Install adapters (all | claude | codex | opencode)",
333
- usage: "birdybeep agent install [all|claude|codex|opencode]",
387
+ summary: "Install adapters (all | claude | codex | opencode | cursor | copilot)",
388
+ usage: "birdybeep agent install [all|claude|codex|opencode|cursor|copilot]",
334
389
  run: (ctx) => installSelected(adapters, ctx)
335
390
  },
336
391
  {
337
392
  name: "uninstall",
338
393
  summary: "Restore harness config to its pre-install state",
339
- usage: "birdybeep agent uninstall [all|claude|codex|opencode]",
394
+ usage: "birdybeep agent uninstall [all|claude|codex|opencode|cursor|copilot]",
340
395
  run: (ctx) => uninstallSelected(adapters, ctx)
341
396
  }
342
397
  ]
@@ -347,6 +402,8 @@ function createAgentCommand(deps = {}) {
347
402
  var import_agent_core4 = require("@birdybeep/agent-core");
348
403
  var import_claude_code2 = require("@birdybeep/claude-code");
349
404
  var import_codex2 = require("@birdybeep/codex");
405
+ var import_copilot2 = require("@birdybeep/copilot");
406
+ var import_cursor2 = require("@birdybeep/cursor");
350
407
  var import_opencode2 = require("@birdybeep/opencode");
351
408
 
352
409
  // src/config.ts
@@ -371,6 +428,8 @@ function writeCliConfig(patch) {
371
428
  const merged = {};
372
429
  const apiUrl = patch.apiUrl ?? current.apiUrl;
373
430
  if (apiUrl !== void 0) merged.apiUrl = apiUrl;
431
+ const expectEmail = patch.expectEmail ?? current.expectEmail;
432
+ if (expectEmail !== void 0) merged.expectEmail = expectEmail;
374
433
  (0, import_node_fs2.mkdirSync)((0, import_agent_core2.birdyBeepConfigDir)(), { recursive: true, mode: 448 });
375
434
  (0, import_node_fs2.writeFileSync)(cliConfigPath(), `${JSON.stringify(merged, null, 2)}
376
435
  `, { mode: 384 });
@@ -409,7 +468,13 @@ function machineIdentity() {
409
468
  }
410
469
 
411
470
  // src/commands/doctor.ts
412
- var DEFAULT_ADAPTERS2 = [import_claude_code2.claudeCodeAdapter, import_codex2.codexAdapter, import_opencode2.opencodeAdapter];
471
+ var DEFAULT_ADAPTERS2 = [
472
+ import_claude_code2.claudeCodeAdapter,
473
+ import_codex2.codexAdapter,
474
+ import_opencode2.opencodeAdapter,
475
+ import_cursor2.cursorAdapter,
476
+ import_copilot2.copilotAdapter
477
+ ];
413
478
  async function defaultProbeNetwork(baseUrl) {
414
479
  try {
415
480
  const controller = new AbortController();
@@ -492,16 +557,30 @@ function createDoctorCommand(deps = {}) {
492
557
  }
493
558
 
494
559
  // src/commands/hook.ts
560
+ var import_node_child_process = require("child_process");
561
+ var import_node_crypto = require("crypto");
562
+ var import_node_fs3 = require("fs");
563
+ var import_node_os = require("os");
564
+ var import_node_path2 = require("path");
495
565
  var import_agent_core5 = require("@birdybeep/agent-core");
496
566
  var import_claude_code3 = require("@birdybeep/claude-code");
497
567
  var import_codex3 = require("@birdybeep/codex");
568
+ var import_copilot3 = require("@birdybeep/copilot");
569
+ var import_cursor3 = require("@birdybeep/cursor");
498
570
  var import_opencode3 = require("@birdybeep/opencode");
499
571
  var RUNNERS = {
500
572
  claude: import_claude_code3.runClaudeHook,
501
573
  codex: import_codex3.runCodexHook,
502
- opencode: import_opencode3.runOpenCodeHook
574
+ opencode: import_opencode3.runOpenCodeHook,
575
+ cursor: import_cursor3.runCursorHook
503
576
  };
504
- var HOOK_HARNESSES = ["claude", "codex", "opencode"];
577
+ var HOOK_HARNESSES = [
578
+ "claude",
579
+ "codex",
580
+ "opencode",
581
+ "cursor",
582
+ "copilot"
583
+ ];
505
584
  var STDIN_READ_TIMEOUT_MS = 3e3;
506
585
  function withTimeout(promise, ms, fallback) {
507
586
  return new Promise((resolve) => {
@@ -518,10 +597,31 @@ function withTimeout(promise, ms, fallback) {
518
597
  });
519
598
  }
520
599
  function isHarnessName(value) {
521
- return value === "claude" || value === "codex" || value === "opencode";
600
+ return value === "claude" || value === "codex" || value === "opencode" || value === "cursor" || value === "copilot";
601
+ }
602
+ function resolveHookHarness(harness, payload) {
603
+ return harness === "claude" && (0, import_cursor3.isCursorHookPayload)(payload) ? "cursor" : harness;
604
+ }
605
+ function recognizesPayload(harness, payload) {
606
+ if (harness === "claude") return (0, import_claude_code3.isClaudeCodeHookPayload)(payload);
607
+ if (harness === "cursor") return (0, import_cursor3.isCursorHookEventName)(asRecord(payload)["hook_event_name"]);
608
+ return true;
522
609
  }
523
- function runHookCommand(harness, payload, sender) {
524
- return RUNNERS[harness](payload, { sender });
610
+ function asRecord(value) {
611
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
612
+ }
613
+ function describeEventName(payload) {
614
+ const name = asRecord(payload)["hook_event_name"];
615
+ if (typeof name !== "string") return "(absent)";
616
+ return JSON.stringify(name.length > 64 ? `${name.slice(0, 63)}\u2026` : name);
617
+ }
618
+ function runHookCommand(harness, payload, sender, copilotEventName) {
619
+ const handler = resolveHookHarness(harness, payload);
620
+ if (handler === "copilot") {
621
+ if (copilotEventName === void 0) return Promise.resolve({ outcome: "skipped" });
622
+ return (0, import_copilot3.runCopilotHook)(copilotEventName, payload, { sender });
623
+ }
624
+ return RUNNERS[handler](payload, { sender });
525
625
  }
526
626
  function readStdinDefault() {
527
627
  return new Promise((resolve) => {
@@ -536,24 +636,90 @@ function readStdinDefault() {
536
636
  process.stdin.on("error", () => resolve(""));
537
637
  });
538
638
  }
539
- async function readHookPayload(args, readStdin) {
540
- return args[1] ?? await readStdin();
639
+ async function readHookPayload(args, readStdin, stdinOnly = false) {
640
+ return stdinOnly ? readStdin() : args[1] ?? await readStdin();
641
+ }
642
+ var NOTIFY_STDIN_FILE_ENV = "BIRDYBEEP_CODEX_NOTIFY_STDIN_FILE";
643
+ function detachCodexNotifyWorker(payload) {
644
+ if (process.platform === "win32") return false;
645
+ let file;
646
+ let fd;
647
+ try {
648
+ const birdybeep = (0, import_agent_core5.resolveOnPath)("birdybeep");
649
+ if (birdybeep === null) return false;
650
+ const tmpFile = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `birdybeep-notify-${(0, import_node_crypto.randomBytes)(16).toString("hex")}.json`);
651
+ file = tmpFile;
652
+ (0, import_node_fs3.writeFileSync)(tmpFile, payload, { mode: 384 });
653
+ fd = (0, import_node_fs3.openSync)(tmpFile, "r");
654
+ const child = (0, import_node_child_process.spawn)(birdybeep, ["hook", "codex"], {
655
+ cwd: (0, import_node_path2.dirname)(birdybeep),
656
+ // trusted dir, never the inherited/attacker cwd
657
+ detached: true,
658
+ // new session (setsid) → survives `codex exec` reaping the group
659
+ stdio: [fd, "ignore", "ignore"],
660
+ // stdin = the temp file; this process holds no pipe
661
+ env: { ...process.env, [NOTIFY_STDIN_FILE_ENV]: tmpFile },
662
+ // worker cleans it up post-read
663
+ windowsHide: true
664
+ });
665
+ child.on("error", () => {
666
+ try {
667
+ (0, import_node_fs3.rmSync)(tmpFile, { force: true });
668
+ } catch {
669
+ }
670
+ });
671
+ child.unref();
672
+ return true;
673
+ } catch {
674
+ if (file !== void 0) {
675
+ try {
676
+ (0, import_node_fs3.rmSync)(file, { force: true });
677
+ } catch {
678
+ }
679
+ }
680
+ return false;
681
+ } finally {
682
+ if (fd !== void 0) {
683
+ try {
684
+ (0, import_node_fs3.closeSync)(fd);
685
+ } catch {
686
+ }
687
+ }
688
+ }
541
689
  }
542
690
  function createHookCommand(deps = {}) {
543
691
  const makeSender = deps.createSender ?? ((baseUrl) => (0, import_agent_core5.createSender)({ baseUrl }));
544
692
  const readStdin = deps.readStdin ?? readStdinDefault;
545
693
  const stdinTimeoutMs = deps.stdinTimeoutMs ?? STDIN_READ_TIMEOUT_MS;
694
+ const detachCodexNotify = deps.detachCodexNotify ?? detachCodexNotifyWorker;
546
695
  return {
547
696
  name: "hook",
548
697
  summary: "Internal: normalize + send an event fired by a harness hook",
549
- usage: "birdybeep hook <claude|codex|opencode>",
698
+ usage: "birdybeep hook <claude|codex|opencode|cursor|copilot> [copilot-event]",
550
699
  run: async (ctx) => {
551
700
  const harness = ctx.args[0];
552
701
  if (!isHarnessName(harness)) {
553
702
  ctx.io.errline(`birdybeep hook: expected one of ${HOOK_HARNESSES.join("|")}`);
554
703
  return EXIT.USAGE;
555
704
  }
556
- const raw = await withTimeout(readHookPayload(ctx.args, readStdin), stdinTimeoutMs, "");
705
+ const notifyPayload = ctx.args[1];
706
+ if (harness === "codex" && notifyPayload !== void 0 && notifyPayload.length > 0 && detachCodexNotify(notifyPayload)) {
707
+ ctx.io.result({ harness, outcome: "detached" });
708
+ return EXIT.OK;
709
+ }
710
+ const copilotEventName = harness === "copilot" && (0, import_copilot3.isCopilotHookEventName)(ctx.args[1]) ? ctx.args[1] : void 0;
711
+ const raw = await withTimeout(
712
+ readHookPayload(ctx.args, readStdin, harness === "copilot"),
713
+ stdinTimeoutMs,
714
+ ""
715
+ );
716
+ const notifyStdinFile = process.env[NOTIFY_STDIN_FILE_ENV];
717
+ if (notifyStdinFile !== void 0 && (0, import_node_path2.dirname)(notifyStdinFile) === (0, import_node_os.tmpdir)() && (0, import_node_path2.basename)(notifyStdinFile).startsWith("birdybeep-notify-")) {
718
+ try {
719
+ (0, import_node_fs3.rmSync)(notifyStdinFile, { force: true });
720
+ } catch {
721
+ }
722
+ }
557
723
  let payload;
558
724
  try {
559
725
  payload = JSON.parse(raw);
@@ -562,8 +728,23 @@ function createHookCommand(deps = {}) {
562
728
  return EXIT.OK;
563
729
  }
564
730
  const sender = makeSender(resolveApiUrl());
565
- const result = await runHookCommand(harness, payload, sender);
566
- ctx.io.result({ harness, outcome: result.outcome, eventType: result.eventType });
731
+ const handler = resolveHookHarness(harness, payload);
732
+ const result = await runHookCommand(harness, payload, sender, copilotEventName);
733
+ ctx.io.result({
734
+ harness: handler,
735
+ ...handler !== harness ? { routedFrom: harness } : {},
736
+ ...copilotEventName !== void 0 ? { event: copilotEventName } : {},
737
+ outcome: result.outcome,
738
+ eventType: result.eventType,
739
+ ...result.send?.decision ? { decision: result.send.decision } : {},
740
+ ...result.send?.status !== void 0 ? { status: result.send.status } : {}
741
+ });
742
+ if (result.outcome === "skipped" && !recognizesPayload(handler, payload)) {
743
+ ctx.io.errline(
744
+ `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.`
745
+ );
746
+ return EXIT.ERROR;
747
+ }
567
748
  return EXIT.OK;
568
749
  }
569
750
  };
@@ -571,57 +752,73 @@ function createHookCommand(deps = {}) {
571
752
 
572
753
  // src/commands/logout.ts
573
754
  var import_agent_core6 = require("@birdybeep/agent-core");
574
- function createClearTokenCommand(spec, deps = {}) {
755
+ var base = (apiUrl) => apiUrl.replace(/\/$/, "");
756
+ function createLogoutCommand(deps = {}) {
575
757
  return {
576
- name: spec.name,
577
- summary: spec.summary,
578
- usage: `birdybeep ${spec.name}`,
758
+ name: "logout",
759
+ summary: "Remove the local machine token (does NOT revoke the machine server-side)",
760
+ usage: "birdybeep logout",
579
761
  run: async (ctx) => {
580
762
  await (0, import_agent_core6.clearToken)(deps.tokenOptions ?? {});
581
- ctx.io.emit(spec.humanMessage, { [spec.jsonKey]: true });
763
+ ctx.io.emit("Logged out \u2014 the machine token was removed.", { loggedOut: true });
582
764
  return EXIT.OK;
583
765
  }
584
766
  };
585
767
  }
586
- function createLogoutCommand(deps = {}) {
587
- return createClearTokenCommand(
588
- {
589
- name: "logout",
590
- summary: "Remove the local machine token (same as `unpair`)",
591
- humanMessage: "Logged out \u2014 the machine token was removed.",
592
- jsonKey: "loggedOut"
593
- },
594
- deps
595
- );
768
+ async function revokeSelf(token, fetchImpl, timeoutMs) {
769
+ const controller = new AbortController();
770
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
771
+ try {
772
+ const res = await fetchImpl(`${base(resolveApiUrl())}/v1/machine/revoke-self`, {
773
+ method: "POST",
774
+ headers: { authorization: `Bearer ${token}` },
775
+ signal: controller.signal
776
+ });
777
+ if (res.ok || res.status === 403) return "revoked";
778
+ return "rejected";
779
+ } catch {
780
+ return "unreachable";
781
+ } finally {
782
+ clearTimeout(timer);
783
+ }
596
784
  }
597
785
  function createUnpairCommand(deps = {}) {
598
- return createClearTokenCommand(
599
- {
600
- name: "unpair",
601
- summary: "Unpair this machine \u2014 remove the local machine token (same as `logout`)",
602
- humanMessage: "Unpaired \u2014 the machine token was removed.",
603
- jsonKey: "unpaired"
604
- },
605
- deps
606
- );
786
+ const fetchImpl = deps.fetchImpl ?? fetch;
787
+ const timeoutMs = deps.timeoutMs ?? 1e4;
788
+ return {
789
+ name: "unpair",
790
+ summary: "Unpair this machine \u2014 revoke it server-side and remove the local token",
791
+ usage: "birdybeep unpair",
792
+ run: async (ctx) => {
793
+ const token = await (0, import_agent_core6.getToken)(deps.tokenOptions ?? {});
794
+ const outcome = token === null ? "no_token" : await revokeSelf(token, fetchImpl, timeoutMs);
795
+ await (0, import_agent_core6.clearToken)(deps.tokenOptions ?? {});
796
+ const serverRevoked = outcome === "revoked";
797
+ 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.";
798
+ ctx.io.emit(human, { unpaired: true, serverRevoked });
799
+ return EXIT.OK;
800
+ }
801
+ };
607
802
  }
608
803
 
609
804
  // src/commands/pair.ts
805
+ var import_node_fs4 = require("fs");
610
806
  var import_agent_core8 = require("@birdybeep/agent-core");
611
807
  var import_uqr = require("uqr");
612
808
 
613
809
  // src/pairing.ts
614
810
  var import_agent_core7 = require("@birdybeep/agent-core");
615
- function base(apiUrl) {
811
+ function base2(apiUrl) {
616
812
  return apiUrl.replace(/\/$/, "");
617
813
  }
618
814
  async function pairStart(apiUrl, input, fetchImpl) {
619
815
  const body = {
620
816
  machine_label: input.machineLabel,
621
817
  ...input.os !== void 0 ? { os: input.os } : {},
622
- ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {}
818
+ ...input.cliVersion !== void 0 ? { cli_version: input.cliVersion } : {},
819
+ ...input.codeChallenge !== void 0 ? { code_challenge: input.codeChallenge } : {}
623
820
  };
624
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/start`, {
821
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/start`, {
625
822
  method: "POST",
626
823
  headers: { "content-type": "application/json" },
627
824
  body: JSON.stringify(body)
@@ -639,12 +836,13 @@ var TERMINAL_TOKEN_ERRORS = /* @__PURE__ */ new Set([
639
836
  "not_found",
640
837
  "payload_too_large"
641
838
  ]);
642
- async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint) {
839
+ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint, codeVerifier) {
643
840
  const body = {
644
841
  device_code: deviceCode,
645
- ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {}
842
+ ...machineFingerprint !== void 0 ? { machine_fingerprint: machineFingerprint } : {},
843
+ ...codeVerifier !== void 0 ? { code_verifier: codeVerifier } : {}
646
844
  };
647
- const res = await fetchImpl(`${base(apiUrl)}/v1/pair/token`, {
845
+ const res = await fetchImpl(`${base2(apiUrl)}/v1/pair/token`, {
648
846
  method: "POST",
649
847
  headers: { "content-type": "application/json" },
650
848
  body: JSON.stringify(body)
@@ -655,7 +853,10 @@ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint)
655
853
  return {
656
854
  status: "paired",
657
855
  machineToken: parsed.data.machine_token,
658
- machineId: parsed.data.machine_id
856
+ machineId: parsed.data.machine_id,
857
+ // Only surface the key when the server reported it (exactOptionalPropertyTypes: no explicit
858
+ // undefined). Older servers omit approved_by_email; newer ones (dgxd) include it.
859
+ ...parsed.data.approved_by_email !== void 0 ? { approvedByEmail: parsed.data.approved_by_email } : {}
659
860
  };
660
861
  }
661
862
  let errBody = null;
@@ -676,7 +877,7 @@ async function pairTokenPoll(apiUrl, deviceCode, fetchImpl, machineFingerprint)
676
877
  }
677
878
 
678
879
  // src/version.ts
679
- var CLI_VERSION = "0.2.0".length > 0 ? "0.2.0" : "0.0.0";
880
+ var CLI_VERSION = "0.4.0".length > 0 ? "0.4.0" : "0.0.0";
680
881
 
681
882
  // src/commands/pair.ts
682
883
  var DEFAULT_POLL_INTERVAL_MS = 2e3;
@@ -684,22 +885,172 @@ var HEARTBEAT_MS = 15e3;
684
885
  function renderQrMatrix(qrPayload) {
685
886
  return (0, import_uqr.renderUnicodeCompact)(qrPayload, { border: 2 });
686
887
  }
888
+ function parsePairFlags(args) {
889
+ const flags = { yes: false };
890
+ for (let i = 0; i < args.length; i += 1) {
891
+ const token = args[i] ?? "";
892
+ if (token === "--yes" || token === "-y") {
893
+ flags.yes = true;
894
+ } else if (token === "--expect-email" || token.startsWith("--expect-email=")) {
895
+ const inline = token.startsWith("--expect-email=") ? token.slice("--expect-email=".length) : void 0;
896
+ const value = inline ?? args[++i];
897
+ if (value === void 0 || value.length === 0 || value.startsWith("-")) {
898
+ return { ...flags, error: "--expect-email requires an email address" };
899
+ }
900
+ flags.expectEmail = value;
901
+ } else {
902
+ return { ...flags, error: `unexpected argument "${token}"` };
903
+ }
904
+ }
905
+ return flags;
906
+ }
907
+ function sameEmail(a, b) {
908
+ const fold = (v) => v.trim().toLowerCase();
909
+ const rawEqual = fold(a) === fold(b);
910
+ const nfkcEqual = fold(a.normalize("NFKC")) === fold(b.normalize("NFKC"));
911
+ return rawEqual && nfkcEqual;
912
+ }
913
+ function decidePairConfirmation(input) {
914
+ const { approvedByEmail, expectEmail } = input;
915
+ const platform = input.platform ?? process.platform;
916
+ if (expectEmail !== void 0) {
917
+ if (approvedByEmail === void 0) {
918
+ 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.";
919
+ return {
920
+ action: "reject",
921
+ reason: "expected_email_unverifiable",
922
+ 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}`
923
+ };
924
+ }
925
+ if (sameEmail(approvedByEmail, expectEmail)) {
926
+ return { action: "approve", reason: "expected_email_match" };
927
+ }
928
+ return {
929
+ action: "reject",
930
+ reason: "expected_email_mismatch",
931
+ 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\`.`
932
+ };
933
+ }
934
+ if (input.yes) return { action: "approve", reason: "yes_flag" };
935
+ 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] ";
936
+ if (!input.nonInteractive) {
937
+ if (input.stdinIsTTY) return { action: "prompt", question, on: "stdin" };
938
+ if (input.controllingTerminalAvailable) {
939
+ return { action: "prompt", question, on: "controlling-terminal" };
940
+ }
941
+ }
942
+ const who = approvedByEmail !== void 0 ? ` (approved by ${approvedByEmail})` : "";
943
+ const winptyHint = platform === "win32" && !input.nonInteractive ? " In Git Bash / MSYS, `winpty birdybeep pair` attaches a real console so the prompt can appear." : "";
944
+ return {
945
+ action: "reject",
946
+ reason: "non_interactive",
947
+ 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
948
+ };
949
+ }
950
+ function isAffirmative(answer) {
951
+ return /^(y|yes)$/i.test(answer.trim());
952
+ }
953
+ function controllingTerminalPath() {
954
+ return "/dev/tty";
955
+ }
956
+ function canOpenControllingTerminal(path = controllingTerminalPath(), platform = process.platform) {
957
+ if (platform === "win32") return false;
958
+ let fd;
959
+ try {
960
+ fd = (0, import_node_fs4.openSync)(path, "r");
961
+ return true;
962
+ } catch {
963
+ return false;
964
+ } finally {
965
+ if (fd !== void 0) {
966
+ try {
967
+ (0, import_node_fs4.closeSync)(fd);
968
+ } catch {
969
+ }
970
+ }
971
+ }
972
+ }
973
+ async function promptForAnswer(question, on) {
974
+ const { createInterface } = await import("readline/promises");
975
+ let ttyFd;
976
+ let input;
977
+ if (on === "stdin") {
978
+ input = process.stdin;
979
+ } else {
980
+ const { ReadStream } = await import("tty");
981
+ ttyFd = (0, import_node_fs4.openSync)(controllingTerminalPath(), "r");
982
+ input = new ReadStream(ttyFd);
983
+ }
984
+ return new Promise((resolve) => {
985
+ const rl = createInterface({ input, output: process.stderr });
986
+ let settled = false;
987
+ const done = (value) => {
988
+ if (settled) return;
989
+ settled = true;
990
+ rl.close();
991
+ if (on === "stdin") {
992
+ process.stdin.unref?.();
993
+ } else {
994
+ try {
995
+ input.unref?.();
996
+ input.destroy?.();
997
+ } catch {
998
+ }
999
+ if (ttyFd !== void 0) {
1000
+ try {
1001
+ (0, import_node_fs4.closeSync)(ttyFd);
1002
+ } catch {
1003
+ }
1004
+ }
1005
+ }
1006
+ resolve(value);
1007
+ };
1008
+ rl.question(question).then(done, () => done(""));
1009
+ rl.once("close", () => done(""));
1010
+ input.once?.("error", () => done(""));
1011
+ });
1012
+ }
687
1013
  function createPairCommand(deps = {}) {
688
1014
  const fetchImpl = deps.fetchImpl ?? fetch;
689
1015
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
690
1016
  const clock = deps.now ?? (() => Date.now());
691
1017
  const renderQr = deps.renderQr ?? renderQrMatrix;
692
1018
  const intervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1019
+ const promptLine = deps.promptLine ?? promptForAnswer;
1020
+ const hasControllingTerminal = deps.hasControllingTerminal ?? (() => canOpenControllingTerminal());
1021
+ const configuredExpectEmail = deps.configuredExpectEmail ?? (() => {
1022
+ const pinned = readCliConfig().expectEmail;
1023
+ return typeof pinned === "string" && pinned.trim().length > 0 ? pinned : void 0;
1024
+ });
693
1025
  return {
694
1026
  name: "pair",
695
1027
  summary: "Pair this machine with your BirdyBeep account (QR or manual)",
696
- usage: "birdybeep pair [--json]",
1028
+ usage: "birdybeep pair [--yes] [--expect-email <addr>] [--json]",
1029
+ options: [
1030
+ {
1031
+ flag: "--yes",
1032
+ aliases: ["-y"],
1033
+ summary: "Skip the approving-account confirmation (headless/CI)"
1034
+ },
1035
+ {
1036
+ flag: "--expect-email",
1037
+ value: "<addr>",
1038
+ summary: "Only trust the pairing if this account approved it (else fail)"
1039
+ }
1040
+ ],
697
1041
  run: async (ctx) => {
1042
+ const pairFlags = parsePairFlags(ctx.args);
1043
+ if (pairFlags.error !== void 0) {
1044
+ ctx.io.errline(`birdybeep pair: ${pairFlags.error}.`);
1045
+ return EXIT.USAGE;
1046
+ }
698
1047
  const apiUrl = resolveApiUrl();
699
1048
  const identity = (0, import_agent_core8.getMachineIdentity)();
1049
+ const codeVerifier = (0, import_agent_core8.generateCodeVerifier)();
1050
+ const codeChallenge = (0, import_agent_core8.deriveCodeChallengeS256)(codeVerifier);
700
1051
  const start = await pairStart(
701
1052
  apiUrl,
702
- { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION },
1053
+ { machineLabel: identity.label, os: identity.os, cliVersion: CLI_VERSION, codeChallenge },
703
1054
  fetchImpl
704
1055
  );
705
1056
  if (ctx.flags.json) {
@@ -711,12 +1062,14 @@ function createPairCommand(deps = {}) {
711
1062
  });
712
1063
  } else {
713
1064
  ctx.io.line(
714
- "To pair this machine, open the BirdyBeep app, tap \u201Cpair a machine\u201D, and scan this QR (or enter the code):"
1065
+ "To pair this machine, open the BirdyBeep app, tap \u201Cpair a machine\u201D, and scan this QR or open the complete link:"
715
1066
  );
716
1067
  const isTTY = deps.isTTY ?? process.stdout.isTTY === true;
717
1068
  if (isTTY) ctx.io.line(renderQr(start.qr_payload));
718
1069
  ctx.io.line(` Scan or open: ${start.qr_payload}`);
719
- ctx.io.line(` Code: ${start.user_code}`);
1070
+ ctx.io.line(
1071
+ ` Session code (display only; cannot approve by itself): ${start.user_code}`
1072
+ );
720
1073
  ctx.io.line("Waiting for you to approve this machine in the app\u2026");
721
1074
  }
722
1075
  const deadline = Date.parse(start.expires_at);
@@ -732,7 +1085,9 @@ function createPairCommand(deps = {}) {
732
1085
  apiUrl,
733
1086
  start.device_code,
734
1087
  fetchImpl,
735
- identity.fingerprintHash
1088
+ identity.fingerprintHash,
1089
+ codeVerifier
1090
+ // PKCE proof-of-possession (dgxd) — sent on every poll
736
1091
  );
737
1092
  if (poll.status === "paired") {
738
1093
  paired = poll;
@@ -757,15 +1112,44 @@ function createPairCommand(deps = {}) {
757
1112
  if (paired === void 0 || paired.status !== "paired") {
758
1113
  ctx.io.result({ paired: false, reason: "timeout" });
759
1114
  ctx.io.errline(
760
- "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."
1115
+ "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."
1116
+ );
1117
+ return EXIT.ERROR;
1118
+ }
1119
+ const approvedBy = paired.approvedByEmail;
1120
+ const expectEmail = pairFlags.expectEmail ?? configuredExpectEmail();
1121
+ const stdinIsTTY = deps.isStdinTTY ?? process.stdin.isTTY === true;
1122
+ const decision = decidePairConfirmation({
1123
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {},
1124
+ ...expectEmail !== void 0 ? { expectEmail } : {},
1125
+ ...expectEmail !== void 0 ? { expectEmailSource: pairFlags.expectEmail !== void 0 ? "flag" : "config" } : {},
1126
+ yes: pairFlags.yes,
1127
+ nonInteractive: ctx.flags.nonInteractive,
1128
+ stdinIsTTY,
1129
+ // Probed ONLY when stdin can't answer — opening /dev/tty is a syscall, and when stdin is
1130
+ // already a terminal the answer is irrelevant.
1131
+ controllingTerminalAvailable: stdinIsTTY ? false : hasControllingTerminal(),
1132
+ configPath: cliConfigPath()
1133
+ });
1134
+ if (decision.action === "reject") {
1135
+ ctx.io.result({ paired: false, reason: decision.reason });
1136
+ ctx.io.errline(decision.message);
1137
+ return EXIT.ERROR;
1138
+ }
1139
+ if (decision.action === "prompt" && !isAffirmative(await promptLine(decision.question, decision.on))) {
1140
+ ctx.io.result({ paired: false, reason: "declined" });
1141
+ ctx.io.errline(
1142
+ "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."
761
1143
  );
762
1144
  return EXIT.ERROR;
763
1145
  }
764
1146
  await (0, import_agent_core8.setToken)(paired.machineToken, deps.tokenOptions ?? {});
765
1147
  writeCliConfig({ apiUrl });
766
- ctx.io.emit(`\u2713 Paired. Run \`birdybeep test\` to send a test Beep.`, {
1148
+ const humanSuffix = approvedBy !== void 0 ? ` to ${approvedBy}` : "";
1149
+ ctx.io.emit(`\u2713 Paired${humanSuffix}. Run \`birdybeep test\` to send a test Beep.`, {
767
1150
  paired: true,
768
- machineId: paired.machineId
1151
+ machineId: paired.machineId,
1152
+ ...approvedBy !== void 0 ? { approvedByEmail: approvedBy } : {}
769
1153
  });
770
1154
  return EXIT.OK;
771
1155
  }
@@ -798,14 +1182,24 @@ function createQueueCommand() {
798
1182
  var import_agent_core10 = require("@birdybeep/agent-core");
799
1183
  var import_claude_code4 = require("@birdybeep/claude-code");
800
1184
  var import_codex4 = require("@birdybeep/codex");
1185
+ var import_copilot4 = require("@birdybeep/copilot");
1186
+ var import_cursor4 = require("@birdybeep/cursor");
801
1187
  var import_opencode4 = require("@birdybeep/opencode");
802
- var DEFAULT_ADAPTERS3 = [import_claude_code4.claudeCodeAdapter, import_codex4.codexAdapter, import_opencode4.opencodeAdapter];
1188
+ var DEFAULT_ADAPTERS3 = [
1189
+ import_claude_code4.claudeCodeAdapter,
1190
+ import_codex4.codexAdapter,
1191
+ import_opencode4.opencodeAdapter,
1192
+ import_cursor4.cursorAdapter,
1193
+ import_copilot4.copilotAdapter
1194
+ ];
803
1195
  var ADAPTER_VERSIONS = {
804
1196
  claude_code: import_claude_code4.CLAUDE_CODE_ADAPTER_VERSION,
805
1197
  codex: import_codex4.CODEX_ADAPTER_VERSION,
806
- opencode: import_opencode4.OPENCODE_ADAPTER_VERSION
1198
+ opencode: import_opencode4.OPENCODE_ADAPTER_VERSION,
1199
+ cursor: import_cursor4.CURSOR_ADAPTER_VERSION,
1200
+ copilot: import_copilot4.COPILOT_ADAPTER_VERSION
807
1201
  };
808
- var base2 = (apiUrl) => apiUrl.replace(/\/$/, "");
1202
+ var base3 = (apiUrl) => apiUrl.replace(/\/$/, "");
809
1203
  async function gatherItems(adapters) {
810
1204
  return Promise.all(
811
1205
  adapters.map(async (a) => {
@@ -840,7 +1234,7 @@ function createReportStatusCommand(deps = {}) {
840
1234
  let outcome = "deferred";
841
1235
  let errorCode;
842
1236
  try {
843
- const res = await fetchImpl(`${base2(resolveApiUrl())}/v1/integrations/status`, {
1237
+ const res = await fetchImpl(`${base3(resolveApiUrl())}/v1/integrations/status`, {
844
1238
  method: "POST",
845
1239
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
846
1240
  body: JSON.stringify({ integrations: items })
@@ -891,8 +1285,16 @@ function createReportStatusCommand(deps = {}) {
891
1285
  var import_agent_core11 = require("@birdybeep/agent-core");
892
1286
  var import_claude_code5 = require("@birdybeep/claude-code");
893
1287
  var import_codex5 = require("@birdybeep/codex");
1288
+ var import_copilot5 = require("@birdybeep/copilot");
1289
+ var import_cursor5 = require("@birdybeep/cursor");
894
1290
  var import_opencode5 = require("@birdybeep/opencode");
895
- var DEFAULT_ADAPTERS4 = [import_claude_code5.claudeCodeAdapter, import_codex5.codexAdapter, import_opencode5.opencodeAdapter];
1291
+ var DEFAULT_ADAPTERS4 = [
1292
+ import_claude_code5.claudeCodeAdapter,
1293
+ import_codex5.codexAdapter,
1294
+ import_opencode5.opencodeAdapter,
1295
+ import_cursor5.cursorAdapter,
1296
+ import_copilot5.copilotAdapter
1297
+ ];
896
1298
  function createStatusCommand(deps = {}) {
897
1299
  const adapters = deps.adapters ?? DEFAULT_ADAPTERS4;
898
1300
  const makeSender = deps.createSender ?? ((baseUrl) => (0, import_agent_core11.createSender)(
@@ -932,7 +1334,7 @@ function createStatusCommand(deps = {}) {
932
1334
  }
933
1335
 
934
1336
  // src/commands/test.ts
935
- var import_node_crypto = require("crypto");
1337
+ var import_node_crypto2 = require("crypto");
936
1338
  var import_agent_core12 = require("@birdybeep/agent-core");
937
1339
  function buildTestEvent(opts = {}) {
938
1340
  const machine = (0, import_agent_core12.getMachineIdentity)();
@@ -944,7 +1346,7 @@ function buildTestEvent(opts = {}) {
944
1346
  // schema requires a harness; the "test" type distinguishes it
945
1347
  // Unique per run: a repeat `birdybeep test` inside the backend's dedupe window must
946
1348
  // still beep — a constant id made the second test silently "deduped" (9fh).
947
- source_session_id: `birdybeep-cli-test-${(0, import_node_crypto.randomUUID)()}`,
1349
+ source_session_id: `birdybeep-cli-test-${(0, import_node_crypto2.randomUUID)()}`,
948
1350
  machine: { label: machine.label, os: machine.os },
949
1351
  workspace: { cwd: process.cwd() },
950
1352
  title: "BirdyBeep test event",
@@ -1014,8 +1416,8 @@ function buildCommands() {
1014
1416
  }
1015
1417
 
1016
1418
  // src/update-check.ts
1017
- var import_node_fs3 = require("fs");
1018
- var import_node_path2 = require("path");
1419
+ var import_node_fs5 = require("fs");
1420
+ var import_node_path3 = require("path");
1019
1421
  var import_agent_core13 = require("@birdybeep/agent-core");
1020
1422
  var PACKAGE_NAME = "@birdybeep/cli";
1021
1423
  var PACKAGE_PATH = "@birdybeep%2Fcli";
@@ -1070,11 +1472,11 @@ function isNewer(current, latest) {
1070
1472
  return cur !== null && lat !== null && compareSemver(cur, lat) < 0;
1071
1473
  }
1072
1474
  function updateCachePath() {
1073
- return (0, import_node_path2.join)((0, import_agent_core13.birdyBeepConfigDir)(), UPDATE_CACHE_FILE);
1475
+ return (0, import_node_path3.join)((0, import_agent_core13.birdyBeepConfigDir)(), UPDATE_CACHE_FILE);
1074
1476
  }
1075
1477
  function readUpdateCache() {
1076
1478
  try {
1077
- const parsed = JSON.parse((0, import_node_fs3.readFileSync)(updateCachePath(), "utf8"));
1479
+ const parsed = JSON.parse((0, import_node_fs5.readFileSync)(updateCachePath(), "utf8"));
1078
1480
  if (typeof parsed !== "object" || parsed === null) return null;
1079
1481
  const { checkedAt, latest } = parsed;
1080
1482
  if (typeof checkedAt !== "number") return null;
@@ -1085,8 +1487,8 @@ function readUpdateCache() {
1085
1487
  }
1086
1488
  }
1087
1489
  function writeUpdateCache(cache) {
1088
- (0, import_node_fs3.mkdirSync)((0, import_agent_core13.birdyBeepConfigDir)(), { recursive: true, mode: 448 });
1089
- (0, import_node_fs3.writeFileSync)(updateCachePath(), `${JSON.stringify(cache)}
1490
+ (0, import_node_fs5.mkdirSync)((0, import_agent_core13.birdyBeepConfigDir)(), { recursive: true, mode: 448 });
1491
+ (0, import_node_fs5.writeFileSync)(updateCachePath(), `${JSON.stringify(cache)}
1090
1492
  `, { mode: 384 });
1091
1493
  }
1092
1494
  async function fetchLatestVersion(registryUrl, fetchImpl, timeoutMs) {