@pingroom/cli 0.1.0 → 0.2.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 +37 -2
- package/bin/pingroom.js +339 -42
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @pingroom/cli
|
|
2
2
|
|
|
3
|
-
Send PingRoom pings
|
|
4
|
-
|
|
3
|
+
Send PingRoom pings — and ask a human a question and block for their answer —
|
|
4
|
+
from CI, scripts, and agents. Delivered as push straight to your phone.
|
|
5
5
|
|
|
6
6
|
Zero dependencies. Works anywhere Node ≥ 20 runs.
|
|
7
7
|
|
|
@@ -9,6 +9,9 @@ Zero dependencies. Works anywhere Node ≥ 20 runs.
|
|
|
9
9
|
npx @pingroom/cli ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
+
Commands: `ping` (send), `ask` (ask a human), `watch` (block on an existing
|
|
13
|
+
question), `list`, `cancel`. Run `pingroom --help` for the full reference.
|
|
14
|
+
|
|
12
15
|
## Getting a webhook URL
|
|
13
16
|
|
|
14
17
|
In the PingRoom app, open a room → **Connections → Incoming webhooks → Add**. Copy the
|
|
@@ -86,6 +89,38 @@ pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped" \
|
|
|
86
89
|
-d '{"version":"1.4.0"}'
|
|
87
90
|
```
|
|
88
91
|
|
|
92
|
+
## Ask a human (Questions)
|
|
93
|
+
|
|
94
|
+
Turn a human decision into a shell gate. `ask --wait` blocks until someone taps
|
|
95
|
+
an answer on their phone, prints the chosen option **value** to stdout, and
|
|
96
|
+
encodes the outcome in the exit code — `0` answered, `3` expired, `4` cancelled.
|
|
97
|
+
Needs an agent token and a room.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Gate a production deploy on a lock-screen tap (Approve/Deny is the default):
|
|
101
|
+
if [ "$(pingroom ask --token "$PINGROOM_TOKEN" --room ab12cd --wait \
|
|
102
|
+
-p 'Deploy 1.4.0 to production?')" = approve ]; then
|
|
103
|
+
./deploy-prod.sh
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# A multi-option question, answerable by anyone in the room:
|
|
107
|
+
pingroom ask --token "$PINGROOM_TOKEN" --room ab12cd --scope room --wait \
|
|
108
|
+
-p 'Which environment?' -o prod:Production -o staging:Staging -o cancel:Cancel
|
|
109
|
+
|
|
110
|
+
# Fire-and-forget (prints the question id), then watch it later:
|
|
111
|
+
ID=$(pingroom ask --token "$PINGROOM_TOKEN" --room ab12cd -p 'Merge PR #42?' --ttl 1800)
|
|
112
|
+
pingroom watch --token "$PINGROOM_TOKEN" "$ID"
|
|
113
|
+
|
|
114
|
+
pingroom list --token "$PINGROOM_TOKEN" --state pending
|
|
115
|
+
pingroom cancel --token "$PINGROOM_TOKEN" "$ID"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Options are `value:label` pairs (repeat `-o` for 2–4). Omit them for the binary
|
|
119
|
+
Approve/Deny default — two options is the lock-screen fast path. `--ttl` sets the
|
|
120
|
+
expiry in seconds (default 1h; 30–86400). `--scope room` lets any eligible member
|
|
121
|
+
answer (first tap wins); the default `direct` asks your bound user.
|
|
122
|
+
|
|
123
|
+
For a fully typed client, use [`@pingroom/sdk`](https://www.npmjs.com/package/@pingroom/sdk).
|
|
89
124
|
See <https://pingroom.io/connect-mcp.md> to connect Cursor, Claude Desktop, or Claude Code.
|
|
90
125
|
|
|
91
126
|
## License
|
package/bin/pingroom.js
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @pingroom/cli —
|
|
2
|
+
// @pingroom/cli — pings and human-in-the-loop questions for CI, scripts, agents.
|
|
3
3
|
// Zero dependencies: uses Node's built-in fetch (Node >= 20).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// Commands:
|
|
6
|
+
// ping Send a ping to a room. Webhook mode (a room URL carries its own
|
|
7
|
+
// secret — best for CI) or agent-token mode (Bearer + room code).
|
|
8
|
+
// ask Ask a human a question in a room and, with --wait, block until they
|
|
9
|
+
// tap an answer — turning a human decision into a shell gate.
|
|
10
|
+
// watch Block until a question resolves and print the outcome.
|
|
11
|
+
// list List the agent's questions by state.
|
|
12
|
+
// cancel Withdraw a pending question.
|
|
13
|
+
//
|
|
14
|
+
// Exit codes: 0 success/answered · 1 error · 2 bad usage · 3 expired · 4 cancelled.
|
|
10
15
|
|
|
11
16
|
const DEFAULT_API = process.env.PINGROOM_API_URL || 'https://api.pingroom.io';
|
|
12
17
|
|
|
13
|
-
const HELP = `pingroom — send a ping
|
|
18
|
+
const HELP = `pingroom — send a ping, or ask a human a question, from CI/scripts/agents
|
|
14
19
|
|
|
15
20
|
Usage:
|
|
16
|
-
pingroom
|
|
21
|
+
pingroom <command> [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
ping Send a ping to a room (webhook URL, or agent token + room)
|
|
25
|
+
ask Ask a human a question; with --wait, block until they answer
|
|
26
|
+
watch Block until a question resolves and print the outcome
|
|
27
|
+
list List the agent's questions by state
|
|
28
|
+
cancel Withdraw a pending question
|
|
17
29
|
|
|
18
|
-
|
|
30
|
+
ping options:
|
|
19
31
|
-m, --message <text> Ping body text (required)
|
|
20
32
|
-t, --title <text> Ping title (<= 40 chars)
|
|
21
33
|
-a, --action <1-4> Quick-action slot to attribute the ping to
|
|
@@ -23,17 +35,62 @@ Options:
|
|
|
23
35
|
-w, --webhook <url> Room webhook URL (or env PINGROOM_WEBHOOK_URL)
|
|
24
36
|
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
25
37
|
--room <code> Room invite code (used with --token)
|
|
38
|
+
|
|
39
|
+
ask options (agent token required):
|
|
40
|
+
-p, --prompt <text> The question a human reads (required)
|
|
41
|
+
-o, --option <v:label> An answer option; repeat for 2–4. Omit for Approve/Deny
|
|
42
|
+
-c, --context <text> Secondary line, e.g. a build number (<= 40 chars)
|
|
43
|
+
--scope <s> Who answers: 'direct' (default) or 'room'
|
|
44
|
+
--target <uuid> For --scope direct: a specific room member
|
|
45
|
+
--ttl <seconds> Expiry; omit for the server default (1h; 30..86400)
|
|
46
|
+
--wait Block until answered/expired/cancelled
|
|
47
|
+
--timeout <sec> Per long-poll hold with --wait/watch (0–30, default 25)
|
|
48
|
+
-d, --data <json> Structured data object echoed back on the answer
|
|
49
|
+
--correlation-id <id> Opaque id echoed on every read of this question
|
|
50
|
+
--room <code> Room invite code (required for ask)
|
|
51
|
+
|
|
52
|
+
list options:
|
|
53
|
+
--state <s> pending | answered | expired | cancelled | all
|
|
54
|
+
|
|
55
|
+
Shared:
|
|
56
|
+
--token <token> Agent access token (or env PINGROOM_TOKEN)
|
|
26
57
|
--api <url> API base URL (default ${DEFAULT_API}; env PINGROOM_API_URL)
|
|
27
58
|
--json Print the raw JSON response
|
|
28
59
|
-h, --help Show this help
|
|
29
60
|
|
|
30
61
|
Examples:
|
|
31
62
|
pingroom ping -w "$PINGROOM_WEBHOOK_URL" -m "Deploy succeeded ✅"
|
|
32
|
-
pingroom ping
|
|
33
|
-
|
|
34
|
-
|
|
63
|
+
pingroom ping --token "$PINGROOM_TOKEN" --room ab12cd -m "Release shipped"
|
|
64
|
+
|
|
65
|
+
# Gate a deploy on a human tap — the chosen value prints to stdout:
|
|
66
|
+
if [ "$(pingroom ask --token "$T" --room ab12cd --wait \\
|
|
67
|
+
-p 'Deploy 1.4.0 to production?')" = approve ]; then ./deploy.sh; fi
|
|
68
|
+
|
|
69
|
+
# Multi-option question, blocking:
|
|
70
|
+
pingroom ask --token "$T" --room ab12cd --scope room --wait \\
|
|
71
|
+
-p 'Which environment?' -o prod:Production -o staging:Staging
|
|
72
|
+
|
|
73
|
+
pingroom list --token "$T" --state pending
|
|
74
|
+
pingroom watch --token "$T" q_01H... # block on an existing question
|
|
75
|
+
pingroom cancel --token "$T" q_01H...
|
|
76
|
+
|
|
77
|
+
Security:
|
|
78
|
+
Prefer the env vars (PINGROOM_WEBHOOK_URL / PINGROOM_TOKEN) over passing
|
|
79
|
+
secrets as --webhook / --token flags: argv is visible to other users via the
|
|
80
|
+
process table (ps) and may be captured in shell history. URLs must use https
|
|
81
|
+
(loopback http is allowed for local dev).
|
|
82
|
+
|
|
83
|
+
Exit codes: 0 on success (answered), 1 on error, 2 on bad usage,
|
|
84
|
+
3 when a question expired, 4 when it was cancelled.`;
|
|
85
|
+
|
|
86
|
+
const EXIT = { OK: 0, ERROR: 1, USAGE: 2, EXPIRED: 3, CANCELLED: 4 };
|
|
35
87
|
|
|
36
|
-
|
|
88
|
+
function fail(message, code = EXIT.ERROR) {
|
|
89
|
+
process.stderr.write(`pingroom: ${message}\n`);
|
|
90
|
+
process.exit(code);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- ping (unchanged wire behaviour) ---------------------------------------
|
|
37
94
|
|
|
38
95
|
function parseArgs(argv) {
|
|
39
96
|
const args = { _: [] };
|
|
@@ -60,7 +117,7 @@ function parseArgs(argv) {
|
|
|
60
117
|
if (key) {
|
|
61
118
|
args[key] = argv[++i];
|
|
62
119
|
} else if (token.startsWith('-')) {
|
|
63
|
-
fail(`Unknown option: ${token}`,
|
|
120
|
+
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
64
121
|
} else {
|
|
65
122
|
args._.push(token);
|
|
66
123
|
}
|
|
@@ -68,18 +125,95 @@ function parseArgs(argv) {
|
|
|
68
125
|
return args;
|
|
69
126
|
}
|
|
70
127
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
128
|
+
// Parser for the question commands: supports repeatable --option and a trailing
|
|
129
|
+
// positional (a question id). Unknown flags fail like the ping parser.
|
|
130
|
+
function parseQArgs(argv) {
|
|
131
|
+
const args = { _: [] };
|
|
132
|
+
const alias = {
|
|
133
|
+
'-p': 'prompt', '--prompt': 'prompt',
|
|
134
|
+
'-o': 'option', '--option': 'option',
|
|
135
|
+
'-c': 'context', '--context': 'context',
|
|
136
|
+
'--scope': 'scope',
|
|
137
|
+
'--target': 'target',
|
|
138
|
+
'--ttl': 'ttl',
|
|
139
|
+
'-d': 'data', '--data': 'data',
|
|
140
|
+
'--correlation-id': 'correlation_id',
|
|
141
|
+
'--timeout': 'timeout',
|
|
142
|
+
'--state': 'state',
|
|
143
|
+
'--token': 'token',
|
|
144
|
+
'--room': 'room',
|
|
145
|
+
'--api': 'api',
|
|
146
|
+
'--wait': 'wait',
|
|
147
|
+
'--json': 'json',
|
|
148
|
+
'-h': 'help', '--help': 'help',
|
|
149
|
+
};
|
|
150
|
+
const booleans = new Set(['wait', 'json', 'help']);
|
|
151
|
+
const multi = new Set(['option']);
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < argv.length; i++) {
|
|
154
|
+
const token = argv[i];
|
|
155
|
+
const key = alias[token];
|
|
156
|
+
if (key && booleans.has(key)) {
|
|
157
|
+
args[key] = true;
|
|
158
|
+
} else if (key) {
|
|
159
|
+
const value = argv[++i];
|
|
160
|
+
if (value === undefined) {
|
|
161
|
+
fail(`option ${token} needs a value`, EXIT.USAGE);
|
|
162
|
+
}
|
|
163
|
+
if (multi.has(key)) {
|
|
164
|
+
(args[key] ||= []).push(value);
|
|
165
|
+
} else {
|
|
166
|
+
args[key] = value;
|
|
167
|
+
}
|
|
168
|
+
} else if (token.startsWith('-') && token !== '-') {
|
|
169
|
+
fail(`Unknown option: ${token}`, EXIT.USAGE);
|
|
170
|
+
} else {
|
|
171
|
+
args._.push(token);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return args;
|
|
74
175
|
}
|
|
75
176
|
|
|
76
|
-
|
|
177
|
+
// Refuse to send a bearer token or webhook secret over cleartext http. A
|
|
178
|
+
// loopback host is allowed so local dev against http://localhost still works.
|
|
179
|
+
function requireSafeUrl(kind, raw) {
|
|
180
|
+
let u;
|
|
181
|
+
try {
|
|
182
|
+
u = new URL(raw);
|
|
183
|
+
} catch {
|
|
184
|
+
fail(`${kind} is not a valid URL`, EXIT.USAGE);
|
|
185
|
+
}
|
|
186
|
+
const isLoopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
187
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && isLoopback)) {
|
|
188
|
+
fail(`${kind} must use https (refusing to send credentials over cleartext)`, EXIT.USAGE);
|
|
189
|
+
}
|
|
190
|
+
return raw;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseDataObject(raw) {
|
|
194
|
+
let data;
|
|
195
|
+
try {
|
|
196
|
+
data = JSON.parse(raw);
|
|
197
|
+
} catch {
|
|
198
|
+
fail('--data must be valid JSON', EXIT.USAGE);
|
|
199
|
+
}
|
|
200
|
+
if (typeof data !== 'object' || Array.isArray(data) || data === null) {
|
|
201
|
+
fail('--data must be a JSON object', EXIT.USAGE);
|
|
202
|
+
}
|
|
203
|
+
return data;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function httpJson(method, url, { body, headers = {} } = {}) {
|
|
77
207
|
let res;
|
|
78
208
|
try {
|
|
79
209
|
res = await fetch(url, {
|
|
80
|
-
method
|
|
81
|
-
headers: {
|
|
82
|
-
|
|
210
|
+
method,
|
|
211
|
+
headers: {
|
|
212
|
+
Accept: 'application/json',
|
|
213
|
+
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
214
|
+
...headers,
|
|
215
|
+
},
|
|
216
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
83
217
|
});
|
|
84
218
|
} catch (err) {
|
|
85
219
|
fail(`network error: ${err.message}`);
|
|
@@ -93,25 +227,18 @@ async function postJson(url, body, headers = {}) {
|
|
|
93
227
|
}
|
|
94
228
|
|
|
95
229
|
async function ping(args) {
|
|
96
|
-
if (args.help) { process.stdout.write(`${HELP}\n`); return
|
|
230
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
97
231
|
|
|
98
232
|
const message = args.message;
|
|
99
|
-
if (!message) fail('a --message is required',
|
|
233
|
+
if (!message) fail('a --message is required', EXIT.USAGE);
|
|
100
234
|
|
|
101
235
|
if (args.action !== undefined && !/^[1-4]$/.test(String(args.action))) {
|
|
102
|
-
fail('--action must be an integer 1–4',
|
|
236
|
+
fail('--action must be an integer 1–4', EXIT.USAGE);
|
|
103
237
|
}
|
|
104
238
|
|
|
105
239
|
let data;
|
|
106
240
|
if (args.data !== undefined) {
|
|
107
|
-
|
|
108
|
-
data = JSON.parse(args.data);
|
|
109
|
-
} catch {
|
|
110
|
-
fail('--data must be valid JSON', 2);
|
|
111
|
-
}
|
|
112
|
-
if (typeof data !== 'object' || Array.isArray(data) || data === null) {
|
|
113
|
-
fail('--data must be a JSON object', 2);
|
|
114
|
-
}
|
|
241
|
+
data = parseDataObject(args.data);
|
|
115
242
|
}
|
|
116
243
|
|
|
117
244
|
const webhook = args.webhook || process.env.PINGROOM_WEBHOOK_URL;
|
|
@@ -121,21 +248,23 @@ async function ping(args) {
|
|
|
121
248
|
let result;
|
|
122
249
|
|
|
123
250
|
if (webhook) {
|
|
251
|
+
requireSafeUrl('--webhook', webhook);
|
|
124
252
|
const body = { message };
|
|
125
253
|
if (args.title) body.title = args.title;
|
|
126
254
|
if (args.action !== undefined) body.action = Number(args.action);
|
|
127
255
|
if (data) body.data = data;
|
|
128
|
-
result = await
|
|
256
|
+
result = await httpJson('POST', webhook, { body });
|
|
129
257
|
} else if (token) {
|
|
130
|
-
if (!args.room) fail('--room is required when using --token',
|
|
258
|
+
if (!args.room) fail('--room is required when using --token', EXIT.USAGE);
|
|
259
|
+
requireSafeUrl('--api', apiBase);
|
|
131
260
|
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(args.room)}/notifications`;
|
|
132
261
|
const body = { message };
|
|
133
262
|
if (args.title) body.title = args.title;
|
|
134
263
|
if (args.action !== undefined) body.action_number = Number(args.action);
|
|
135
264
|
if (data) body.data = data;
|
|
136
|
-
result = await
|
|
265
|
+
result = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
137
266
|
} else {
|
|
138
|
-
fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)',
|
|
267
|
+
fail('provide a webhook (--webhook / PINGROOM_WEBHOOK_URL) or an agent token (--token / PINGROOM_TOKEN)', EXIT.USAGE);
|
|
139
268
|
}
|
|
140
269
|
|
|
141
270
|
const { res, text, json } = result;
|
|
@@ -152,7 +281,174 @@ async function ping(args) {
|
|
|
152
281
|
}
|
|
153
282
|
|
|
154
283
|
if (!args.json) process.stdout.write('ping sent ✅\n');
|
|
155
|
-
return
|
|
284
|
+
return EXIT.OK;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- questions -------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function agentContext(args, { needRoom = false } = {}) {
|
|
290
|
+
const token = args.token || process.env.PINGROOM_TOKEN;
|
|
291
|
+
if (!token) fail('an agent token is required (--token or PINGROOM_TOKEN)', EXIT.USAGE);
|
|
292
|
+
const apiBase = (args.api || DEFAULT_API).replace(/\/$/, '');
|
|
293
|
+
requireSafeUrl('--api', apiBase);
|
|
294
|
+
if (needRoom && !args.room) fail('--room is required', EXIT.USAGE);
|
|
295
|
+
return { token, apiBase, room: args.room };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// value:label -> {value, label}. Labels may contain colons (only the first
|
|
299
|
+
// splits). A bare token is both value and label. Omit all for Approve/Deny.
|
|
300
|
+
function buildOptions(list) {
|
|
301
|
+
if (!list || list.length === 0) return undefined;
|
|
302
|
+
return list.map((spec) => {
|
|
303
|
+
const idx = spec.indexOf(':');
|
|
304
|
+
const value = idx === -1 ? spec : spec.slice(0, idx);
|
|
305
|
+
const label = idx === -1 ? spec : spec.slice(idx + 1);
|
|
306
|
+
if (!value) fail(`--option must be "value" or "value:label" (got "${spec}")`, EXIT.USAGE);
|
|
307
|
+
return { value, label };
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function exitForState(state) {
|
|
312
|
+
switch (state) {
|
|
313
|
+
case 'answered': return EXIT.OK;
|
|
314
|
+
case 'expired': return EXIT.EXPIRED;
|
|
315
|
+
case 'cancelled': return EXIT.CANCELLED;
|
|
316
|
+
default: return EXIT.ERROR;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Print the outcome. On `answered`, the chosen value (or typed text) goes to
|
|
321
|
+
// stdout so `$(pingroom ask --wait ...)` captures it; other outcomes report to
|
|
322
|
+
// stderr and leave stdout empty.
|
|
323
|
+
function printResolution(q) {
|
|
324
|
+
if (q.state === 'answered') {
|
|
325
|
+
const out = q.answer && (q.answer.text || q.answer.value) || '';
|
|
326
|
+
process.stdout.write(`${out}\n`);
|
|
327
|
+
} else {
|
|
328
|
+
process.stderr.write(`pingroom: question ${q.state}\n`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Long-poll the wait endpoint until the question leaves `pending`, then print
|
|
333
|
+
// and return the state's exit code. The server expires it at its ttl, so this
|
|
334
|
+
// always terminates.
|
|
335
|
+
async function waitForResolution(id, args, { token, apiBase }) {
|
|
336
|
+
let hold = args.timeout !== undefined ? Number(args.timeout) : 25;
|
|
337
|
+
if (!Number.isFinite(hold) || hold < 0) fail('--timeout must be a non-negative integer', EXIT.USAGE);
|
|
338
|
+
hold = Math.min(hold, 30);
|
|
339
|
+
|
|
340
|
+
for (;;) {
|
|
341
|
+
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
342
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
|
|
345
|
+
fail(`wait failed: ${detail}`);
|
|
346
|
+
}
|
|
347
|
+
if (json && json.state && json.state !== 'pending') {
|
|
348
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
349
|
+
else printResolution(json);
|
|
350
|
+
return exitForState(json.state);
|
|
351
|
+
}
|
|
352
|
+
// Still pending at the hold timeout — poll again.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function ask(args) {
|
|
357
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
358
|
+
|
|
359
|
+
const prompt = args.prompt;
|
|
360
|
+
if (!prompt) fail('a --prompt is required', EXIT.USAGE);
|
|
361
|
+
|
|
362
|
+
const { token, apiBase, room } = agentContext(args, { needRoom: true });
|
|
363
|
+
|
|
364
|
+
const body = { prompt };
|
|
365
|
+
const options = buildOptions(args.option);
|
|
366
|
+
if (options) body.options = options;
|
|
367
|
+
if (args.context) body.context = args.context;
|
|
368
|
+
if (args.scope !== undefined) {
|
|
369
|
+
if (args.scope !== 'direct' && args.scope !== 'room') fail("--scope must be 'direct' or 'room'", EXIT.USAGE);
|
|
370
|
+
body.responder_scope = args.scope;
|
|
371
|
+
}
|
|
372
|
+
if (args.target !== undefined) body.target_user_id = args.target;
|
|
373
|
+
if (args.ttl !== undefined) {
|
|
374
|
+
if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
|
|
375
|
+
body.ttl = Number(args.ttl);
|
|
376
|
+
}
|
|
377
|
+
if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
|
|
378
|
+
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
379
|
+
|
|
380
|
+
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
|
|
381
|
+
const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
382
|
+
if (!res.ok) {
|
|
383
|
+
const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
|
|
384
|
+
fail(`ask failed: ${detail}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (!args.wait) {
|
|
388
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
389
|
+
else process.stdout.write(`${json.id}\n`);
|
|
390
|
+
return EXIT.OK;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return waitForResolution(json.id, args, { token, apiBase });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function watch(args) {
|
|
397
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
398
|
+
const id = args._[0];
|
|
399
|
+
if (!id) fail('a question id is required (pingroom watch <id>)', EXIT.USAGE);
|
|
400
|
+
const { token, apiBase } = agentContext(args);
|
|
401
|
+
return waitForResolution(id, args, { token, apiBase });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function cancel(args) {
|
|
405
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
406
|
+
const id = args._[0];
|
|
407
|
+
if (!id) fail('a question id is required (pingroom cancel <id>)', EXIT.USAGE);
|
|
408
|
+
const { token, apiBase } = agentContext(args);
|
|
409
|
+
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/cancel`;
|
|
410
|
+
const { res, text, json } = await httpJson('POST', url, { body: {}, headers: { Authorization: `Bearer ${token}` } });
|
|
411
|
+
if (!res.ok) {
|
|
412
|
+
const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
|
|
413
|
+
fail(`cancel failed: ${detail}`);
|
|
414
|
+
}
|
|
415
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
416
|
+
else process.stdout.write(`cancelled (${json && json.state})\n`);
|
|
417
|
+
return EXIT.OK;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function list(args) {
|
|
421
|
+
if (args.help) { process.stdout.write(`${HELP}\n`); return EXIT.OK; }
|
|
422
|
+
const { token, apiBase } = agentContext(args);
|
|
423
|
+
const qs = args.state ? `?state=${encodeURIComponent(args.state)}` : '';
|
|
424
|
+
const url = `${apiBase}/api/agent/questions${qs}`;
|
|
425
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
426
|
+
if (!res.ok) {
|
|
427
|
+
const detail = (json && (json.message || json.code)) || `HTTP ${res.status}`;
|
|
428
|
+
fail(`list failed: ${detail}`);
|
|
429
|
+
}
|
|
430
|
+
if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
|
|
431
|
+
|
|
432
|
+
const questions = (json && json.questions) || [];
|
|
433
|
+
if (questions.length === 0) { process.stdout.write('no questions\n'); return EXIT.OK; }
|
|
434
|
+
for (const q of questions) {
|
|
435
|
+
const answer = q.answer && q.answer.value ? ` → ${q.answer.value}` : '';
|
|
436
|
+
process.stdout.write(`${q.id} ${String(q.state).padEnd(9)} ${q.prompt}${answer}\n`);
|
|
437
|
+
}
|
|
438
|
+
return EXIT.OK;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const COMMANDS = {
|
|
442
|
+
ping: (rest) => ping(parseArgs(rest)),
|
|
443
|
+
ask: (rest) => ask(parseQArgs(rest)),
|
|
444
|
+
watch: (rest) => waitFrom(watch, rest),
|
|
445
|
+
await: (rest) => waitFrom(watch, rest),
|
|
446
|
+
cancel: (rest) => cancel(parseQArgs(rest)),
|
|
447
|
+
list: (rest) => list(parseQArgs(rest)),
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
function waitFrom(handler, rest) {
|
|
451
|
+
return handler(parseQArgs(rest));
|
|
156
452
|
}
|
|
157
453
|
|
|
158
454
|
async function main() {
|
|
@@ -161,15 +457,16 @@ async function main() {
|
|
|
161
457
|
|
|
162
458
|
if (!command || command === '-h' || command === '--help' || command === 'help') {
|
|
163
459
|
process.stdout.write(`${HELP}\n`);
|
|
164
|
-
process.exit(
|
|
460
|
+
process.exit(EXIT.OK);
|
|
165
461
|
}
|
|
166
462
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
463
|
+
const handler = COMMANDS[command];
|
|
464
|
+
if (!handler) {
|
|
465
|
+
fail(`unknown command: ${command}\nRun "pingroom --help".`, EXIT.USAGE);
|
|
170
466
|
}
|
|
171
467
|
|
|
172
|
-
|
|
468
|
+
const code = await handler(argv.slice(1));
|
|
469
|
+
process.exit(code);
|
|
173
470
|
}
|
|
174
471
|
|
|
175
472
|
main();
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pingroom/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Send PingRoom pings from CI, scripts, and agents
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Send PingRoom pings and ask humans blocking questions from CI, scripts, and agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pingroom": "bin/pingroom.js"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin"
|
|
11
14
|
],
|