@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 +34 -1
- package/bin/pingroom.js +4 -3
- package/lib/commands/connect.js +174 -23
- package/lib/commands/skills.js +2 -0
- package/lib/config.js +1 -1
- package/lib/help.js +21 -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
|
|
@@ -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,
|
|
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/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
|
@@ -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
|
|
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
|
/**
|