@pingroom/cli 0.7.4 โ†’ 0.7.6

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
@@ -159,6 +159,9 @@ pingroom ping [options]
159
159
  -d, --data <json> Extra JSON data, e.g. '{"commit":"abc123"}'
160
160
  --url <https-url> Make the ping a tappable link (absolute http(s) URL)
161
161
  --button-label <t> Link button text (<= 26 chars; requires --url)
162
+ --location <lat,lng> Attach a map location (latitude,longitude)
163
+ --location-label <t> Location label (<= 100 chars; requires --location)
164
+ --location-address <t> Address (<= 255 chars; requires --location)
162
165
  --require-ack Keep the ping open until an eligible recipient acknowledges it
163
166
  --ack-timeout <s> Ack deadline in seconds (requires --require-ack)
164
167
  --attach <path> Attach a file; repeat for up to 4 (requires --token)
@@ -174,6 +177,23 @@ in private rooms and 160 in public rooms. A room code or webhook URL does not
174
177
  reveal room visibility, so the CLI rejects only bodies over 160 locally; the
175
178
  server applies the tighter 120-character private-room limit.
176
179
 
180
+ To send a location, pass decimal latitude and longitude as one comma-separated
181
+ value. Optional map text rides inside the same reserved `data.location` object:
182
+
183
+ ```bash
184
+ pingroom ping --room ab12cd -m "Meet me here" \
185
+ --location "25.2048,55.2708" \
186
+ --location-label "Dubai Mall" \
187
+ --location-address "Downtown Dubai"
188
+ ```
189
+
190
+ Latitude is inclusive -90..90 and longitude is inclusive -180..180. Labels are
191
+ limited to 100 Unicode characters and addresses to 255. The recipient can share
192
+ the point or open it in Waze, Google Maps, Apple Maps, or another installed map
193
+ app. These flags work with agent-token and incoming-webhook sends. When combined
194
+ with `--data`, the explicit flags replace only `data.location`; sibling keys are
195
+ preserved.
196
+
177
197
  To make the ping actionable, add `--require-ack`. The first eligible recipient to
178
198
  acknowledge it wins; `--ack-timeout` optionally expires it if nobody responds:
179
199
 
@@ -223,6 +243,36 @@ on the Lock Screen (iOS Live Activity / Dynamic Island, Android live update, and
223
243
  a full inline card in the app). `start` opens it with one alert, `update` moves
224
244
  it **silently**, `end` closes it with one completion alert.
225
245
 
246
+ ## Managing rooms, webhooks, and quick actions
247
+
248
+ The management nouns cover the rest of the agent surface (agent token required;
249
+ `--room` where noted):
250
+
251
+ ```bash
252
+ pingroom rooms list # rooms this account belongs to
253
+ pingroom rooms get GZNFB6BZGJIH
254
+ pingroom rooms create -n "Deploys" --icon bell --color "#e33122"
255
+ pingroom rooms create -n "Status" --icon globe --color "#0391fe" --public --handle status
256
+ pingroom rooms join GZNFB6BZGJIH
257
+
258
+ pingroom webhooks list --room GZNFB6BZGJIH
259
+ pingroom webhooks create --room GZNFB6BZGJIH --name "CI" --action 2 # prints the secret URL once
260
+ pingroom webhooks update <id> --room GZNFB6BZGJIH --enabled false
261
+ pingroom webhooks delete <id> --room GZNFB6BZGJIH
262
+
263
+ pingroom actions list --room GZNFB6BZGJIH
264
+ pingroom actions set 3 --room GZNFB6BZGJIH --label "Deploy done" --icon ๐Ÿš€
265
+ pingroom actions trigger 3 --room GZNFB6BZGJIH
266
+
267
+ pingroom approval -p "Ship v2 to production?" --wait # exit 0 approved ยท 4 denied ยท 3 expired
268
+
269
+ pingroom attachment get <id> --out report.md
270
+ pingroom attachment delete <id>
271
+ ```
272
+
273
+ Creating webhooks and uploading attachments require a Pro account; public-room
274
+ creation runs under its own consent scope (`pingroom:rooms:publish`).
275
+
226
276
  ## Hearing replies
227
277
 
228
278
  Everything else here talks; `listen` is how an agent hears โ€” replies to its own
@@ -375,13 +425,15 @@ Full protocol: <https://pingroom.io/liveactivities.md>
375
425
  run: ./deploy-prod.sh
376
426
 
377
427
  # Or ask the whole room instead of one person. Same outputs, plus question-id.
378
- # Requires v0.7.3 or newer โ€” see the note below before copying this block.
428
+ # `scope: room` is what opens it to the room โ€” without it a question is
429
+ # answerable only by the connecting account. Requires v0.7.5 or newer.
379
430
  - id: env
380
- uses: pingroom/cli@v0.7.3
431
+ uses: pingroom/cli@v0.7.5
381
432
  with:
382
433
  token: ${{ secrets.PINGROOM_TOKEN }}
383
434
  room: ab12cd
384
435
  ask: 'true'
436
+ scope: 'room'
385
437
  message: 'Which environment?'
386
438
  context: 'build ${{ github.run_number }}'
387
439
  options: |
@@ -398,7 +450,13 @@ including `@v0` before it is moved to v0.7.3 โ€” GitHub treats them as unexpecte
398
450
  inputs, warns, and drops them; the step then falls through to the plain `ping`
399
451
  path, `outputs.answer` is never set, and any job gated on it silently proceeds.
400
452
 
401
- `urgent` was added in **v0.7.4** and has the same failure mode on an older pin:
453
+ `scope` was added in **v0.7.5**, and without it every `ask` goes out as a direct
454
+ question to the connecting account โ€” the step still succeeds, so an Action meant
455
+ to poll the room silently polls one person instead.
456
+
457
+ `urgent` shipped in the **0.7.4 npm package**, but no `v0.7.4` Action tag was
458
+ ever cut, so **v0.7.5 is the first tag that carries it**. It has the same failure
459
+ mode on an older pin:
402
460
  GitHub drops the unknown input and the Ping goes out at normal priority, which
403
461
  looks like it worked. `require-ack` is not a substitute โ€” as of the same release
404
462
  it opens the acknowledgement lifecycle without raising the interruption level,
@@ -478,6 +536,9 @@ Options are `value:label` pairs (repeat `-o` for 2โ€“4). Omit them for the binar
478
536
  Approve/Deny default โ€” two options is the lock-screen fast path. `--ttl` sets the
479
537
  expiry in seconds (default 1h; 30โ€“86400). `--scope room` lets any eligible member
480
538
  answer (first tap wins); the default `direct` asks your bound user.
539
+ `--idempotency-key <key>` sends a printable 1โ€“255 character `Idempotency-Key`
540
+ header. Retrying the same key with the same request returns the original
541
+ Question; reusing it with a changed request returns `409 idempotency_conflict`.
481
542
 
482
543
  ## Handoffs (agent โ†’ human)
483
544
 
@@ -562,18 +623,20 @@ tune the approval-question expiry with `--ttl <seconds>` (default 900).
562
623
 
563
624
  ## MCP client setup
564
625
 
565
- Print the canonical remote endpoint, a copy-ready Claude Code command, Cursor
566
- JSON, and the Claude Desktop custom-connector steps:
626
+ Print the canonical remote endpoint, copy-ready Codex and Claude Code commands,
627
+ Cursor JSON, Claude custom-connector steps, and the public OpenAI directory:
567
628
 
568
629
  ```bash
569
630
  pingroom mcp
570
631
  # no global install: npx --yes @pingroom/cli mcp
571
632
  ```
572
633
 
573
- `pingroom mcp add claude-code` prints the exact `claude mcp add` command but does
574
- not execute it or modify client configuration. After adding the server, use the
575
- client's MCP controls to authenticate in the browser; no PingRoom API key is
576
- pasted into its config.
634
+ `pingroom mcp add codex` and `pingroom mcp add claude-code` print the exact setup
635
+ commands but do not execute them or modify client configuration. After adding
636
+ the server, use the client's MCP controls to authenticate in the browser; no
637
+ PingRoom API key is pasted into its config. Once PingRoom's public listing is
638
+ approved, the same plugin will also be discoverable in the Plugins Directory
639
+ shared by ChatGPT and Codex.
577
640
 
578
641
  For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
579
642
  See <https://pingroom.io/connect-mcp.md> for the complete MCP and OAuth guide.
package/bin/pingroom.js CHANGED
@@ -41,8 +41,9 @@ import { VERSION } from '../lib/version.js';
41
41
  import { HELP } from '../lib/help.js';
42
42
  import {
43
43
  parseArgs, parseConfigArgs, parseHandoffArgs, parseHandoffsArgs, parseHookArgs,
44
- parseLiveArgs, parseLogoutArgs, parseQArgs,
44
+ parseLiveArgs, parseLogoutArgs, parseManageArgs, parseQArgs,
45
45
  } from '../lib/parser.js';
46
+ import { actions, approval, attachment, rooms, webhooks } from '../lib/commands/manage.js';
46
47
  import { ping } from '../lib/commands/ping.js';
47
48
  import { ask, cancel, list, watch } from '../lib/commands/ask.js';
48
49
  import { handoff, listHandoffs } from '../lib/commands/handoff.js';
@@ -67,6 +68,11 @@ const COMMANDS = {
67
68
  mcp,
68
69
  activate: (rest) => activateStoredInbox(parseQArgs(rest)),
69
70
  live: (rest) => live(parseLiveArgs(rest)),
71
+ rooms: (rest) => rooms(parseManageArgs(rest)),
72
+ webhooks: (rest) => webhooks(parseManageArgs(rest)),
73
+ actions: (rest) => actions(parseManageArgs(rest)),
74
+ approval: (rest) => approval(parseManageArgs(rest)),
75
+ attachment: (rest) => attachment(parseManageArgs(rest)),
70
76
  config: (rest) => config(parseConfigArgs(rest)),
71
77
  logout: (rest) => logout(parseLogoutArgs(rest)),
72
78
  };
@@ -76,11 +76,24 @@ export async function ask(args) {
76
76
  }
77
77
  if (args.data !== undefined) body.data = parseDataObject(args.data);
78
78
 
79
+ const headers = { Authorization: `Bearer ${token}` };
80
+ // Question creation is the durable human gate for workflows. A printable,
81
+ // bounded key lets a caller safely replay the exact same create request
82
+ // after an ambiguous transport failure; the server returns 409 if the key is
83
+ // ever reused for a different payload.
84
+ if (args.idempotency_key !== undefined) {
85
+ const key = String(args.idempotency_key);
86
+ if (!/^[\x21-\x7E]{1,255}$/.test(key)) {
87
+ fail('--idempotency-key must be 1โ€“255 printable ASCII characters without spaces', EXIT.USAGE);
88
+ }
89
+ headers['Idempotency-Key'] = key;
90
+ }
91
+
79
92
  // Pre-flight: reject a bad --timeout before the question exists.
80
93
  if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
81
94
 
82
95
  const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
83
- const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
96
+ const { res, text, json } = await httpJson('POST', url, { body, headers });
84
97
  if (!res.ok) {
85
98
  const detail = apiDetail(res, json);
86
99
  fail(`ask failed: ${detail}`);
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
 
3
- import { EXIT, PUBLIC_PING_MESSAGE_MAX_LENGTH } from '../constants.js';
3
+ import { EXIT, PRIVATE_PING_MESSAGE_MAX_LENGTH } from '../constants.js';
4
4
  import { truncate } from '../util.js';
5
5
  import { commandHelp } from '../help.js';
6
6
  import { hookFetch, isSafeUrl } from '../http.js';
@@ -62,7 +62,14 @@ function summarizeTranscript(path) {
62
62
  const msg = entry && entry.message;
63
63
  if (!msg || msg.role !== 'assistant') continue;
64
64
  const text = extractAssistantText(msg.content).replace(/\s+/g, ' ').trim();
65
- if (text) return truncate(text, PUBLIC_PING_MESSAGE_MAX_LENGTH);
65
+ // The tighter PRIVATE bound, not the public one. `ping --message` may sit at
66
+ // the public ceiling because the caller typed that text and the server is
67
+ // entitled to reject it. Here the CLI COMPOSES the body, and a hook room is
68
+ // private (max 120), so truncating at 160 would hand the server a body it
69
+ // rejects โ€” and hookPing swallows the 422, silently dropping the ping. The
70
+ // text is already being truncated, so the tighter cut costs nothing and is
71
+ // valid in a public room too.
72
+ if (text) return truncate(text, PRIVATE_PING_MESSAGE_MAX_LENGTH);
66
73
  }
67
74
  return '';
68
75
  }
@@ -185,7 +192,7 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
185
192
  title = 'Claude finished';
186
193
  message = summarizeTranscript(event.transcript_path) || 'Session finished โ€” waiting for you.';
187
194
  } else if (name === 'Notification') {
188
- message = truncate(event.message || 'Claude is waiting for your input.', PUBLIC_PING_MESSAGE_MAX_LENGTH);
195
+ message = truncate(event.message || 'Claude is waiting for your input.', PRIVATE_PING_MESSAGE_MAX_LENGTH);
189
196
  // A PreToolUse hook already turns permission prompts into a question; skip
190
197
  // the duplicate "needs your permission" Notification so you aren't paged twice.
191
198
  if (/permission/i.test(message)) return EXIT.OK;
@@ -193,7 +200,12 @@ async function hookNotify(event, name, { token, room, apiBase, args }) {
193
200
  } else if (name === 'SessionEnd') {
194
201
  if (event.reason === 'clear') return EXIT.OK; // /clear isn't worth a ping
195
202
  title = 'Session ended';
196
- message = `Claude Code session ended (${event.reason || 'unknown'}).`;
203
+ // `reason` is whatever the host put in the event, so bound the composed
204
+ // line rather than trusting it to stay short.
205
+ message = truncate(
206
+ `Claude Code session ended (${event.reason || 'unknown'}).`,
207
+ PRIVATE_PING_MESSAGE_MAX_LENGTH,
208
+ );
197
209
  } else {
198
210
  return EXIT.OK; // unknown event โ€” stay silent rather than send noise
199
211
  }
@@ -0,0 +1,260 @@
1
+ // Room, webhook, quick-action, approval, and attachment management โ€” the
2
+ // agent REST surface that previously had no CLI verbs. One module, one
3
+ // sub-command dispatch per noun, JSON-first output (--json prints the raw
4
+ // response; the default prints a compact human line per record).
5
+
6
+ import { EXIT } from '../constants.js';
7
+ import { fail } from '../util.js';
8
+ import { commandHelp } from '../help.js';
9
+ import { apiDetail, httpJson } from '../http.js';
10
+ import { agentContext } from '../config.js';
11
+
12
+ function sub(args, allowed, noun) {
13
+ const name = args._[0];
14
+ if (!name || !allowed.includes(name)) {
15
+ fail(`usage: pingroom ${noun} <${allowed.join('|')}>\nRun "pingroom ${noun} --help".`, EXIT.USAGE);
16
+ }
17
+ return name;
18
+ }
19
+
20
+ function auth(token) {
21
+ return { Authorization: `Bearer ${token}` };
22
+ }
23
+
24
+ async function requireOk(promise, what) {
25
+ const { res, text, json } = await promise;
26
+ if (!res.ok) fail(`${what} failed: ${apiDetail(res, json)}`);
27
+ return { text, json };
28
+ }
29
+
30
+ function printJsonOr(args, text, lines) {
31
+ if (args.json) process.stdout.write(`${text}\n`);
32
+ else process.stdout.write(`${lines}\n`);
33
+ }
34
+
35
+ // ---------------------------------------------------------------- rooms
36
+
37
+ export async function rooms(args) {
38
+ if (args.help) { process.stdout.write(`${commandHelp('rooms')}\n`); return EXIT.OK; }
39
+ const action = sub(args, ['list', 'get', 'create', 'join'], 'rooms');
40
+ const { token, apiBase } = agentContext(args);
41
+
42
+ if (action === 'list') {
43
+ const { text, json } = await requireOk(
44
+ httpJson('GET', `${apiBase}/api/agent/rooms`, { headers: auth(token) }),
45
+ 'rooms list',
46
+ );
47
+ const items = Array.isArray(json) ? json : (json.rooms ?? json.data ?? []);
48
+ printJsonOr(args, text, items.map((r) => `${r.invite_code} ${r.name}${r.is_public ? ' (public)' : ''}`).join('\n') || '(no rooms)');
49
+ return EXIT.OK;
50
+ }
51
+
52
+ if (action === 'get') {
53
+ const code = args._[1];
54
+ if (!code) fail('usage: pingroom rooms get <invite-code>', EXIT.USAGE);
55
+ const { text } = await requireOk(
56
+ httpJson('GET', `${apiBase}/api/agent/rooms/${encodeURIComponent(code)}`, { headers: auth(token) }),
57
+ 'rooms get',
58
+ );
59
+ process.stdout.write(`${text}\n`);
60
+ return EXIT.OK;
61
+ }
62
+
63
+ if (action === 'join') {
64
+ const code = args._[1];
65
+ if (!code) fail('usage: pingroom rooms join <invite-code>', EXIT.USAGE);
66
+ const { text, json } = await requireOk(
67
+ httpJson('POST', `${apiBase}/api/agent/rooms/join`, { headers: auth(token), body: { invite_code: code } }),
68
+ 'rooms join',
69
+ );
70
+ printJsonOr(args, text, `joined ${json.room?.name ?? code}`);
71
+ return EXIT.OK;
72
+ }
73
+
74
+ // create โ€” private by default; --public requires --handle and uses the
75
+ // dedicated consent scope on the server.
76
+ if (!args.name) fail('rooms create needs --name', EXIT.USAGE);
77
+ 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);
78
+
79
+ const body = { name: args.name, icon: args.icon, color: args.color };
80
+ if (args.description) body.description = args.description;
81
+
82
+ let url = `${apiBase}/api/agent/rooms`;
83
+ if (args.public) {
84
+ if (!args.handle) fail('a public room needs --handle', EXIT.USAGE);
85
+ body.handle = args.handle;
86
+ url = `${apiBase}/api/agent/rooms/public`;
87
+ }
88
+
89
+ const { text, json } = await requireOk(httpJson('POST', url, { headers: auth(token), body }), 'rooms create');
90
+ printJsonOr(args, text, `created ${json.room?.invite_code ?? json.invite_code ?? ''} ${args.name}`.trim());
91
+ return EXIT.OK;
92
+ }
93
+
94
+ // ------------------------------------------------------------- webhooks
95
+
96
+ export async function webhooks(args) {
97
+ if (args.help) { process.stdout.write(`${commandHelp('webhooks')}\n`); return EXIT.OK; }
98
+ const action = sub(args, ['list', 'create', 'update', 'delete'], 'webhooks');
99
+ const { token, apiBase, room } = agentContext(args, { needRoom: true });
100
+ const base = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/webhooks`;
101
+
102
+ if (action === 'list') {
103
+ const { text, json } = await requireOk(httpJson('GET', base, { headers: auth(token) }), 'webhooks list');
104
+ const items = Array.isArray(json) ? json : (json.webhooks ?? json.data ?? []);
105
+ printJsonOr(args, text, items.map((w) => `${w.id} ${w.name}${w.enabled === false ? ' (disabled)' : ''}`).join('\n') || '(no webhooks)');
106
+ return EXIT.OK;
107
+ }
108
+
109
+ if (action === 'delete') {
110
+ const id = args._[1];
111
+ if (!id) fail('usage: pingroom webhooks delete <id> --room <code>', EXIT.USAGE);
112
+ await requireOk(httpJson('DELETE', `${base}/${encodeURIComponent(id)}`, { headers: auth(token) }), 'webhooks delete');
113
+ process.stdout.write('deleted\n');
114
+ return EXIT.OK;
115
+ }
116
+
117
+ const body = {};
118
+ for (const key of ['name', 'title', 'message', 'icon', 'color', 'sound']) {
119
+ if (args[key] !== undefined) body[key] = args[key];
120
+ }
121
+ if (args.action !== undefined) body.action_number = Number(args.action);
122
+ if (args.cooldown !== undefined) body.cooldown_seconds = Number(args.cooldown);
123
+ if (args.enabled !== undefined) body.enabled = args.enabled !== 'false';
124
+
125
+ if (action === 'create') {
126
+ if (!body.name) fail('webhooks create needs --name', EXIT.USAGE);
127
+ const { text, json } = await requireOk(httpJson('POST', base, { headers: auth(token), body }), 'webhooks create');
128
+ // The trigger URL carries the webhook secret โ€” print it once, like the app.
129
+ printJsonOr(args, text, `created ${json.webhook?.id ?? ''}\n${json.webhook?.url ?? json.url ?? ''}`.trim());
130
+ return EXIT.OK;
131
+ }
132
+
133
+ const id = args._[1];
134
+ if (!id) fail('usage: pingroom webhooks update <id> --room <code> [fields]', EXIT.USAGE);
135
+ const { text } = await requireOk(
136
+ httpJson('PUT', `${base}/${encodeURIComponent(id)}`, { headers: auth(token), body }),
137
+ 'webhooks update',
138
+ );
139
+ process.stdout.write(`${text}\n`);
140
+ return EXIT.OK;
141
+ }
142
+
143
+ // -------------------------------------------------------------- actions
144
+
145
+ export async function actions(args) {
146
+ if (args.help) { process.stdout.write(`${commandHelp('actions')}\n`); return EXIT.OK; }
147
+ const action = sub(args, ['list', 'set', 'trigger'], 'actions');
148
+ const { token, apiBase, room } = agentContext(args, { needRoom: true });
149
+ const base = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/actions`;
150
+
151
+ if (action === 'list') {
152
+ const { text, json } = await requireOk(httpJson('GET', base, { headers: auth(token) }), 'actions list');
153
+ const items = Array.isArray(json) ? json : (json.actions ?? json.quick_actions ?? json.data ?? []);
154
+ printJsonOr(args, text, items.map((a) => `${a.action_number} ${a.icon ?? ''} ${a.label ?? '(unset)'}`).join('\n') || '(no actions)');
155
+ return EXIT.OK;
156
+ }
157
+
158
+ const slot = args._[1];
159
+ if (!/^[1-4]$/.test(String(slot))) fail(`usage: pingroom actions ${action} <1-4> --room <code>`, EXIT.USAGE);
160
+
161
+ if (action === 'trigger') {
162
+ const { text } = await requireOk(
163
+ httpJson('POST', `${base}/${slot}/trigger`, { headers: auth(token), body: {} }),
164
+ 'actions trigger',
165
+ );
166
+ process.stdout.write(`${text}\n`);
167
+ return EXIT.OK;
168
+ }
169
+
170
+ if (!args.label || !args.icon) fail('actions set needs --label and --icon', EXIT.USAGE);
171
+ const body = { label: args.label, icon: args.icon };
172
+ if (args.sound !== undefined) body.sound = args.sound;
173
+ if (args.require_ack) body.requires_ack = true;
174
+
175
+ const { text } = await requireOk(
176
+ httpJson('PUT', `${base}/${slot}`, { headers: auth(token), body }),
177
+ 'actions set',
178
+ );
179
+ process.stdout.write(`${text}\n`);
180
+ return EXIT.OK;
181
+ }
182
+
183
+ // ------------------------------------------------------------- approval
184
+
185
+ const APPROVAL_EXIT = { approved: EXIT.OK, denied: EXIT.CANCELLED, expired: EXIT.EXPIRED };
186
+
187
+ export async function approval(args) {
188
+ if (args.help) { process.stdout.write(`${commandHelp('approval')}\n`); return EXIT.OK; }
189
+
190
+ if (!args.prompt) fail('an approval needs --prompt', EXIT.USAGE);
191
+ const { token, apiBase, room } = agentContext(args, { needRoom: true });
192
+
193
+ const body = { prompt: args.prompt };
194
+ if (args.context) body.context = args.context;
195
+ if (args.ttl !== undefined) {
196
+ if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
197
+ body.ttl = Number(args.ttl);
198
+ }
199
+
200
+ const { text, json } = await requireOk(
201
+ httpJson('POST', `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/approvals`, { headers: auth(token), body }),
202
+ 'approval',
203
+ );
204
+
205
+ if (!args.wait) {
206
+ process.stdout.write(`${text}\n`);
207
+ return EXIT.OK;
208
+ }
209
+
210
+ for (;;) {
211
+ const { res, text: waitText, json: state } = await httpJson(
212
+ 'GET',
213
+ `${apiBase}/api/agent/approvals/${encodeURIComponent(json.id)}/wait?timeout=25`,
214
+ { headers: auth(token) },
215
+ );
216
+ if (!res.ok) fail(`approval wait failed: ${apiDetail(res, state)}`);
217
+ if (state && state.state && state.state !== 'pending') {
218
+ process.stdout.write(`${args.json ? waitText : state.state}\n`);
219
+ return APPROVAL_EXIT[state.state] ?? EXIT.OK;
220
+ }
221
+ }
222
+ }
223
+
224
+ // ----------------------------------------------------------- attachment
225
+
226
+ export async function attachment(args) {
227
+ if (args.help) { process.stdout.write(`${commandHelp('attachment')}\n`); return EXIT.OK; }
228
+ const action = sub(args, ['get', 'delete'], 'attachment');
229
+ const id = args._[1];
230
+ if (!id) fail(`usage: pingroom attachment ${action} <id>`, EXIT.USAGE);
231
+ const { token, apiBase } = agentContext(args);
232
+
233
+ if (action === 'delete') {
234
+ await requireOk(
235
+ httpJson('DELETE', `${apiBase}/api/agent/attachments/${encodeURIComponent(id)}`, { headers: auth(token) }),
236
+ 'attachment delete',
237
+ );
238
+ process.stdout.write('deleted\n');
239
+ return EXIT.OK;
240
+ }
241
+
242
+ // Binary download: raw fetch, not httpJson. Bytes go to --out or stdout.
243
+ const res = await fetch(`${apiBase}/api/agent/attachments/${encodeURIComponent(id)}/content`, {
244
+ headers: auth(token),
245
+ });
246
+ if (!res.ok) {
247
+ let json = null;
248
+ try { json = await res.json(); } catch { /* binary error bodies stay generic */ }
249
+ fail(`attachment get failed: ${apiDetail(res, json)}`);
250
+ }
251
+ const bytes = Buffer.from(await res.arrayBuffer());
252
+ if (args.out) {
253
+ const { writeFileSync } = await import('node:fs');
254
+ writeFileSync(args.out, bytes);
255
+ process.stdout.write(`${args.out} ${bytes.length} bytes\n`);
256
+ } else {
257
+ process.stdout.write(bytes);
258
+ }
259
+ return EXIT.OK;
260
+ }
@@ -6,6 +6,8 @@ import { fail } from '../util.js';
6
6
 
7
7
  export function mcp(rest) {
8
8
  const claudeCommand = `claude mcp add --transport http pingroom ${MCP_ENDPOINT}`;
9
+ const codexAddCommand = `codex mcp add pingroom --url ${MCP_ENDPOINT}`;
10
+ const codexLoginCommand = 'codex mcp login pingroom';
9
11
 
10
12
  if (rest.length === 0 || (rest.length === 1 && (rest[0] === '-h' || rest[0] === '--help'))) {
11
13
  const config = {
@@ -20,6 +22,13 @@ export function mcp(rest) {
20
22
  Claude Code:
21
23
  ${claudeCommand}
22
24
 
25
+ Codex CLI:
26
+ ${codexAddCommand}
27
+ ${codexLoginCommand}
28
+
29
+ OpenAI Plugins Directory (ChatGPT and Codex):
30
+ Search for PingRoom after its public listing is approved.
31
+
23
32
  Cursor JSON (~/.cursor/mcp.json):
24
33
  ${JSON.stringify(config, null, 2)}
25
34
 
@@ -43,5 +52,14 @@ This command only prints setup instructions and does not modify client config.
43
52
  return EXIT.OK;
44
53
  }
45
54
 
46
- fail('usage: pingroom mcp [add claude-code]', EXIT.USAGE);
55
+ if (rest.length === 2 && rest[0] === 'add' && rest[1] === 'codex') {
56
+ process.stdout.write(
57
+ `No client configuration was changed. Copy and run:
58
+ ${codexAddCommand}
59
+ ${codexLoginCommand}
60
+ `);
61
+ return EXIT.OK;
62
+ }
63
+
64
+ fail('usage: pingroom mcp [add claude-code|codex]', EXIT.USAGE);
47
65
  }
@@ -1,9 +1,42 @@
1
- import { EXIT, PING_TITLE_MAX_LENGTH, PUBLIC_PING_MESSAGE_MAX_LENGTH } from '../constants.js';
1
+ import {
2
+ EXIT,
3
+ LOCATION_ADDRESS_MAX_LENGTH,
4
+ LOCATION_LABEL_MAX_LENGTH,
5
+ PING_TITLE_MAX_LENGTH,
6
+ PUBLIC_PING_MESSAGE_MAX_LENGTH,
7
+ } from '../constants.js';
2
8
  import { fail, parseDataObject, requireMaxLength } from '../util.js';
3
9
  import { commandHelp } from '../help.js';
4
10
  import { apiDetail, httpJson, requireSafeUrl, uploadAttachments } from '../http.js';
5
11
  import { requireStoredCredentialOrigin, resolveApiBase, resolveRoom, resolveToken } from '../config.js';
6
12
 
13
+ const DECIMAL_COORDINATE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
14
+
15
+ function parseLocation(value) {
16
+ const parts = String(value).split(',');
17
+ if (parts.length !== 2) {
18
+ fail('--location must contain exactly two coordinates formatted "latitude,longitude"', EXIT.USAGE);
19
+ }
20
+
21
+ const [latitudeText, longitudeText] = parts.map((part) => part.trim());
22
+ if (!DECIMAL_COORDINATE.test(latitudeText) || !DECIMAL_COORDINATE.test(longitudeText)) {
23
+ fail('--location coordinates must be finite numbers formatted "latitude,longitude"', EXIT.USAGE);
24
+ }
25
+
26
+ const latitude = Number(latitudeText);
27
+ const longitude = Number(longitudeText);
28
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
29
+ fail('--location coordinates must be finite numbers formatted "latitude,longitude"', EXIT.USAGE);
30
+ }
31
+ if (latitude < -90 || latitude > 90) {
32
+ fail('--location latitude must be between -90 and 90', EXIT.USAGE);
33
+ }
34
+ if (longitude < -180 || longitude > 180) {
35
+ fail('--location longitude must be between -180 and 180', EXIT.USAGE);
36
+ }
37
+ return { latitude, longitude };
38
+ }
39
+
7
40
  export async function ping(args) {
8
41
  if (args.help) { process.stdout.write(`${commandHelp('ping')}\n`); return EXIT.OK; }
9
42
 
@@ -59,6 +92,23 @@ export async function ping(args) {
59
92
  if (args.button_label !== undefined) data.button_label = args.button_label;
60
93
  }
61
94
 
95
+ // Location ping: explicit flags own the reserved data.location object. They
96
+ // replace a raw --data location while leaving every sibling key untouched.
97
+ if (args.location_label !== undefined && args.location === undefined) {
98
+ fail('--location-label requires --location', EXIT.USAGE);
99
+ }
100
+ if (args.location_address !== undefined && args.location === undefined) {
101
+ fail('--location-address requires --location', EXIT.USAGE);
102
+ }
103
+ if (args.location !== undefined) {
104
+ requireMaxLength(args.location_label, LOCATION_LABEL_MAX_LENGTH, '--location-label');
105
+ requireMaxLength(args.location_address, LOCATION_ADDRESS_MAX_LENGTH, '--location-address');
106
+ const location = parseLocation(args.location);
107
+ if (args.location_label !== undefined) location.label = args.location_label;
108
+ if (args.location_address !== undefined) location.address = args.location_address;
109
+ data = { ...(data || {}), location };
110
+ }
111
+
62
112
  const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
63
113
  const token = resolveToken(args);
64
114
  const apiBase = resolveApiBase(args);
package/lib/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Local state (~/.pingroom) and the layered resolution every command shares:
2
2
  // explicit flag > env var > config file > the paired credential > built-in.
3
3
 
4
- import { randomBytes } from 'node:crypto';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
5
  import {
6
6
  chmodSync, closeSync, fchmodSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync,
7
7
  writeFileSync,
@@ -12,6 +12,7 @@ import { join } from 'node:path';
12
12
  import { BUILTIN_API, EXIT } from './constants.js';
13
13
  import { fail } from './util.js';
14
14
  import { requireSafeUrl } from './http.js';
15
+ import { parseJsonStrict } from './strict-json.js';
15
16
 
16
17
  // --- local state (~/.pingroom) ---------------------------------------------
17
18
  //
@@ -36,9 +37,9 @@ export function configPath() { return join(pingroomHome(), 'config.json'); }
36
37
  // never be able to crash a ping: a hand-edited file degrades to "not set".
37
38
  export function readJsonFile(path) {
38
39
  let raw;
39
- try { raw = readFileSync(path, 'utf8'); } catch { return null; }
40
+ try { raw = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path)); } catch { return null; }
40
41
  let value;
41
- try { value = JSON.parse(raw); } catch { return null; }
42
+ try { value = parseJsonStrict(raw, path); } catch { return null; }
42
43
  if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
43
44
  return value;
44
45
  }
@@ -183,6 +184,13 @@ export function agentContext(args, { needRoom = false } = {}) {
183
184
  if (needRoom && !room) {
184
185
  fail('--room is required (or set one with "pingroom config set default_room <code>")', EXIT.USAGE);
185
186
  }
187
+ if (args.expected_room_sha256 !== undefined) {
188
+ const expected = String(args.expected_room_sha256);
189
+ if (!/^[a-f0-9]{64}$/.test(expected)) fail('--expected-room-sha256 must be 64 lowercase hexadecimal characters', EXIT.USAGE);
190
+ if (!room) fail('--expected-room-sha256 requires a resolved room', EXIT.USAGE);
191
+ const actual = createHash('sha256').update(String(room), 'utf8').digest('hex');
192
+ if (actual !== expected) fail('resolved room does not match --expected-room-sha256', EXIT.USAGE);
193
+ }
186
194
  return { token, apiBase, room };
187
195
  }
188
196
 
package/lib/constants.js CHANGED
@@ -12,3 +12,8 @@ export const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
12
12
  export const PING_TITLE_MAX_LENGTH = 40;
13
13
  export const PRIVATE_PING_MESSAGE_MAX_LENGTH = 120;
14
14
  export const PUBLIC_PING_MESSAGE_MAX_LENGTH = 160;
15
+
16
+ // Reserved data.location display metadata. Coordinate ranges are part of the
17
+ // numeric format itself; these caps mirror the API's Unicode-aware string rules.
18
+ export const LOCATION_LABEL_MAX_LENGTH = 100;
19
+ export const LOCATION_ADDRESS_MAX_LENGTH = 255;
package/lib/help.js CHANGED
@@ -31,6 +31,11 @@ Commands:
31
31
  mcp Print the remote MCP endpoint and setup for Claude Code, Cursor, and
32
32
  Claude Desktop
33
33
  activate Retry Agent Inbox activation with the saved QR-paired credential
34
+ rooms List, inspect, create, or join rooms (rooms list|get|create|join)
35
+ webhooks Manage a room's incoming webhooks (webhooks list|create|update|delete)
36
+ actions List, configure, or trigger a room's quick actions (actions list|set|trigger)
37
+ approval Send an approve/deny request; with --wait, block on the decision
38
+ attachment Download or delete an attachment by id (attachment get|delete)
34
39
  config Read/write local settings (config list | get <key> | set <key> <val>)
35
40
  logout Forget the stored credential`;
36
41
 
@@ -41,6 +46,9 @@ export const HELP_PING = `ping options:
41
46
  -d, --data <json> Extra JSON data object, e.g. '{"commit":"abc123"}'
42
47
  --url <https-url> Make the ping a tappable link (absolute http(s) URL)
43
48
  --button-label <t> Link button text (<= 26 chars; requires --url)
49
+ --location <lat,lng> Attach a map location (latitude,longitude)
50
+ --location-label <t> Location label (<= 100 chars; requires --location)
51
+ --location-address <t> Address (<= 255 chars; requires --location)
44
52
  --urgent Deliver time-sensitive so it breaks through Focus / Do Not
45
53
  Disturb. Delivery only - asks nothing of the recipient
46
54
  --require-ack Keep the ping open until an eligible recipient acknowledges it,
@@ -62,6 +70,7 @@ export const HELP_ASK = `ask options (agent token required):
62
70
  --scope <s> Who answers: 'direct' (default) or 'room'
63
71
  --target <uuid> For --scope direct: a specific room member
64
72
  --ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
73
+ --idempotency-key <key> Dedupe this exact create request across retries
65
74
  --text-input <ph> Invite a short typed answer; <ph> is the placeholder
66
75
  --text-max <n> Max typed-answer length (1..60)
67
76
  --wait Block until answered/expired/cancelled
@@ -70,6 +79,8 @@ export const HELP_ASK = `ask options (agent token required):
70
79
  --correlation-id <id> Opaque id echoed on every read of this question
71
80
  --reply-to <id> Id of the ping this question replies to
72
81
  --room <code> Room invite code (required for ask)
82
+ --expected-room-sha256 <hex>
83
+ Fail unless the resolved room matches this SHA-256
73
84
  --github-output <path> Safely append question outputs for GitHub Actions`;
74
85
 
75
86
  export const HELP_LIST = `list options:
@@ -207,6 +218,10 @@ Examples:
207
218
  pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Build 512 ready" \\
208
219
  --url https://ci.example.com/builds/512 --button-label "Open build"
209
220
 
221
+ # Location ping โ€” recipients can share it or open it in a maps app:
222
+ pingroom ping --room ab12cd -m "Meet me here" \\
223
+ --location "25.2048,55.2708" --location-label "Dubai Mall"
224
+
210
225
  # Gate a deploy on a human tap โ€” the chosen value prints to stdout:
211
226
  if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
212
227
  -p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
@@ -285,6 +300,37 @@ export const COMMAND_HELP_SECTIONS = {
285
300
  live: HELP_LIVE,
286
301
  hook: HELP_HOOK,
287
302
  activate: HELP_ACTIVATE,
303
+ rooms: `rooms (agent token required):
304
+ pingroom rooms list List the rooms this account belongs to
305
+ pingroom rooms get <code> Show one room
306
+ pingroom rooms create -n <name> --icon <id> --color <hex>
307
+ Create a private room (add --description);
308
+ --public --handle <handle> creates a public
309
+ room under its own consent scope
310
+ pingroom rooms join <code> Join a room by invite code`,
311
+ webhooks: `webhooks (agent token + --room required):
312
+ pingroom webhooks list --room <code>
313
+ pingroom webhooks create --room <code> --name <n>
314
+ Optional: --title --message --icon --color
315
+ --sound --action <1-4> --cooldown <sec>
316
+ Prints the secret trigger URL once
317
+ pingroom webhooks update <id> --room <code> [same fields, plus --enabled true|false]
318
+ pingroom webhooks delete <id> --room <code>`,
319
+ actions: `actions (agent token + --room required):
320
+ pingroom actions list --room <code>
321
+ pingroom actions set <1-4> --room <code> --label <text> --icon <emoji>
322
+ Optional: --sound, --require-ack
323
+ pingroom actions trigger <1-4> --room <code>`,
324
+ approval: `approval (agent token + --room required):
325
+ pingroom approval -p <prompt> Send an approve/deny request
326
+ -c, --context <text> Secondary line (<= 40 chars)
327
+ --ttl <seconds> Expiry; omit for the server default
328
+ --wait Block until approved/denied/expired
329
+ (exit 0 approved ยท 4 denied ยท 3 expired)`,
330
+ attachment: `attachment (agent token required):
331
+ pingroom attachment get <id> [--out <path>]
332
+ Download; bytes go to --out or stdout
333
+ pingroom attachment delete <id> Delete an unclaimed upload`,
288
334
  config: HELP_CONFIG,
289
335
  logout: `logout:
290
336
  pingroom logout Forget the stored credential (PINGROOM_TOKEN
package/lib/http.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { EXIT } from './constants.js';
5
5
  import { fail, stripControlChars } from './util.js';
6
+ import { readStrictJsonResponse } from './strict-json.js';
6
7
 
7
8
  /**
8
9
  * The fixes that live on THIS side of the wire. The server's message always
@@ -85,15 +86,13 @@ export async function httpJson(method, url, { body, headers = {}, soft = false,
85
86
  }
86
87
 
87
88
  let text;
89
+ let json;
88
90
  try {
89
- text = await res.text();
91
+ ({ text, json } = await readStrictJsonResponse(res, 'PingRoom API response'));
90
92
  } catch (err) {
91
- // A connection dropped mid-body throws here, not at fetch().
92
93
  if (soft) return { res: null, text: '', json: null, error: err };
93
- fail(`network error: ${err.message}`);
94
+ fail(`untrusted JSON response: ${err.message}`);
94
95
  }
95
- let json = null;
96
- try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
97
96
 
98
97
  return { res, text, json };
99
98
  }
@@ -165,9 +164,10 @@ export async function uploadAttachments(paths, apiBase, token) {
165
164
  fail(`network error uploading ${name}: ${err.message}`);
166
165
  }
167
166
 
168
- const text = await res.text().catch(() => '');
169
167
  let json = null;
170
- try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
168
+ try { ({ json } = await readStrictJsonResponse(res, 'attachment API response')); } catch (error) {
169
+ fail(`untrusted JSON response uploading ${name}: ${error.message}`);
170
+ }
171
171
 
172
172
  if (res.status === 402) {
173
173
  fail(`--attach ${name}: ping attachments are a Pro feature`, EXIT.USAGE);
@@ -196,9 +196,7 @@ export async function hookFetch(method, url, { body, token } = {}) {
196
196
  },
197
197
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
198
198
  });
199
- const text = await res.text();
200
- let json = null;
201
- try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON response */ }
199
+ const { json } = await readStrictJsonResponse(res, 'hook response');
202
200
  if (!res.ok) {
203
201
  throw new Error(apiDetail(res, json));
204
202
  }
package/lib/parser.js CHANGED
@@ -61,12 +61,16 @@ export const parseArgs = makeParser({
61
61
  '-w': 'webhook', '--webhook': 'webhook',
62
62
  '--url': 'url',
63
63
  '--button-label': 'button_label',
64
+ '--location': 'location',
65
+ '--location-label': 'location_label',
66
+ '--location-address': 'location_address',
64
67
  '--require-ack': 'require_ack',
65
68
  '--urgent': 'urgent',
66
69
  '--ack-timeout': 'ack_timeout',
67
70
  '--attach': 'attach',
68
71
  '--token': 'token',
69
72
  '--room': 'room',
73
+ '--expected-room-sha256': 'expected_room_sha256',
70
74
  '--api': 'api',
71
75
  '--json': 'json',
72
76
  '-h': 'help', '--help': 'help',
@@ -85,6 +89,7 @@ export const parseQArgs = makeParser({
85
89
  '--scope': 'scope',
86
90
  '--target': 'target',
87
91
  '--ttl': 'ttl',
92
+ '--idempotency-key': 'idempotency_key',
88
93
  '-d': 'data', '--data': 'data',
89
94
  '--correlation-id': 'correlation_id',
90
95
  '--reply-to': 'reply_to',
@@ -98,6 +103,7 @@ export const parseQArgs = makeParser({
98
103
  '--github-output': 'github_output',
99
104
  '--token': 'token',
100
105
  '--room': 'room',
106
+ '--expected-room-sha256': 'expected_room_sha256',
101
107
  '--api': 'api',
102
108
  '--wait': 'wait',
103
109
  '--json': 'json',
@@ -205,6 +211,40 @@ export const parseLogoutArgs = makeParser({
205
211
  bareDashIsPositional: true,
206
212
  });
207
213
 
214
+ // Parser for the management nouns (rooms / webhooks / actions / approval /
215
+ // attachment). One shared vocabulary: each sub-command reads the flags it
216
+ // needs and the rest are usage errors at the command layer, same as everywhere.
217
+ export const parseManageArgs = makeParser({
218
+ aliases: {
219
+ '-n': 'name', '--name': 'name',
220
+ '--description': 'description',
221
+ '--icon': 'icon',
222
+ '--color': 'color',
223
+ '--sound': 'sound',
224
+ '--label': 'label',
225
+ '-t': 'title', '--title': 'title',
226
+ '-m': 'message', '--message': 'message',
227
+ '-p': 'prompt', '--prompt': 'prompt',
228
+ '-c': 'context', '--context': 'context',
229
+ '--ttl': 'ttl',
230
+ '--public': 'public',
231
+ '--handle': 'handle',
232
+ '-a': 'action', '--action': 'action',
233
+ '--cooldown': 'cooldown',
234
+ '--enabled': 'enabled',
235
+ '--require-ack': 'require_ack',
236
+ '--out': 'out',
237
+ '--wait': 'wait',
238
+ '--token': 'token',
239
+ '--room': 'room',
240
+ '--expected-room-sha256': 'expected_room_sha256',
241
+ '--api': 'api',
242
+ '--json': 'json',
243
+ '-h': 'help', '--help': 'help',
244
+ },
245
+ booleans: ['public', 'require_ack', 'wait', 'json', 'help'],
246
+ });
247
+
208
248
  export const parseHandoffsArgs = makeParser({
209
249
  aliases: {
210
250
  '--state': 'state',
@@ -0,0 +1,93 @@
1
+ const MAX_JSON_BYTES = 1024 * 1024;
2
+
3
+ export function parseJsonStrict(raw, label = 'JSON') {
4
+ if (typeof raw !== 'string') throw new Error(`${label} must be UTF-8 JSON`);
5
+ rejectDuplicateKeys(raw, label);
6
+ return JSON.parse(raw);
7
+ }
8
+
9
+ export async function readStrictJsonResponse(response, label = 'response') {
10
+ const chunks = [];
11
+ let size = 0;
12
+ if (response.body) {
13
+ const reader = response.body.getReader();
14
+ for (;;) {
15
+ const { done, value } = await reader.read();
16
+ if (done) break;
17
+ size += value.byteLength;
18
+ if (size > MAX_JSON_BYTES) {
19
+ await reader.cancel().catch(() => {});
20
+ throw new Error(`${label} exceeded ${MAX_JSON_BYTES} bytes`);
21
+ }
22
+ chunks.push(Buffer.from(value));
23
+ }
24
+ }
25
+ const bytes = Buffer.concat(chunks);
26
+ let text;
27
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { throw new Error(`${label} was not valid UTF-8`); }
28
+ return { text, json: text ? parseJsonStrict(text, label) : null };
29
+ }
30
+
31
+ function rejectDuplicateKeys(raw, label) {
32
+ let index = 0;
33
+ const invalid = () => { throw new Error(`${label} is not valid JSON`); };
34
+ const whitespace = () => { while (/\s/u.test(raw[index] ?? '')) index += 1; };
35
+ const string = () => {
36
+ if (raw[index] !== '"') invalid();
37
+ const start = index++;
38
+ while (index < raw.length) {
39
+ if (raw[index] === '\\') { index += 2; continue; }
40
+ if (raw[index] === '"') {
41
+ index += 1;
42
+ try { return JSON.parse(raw.slice(start, index)); } catch { invalid(); }
43
+ }
44
+ index += 1;
45
+ }
46
+ invalid();
47
+ };
48
+ const value = () => {
49
+ whitespace();
50
+ if (raw[index] === '{') return object();
51
+ if (raw[index] === '[') return array();
52
+ if (raw[index] === '"') { string(); return; }
53
+ const start = index;
54
+ while (index < raw.length && !/[\s,\]}]/u.test(raw[index])) index += 1;
55
+ if (index === start) invalid();
56
+ };
57
+ const object = () => {
58
+ index += 1;
59
+ whitespace();
60
+ const keys = new Set();
61
+ if (raw[index] === '}') { index += 1; return; }
62
+ for (;;) {
63
+ whitespace();
64
+ const key = string();
65
+ if (keys.has(key)) throw new Error(`${label} contains a duplicate object key`);
66
+ keys.add(key);
67
+ whitespace();
68
+ if (raw[index] !== ':') invalid();
69
+ index += 1;
70
+ value();
71
+ whitespace();
72
+ if (raw[index] === '}') { index += 1; return; }
73
+ if (raw[index] !== ',') invalid();
74
+ index += 1;
75
+ }
76
+ };
77
+ const array = () => {
78
+ index += 1;
79
+ whitespace();
80
+ if (raw[index] === ']') { index += 1; return; }
81
+ for (;;) {
82
+ value();
83
+ whitespace();
84
+ if (raw[index] === ']') { index += 1; return; }
85
+ if (raw[index] !== ',') invalid();
86
+ index += 1;
87
+ }
88
+ };
89
+ whitespace();
90
+ value();
91
+ whitespace();
92
+ if (index !== raw.length) invalid();
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pingroom/cli",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Send PingRoom Pings and wait for human decisions from CI, scripts, and agents.",
5
5
  "type": "module",
6
6
  "bin": {