@pingroom/cli 0.7.2 → 0.7.3
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 +41 -11
- package/bin/pingroom.js +22 -3016
- package/lib/commands/ask.js +146 -0
- package/lib/commands/config.js +114 -0
- package/lib/commands/connect.js +726 -0
- package/lib/commands/handoff.js +149 -0
- package/lib/commands/hook.js +301 -0
- package/lib/commands/listen.js +83 -0
- package/lib/commands/live.js +165 -0
- package/lib/commands/mcp.js +47 -0
- package/lib/commands/ping.js +123 -0
- package/lib/config.js +206 -0
- package/lib/constants.js +7 -0
- package/lib/github-output.js +76 -0
- package/lib/help.js +305 -0
- package/lib/http.js +214 -0
- package/lib/parser.js +214 -0
- package/lib/render.js +174 -0
- package/lib/util.js +105 -0
- package/lib/version.js +10 -0
- package/package.json +3 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The Question protocol: `ask` (create, optionally block), `watch` (block on an
|
|
2
|
+
// existing one), `cancel`, and `list`.
|
|
3
|
+
|
|
4
|
+
import { EXIT } from '../constants.js';
|
|
5
|
+
import { fail, parseDataObject, requireMaxLength, resolveWaitHold, sleep } from '../util.js';
|
|
6
|
+
import { commandHelp } from '../help.js';
|
|
7
|
+
import { apiDetail, httpJson } from '../http.js';
|
|
8
|
+
import { agentContext } from '../config.js';
|
|
9
|
+
import { buildOptions, exitForState, printResolution } from '../render.js';
|
|
10
|
+
import { writeGitHubQuestionOutputs } from '../github-output.js';
|
|
11
|
+
|
|
12
|
+
// Long-poll the wait endpoint until the question leaves `pending`, then print
|
|
13
|
+
// and return the state's exit code. The server expires it at its ttl, so this
|
|
14
|
+
// always terminates.
|
|
15
|
+
async function waitForResolution(id, args, { token, apiBase }) {
|
|
16
|
+
const hold = resolveWaitHold(args, { def: 25, cap: 30 });
|
|
17
|
+
|
|
18
|
+
for (;;) {
|
|
19
|
+
const started = Date.now();
|
|
20
|
+
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/wait?timeout=${hold}`;
|
|
21
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
const detail = apiDetail(res, json);
|
|
24
|
+
fail(`wait failed: ${detail}`);
|
|
25
|
+
}
|
|
26
|
+
if (json && json.state && json.state !== 'pending') {
|
|
27
|
+
if (args.github_output !== undefined) writeGitHubQuestionOutputs(args.github_output, json);
|
|
28
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
29
|
+
else printResolution(json);
|
|
30
|
+
return exitForState(json.state);
|
|
31
|
+
}
|
|
32
|
+
// Still pending at the hold timeout — poll again, but never hot-loop: a
|
|
33
|
+
// misbehaving server that answers `pending` instantly (ignoring the hold)
|
|
34
|
+
// would otherwise be hammered at full speed.
|
|
35
|
+
const elapsed = Date.now() - started;
|
|
36
|
+
if (elapsed < 1000) await sleep(1000 - elapsed);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function ask(args) {
|
|
41
|
+
if (args.help) { process.stdout.write(`${commandHelp('ask')}\n`); return EXIT.OK; }
|
|
42
|
+
|
|
43
|
+
const prompt = args.prompt;
|
|
44
|
+
if (!prompt) fail('a --prompt is required', EXIT.USAGE);
|
|
45
|
+
requireMaxLength(prompt, 500, '--prompt');
|
|
46
|
+
requireMaxLength(args.context, 40, '--context');
|
|
47
|
+
|
|
48
|
+
const { token, apiBase, room } = agentContext(args, { needRoom: true });
|
|
49
|
+
|
|
50
|
+
const body = { prompt };
|
|
51
|
+
const options = buildOptions(args.option);
|
|
52
|
+
if (options) body.options = options;
|
|
53
|
+
if (args.context) body.context = args.context;
|
|
54
|
+
if (args.scope !== undefined) {
|
|
55
|
+
if (args.scope !== 'direct' && args.scope !== 'room') fail("--scope must be 'direct' or 'room'", EXIT.USAGE);
|
|
56
|
+
body.responder_scope = args.scope;
|
|
57
|
+
}
|
|
58
|
+
if (args.target !== undefined) body.target_user_id = args.target;
|
|
59
|
+
if (args.ttl !== undefined) {
|
|
60
|
+
if (!/^\d+$/.test(String(args.ttl))) fail('--ttl must be an integer number of seconds', EXIT.USAGE);
|
|
61
|
+
body.ttl = Number(args.ttl);
|
|
62
|
+
}
|
|
63
|
+
if (args.correlation_id !== undefined) body.correlation_id = args.correlation_id;
|
|
64
|
+
if (args.reply_to !== undefined) body.reply_to = args.reply_to;
|
|
65
|
+
if (args.text_input !== undefined || args.text_max !== undefined) {
|
|
66
|
+
const textInput = {};
|
|
67
|
+
if (args.text_input) textInput.placeholder = String(args.text_input).slice(0, 60);
|
|
68
|
+
if (args.text_max !== undefined) {
|
|
69
|
+
const n = Number(args.text_max);
|
|
70
|
+
if (!/^\d+$/.test(String(args.text_max)) || n < 1 || n > 60) {
|
|
71
|
+
fail('--text-max must be an integer between 1 and 60', EXIT.USAGE);
|
|
72
|
+
}
|
|
73
|
+
textInput.max_length = n;
|
|
74
|
+
}
|
|
75
|
+
body.text_input = textInput;
|
|
76
|
+
}
|
|
77
|
+
if (args.data !== undefined) body.data = parseDataObject(args.data);
|
|
78
|
+
|
|
79
|
+
// Pre-flight: reject a bad --timeout before the question exists.
|
|
80
|
+
if (args.wait) resolveWaitHold(args, { def: 25, cap: 30 });
|
|
81
|
+
|
|
82
|
+
const url = `${apiBase}/api/agent/rooms/${encodeURIComponent(room)}/questions`;
|
|
83
|
+
const { res, text, json } = await httpJson('POST', url, { body, headers: { Authorization: `Bearer ${token}` } });
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const detail = apiDetail(res, json);
|
|
86
|
+
fail(`ask failed: ${detail}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!args.wait) {
|
|
90
|
+
// The question is live but nobody has answered yet, so the only honest
|
|
91
|
+
// state for a workflow to read is `pending`.
|
|
92
|
+
if (args.github_output !== undefined) {
|
|
93
|
+
writeGitHubQuestionOutputs(args.github_output, { id: json.id, state: 'pending' });
|
|
94
|
+
}
|
|
95
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
96
|
+
else process.stdout.write(`${json.id}\n`);
|
|
97
|
+
return EXIT.OK;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return waitForResolution(json.id, args, { token, apiBase });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function watch(args) {
|
|
104
|
+
if (args.help) { process.stdout.write(`${commandHelp('watch')}\n`); return EXIT.OK; }
|
|
105
|
+
const id = args._[0];
|
|
106
|
+
if (!id) fail('a question id is required (pingroom watch <id>)', EXIT.USAGE);
|
|
107
|
+
const { token, apiBase } = agentContext(args);
|
|
108
|
+
return waitForResolution(id, args, { token, apiBase });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function cancel(args) {
|
|
112
|
+
if (args.help) { process.stdout.write(`${commandHelp('cancel')}\n`); return EXIT.OK; }
|
|
113
|
+
const id = args._[0];
|
|
114
|
+
if (!id) fail('a question id is required (pingroom cancel <id>)', EXIT.USAGE);
|
|
115
|
+
const { token, apiBase } = agentContext(args);
|
|
116
|
+
const url = `${apiBase}/api/agent/questions/${encodeURIComponent(id)}/cancel`;
|
|
117
|
+
const { res, text, json } = await httpJson('POST', url, { body: {}, headers: { Authorization: `Bearer ${token}` } });
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
const detail = apiDetail(res, json);
|
|
120
|
+
fail(`cancel failed: ${detail}`);
|
|
121
|
+
}
|
|
122
|
+
if (args.json) process.stdout.write(`${text}\n`);
|
|
123
|
+
else process.stdout.write(`cancelled (${json && json.state})\n`);
|
|
124
|
+
return EXIT.OK;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function list(args) {
|
|
128
|
+
if (args.help) { process.stdout.write(`${commandHelp('list')}\n`); return EXIT.OK; }
|
|
129
|
+
const { token, apiBase } = agentContext(args);
|
|
130
|
+
const qs = args.state ? `?state=${encodeURIComponent(args.state)}` : '';
|
|
131
|
+
const url = `${apiBase}/api/agent/questions${qs}`;
|
|
132
|
+
const { res, text, json } = await httpJson('GET', url, { headers: { Authorization: `Bearer ${token}` } });
|
|
133
|
+
if (!res.ok) {
|
|
134
|
+
const detail = apiDetail(res, json);
|
|
135
|
+
fail(`list failed: ${detail}`);
|
|
136
|
+
}
|
|
137
|
+
if (args.json) { process.stdout.write(`${text}\n`); return EXIT.OK; }
|
|
138
|
+
|
|
139
|
+
const questions = (json && json.questions) || [];
|
|
140
|
+
if (questions.length === 0) { process.stdout.write('no questions\n'); return EXIT.OK; }
|
|
141
|
+
for (const q of questions) {
|
|
142
|
+
const answer = q.answer && q.answer.value ? ` → ${q.answer.value}` : '';
|
|
143
|
+
process.stdout.write(`${q.id} ${String(q.state).padEnd(9)} ${q.prompt}${answer}\n`);
|
|
144
|
+
}
|
|
145
|
+
return EXIT.OK;
|
|
146
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `config` (read/write ~/.pingroom/config.json) and `logout` (forget the stored
|
|
2
|
+
// credential). The only two commands that never touch the network.
|
|
3
|
+
|
|
4
|
+
import { unlinkSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
import { BUILTIN_API, EXIT } from '../constants.js';
|
|
7
|
+
import { fail } from '../util.js';
|
|
8
|
+
import { commandHelp } from '../help.js';
|
|
9
|
+
import { configPath, credentialsPath, readConfigFile, readStoredCredential, writeJsonFile } from '../config.js';
|
|
10
|
+
|
|
11
|
+
// Only these keys are storable. An unknown key is a usage error rather than a
|
|
12
|
+
// silently-ignored setting the user then blames the tool for not honouring.
|
|
13
|
+
const CONFIG_KEYS = {
|
|
14
|
+
default_room: {
|
|
15
|
+
describe: 'Room invite code used when --room / PINGROOM_ROOM is absent',
|
|
16
|
+
validate: (value) => {
|
|
17
|
+
if (/\s/.test(value) || value.length > 64) return 'default_room must be an invite code (no spaces, <= 64 chars)';
|
|
18
|
+
return null;
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
api_url: {
|
|
22
|
+
describe: `API base URL (default ${BUILTIN_API})`,
|
|
23
|
+
validate: (value) => {
|
|
24
|
+
let u;
|
|
25
|
+
try { u = new URL(value); } catch { return 'api_url must be a valid URL'; }
|
|
26
|
+
const loopback = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
27
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) {
|
|
28
|
+
return 'api_url must use https (refusing to send credentials over cleartext)';
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function config(args) {
|
|
36
|
+
if (args.help) { process.stdout.write(`${commandHelp('config')}\n`); return EXIT.OK; }
|
|
37
|
+
|
|
38
|
+
const sub = args._[0];
|
|
39
|
+
const known = ['list', 'get', 'set'];
|
|
40
|
+
if (!sub || !known.includes(sub)) {
|
|
41
|
+
fail(`config needs a subcommand: ${known.join(' | ')}`, EXIT.USAGE);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const stored = readConfigFile();
|
|
45
|
+
|
|
46
|
+
if (sub === 'list') {
|
|
47
|
+
if (args.json) { process.stdout.write(`${JSON.stringify(stored)}\n`); return EXIT.OK; }
|
|
48
|
+
const keys = Object.keys(CONFIG_KEYS).filter((k) => stored[k] !== undefined && stored[k] !== '');
|
|
49
|
+
if (keys.length === 0) {
|
|
50
|
+
process.stdout.write(`no settings stored in ${configPath()}\n`);
|
|
51
|
+
return EXIT.OK;
|
|
52
|
+
}
|
|
53
|
+
for (const key of keys) process.stdout.write(`${key}=${stored[key]}\n`);
|
|
54
|
+
return EXIT.OK;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const key = args._[1];
|
|
58
|
+
if (!key) fail(`config ${sub} needs a key (${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
|
|
59
|
+
if (!Object.hasOwn(CONFIG_KEYS, key)) {
|
|
60
|
+
fail(`unknown config key: ${key} (known keys: ${Object.keys(CONFIG_KEYS).join(', ')})`, EXIT.USAGE);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (sub === 'get') {
|
|
64
|
+
const value = stored[key];
|
|
65
|
+
if (value === undefined || value === '') return EXIT.OK; // unset: print nothing, exit 0
|
|
66
|
+
process.stdout.write(`${value}\n`);
|
|
67
|
+
return EXIT.OK;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// set
|
|
71
|
+
const raw = args._[2];
|
|
72
|
+
if (raw === undefined) fail(`config set needs a value (pass "" to clear ${key})`, EXIT.USAGE);
|
|
73
|
+
const value = String(raw).trim();
|
|
74
|
+
|
|
75
|
+
if (value === '') {
|
|
76
|
+
delete stored[key];
|
|
77
|
+
writeJsonFile(configPath(), stored);
|
|
78
|
+
process.stdout.write(`${key} cleared\n`);
|
|
79
|
+
return EXIT.OK;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const problem = CONFIG_KEYS[key].validate(value);
|
|
83
|
+
if (problem) fail(problem, EXIT.USAGE);
|
|
84
|
+
|
|
85
|
+
stored[key] = value;
|
|
86
|
+
writeJsonFile(configPath(), stored);
|
|
87
|
+
process.stdout.write(`${key}=${value}\n`);
|
|
88
|
+
return EXIT.OK;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- logout ----------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export async function logout(args) {
|
|
94
|
+
if (args.help) { process.stdout.write(`${commandHelp('logout')}\n`); return EXIT.OK; }
|
|
95
|
+
|
|
96
|
+
const path = credentialsPath();
|
|
97
|
+
const stored = readStoredCredential();
|
|
98
|
+
try {
|
|
99
|
+
unlinkSync(path);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err.code === 'ENOENT') {
|
|
102
|
+
process.stdout.write('not connected — there was no stored credential to clear\n');
|
|
103
|
+
return EXIT.OK;
|
|
104
|
+
}
|
|
105
|
+
fail(`could not clear ${path}: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const who = stored && stored.handle ? ` (@${stored.handle})` : '';
|
|
109
|
+
process.stdout.write(`logged out${who} — cleared ${path}\n`);
|
|
110
|
+
if (process.env.PINGROOM_TOKEN) {
|
|
111
|
+
process.stdout.write('note: PINGROOM_TOKEN is still set in this environment and will keep being used\n');
|
|
112
|
+
}
|
|
113
|
+
return EXIT.OK;
|
|
114
|
+
}
|