@pingroom/cli 0.8.1 → 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
@@ -257,7 +286,12 @@ it **silently**, `end` closes it with one completion alert.
257
286
  The management nouns cover the rest of the agent surface (agent token required;
258
287
  `--room` where noted):
259
288
 
289
+ Room `--icon` takes a v3 catalog id (`bell`, `globe`, `terminal`, …), never an
290
+ emoji — the server rejects anything off-catalog. Browse ids with
291
+ `pingroom rooms icons`. Quick-action `--icon` is the opposite: it takes an emoji.
292
+
260
293
  ```bash
294
+ pingroom rooms icons # browse the v3 room-icon catalog
261
295
  pingroom rooms list # rooms this account belongs to
262
296
  pingroom rooms get GZNFB6BZGJIH
263
297
  pingroom rooms create -n "Deploys" --icon bell --color "#e33122"
@@ -652,6 +686,10 @@ See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
652
686
 
653
687
  ## Agent skills
654
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
+
655
693
  Two ready-to-install [Claude Code skills](https://github.com/pingroom/skills)
656
694
  teach an agent when and how to reach a human — `pingroom-mcp` for conversational
657
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
  }
@@ -38,9 +38,21 @@ function printJsonOr(args, text, lines) {
38
38
 
39
39
  export async function rooms(args) {
40
40
  if (args.help) { process.stdout.write(`${commandHelp('rooms')}\n`); return EXIT.OK; }
41
- const action = sub(args, ['list', 'get', 'create', 'join'], 'rooms');
41
+ const action = sub(args, ['list', 'get', 'create', 'join', 'icons'], 'rooms');
42
42
  const { token, apiBase } = agentContext(args);
43
43
 
44
+ if (action === 'icons') {
45
+ const { text, json } = await requireOk(
46
+ httpJson('GET', `${apiBase}/api/agent/room-icons`, { headers: auth(token) }),
47
+ 'rooms icons',
48
+ );
49
+ if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
50
+ const categories = Array.isArray(json.categories) ? json.categories : [];
51
+ const lines = categories.map((c) => `${c.label ?? c.id}: ${(c.icons ?? []).join(' ')}`);
52
+ process.stdout.write(`${lines.join('\n') || '(no icons)'}\n`);
53
+ return EXIT.OK;
54
+ }
55
+
44
56
  if (action === 'list') {
45
57
  const { text, json } = await requireOk(
46
58
  httpJson('GET', `${apiBase}/api/agent/rooms`, { headers: auth(token) }),
@@ -69,17 +81,18 @@ export async function rooms(args) {
69
81
  httpJson('POST', `${apiBase}/api/agent/rooms/join`, { headers: auth(token), body: { invite_code: code } }),
70
82
  'rooms join',
71
83
  );
72
- printJsonOr(args, text, `joined ${json.room?.name ?? code}`);
84
+ // The server returns the room flat, so `json.room` is never set and the
85
+ // name always fell back to the invite code.
86
+ printJsonOr(args, text, `joined ${json.room?.name ?? json.name ?? code}`);
73
87
  return EXIT.OK;
74
88
  }
75
89
 
76
90
  // create — private by default; --public requires --handle and uses the
77
91
  // dedicated consent scope on the server.
78
92
  if (!args.name) fail('rooms create needs --name', EXIT.USAGE);
79
- if (!args.icon || !args.color) fail('rooms create needs --icon and --color (see "pingroom rooms list-icons" via the app, e.g. --icon bell --color "#e33122")', EXIT.USAGE);
93
+ if (!args.icon || !args.color) fail('rooms create needs --icon and --color. --icon is a v3 catalog id, not an emoji — run "pingroom rooms icons" to browse, e.g. --icon bell --color "#e33122"', EXIT.USAGE);
80
94
 
81
95
  const body = { name: args.name, icon: args.icon, color: args.color };
82
- if (args.description) body.description = args.description;
83
96
 
84
97
  let url = `${apiBase}/api/agent/rooms`;
85
98
  if (args.public) {
@@ -88,6 +101,17 @@ export async function rooms(args) {
88
101
  url = `${apiBase}/api/agent/rooms/public`;
89
102
  }
90
103
 
104
+ // The ordinary rooms:write scope creates a MINIMAL private room: the server
105
+ // marks description (with is_public, handle, category, actions and friends)
106
+ // `prohibited` there and answers 422. Only the public create carries them.
107
+ // Say so here rather than posting a request that cannot succeed.
108
+ if (args.description !== undefined) {
109
+ if (!args.public) {
110
+ fail('--description is only accepted on a public room (add --public --handle <handle>). A private agent room is created minimal; set its description from the app.', EXIT.USAGE);
111
+ }
112
+ body.description = args.description;
113
+ }
114
+
91
115
  const { text, json } = await requireOk(httpJson('POST', url, { headers: auth(token), body }), 'rooms create');
92
116
  printJsonOr(args, text, `created ${json.room?.invite_code ?? json.invite_code ?? ''} ${args.name}`.trim());
93
117
  return EXIT.OK;
@@ -128,7 +152,13 @@ export async function webhooks(args) {
128
152
  if (!body.name) fail('webhooks create needs --name', EXIT.USAGE);
129
153
  const { text, json } = await requireOk(httpJson('POST', base, { headers: auth(token), body }), 'webhooks create');
130
154
  // The trigger URL carries the webhook secret — print it once, like the app.
131
- printJsonOr(args, text, `created ${json.webhook?.id ?? ''}\n${json.webhook?.url ?? json.url ?? ''}`.trim());
155
+ // The server answers with the webhook FLAT plus `webhook_url`; the nested
156
+ // shapes are only kept as fallbacks. Reading `json.webhook.*` alone printed
157
+ // a bare "created" and swallowed the one credential this command exists to
158
+ // hand over.
159
+ const created = json.webhook ?? json;
160
+ const url = created.webhook_url ?? created.url ?? json.webhook_url ?? json.url ?? '';
161
+ printJsonOr(args, text, `created ${created.id ?? ''}\n${url}`.trim());
132
162
  return EXIT.OK;
133
163
  }
134
164
 
@@ -169,7 +199,12 @@ export async function actions(args) {
169
199
  return EXIT.OK;
170
200
  }
171
201
 
172
- if (!args.label || !args.icon) fail('actions set needs --label and --icon', EXIT.USAGE);
202
+ // A Ping's title is optional its emoji can be the whole name — so
203
+ // `--label ""` is a deliberate value, not a missing flag. The emoji is the
204
+ // half that must be there.
205
+ if (args.label === undefined || !args.icon) {
206
+ fail('actions set needs --label (may be empty) and --icon', EXIT.USAGE);
207
+ }
173
208
  const body = { label: args.label, icon: args.icon };
174
209
  if (args.sound !== undefined) body.sound = args.sound;
175
210
  if (args.require_ack) body.requires_ack = true;
@@ -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
@@ -32,13 +32,15 @@ Commands:
32
32
  Claude Desktop
33
33
  skills List the PingRoom agent skills, or install them for Claude Code
34
34
  activate Retry Agent Inbox activation with the saved QR-paired credential
35
- rooms List, inspect, create, or join rooms (rooms list|get|create|join)
35
+ rooms List, inspect, create, or join rooms; browse the icon catalog
36
+ (rooms list|get|create|join|icons)
36
37
  webhooks Manage a room's incoming webhooks (webhooks list|create|update|delete)
37
38
  actions List, configure, or trigger a room's quick actions (actions list|set|trigger)
38
39
  approval Send an approve/deny request; with --wait, block on the decision
39
40
  attachment Download or delete an attachment by id (attachment get|delete)
40
41
  config Read/write local settings (config list | get <key> | set <key> <val>)
41
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
42
44
  logout Forget the stored credential`;
43
45
 
44
46
  export const HELP_PING = `ping options:
@@ -217,7 +219,8 @@ export const HELP_TAIL = `Connecting:
217
219
  intentionally, provide that host's token with --token or PINGROOM_TOKEN.
218
220
 
219
221
  Non-interactive shells (CI, pipes) never prompt and never draw a QR: set
220
- 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).
221
224
 
222
225
  Examples:
223
226
  pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
@@ -313,10 +316,14 @@ export const COMMAND_HELP_SECTIONS = {
313
316
  rooms: `rooms (agent token required):
314
317
  pingroom rooms list List the rooms this account belongs to
315
318
  pingroom rooms get <code> Show one room
319
+ pingroom rooms icons Browse the v3 room-icon catalog
316
320
  pingroom rooms create -n <name> --icon <id> --color <hex>
317
- Create a private room (add --description);
321
+ Create a minimal private room.
322
+ --icon is a v3 catalog id from "rooms icons"
323
+ (e.g. bell), NOT an emoji;
318
324
  --public --handle <handle> creates a public
319
- room under its own consent scope
325
+ room under its own consent scope, and is
326
+ the only create that takes --description
320
327
  pingroom rooms join <code> Join a room by invite code`,
321
328
  webhooks: `webhooks (agent token + --room required):
322
329
  pingroom webhooks list --room <code>
@@ -329,6 +336,7 @@ export const COMMAND_HELP_SECTIONS = {
329
336
  actions: `actions (agent token + --room required):
330
337
  pingroom actions list --room <code>
331
338
  pingroom actions set <1-4> --room <code> --label <text> --icon <emoji>
339
+ (--label "" makes an emoji-only Ping)
332
340
  Optional: --sound, --require-ack
333
341
  pingroom actions trigger <1-4> --room <code>`,
334
342
  approval: `approval (agent token + --room required):
@@ -345,6 +353,21 @@ export const COMMAND_HELP_SECTIONS = {
345
353
  Download; bytes go to --out or stdout
346
354
  pingroom attachment delete <id> Delete an unclaimed upload`,
347
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.`,
348
371
  reconnect: `reconnect:
349
372
  pingroom reconnect Re-approve this CLI with the permissions the
350
373
  current version needs, then revoke the old
@@ -376,6 +399,9 @@ export const COMMAND_HELP_FOOTERS = {
376
399
  // with the credential it is about to replace.
377
400
  reconnect: `Shared:
378
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`,
379
405
  // `skills` reaches GitHub, never the PingRoom API, so the shared credential
380
406
  // and --json flags would all be lies here.
381
407
  skills: `Shared:
package/lib/http.js CHANGED
@@ -35,7 +35,13 @@ export function apiDetail(res, json) {
35
35
  const base = stripControlChars(
36
36
  (json && (json.message || json.error || json.code)) || `HTTP ${res ? res.status : 'error'}`,
37
37
  );
38
- const hint = json && typeof json.code === 'string' ? API_HINTS[json.code] : undefined;
38
+ let hint = json && typeof json.code === 'string' ? API_HINTS[json.code] : undefined;
39
+ // A 401 means the stored credential is dead (revoked in the app, or the
40
+ // pairing was cleaned up). Without this line the CLI prints a bare
41
+ // "Unauthenticated." and leaves the user to guess the remedy.
42
+ if (!hint && res && res.status === 401) {
43
+ hint = 'This credential is no longer valid — run "pingroom reconnect" to pair again.';
44
+ }
39
45
  return hint ? `${base}\n ${hint}` : base;
40
46
  }
41
47
 
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.8.1",
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": {