@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 +39 -1
- package/bin/pingroom.js +4 -3
- package/lib/commands/connect.js +174 -23
- package/lib/commands/manage.js +41 -6
- package/lib/commands/skills.js +2 -0
- package/lib/config.js +1 -1
- package/lib/help.js +30 -4
- package/lib/http.js +7 -1
- package/lib/parser.js +9 -0
- package/lib/scopes.js +4 -0
- package/package.json +1 -1
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,
|
|
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
|
|
package/lib/commands/connect.js
CHANGED
|
@@ -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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
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
|
-
|
|
802
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/commands/manage.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/commands/skills.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
/**
|