@bridge_gpt/mcp-server 0.2.32 → 0.2.34

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.
@@ -22,6 +22,7 @@ const TERMINAL_STATUSES = new Set([
22
22
  "connected",
23
23
  "expired",
24
24
  "invalid",
25
+ "revoked",
25
26
  "verification-failed",
26
27
  "no-repositories",
27
28
  "conflict",
@@ -228,6 +229,108 @@ export async function confirmGithubConnection(deps, repoName, state, githubRepos
228
229
  },
229
230
  };
230
231
  }
232
+ const ALL_HANDOFF_STATUSES = new Set([
233
+ "pending",
234
+ "staged",
235
+ "awaiting-organization-approval",
236
+ "connected",
237
+ "expired",
238
+ "revoked",
239
+ "verification-failed",
240
+ "no-repositories",
241
+ "conflict",
242
+ "failed",
243
+ ]);
244
+ /**
245
+ * Mint a delegated handoff link.
246
+ *
247
+ * Deliberately parses only the URL and expiry: the backend does not return the state as
248
+ * a separate field, and nothing here should reconstruct one.
249
+ */
250
+ export async function mintGithubHandoff(deps, repoName) {
251
+ const res = await postJson(deps, "/setup/github/cli/handoff", {
252
+ repo_name: repoName,
253
+ });
254
+ if (!res.ok)
255
+ return res;
256
+ if (res.value.status !== 200) {
257
+ return { ok: false, kind: classifyStatus(res.value.status) };
258
+ }
259
+ const body = asRecord(res.value.body);
260
+ const installUrl = asString(body?.install_url);
261
+ const expiresAt = asString(body?.expires_at);
262
+ const ttlSeconds = body?.ttl_seconds;
263
+ if (!body || !installUrl || !expiresAt || typeof ttlSeconds !== "number") {
264
+ return { ok: false, kind: "malformed" };
265
+ }
266
+ return { ok: true, value: { installUrl, expiresAt, ttlSeconds } };
267
+ }
268
+ /** Strictly parse one handoff list item. Returns null for any malformed shape. */
269
+ function parseHandoffSnapshot(raw) {
270
+ const rec = asRecord(raw);
271
+ if (!rec)
272
+ return null;
273
+ const state = asString(rec.state);
274
+ const status = asString(rec.status);
275
+ if (!state || !status || !ALL_HANDOFF_STATUSES.has(status))
276
+ return null;
277
+ const candidates = parseCandidates(rec.candidates ?? []);
278
+ if (candidates === null)
279
+ return null;
280
+ return {
281
+ state,
282
+ status: status,
283
+ createdAt: asNullableString(rec.created_at),
284
+ expiresAt: asNullableString(rec.expires_at),
285
+ revokedAt: asNullableString(rec.revoked_at),
286
+ candidates,
287
+ githubRepoName: asNullableString(rec.github_repo_name),
288
+ };
289
+ }
290
+ /** Discover outstanding delegated handoffs for a project (the resume/revoke lookup). */
291
+ export async function listGithubHandoffs(deps, repoName) {
292
+ const res = await postJson(deps, "/setup/github/cli/handoffs", {
293
+ repo_name: repoName,
294
+ });
295
+ if (!res.ok)
296
+ return res;
297
+ if (res.value.status !== 200) {
298
+ return { ok: false, kind: classifyStatus(res.value.status) };
299
+ }
300
+ const body = asRecord(res.value.body);
301
+ const rawHandoffs = body?.handoffs;
302
+ if (!body || !Array.isArray(rawHandoffs)) {
303
+ return { ok: false, kind: "malformed" };
304
+ }
305
+ const out = [];
306
+ for (const raw of rawHandoffs) {
307
+ const parsed = parseHandoffSnapshot(raw);
308
+ // A malformed entry is a malformed RESPONSE, not an item to quietly drop: dropping
309
+ // it would silently shrink the admin's picker and could hide a live handoff.
310
+ if (!parsed)
311
+ return { ok: false, kind: "malformed" };
312
+ out.push(parsed);
313
+ }
314
+ return { ok: true, value: out };
315
+ }
316
+ /** Revoke one outstanding delegated handoff. The state travels in the body. */
317
+ export async function revokeGithubHandoff(deps, repoName, state) {
318
+ const res = await postJson(deps, "/setup/github/cli/revoke", {
319
+ repo_name: repoName,
320
+ state,
321
+ });
322
+ if (!res.ok)
323
+ return res;
324
+ if (res.value.status !== 200) {
325
+ return { ok: false, kind: classifyStatus(res.value.status) };
326
+ }
327
+ const body = asRecord(res.value.body);
328
+ const status = asString(body?.status);
329
+ if (!body || (status !== "revoked" && status !== "already-revoked")) {
330
+ return { ok: false, kind: "malformed" };
331
+ }
332
+ return { ok: true, value: status };
333
+ }
231
334
  /**
232
335
  * Read whether GitHub is already configured for a project.
233
336
  *
@@ -28,8 +28,8 @@ import { DEFAULT_BAPI_BASE_URL } from "./install-bridge.js";
28
28
  import { validateRepoName } from "./bridge-config.js";
29
29
  import { resolveStartTicketsRepoName } from "./start-tickets-repo.js";
30
30
  import { resolveBapiCredentials, } from "./credential-store.js";
31
- import { POLL_DEADLINE_MS, confirmGithubConnection, mintGithubConnection, pollGithubConnection, } from "./connect-github-api.js";
32
- const USAGE = `Usage: connect-github [--repo <repo_name>]
31
+ import { POLL_DEADLINE_MS, confirmGithubConnection, listGithubHandoffs, mintGithubConnection, mintGithubHandoff, pollGithubConnection, revokeGithubHandoff, } from "./connect-github-api.js";
32
+ const USAGE = `Usage: connect-github [--repo <repo_name>] [--handoff | --resume | --revoke]
33
33
 
34
34
  Connect a GitHub repository to a Bridge project from your terminal.
35
35
 
@@ -37,10 +37,24 @@ Opens the GitHub App install page in your browser, waits for you to install it,
37
37
  then asks which repository to connect. You are never asked for a GitHub token or
38
38
  password — you authenticate to GitHub in the browser.
39
39
 
40
+ If you do not administer the GitHub organization yourself, use the delegated
41
+ handoff: mint a link with --handoff, send it to whoever does, and finish with
42
+ --resume once they have completed their part.
43
+
40
44
  Options:
41
45
  --repo <repo_name> Bridge project to connect (inferred from this directory
42
46
  when omitted; you will be asked to confirm).
43
- --help Show this message.`;
47
+ --handoff Print a shareable install link (valid 72 hours) instead of
48
+ connecting here. Does not open a browser or wait.
49
+ --resume Look up an outstanding handoff and finish connecting it.
50
+ Works from any machine holding this project's API key.
51
+ --revoke Invalidate an outstanding handoff link.
52
+ --help Show this message.
53
+
54
+ --handoff, --resume, and --revoke are mutually exclusive. Minting a new link does
55
+ not invalidate an existing one — use --revoke for that.`;
56
+ /** The three delegated modes, for the mutual-exclusion check and its message. */
57
+ const DELEGATED_MODE_FLAGS = ["--handoff", "--resume", "--revoke"];
44
58
  /**
45
59
  * Parse argv. Deliberately strict — an unknown flag is an error, not something to
46
60
  * ignore. In particular there is NO `--yes` (every bind is confirmed by a human) and no
@@ -55,6 +69,18 @@ export function parseConnectGithubArgs(argv) {
55
69
  out.help = true;
56
70
  continue;
57
71
  }
72
+ if (arg === "--handoff") {
73
+ out.handoff = true;
74
+ continue;
75
+ }
76
+ if (arg === "--resume") {
77
+ out.resume = true;
78
+ continue;
79
+ }
80
+ if (arg === "--revoke") {
81
+ out.revoke = true;
82
+ continue;
83
+ }
58
84
  if (arg === "--repo") {
59
85
  const value = argv[i + 1];
60
86
  if (!value || value.startsWith("-")) {
@@ -91,6 +117,18 @@ export function parseConnectGithubArgs(argv) {
91
117
  }
92
118
  return { ok: false, error: `Unexpected argument: ${arg}` };
93
119
  }
120
+ // Mutually exclusive: each mode is a different operation on a different object, and
121
+ // guessing at a precedence order would silently do something the caller did not ask
122
+ // for. Refuse rather than pick.
123
+ const selected = DELEGATED_MODE_FLAGS.filter((flag) => (flag === "--handoff" && out.handoff) ||
124
+ (flag === "--resume" && out.resume) ||
125
+ (flag === "--revoke" && out.revoke));
126
+ if (selected.length > 1) {
127
+ return {
128
+ ok: false,
129
+ error: `Choose only one of ${DELEGATED_MODE_FLAGS.join(", ")} (got ${selected.join(", ")}).`,
130
+ };
131
+ }
94
132
  return { ok: true, value: out };
95
133
  }
96
134
  /** Echoed single-line prompt on stderr. */
@@ -195,6 +233,28 @@ export async function resolveConnectGithubRepoName(args, deps) {
195
233
  const validated = validateRepoName(chosen);
196
234
  return validated.ok ? { ok: true, value: validated.value } : { ok: false, error: validated.error };
197
235
  }
236
+ /**
237
+ * Resolve the project for non-interactive `--handoff` (BAPI-686).
238
+ *
239
+ * Synchronous and prompt-free, because mint mode may legitimately run without a TTY.
240
+ * That removes the interactive confirmation that makes inference safe in the default
241
+ * flow, so this deliberately does NOT fall back to guessing from the directory name:
242
+ * either `--repo` was given, or the caller is told to give it. Silently minting a live
243
+ * bearer link against the wrong project is a worse outcome than an error message.
244
+ */
245
+ export function resolveHandoffRepoName(args, _deps) {
246
+ if (!args.repo) {
247
+ return {
248
+ ok: false,
249
+ error: "connect-github --handoff needs an explicit project: pass --repo <repo_name>. " +
250
+ "It cannot ask you to confirm an inferred name when run non-interactively.",
251
+ };
252
+ }
253
+ const validated = validateRepoName(args.repo);
254
+ return validated.ok
255
+ ? { ok: true, value: validated.value }
256
+ : { ok: false, error: validated.error };
257
+ }
198
258
  // ---------------------------------------------------------------------------
199
259
  // Browser handoff
200
260
  // ---------------------------------------------------------------------------
@@ -262,6 +322,47 @@ function reportNoConnection(deps, detail) {
262
322
  deps.stderr(detail);
263
323
  return 1;
264
324
  }
325
+ /**
326
+ * Ask which repository to bind. Returns null when the user declined or chose nothing.
327
+ *
328
+ * Extracted from `runGithubConnectionFlow` (BAPI-686) so the delegated `--resume` path
329
+ * reuses this exact interaction rather than growing a parallel copy that could drift
330
+ * from it. Behavior — the single-candidate `[y/N]`, the numbered picker, the absence of
331
+ * a default, and every message — is unchanged from BAPI-631.
332
+ */
333
+ async function chooseCandidate(deps, candidates) {
334
+ if (candidates.length === 1) {
335
+ const only = candidates[0];
336
+ // Even with one option: show the full identity and require a yes. The user is
337
+ // authorizing a binding, not acknowledging a notice.
338
+ const answer = (await deps.promptLine(`Connect ${candidateLabel(only)}? [y/N]: `))
339
+ .trim()
340
+ .toLowerCase();
341
+ if (answer !== "y" && answer !== "yes") {
342
+ deps.stderr("");
343
+ deps.stderr("No GitHub connection was made. Nothing was changed.");
344
+ return null;
345
+ }
346
+ return only;
347
+ }
348
+ deps.stderr("");
349
+ deps.stderr("Your GitHub installation includes multiple repositories:");
350
+ candidates.forEach((c, i) => {
351
+ deps.stderr(` ${String(i + 1).padStart(2, " ")}. ${candidateLabel(c)}`);
352
+ });
353
+ deps.stderr("");
354
+ // No default: a stray Enter must not bind anything.
355
+ const answer = (await deps.promptLine(`Choose a repository [1-${candidates.length}]: `)).trim();
356
+ const index = Number(answer);
357
+ if (!/^\d+$/.test(answer) || !Number.isInteger(index) || index < 1 || index > candidates.length) {
358
+ deps.stderr("");
359
+ deps.stderr("No GitHub connection was made. No repository was selected.");
360
+ return null;
361
+ }
362
+ const selected = candidates[index - 1];
363
+ deps.stderr(`Selected ${candidateLabel(selected)}.`);
364
+ return selected;
365
+ }
265
366
  export async function runGithubConnectionFlow(deps, api, repoName) {
266
367
  renderStep(deps, 0);
267
368
  const minted = await mintGithubConnection(api, repoName);
@@ -313,39 +414,9 @@ export async function runGithubConnectionFlow(deps, api, repoName) {
313
414
  return reportNoConnection(deps, OUTCOME_MESSAGES["no-repositories"]);
314
415
  }
315
416
  renderStep(deps, 3);
316
- let selected = null;
317
- if (candidates.length === 1) {
318
- const only = candidates[0];
319
- // Even with one option: show the full identity and require a yes. The user is
320
- // authorizing a binding, not acknowledging a notice.
321
- const answer = (await deps.promptLine(`Connect ${candidateLabel(only)}? [y/N]: `))
322
- .trim()
323
- .toLowerCase();
324
- if (answer !== "y" && answer !== "yes") {
325
- deps.stderr("");
326
- deps.stderr("No GitHub connection was made. Nothing was changed.");
327
- return 1;
328
- }
329
- selected = only;
330
- }
331
- else {
332
- deps.stderr("");
333
- deps.stderr("Your GitHub installation includes multiple repositories:");
334
- candidates.forEach((c, i) => {
335
- deps.stderr(` ${String(i + 1).padStart(2, " ")}. ${candidateLabel(c)}`);
336
- });
337
- deps.stderr("");
338
- // No default: a stray Enter must not bind anything.
339
- const answer = (await deps.promptLine(`Choose a repository [1-${candidates.length}]: `)).trim();
340
- const index = Number(answer);
341
- if (!/^\d+$/.test(answer) || !Number.isInteger(index) || index < 1 || index > candidates.length) {
342
- deps.stderr("");
343
- deps.stderr("No GitHub connection was made. No repository was selected.");
344
- return 1;
345
- }
346
- selected = candidates[index - 1];
347
- deps.stderr(`Selected ${candidateLabel(selected)}.`);
348
- }
417
+ const selected = await chooseCandidate(deps, candidates);
418
+ if (!selected)
419
+ return 1;
349
420
  renderStep(deps, 4);
350
421
  const confirmed = await confirmGithubConnection(api, repoName, minted.value.state, selected.github_repository_id);
351
422
  if (!confirmed.ok) {
@@ -365,6 +436,207 @@ export async function runGithubConnectionFlow(deps, api, repoName) {
365
436
  return 0;
366
437
  }
367
438
  // ---------------------------------------------------------------------------
439
+ // BAPI-686: delegated handoff modes
440
+ //
441
+ // Three self-contained flows that share nothing with `runGithubConnectionFlow` except
442
+ // the API transport and the candidate picker. The default flow above is deliberately
443
+ // untouched (AC-7): it still opens the browser itself and still never emits a URL.
444
+ // ---------------------------------------------------------------------------
445
+ /**
446
+ * The one place in this file permitted to write a bearer URL to stdout.
447
+ *
448
+ * `openGithubInstallPage`'s no-print contract is what keeps the *default* flow's nonce
449
+ * out of the user's scrollback, and it stays intact — mint mode never calls it. Here the
450
+ * URL is the entire deliverable: the admin has to be able to copy it and send it on. It
451
+ * goes to stdout only (never stderr, never a log, never an error path), so it can be
452
+ * piped without dragging the surrounding narration along with it.
453
+ */
454
+ export async function runGithubHandoffMintFlow(deps, api, repoName) {
455
+ const minted = await mintGithubHandoff(api, repoName);
456
+ if (!minted.ok) {
457
+ return reportNoHandoff(deps, FAILURE_MESSAGES[minted.kind]);
458
+ }
459
+ deps.stdout(minted.value.installUrl);
460
+ deps.stderr("");
461
+ deps.stderr(`Send this link to whoever administers your GitHub organization.`);
462
+ deps.stderr(`It works once and expires ${formatExpiry(minted.value.expiresAt)}.`);
463
+ deps.stderr("They'll finish in their browser — ask them to ping you, then run " +
464
+ "`connect-github --resume`.");
465
+ return 0;
466
+ }
467
+ /** Render an expiry for humans without pretending to know the reader's locale. */
468
+ function formatExpiry(iso) {
469
+ const parsed = Date.parse(iso);
470
+ if (Number.isNaN(parsed))
471
+ return "in 72 hours";
472
+ return `on ${new Date(parsed).toISOString().replace("T", " ").slice(0, 16)} UTC`;
473
+ }
474
+ /** The no-handoff framing, mirroring `reportNoConnection` for the delegated modes. */
475
+ function reportNoHandoff(deps, detail) {
476
+ deps.stderr("");
477
+ deps.stderr("No handoff link was created.");
478
+ deps.stderr(detail);
479
+ return 1;
480
+ }
481
+ /** Statuses a handoff can still be revoked from. Anything else has nothing to withdraw. */
482
+ const REVOCABLE_STATUSES = new Set([
483
+ "pending",
484
+ "awaiting-organization-approval",
485
+ "staged",
486
+ ]);
487
+ /** One fixed, actionable line per non-staged handoff status. Never any upstream detail. */
488
+ const HANDOFF_STATUS_MESSAGES = {
489
+ pending: "Nobody has completed the link yet. Ask them to open it, then run connect-github --resume again.",
490
+ expired: "The link expired before it was used. Run connect-github --handoff to create a new one.",
491
+ revoked: "This link was revoked. Run connect-github --handoff to create a new one.",
492
+ "verification-failed": "Bridge could not verify the GitHub installation. Run connect-github --handoff to create a new link.",
493
+ "no-repositories": "The GitHub App installation did not include any repositories Bridge can access. " +
494
+ "Create a new link with connect-github --handoff and ask them to grant access to at least one repository.",
495
+ conflict: "This GitHub installation or project is already connected to a different Bridge " +
496
+ "account or repository. Contact support if that is unexpected.",
497
+ failed: "The GitHub install did not complete. Run connect-github --handoff to create a new link.",
498
+ };
499
+ /**
500
+ * Label one handoff for the selection prompt.
501
+ *
502
+ * Uses ONLY status and timestamps. The state never appears — it is a bearer credential,
503
+ * and printing it into a picker would paste it into the user's scrollback for no reason.
504
+ */
505
+ function handoffLabel(h) {
506
+ const created = h.createdAt ? ` · created ${h.createdAt.slice(0, 16).replace("T", " ")}` : "";
507
+ return `${h.status}${created}`;
508
+ }
509
+ /**
510
+ * Choose among discovered handoffs.
511
+ *
512
+ * A single candidate is returned without a prompt: unlike a repository *binding*, which
513
+ * always requires an explicit yes, selecting which handoff to inspect changes nothing on
514
+ * its own — the binding confirmation still happens downstream.
515
+ */
516
+ export async function selectDelegatedHandoff(deps, handoffs) {
517
+ if (handoffs.length === 0)
518
+ return null;
519
+ if (handoffs.length === 1)
520
+ return handoffs[0];
521
+ deps.stderr("");
522
+ deps.stderr("Outstanding handoff links:");
523
+ handoffs.forEach((h, i) => {
524
+ deps.stderr(` ${String(i + 1).padStart(2, " ")}. ${handoffLabel(h)}`);
525
+ });
526
+ deps.stderr("");
527
+ // No default: a stray Enter must not pick one.
528
+ const answer = (await deps.promptLine(`Choose a handoff [1-${handoffs.length}]: `)).trim();
529
+ const index = Number(answer);
530
+ if (!/^\d+$/.test(answer) || !Number.isInteger(index) || index < 1 || index > handoffs.length) {
531
+ return null;
532
+ }
533
+ return handoffs[index - 1];
534
+ }
535
+ /**
536
+ * Look up an outstanding handoff and continue it (AC-4, AC-6).
537
+ *
538
+ * Status-only, never polling: a delegated handoff waits on a person who may take days,
539
+ * so blocking a terminal on it would be useless. The admin re-runs this when they are
540
+ * told the other half is done.
541
+ */
542
+ export async function runGithubHandoffResumeFlow(deps, api, repoName) {
543
+ const listed = await listGithubHandoffs(api, repoName);
544
+ if (!listed.ok) {
545
+ return reportNoConnection(deps, FAILURE_MESSAGES[listed.kind]);
546
+ }
547
+ if (listed.value.length === 0) {
548
+ return reportNoConnection(deps, "No handoff links exist for this project. Run connect-github --handoff to create one.");
549
+ }
550
+ const selected = await selectDelegatedHandoff(deps, listed.value);
551
+ if (!selected) {
552
+ return reportNoConnection(deps, "No handoff was selected.");
553
+ }
554
+ if (selected.status === "connected") {
555
+ deps.stdout(`Connected ${selected.githubRepoName ?? repoName}.`);
556
+ return 0;
557
+ }
558
+ if (selected.status === "awaiting-organization-approval") {
559
+ // R-6: this is genuinely in-flight, not a failure — the link stays live until its
560
+ // deadline, and approval may still land. Reported as a live state (exit 0) so a
561
+ // scripted caller does not treat "waiting on a human" as an error.
562
+ deps.stderr("");
563
+ deps.stderr("The install was sent to a GitHub organization owner for approval, and that " +
564
+ "approval has not happened yet.");
565
+ deps.stderr("GitHub does not notify Bridge when it lands, so re-run connect-github --resume " +
566
+ "once the owner has approved it.");
567
+ if (selected.expiresAt) {
568
+ deps.stderr(`The link stays usable until ${formatExpiry(selected.expiresAt)}.`);
569
+ }
570
+ return 0;
571
+ }
572
+ if (selected.status !== "staged") {
573
+ const message = HANDOFF_STATUS_MESSAGES[selected.status] ?? HANDOFF_STATUS_MESSAGES.failed;
574
+ if (selected.status === "pending" || selected.status === "expired") {
575
+ // AC-8 fallback. Bridge cannot safely discover an existing installation on an org
576
+ // it was never called back about, so this deliberately does NOT claim to have
577
+ // detected one — it points at the documented manual path instead.
578
+ deps.stderr("");
579
+ deps.stderr(message);
580
+ deps.stderr("If the GitHub App is already installed on that organization, GitHub may not " +
581
+ "send Bridge a callback at all. In that case follow the manual installation-ID " +
582
+ "steps in the GitHub App setup guide (docs/install/github-app.md).");
583
+ // `pending` is a live state, not a failure; `expired` is terminal.
584
+ return selected.status === "pending" ? 0 : 1;
585
+ }
586
+ return reportNoConnection(deps, message);
587
+ }
588
+ // Staged: reuse the existing candidate flow verbatim — same labels, same picker, same
589
+ // explicit [y/N] on a single candidate. A delegated handoff gets no auto-bind shortcut.
590
+ const candidates = selected.candidates;
591
+ if (candidates.length === 0) {
592
+ return reportNoConnection(deps, OUTCOME_MESSAGES["no-repositories"]);
593
+ }
594
+ const chosen = await chooseCandidate(deps, candidates);
595
+ if (!chosen)
596
+ return 1;
597
+ const confirmed = await confirmGithubConnection(api, repoName, selected.state, chosen.github_repository_id);
598
+ if (!confirmed.ok) {
599
+ return reportNoConnection(deps, FAILURE_MESSAGES[confirmed.kind]);
600
+ }
601
+ deps.stdout(`Connected ${confirmed.value.githubRepoFullName ?? confirmed.value.githubRepoName}.`);
602
+ if (confirmed.value.indexingStatus === "started") {
603
+ deps.stdout("Indexing started automatically.");
604
+ }
605
+ else if (confirmed.value.indexingStatus === "waiting-for-setup") {
606
+ deps.stdout("Indexing will start automatically once setup completes.");
607
+ }
608
+ return 0;
609
+ }
610
+ /** Invalidate an outstanding handoff (R-5). */
611
+ export async function runGithubHandoffRevokeFlow(deps, api, repoName) {
612
+ const listed = await listGithubHandoffs(api, repoName);
613
+ if (!listed.ok) {
614
+ return reportNoRevocation(deps, FAILURE_MESSAGES[listed.kind]);
615
+ }
616
+ const revocable = listed.value.filter((h) => REVOCABLE_STATUSES.has(h.status));
617
+ if (revocable.length === 0) {
618
+ return reportNoRevocation(deps, "There are no handoff links left to revoke.");
619
+ }
620
+ const selected = await selectDelegatedHandoff(deps, revocable);
621
+ if (!selected) {
622
+ return reportNoRevocation(deps, "No handoff was selected.");
623
+ }
624
+ const revoked = await revokeGithubHandoff(api, repoName, selected.state);
625
+ if (!revoked.ok) {
626
+ return reportNoRevocation(deps, FAILURE_MESSAGES[revoked.kind]);
627
+ }
628
+ deps.stdout(revoked.value === "already-revoked"
629
+ ? "That handoff link was already revoked."
630
+ : "Handoff link revoked. It can no longer be used.");
631
+ return 0;
632
+ }
633
+ function reportNoRevocation(deps, detail) {
634
+ deps.stderr("");
635
+ deps.stderr("Nothing was revoked.");
636
+ deps.stderr(detail);
637
+ return 1;
638
+ }
639
+ // ---------------------------------------------------------------------------
368
640
  // Process boundary
369
641
  // ---------------------------------------------------------------------------
370
642
  /**
@@ -386,14 +658,22 @@ export async function runConnectGithubCli(argv, injected) {
386
658
  deps.stdout(USAGE);
387
659
  return 0;
388
660
  }
389
- // Checked BEFORE minting: this flow always ends in a human choice, so a
661
+ const mintOnly = parsed.value.handoff;
662
+ // Checked BEFORE minting: these flows always end in a human choice, so a
390
663
  // non-interactive run can only ever strand a code it can never confirm.
391
- if (!deps.isTTY) {
664
+ //
665
+ // `--handoff` is the one exception (BAPI-686), and only because the premise no
666
+ // longer holds for it: it ends at a printed URL, not at a prompt, and it strands
667
+ // nothing because handoffs are discoverable server-side afterwards via --resume.
668
+ // --resume and --revoke both still prompt, so they keep the gate.
669
+ if (!deps.isTTY && !mintOnly) {
392
670
  deps.stderr("connect-github needs an interactive terminal: it asks you to confirm which " +
393
671
  "repository to connect. Run it directly in your terminal.");
394
672
  return 1;
395
673
  }
396
- const repo = await resolveConnectGithubRepoName(parsed.value, deps);
674
+ const repo = mintOnly
675
+ ? resolveHandoffRepoName(parsed.value, deps)
676
+ : await resolveConnectGithubRepoName(parsed.value, deps);
397
677
  if (!repo.ok) {
398
678
  deps.stderr(repo.error);
399
679
  return 1;
@@ -415,6 +695,12 @@ export async function runConnectGithubCli(argv, injected) {
415
695
  baseUrl: deps.env.BAPI_BASE_URL?.trim() || DEFAULT_BAPI_BASE_URL,
416
696
  apiKey: cred.credentials.apiKey,
417
697
  };
698
+ if (parsed.value.handoff)
699
+ return await runGithubHandoffMintFlow(deps, api, repo.value);
700
+ if (parsed.value.resume)
701
+ return await runGithubHandoffResumeFlow(deps, api, repo.value);
702
+ if (parsed.value.revoke)
703
+ return await runGithubHandoffRevokeFlow(deps, api, repo.value);
418
704
  return await runGithubConnectionFlow(deps, api, repo.value);
419
705
  }
420
706
  catch {