@pingroom/cli 0.9.0 → 0.10.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/README.md CHANGED
@@ -41,7 +41,7 @@ question), `list`, `cancel`, `handoff` (hand a decision to a specific human),
41
41
  `handoffs` (list open or recent Handoffs), `listen` (hear pings as they land),
42
42
  `live` (lock-screen progress card),
43
43
  `hook` (Claude Code), `mcp` (client setup), `activate` (send an optional test
44
- Question), `config`, and `logout`.
44
+ Question), `pair` (connect a machine with no terminal), `config`, and `logout`.
45
45
  Run `pingroom --help` for the full reference.
46
46
 
47
47
  ## Connecting
@@ -66,6 +66,35 @@ Approving on the phone is the whole ceremony — connecting sends nothing else t
66
66
  your phone. The status line reflects the grant: `→ #Project X` for one room,
67
67
  `→ #Project X +2 more` for several, `→ all rooms` when you granted every room.
68
68
 
69
+ ### Headless pairing (daemons, containers, OpenClaw)
70
+
71
+ A machine with no terminal cannot show a QR, so `pingroom pair` prints the
72
+ approval link and waits for it instead. Nothing prompts, nothing draws, and an
73
+ open stdin never holds it:
74
+
75
+ ```
76
+ $ pingroom pair
77
+ Open: https://pingroom.io/app/agents/pair?token=…
78
+ Waiting for approval… ✓ Connected as @agt_ab12cd34ef → #Project X
79
+ ```
80
+
81
+ Exit 0 once paired; exit 3 if the 15-minute link expired — run it again for a
82
+ fresh one. It re-pairs too: when a credential already exists the new one is
83
+ saved first, then the old one is revoked.
84
+
85
+ For a supervisor that reads the link out of a log, `--json` makes stdout one
86
+ JSON object per line (the credential is never printed):
87
+
88
+ ```
89
+ $ pingroom pair --json
90
+ {"event":"pair_url","pair_url":"https://…","expires_in":900,"poll_interval_ms":1500}
91
+ {"event":"connected","handle":"agt_ab12cd34ef","room":{…},"room_access":"selected",…}
92
+ ```
93
+
94
+ Give each service user its own `PINGROOM_HOME`; the credential is written
95
+ `0600` at `$PINGROOM_HOME/credentials.json`. In CI, prefer `PINGROOM_TOKEN`
96
+ over pairing — there is nobody to approve a link.
97
+
69
98
  ## Proving the round-trip (optional)
70
99
 
71
100
  ```bash
@@ -657,6 +686,10 @@ See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
657
686
 
658
687
  ## Agent skills
659
688
 
689
+ Running under OpenClaw instead of Claude Code? See
690
+ https://pingroom.io/connect-openclaw.md for the skill and headless pairing.
691
+
692
+
660
693
  Two ready-to-install [Claude Code skills](https://github.com/pingroom/skills)
661
694
  teach an agent when and how to reach a human — `pingroom-mcp` for conversational
662
695
  sessions, `pingroom-cli` for shells, CI, and hooks.
package/bin/pingroom.js CHANGED
@@ -43,8 +43,8 @@ import { HELP } from '../lib/help.js';
43
43
  import { maybeNotifyUpdate } from '../lib/update-check.js';
44
44
  import {
45
45
  parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
46
- parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs, parseReconnectArgs,
47
- parseSkillsArgs,
46
+ parseLiveArgs, parseLogoutArgs, parseManageArgs, parsePairArgs, parseQArgs,
47
+ parseReconnectArgs, parseSkillsArgs,
48
48
  } from '../lib/parser.js';
49
49
  import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
50
50
  import { ping } from '../lib/commands/ping.js';
@@ -55,7 +55,7 @@ import { live } from '../lib/commands/live.js';
55
55
  import { hook } from '../lib/commands/hook.js';
56
56
  import { mcp } from '../lib/commands/mcp.js';
57
57
  import { skills } from '../lib/commands/skills.js';
58
- import { activateStoredInbox, bare, reconnect } from '../lib/commands/connect.js';
58
+ import { activateStoredInbox, bare, pair, reconnect } from '../lib/commands/connect.js';
59
59
  import { config, logout } from '../lib/commands/config.js';
60
60
 
61
61
  const COMMANDS = {
@@ -80,6 +80,7 @@ const COMMANDS = {
80
80
  attachment: (rest) => attachment(parseManageArgs(rest)),
81
81
  config: (rest) => config(parseConfigArgs(rest)),
82
82
  reconnect: (rest) => reconnect(parseReconnectArgs(rest)),
83
+ pair: (rest) => pair(parsePairArgs(rest)),
83
84
  logout: (rest) => logout(parseLogoutArgs(rest)),
84
85
  };
85
86
 
@@ -24,6 +24,12 @@ import { CLI_SCOPES } from '../scopes.js';
24
24
  // What the human reads on the approval screen. A product name, not a package
25
25
  // id: the phone shows it verbatim ("PingRoom CLI wants to connect").
26
26
  const AGENT_LABEL = 'PingRoom CLI';
27
+ // One NDJSON record per line on stdout, for `pair --json`. `listen --json`
28
+ // already established the shape: a daemon reads stdout line by line, so nothing
29
+ // else may be written there while it is on.
30
+ function writeEvent(event) {
31
+ process.stdout.write(`${JSON.stringify(event)}\n`);
32
+ }
27
33
  // A connect command should prove the phone round-trip, but it must not hold a
28
34
  // terminal for the onboarding Question's full 24-hour server TTL. The Question
29
35
  // remains answerable after this local deadline and the credential is already
@@ -446,7 +452,7 @@ export async function activateStoredInbox(args) {
446
452
  * token, renders it, then polls until the human approves. Returns a credential
447
453
  * object, or null when the pairing lapsed and the user declined a fresh one.
448
454
  */
449
- async function connectByPairing(apiBase, ask) {
455
+ async function connectByPairing(apiBase, ask, { qr = true, json: jsonOut = false } = {}) {
450
456
  for (;;) {
451
457
  const preClaim = await registerAnonymous(apiBase);
452
458
  const headers = { Authorization: `Bearer ${preClaim}` };
@@ -473,11 +479,24 @@ async function connectByPairing(apiBase, ask) {
473
479
  // collecting 429s instead of the approval.
474
480
  const lifetimeMs = Math.max(1, Number(start.json.expires_in) || 900) * 1000;
475
481
  const intervalMs = Math.min(Math.max(Number(start.json.poll_interval_ms) || 1500, 1000), 10_000);
476
- const deadline = Date.now() + lifetimeMs;
477
-
478
- const drew = await renderQr(pairUrl);
479
- process.stdout.write(`${drew ? ' Or open' : ' Open'}: ${pairUrl}\n`);
480
- process.stdout.write(' Waiting for approval… ');
482
+ const startedAt = Date.now();
483
+ const deadline = startedAt + lifetimeMs;
484
+
485
+ // `qr: false` (headless) forces the QR off rather than leaning on COLUMNS:
486
+ // an unset width reads as "wide enough" in renderQr, so a daemon with no
487
+ // terminal would otherwise get block art written into its log pipe.
488
+ const drew = qr ? await renderQr(pairUrl) : false;
489
+ if (jsonOut) {
490
+ writeEvent({
491
+ event: 'pair_url',
492
+ pair_url: pairUrl,
493
+ expires_in: Math.round(lifetimeMs / 1000),
494
+ poll_interval_ms: intervalMs,
495
+ });
496
+ } else {
497
+ process.stdout.write(`${drew ? ' Or open' : ' Open'}: ${pairUrl}\n`);
498
+ process.stdout.write(' Waiting for approval… ');
499
+ }
481
500
 
482
501
  // A transient failure must not end a wait the human is mid-way through.
483
502
  // Network errors, 5xx and 429 are the load balancer / rate limiter talking,
@@ -504,7 +523,10 @@ async function connectByPairing(apiBase, ask) {
504
523
  // output cannot tell a slow approval from a broken endpoint.
505
524
  if (transientRun === 3 && !warnedTransient) {
506
525
  warnedTransient = true;
507
- process.stdout.write(`\n (still trying ${lastTransient}) `);
526
+ // stdout stays pure NDJSON in json mode, so progress chatter goes to
527
+ // stderr, where a daemon's log still shows it.
528
+ if (jsonOut) process.stderr.write(`pingroom: still trying — ${lastTransient}\n`);
529
+ else process.stdout.write(`\n (still trying — ${lastTransient}) `);
508
530
  }
509
531
  // Ride out a short blip at the normal cadence, then back off
510
532
  // geometrically so a real outage is not also a thundering herd. Never
@@ -516,8 +538,29 @@ async function connectByPairing(apiBase, ask) {
516
538
 
517
539
  transientRun = 0;
518
540
 
541
+ // A 401/403/404 is terminal either way — retrying can only spin — but it
542
+ // means two different things depending on WHEN it lands, and the user
543
+ // needs to be told the right one.
544
+ //
545
+ // Late: the pre-claim credential this loop authenticates with has the
546
+ // same 900s TTL as the pairing token (agent_auth.ttl), so once the window
547
+ // is mostly gone an unapproved pairing 401s instead of returning
548
+ // {"status":"expired"}. That is the ordinary "nobody tapped it" ending,
549
+ // and reporting it as an auth error sent people to fix a credential that
550
+ // was working fine (observed live 2026-09-02).
551
+ //
552
+ // Early: the credential was minted seconds ago, so a rejection means the
553
+ // request is genuinely wrong — a bad --api, a server that is not
554
+ // PingRoom. Calling that "expired" would hide a real fault behind a
555
+ // retry the user can never win.
556
+ if (res.status === 401 || res.status === 403 || res.status === 404) {
557
+ if (Date.now() - startedAt >= lifetimeMs / 2) break;
558
+ if (!jsonOut) process.stdout.write('\n');
559
+ fail(`pairing failed: ${apiDetail(res, json)}`);
560
+ }
561
+
519
562
  if (!res.ok) {
520
- process.stdout.write('\n');
563
+ if (!jsonOut) process.stdout.write('\n');
521
564
  const detail = apiDetail(res, json);
522
565
  fail(`pairing failed: ${detail}`);
523
566
  }
@@ -528,7 +571,7 @@ async function connectByPairing(apiBase, ask) {
528
571
  // every later command reads a credential file that exists but cannot
529
572
  // authenticate — a far more confusing failure than stopping here.
530
573
  if (typeof json.credential !== 'string' || json.credential === '') {
531
- process.stdout.write('\n');
574
+ if (!jsonOut) process.stdout.write('\n');
532
575
  fail('pairing succeeded but the server returned no credential');
533
576
  }
534
577
  const cred = {
@@ -542,7 +585,21 @@ async function connectByPairing(apiBase, ask) {
542
585
  apiBase,
543
586
  };
544
587
  saveCredential(cred);
545
- process.stdout.write(`${connectedLine(cred)}\n`);
588
+ if (jsonOut) {
589
+ // No token: this line lands in daemon logs. Everything here is
590
+ // already visible to the human who just approved the pairing.
591
+ writeEvent({
592
+ event: 'connected',
593
+ handle: cred.handle ?? null,
594
+ room: cred.room ?? null,
595
+ room_access: cred.roomAccess,
596
+ rooms: cred.rooms,
597
+ scopes: cred.scopes ?? [],
598
+ api_url: apiBase,
599
+ });
600
+ } else {
601
+ process.stdout.write(`${connectedLine(cred)}\n`);
602
+ }
546
603
  // Connecting deliberately sends nothing to the human's phone. The
547
604
  // approval they just tapped IS the round-trip; a test Question on top of
548
605
  // it was one more thing to answer before the tool could be used, and it
@@ -555,7 +612,13 @@ async function connectByPairing(apiBase, ask) {
555
612
  await sleep(intervalMs);
556
613
  }
557
614
 
558
- if (transientRun > 0) {
615
+ if (jsonOut) {
616
+ writeEvent({
617
+ event: 'expired',
618
+ reason: transientRun > 0 ? 'server_unavailable' : 'expired',
619
+ last_error: lastTransient,
620
+ });
621
+ } else if (transientRun > 0) {
559
622
  process.stdout.write(`\n Gave up waiting — the server kept failing (last: ${lastTransient}).\n`);
560
623
  } else {
561
624
  process.stdout.write(`\n That code expired.\n`);
@@ -566,6 +629,11 @@ async function connectByPairing(apiBase, ask) {
566
629
  // means "take the default: yes"), restart the for(;;), mint another
567
630
  // anonymous registration, and do it again — a Ctrl-D or a piped stdin turns
568
631
  // a single pairing attempt into thousands of registrations against the API.
632
+ // `ask === null` is the headless contract: one round, no prompt, no retry.
633
+ // Distinct from ask() returning null at EOF just below — both stop here,
634
+ // and neither may loop the for(;;) into minting more registrations.
635
+ if (!ask) return null;
636
+
569
637
  const again = await ask(' Show a fresh QR code? [Y/n]: ');
570
638
  if (again === null) { process.stdout.write('\n'); return null; }
571
639
  const answer = again.trim().toLowerCase();
@@ -643,7 +711,7 @@ async function connectByEmail(apiBase, ask) {
643
711
  export async function connect(args) {
644
712
  if (!isInteractive()) {
645
713
  fail(
646
- 'not connected, and this is not an interactive terminal. Set PINGROOM_TOKEN (CI, pipes), or run "pingroom" from a terminal to pair.',
714
+ 'not connected, and this is not an interactive terminal. Set PINGROOM_TOKEN (CI, pipes), run "pingroom pair" to pair without a terminal, or run "pingroom" from a terminal.',
647
715
  EXIT.USAGE,
648
716
  );
649
717
  }
@@ -705,7 +773,7 @@ export async function bare(args) {
705
773
  }
706
774
 
707
775
  if (!isInteractive()) {
708
- process.stderr.write('pingroom: not connected. Set PINGROOM_TOKEN, or run "pingroom" from an interactive terminal to pair.\n');
776
+ process.stderr.write('pingroom: not connected. Set PINGROOM_TOKEN, run "pingroom pair" to pair without a terminal, or run "pingroom" from an interactive terminal.\n');
709
777
  process.stdout.write(`${HELP}\n`);
710
778
  return EXIT.OK;
711
779
  }
@@ -757,7 +825,7 @@ export async function reconnect(args) {
757
825
  }
758
826
 
759
827
  if (!isInteractive()) {
760
- fail('reconnect needs an interactive terminal to show the QR code.', EXIT.USAGE);
828
+ fail('reconnect needs an interactive terminal to show the QR code. Run "pingroom pair" instead — it prints the approval link and waits.', EXIT.USAGE);
761
829
  }
762
830
 
763
831
  const apiBase = resolveApiBase(args);
@@ -768,10 +836,7 @@ export async function reconnect(args) {
768
836
  // live production credential to an arbitrary host.
769
837
  requireStoredCredentialOrigin(args, apiBase);
770
838
 
771
- process.stdout.write(' Reconnecting updates the permissions this CLI holds.\n');
772
- process.stdout.write(' Your current connection keeps working until the new one is approved,\n');
773
- process.stdout.write(' and is then revoked — any other machine or CI job using that same\n');
774
- process.stdout.write(' credential will stop working.\n\n');
839
+ printReplaceNotice();
775
840
 
776
841
  const prompter = createPrompter();
777
842
  const ask = (question) => prompter.ask(question);
@@ -789,8 +854,19 @@ export async function reconnect(args) {
789
854
  return EXIT.EXPIRED;
790
855
  }
791
856
 
792
- // The new credential is already durable (connectByPairing saved it). From
793
- // here on, failing to revoke is untidy, not dangerous.
857
+ return revokePrevious(apiBase, stored);
858
+ }
859
+
860
+ /**
861
+ * Step 4 of the reconnect contract, shared with `pair`: revoke the credential
862
+ * the new one replaces, using the OLD bearer.
863
+ *
864
+ * Only ever called after connectByPairing has already written the replacement,
865
+ * so a failure here is untidy rather than dangerous — it leaves a stale-but-
866
+ * harmless registration the human can remove from Connected Agents, which is
867
+ * why it returns EXIT.OK either way.
868
+ */
869
+ async function revokePrevious(apiBase, stored, { json: jsonOut = false } = {}) {
794
870
  const { res, json, error } = await httpJson('POST', `${apiBase}/api/agent/auth/revoke`, {
795
871
  headers: { Authorization: `Bearer ${stored.token}` },
796
872
  body: {},
@@ -798,11 +874,86 @@ export async function reconnect(args) {
798
874
  });
799
875
  if (error || !res || !res.ok) {
800
876
  const detail = error ? error.message : apiDetail(res, json);
801
- process.stdout.write(` Note: the previous connection could not be revoked (${detail}).\n`);
802
- process.stdout.write(' Remove it from PingRoom Settings → Connected Agents when convenient.\n');
877
+ if (jsonOut) {
878
+ writeEvent({ event: 'previous_connection', revoked: false, detail });
879
+ } else {
880
+ process.stdout.write(` Note: the previous connection could not be revoked (${detail}).\n`);
881
+ process.stdout.write(' Remove it from PingRoom → Settings → Connected Agents when convenient.\n');
882
+ }
803
883
  return EXIT.OK;
804
884
  }
805
885
 
806
- process.stdout.write(' Previous connection revoked.\n');
886
+ if (jsonOut) writeEvent({ event: 'previous_connection', revoked: true });
887
+ else process.stdout.write(' Previous connection revoked.\n');
888
+ return EXIT.OK;
889
+ }
890
+
891
+ /** The four lines a human sees before re-pairing replaces a live credential. */
892
+ function printReplaceNotice() {
893
+ process.stdout.write(' Reconnecting updates the permissions this CLI holds.\n');
894
+ process.stdout.write(' Your current connection keeps working until the new one is approved,\n');
895
+ process.stdout.write(' and is then revoked — any other machine or CI job using that same\n');
896
+ process.stdout.write(' credential will stop working.\n\n');
897
+ }
898
+
899
+ // --- pair -------------------------------------------------------------------
900
+
901
+ /**
902
+ * Pairing for machines with no terminal: print the approval link, wait once,
903
+ * save, and (when replacing a stored credential) revoke the old one.
904
+ *
905
+ * Deliberately a separate command rather than a flag or a non-TTY fallback.
906
+ * AGENT_PAIRING_SPEC.md § CLI behaviour requires that a non-interactive
907
+ * `pingroom` keep exiting 0 with the PINGROOM_TOKEN hint — `pingroom | head`
908
+ * is a normal thing to do, and silently starting a 15-minute poll there would
909
+ * mint a registration from any stray pipe. Asking for `pair` is the consent.
910
+ *
911
+ * Differences from the interactive path, all of them deliberate:
912
+ * - never renders a QR (nothing can scan a log pipe);
913
+ * - never prompts, and never constructs a prompter, so an open stdin cannot
914
+ * hold the process open and a closed one cannot end it early;
915
+ * - exactly one round — an expired link exits 3 rather than offering another,
916
+ * because there is no human at this terminal to answer the offer.
917
+ */
918
+ export async function pair(args) {
919
+ if (args.help) { process.stdout.write(`${commandHelp('pair')}\n`); return EXIT.OK; }
920
+ if (args._ && args._.length > 0) {
921
+ fail('usage: pingroom pair [--api <url>] [--json]', EXIT.USAGE);
922
+ }
923
+
924
+ // Same reasoning as reconnect: an env token is not ours to replace, and
925
+ // pairing into a credential that PINGROOM_TOKEN immediately shadows would
926
+ // look like it worked while every later command used the other one.
927
+ if (process.env.PINGROOM_TOKEN) {
928
+ fail(
929
+ 'PINGROOM_TOKEN is set, and it would shadow whatever this pairing stores.\n'
930
+ + ' Unset it and run "pingroom pair" again, or keep using the token as-is.',
931
+ EXIT.USAGE,
932
+ );
933
+ }
934
+
935
+ const jsonOut = Boolean(args.json);
936
+ const apiBase = resolveApiBase(args);
937
+ requireSafeUrl('--api', apiBase);
938
+
939
+ // Re-pairing sends the stored credential to `apiBase` in the revoke below, so
940
+ // it is bound by the same origin rule as every other stored-bearer command.
941
+ const stored = readStoredCredential();
942
+ if (stored) {
943
+ requireStoredCredentialOrigin(args, apiBase);
944
+ if (!jsonOut) printReplaceNotice();
945
+ }
946
+
947
+ const cred = await connectByPairing(apiBase, null, { qr: false, json: jsonOut });
948
+
949
+ if (!cred) {
950
+ if (!jsonOut) {
951
+ process.stdout.write(' Run "pingroom pair" again for a fresh link.\n');
952
+ if (stored) process.stdout.write(' Kept your current connection.\n');
953
+ }
954
+ return EXIT.EXPIRED;
955
+ }
956
+
957
+ if (stored) return revokePrevious(apiBase, stored, { json: jsonOut });
807
958
  return EXIT.OK;
808
959
  }
@@ -65,6 +65,8 @@ Or by hand:
65
65
  git clone ${SKILLS_CLONE_URL} /tmp/pingroom-skills
66
66
  ${SKILLS.map((s) => ` cp -r /tmp/pingroom-skills/${s.source.join('/')} ${claudeSkillsDir()}`).join('\n')}
67
67
 
68
+ OpenClaw (headless agents): https://pingroom.io/connect-openclaw.md
69
+
68
70
  Only "pingroom skills install" writes anything; this listing does not.
69
71
  `);
70
72
  return EXIT.OK;
package/lib/config.js CHANGED
@@ -173,7 +173,7 @@ export function agentContext(args, { needRoom = false } = {}) {
173
173
  const token = resolveToken(args);
174
174
  if (!token) {
175
175
  fail(
176
- 'an agent token is required (--token or PINGROOM_TOKEN). Run "pingroom" in an interactive terminal to connect this machine; in CI set PINGROOM_TOKEN.',
176
+ 'an agent token is required (--token or PINGROOM_TOKEN). Run "pingroom" in an interactive terminal to connect this machine, or "pingroom pair" where there is no terminal; in CI set PINGROOM_TOKEN.',
177
177
  EXIT.USAGE,
178
178
  );
179
179
  }
package/lib/help.js CHANGED
@@ -40,6 +40,7 @@ Commands:
40
40
  attachment Download or delete an attachment by id (attachment get|delete)
41
41
  config Read/write local settings (config list | get <key> | set <key> <val>)
42
42
  reconnect Re-approve this CLI so it holds every permission it needs
43
+ pair Pair without a terminal: print the approval link and wait
43
44
  logout Forget the stored credential`;
44
45
 
45
46
  export const HELP_PING = `ping options:
@@ -218,7 +219,8 @@ export const HELP_TAIL = `Connecting:
218
219
  intentionally, provide that host's token with --token or PINGROOM_TOKEN.
219
220
 
220
221
  Non-interactive shells (CI, pipes) never prompt and never draw a QR: set
221
- PINGROOM_TOKEN there instead.
222
+ PINGROOM_TOKEN there, or run "pingroom pair" to pair a machine that has no
223
+ terminal (it prints the approval link and waits).
222
224
 
223
225
  Examples:
224
226
  pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
@@ -351,6 +353,21 @@ export const COMMAND_HELP_SECTIONS = {
351
353
  Download; bytes go to --out or stdout
352
354
  pingroom attachment delete <id> Delete an unclaimed upload`,
353
355
  config: HELP_CONFIG,
356
+ pair: `pair (no terminal required):
357
+ pingroom pair Print a PingRoom approval link, wait for the
358
+ human to approve it on their phone, and save
359
+ the credential. For daemons, containers, and
360
+ agent runtimes with no TTY.
361
+ Re-pairs when a credential already exists:
362
+ the new one is saved first, then the old one
363
+ is revoked.
364
+ Exits 0 once paired, 3 if the link expired
365
+ (run it again for a fresh one).
366
+ --api <url> API base URL (default https://api.pingroom.io)
367
+ --json One JSON object per line on stdout:
368
+ {"event":"pair_url","pair_url":…,"expires_in":…}
369
+ first, then {"event":"connected","handle":…}.
370
+ The credential is never printed.`,
354
371
  reconnect: `reconnect:
355
372
  pingroom reconnect Re-approve this CLI with the permissions the
356
373
  current version needs, then revoke the old
@@ -382,6 +399,9 @@ export const COMMAND_HELP_FOOTERS = {
382
399
  // with the credential it is about to replace.
383
400
  reconnect: `Shared:
384
401
  -h, --help Show this help`,
402
+ // pair writes the stored credential, so --token is meaningless here too.
403
+ pair: `Shared:
404
+ -h, --help Show this help`,
385
405
  // `skills` reaches GitHub, never the PingRoom API, so the shared credential
386
406
  // and --json flags would all be lies here.
387
407
  skills: `Shared:
package/lib/parser.js CHANGED
@@ -214,6 +214,15 @@ export const parseReconnectArgs = makeParser({
214
214
  bareDashIsPositional: true,
215
215
  });
216
216
 
217
+ // pair is reconnect's headless twin and takes no --token for the same reason:
218
+ // the credential it writes is the one on disk. --json makes stdout an NDJSON
219
+ // stream a daemon can read the approval link out of.
220
+ export const parsePairArgs = makeParser({
221
+ aliases: { '--api': 'api', '--json': 'json', '-h': 'help', '--help': 'help' },
222
+ booleans: ['json', 'help'],
223
+ bareDashIsPositional: true,
224
+ });
225
+
217
226
  export const parseLogoutArgs = makeParser({
218
227
  aliases: { '-h': 'help', '--help': 'help' },
219
228
  booleans: ['help'],
package/lib/scopes.js CHANGED
@@ -75,7 +75,11 @@ export const COMMAND_SCOPES = {
75
75
  skills: [],
76
76
  config: [],
77
77
  logout: [],
78
+ // reconnect and pair mint their OWN pre-claim credential and only touch the
79
+ // unscoped pair/start, pair/status and revoke routes, so they request nothing
80
+ // here. What they ask the human to approve is CLI_SCOPES above.
78
81
  reconnect: [],
82
+ pair: [],
79
83
  };
80
84
 
81
85
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {