@pingroom/cli 0.7.5 โ†’ 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
@@ -243,6 +243,36 @@ on the Lock Screen (iOS Live Activity / Dynamic Island, Android live update, and
243
243
  a full inline card in the app). `start` opens it with one alert, `update` moves
244
244
  it **silently**, `end` closes it with one completion alert.
245
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
+
246
276
  ## Hearing replies
247
277
 
248
278
  Everything else here talks; `listen` is how an agent hears โ€” replies to its own
@@ -506,6 +536,9 @@ Options are `value:label` pairs (repeat `-o` for 2โ€“4). Omit them for the binar
506
536
  Approve/Deny default โ€” two options is the lock-screen fast path. `--ttl` sets the
507
537
  expiry in seconds (default 1h; 30โ€“86400). `--scope room` lets any eligible member
508
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`.
509
542
 
510
543
  ## Handoffs (agent โ†’ human)
511
544
 
@@ -590,18 +623,20 @@ tune the approval-question expiry with `--ttl <seconds>` (default 900).
590
623
 
591
624
  ## MCP client setup
592
625
 
593
- Print the canonical remote endpoint, a copy-ready Claude Code command, Cursor
594
- 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:
595
628
 
596
629
  ```bash
597
630
  pingroom mcp
598
631
  # no global install: npx --yes @pingroom/cli mcp
599
632
  ```
600
633
 
601
- `pingroom mcp add claude-code` prints the exact `claude mcp add` command but does
602
- not execute it or modify client configuration. After adding the server, use the
603
- client's MCP controls to authenticate in the browser; no PingRoom API key is
604
- 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.
605
640
 
606
641
  For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
607
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}`);
@@ -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
  }
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/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
 
@@ -65,6 +70,7 @@ export const HELP_ASK = `ask options (agent token required):
65
70
  --scope <s> Who answers: 'direct' (default) or 'room'
66
71
  --target <uuid> For --scope direct: a specific room member
67
72
  --ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
73
+ --idempotency-key <key> Dedupe this exact create request across retries
68
74
  --text-input <ph> Invite a short typed answer; <ph> is the placeholder
69
75
  --text-max <n> Max typed-answer length (1..60)
70
76
  --wait Block until answered/expired/cancelled
@@ -73,6 +79,8 @@ export const HELP_ASK = `ask options (agent token required):
73
79
  --correlation-id <id> Opaque id echoed on every read of this question
74
80
  --reply-to <id> Id of the ping this question replies to
75
81
  --room <code> Room invite code (required for ask)
82
+ --expected-room-sha256 <hex>
83
+ Fail unless the resolved room matches this SHA-256
76
84
  --github-output <path> Safely append question outputs for GitHub Actions`;
77
85
 
78
86
  export const HELP_LIST = `list options:
@@ -292,6 +300,37 @@ export const COMMAND_HELP_SECTIONS = {
292
300
  live: HELP_LIVE,
293
301
  hook: HELP_HOOK,
294
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`,
295
334
  config: HELP_CONFIG,
296
335
  logout: `logout:
297
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
@@ -70,6 +70,7 @@ export const parseArgs = makeParser({
70
70
  '--attach': 'attach',
71
71
  '--token': 'token',
72
72
  '--room': 'room',
73
+ '--expected-room-sha256': 'expected_room_sha256',
73
74
  '--api': 'api',
74
75
  '--json': 'json',
75
76
  '-h': 'help', '--help': 'help',
@@ -88,6 +89,7 @@ export const parseQArgs = makeParser({
88
89
  '--scope': 'scope',
89
90
  '--target': 'target',
90
91
  '--ttl': 'ttl',
92
+ '--idempotency-key': 'idempotency_key',
91
93
  '-d': 'data', '--data': 'data',
92
94
  '--correlation-id': 'correlation_id',
93
95
  '--reply-to': 'reply_to',
@@ -101,6 +103,7 @@ export const parseQArgs = makeParser({
101
103
  '--github-output': 'github_output',
102
104
  '--token': 'token',
103
105
  '--room': 'room',
106
+ '--expected-room-sha256': 'expected_room_sha256',
104
107
  '--api': 'api',
105
108
  '--wait': 'wait',
106
109
  '--json': 'json',
@@ -208,6 +211,40 @@ export const parseLogoutArgs = makeParser({
208
211
  bareDashIsPositional: true,
209
212
  });
210
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
+
211
248
  export const parseHandoffsArgs = makeParser({
212
249
  aliases: {
213
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.5",
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": {