unbound-cli 0.5.0 → 0.6.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/.github/workflows/test.yml +25 -0
- package/LOCAL_DEV.md +45 -0
- package/package.json +2 -2
- package/src/chartRender.js +284 -0
- package/src/commands/chat.js +531 -0
- package/src/commands/onboard.js +6 -3
- package/src/commands/setup.js +38 -20
- package/src/index.js +9 -0
- package/test/chart-render.test.js +205 -0
- package/test/chat-internals.test.js +144 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `unbound chat` — interactive session or one-shot wrapper around
|
|
3
|
+
* POST /api/v1/query_builder/chat/. Conversation history lives in memory
|
|
4
|
+
* only; nothing is written to disk. In one-shot mode (-m), `--json` writes
|
|
5
|
+
* the response to stdout and `-o <path>` writes it to a file.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const readline = require('readline/promises');
|
|
12
|
+
|
|
13
|
+
const api = require('../api');
|
|
14
|
+
const output = require('../output');
|
|
15
|
+
const { ensureLoggedIn } = require('../auth');
|
|
16
|
+
const chart = require('../chartRender');
|
|
17
|
+
const { sanitizeForTerminal } = chart;
|
|
18
|
+
|
|
19
|
+
const ENDPOINT = '/api/v1/query_builder/chat/';
|
|
20
|
+
const HISTORY_CAP = 20;
|
|
21
|
+
const RECENT_RESULTS_CAP = 5;
|
|
22
|
+
const MAX_MESSAGE_LEN = 4000;
|
|
23
|
+
const MAX_TURN_CONTENT_LEN = 8000;
|
|
24
|
+
const ALLOWED_ROLES = new Set(['user', 'assistant']);
|
|
25
|
+
|
|
26
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
27
|
+
const dim = (s) => useColor ? `\x1b[2m${s}\x1b[0m` : s;
|
|
28
|
+
const bold = (s) => useColor ? `\x1b[1m${s}\x1b[0m` : s;
|
|
29
|
+
const cyan = (s) => useColor ? `\x1b[36m${s}\x1b[0m` : s;
|
|
30
|
+
|
|
31
|
+
function nowIso() {
|
|
32
|
+
return new Date().toISOString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Expand a leading `~/` to the user's home directory. Does NOT expand `~user/`
|
|
36
|
+
// (uncommon, not worth parsing). Returns an absolute path.
|
|
37
|
+
function expandPath(p) {
|
|
38
|
+
if (typeof p !== 'string') return p;
|
|
39
|
+
if (p === '~') return os.homedir();
|
|
40
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) {
|
|
41
|
+
return path.join(os.homedir(), p.slice(2));
|
|
42
|
+
}
|
|
43
|
+
return path.resolve(p);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Write `data` to `target` without following symlinks and without overwriting
|
|
47
|
+
// an existing file. Creates the file with mode 0o600 so org analytics don't
|
|
48
|
+
// leak on multi-user hosts. Returns the resolved absolute path on success.
|
|
49
|
+
function safeWriteFile(target, data) {
|
|
50
|
+
if (target === undefined || target === null || target === '') {
|
|
51
|
+
throw new Error('Output path is empty.');
|
|
52
|
+
}
|
|
53
|
+
if (typeof target !== 'string') {
|
|
54
|
+
throw new Error('Output path must be a string.');
|
|
55
|
+
}
|
|
56
|
+
if (target.includes('\0')) {
|
|
57
|
+
throw new Error('Output path contains an invalid character (NUL).');
|
|
58
|
+
}
|
|
59
|
+
const resolved = expandPath(target);
|
|
60
|
+
try {
|
|
61
|
+
const stat = fs.lstatSync(resolved);
|
|
62
|
+
if (stat.isDirectory()) {
|
|
63
|
+
throw new Error(`Refusing to write: '${resolved}' is a directory.`);
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Refusing to overwrite existing file: '${resolved}'. Choose a new path or remove it first.`);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.code !== 'ENOENT') throw err;
|
|
68
|
+
}
|
|
69
|
+
const fd = fs.openSync(
|
|
70
|
+
resolved,
|
|
71
|
+
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
|
|
72
|
+
0o600,
|
|
73
|
+
);
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(fd, data);
|
|
76
|
+
} finally {
|
|
77
|
+
fs.closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
return resolved;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildBody(message, history, recent) {
|
|
83
|
+
return {
|
|
84
|
+
message,
|
|
85
|
+
conversation_history: history.slice(-HISTORY_CAP),
|
|
86
|
+
recent_results: recent.slice(-RECENT_RESULTS_CAP),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pushRecent(recent, query, result) {
|
|
91
|
+
recent.push({
|
|
92
|
+
query,
|
|
93
|
+
view_spec: result.view_spec,
|
|
94
|
+
result_summary: {
|
|
95
|
+
row_count: result.row_count,
|
|
96
|
+
chart_type: result.chart_meta?.chart_type,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
while (recent.length > RECENT_RESULTS_CAP) recent.shift();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function pushHistory(history, turn) {
|
|
103
|
+
history.push(turn);
|
|
104
|
+
while (history.length > HISTORY_CAP) history.shift();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Turn text/field from a backend response is untrusted — normalize it before
|
|
108
|
+
// pushing into conversation_history (which we echo on the wire next turn and
|
|
109
|
+
// may print inside /json). Reject unknown roles to prevent a hostile backend
|
|
110
|
+
// from injecting fake `system` turns that amplify prompt-injection attacks.
|
|
111
|
+
function normalizeAssistantTurn(turn) {
|
|
112
|
+
if (!turn || typeof turn !== 'object') return null;
|
|
113
|
+
const role = typeof turn.role === 'string' ? turn.role.toLowerCase() : '';
|
|
114
|
+
if (!ALLOWED_ROLES.has(role)) return null;
|
|
115
|
+
const content = typeof turn.content === 'string' ? turn.content.slice(0, MAX_TURN_CONTENT_LEN) : '';
|
|
116
|
+
const timestamp = typeof turn.timestamp === 'string' ? turn.timestamp : nowIso();
|
|
117
|
+
return { role, content, timestamp };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Map an error from a chat-endpoint call into a human-readable hint. Keep
|
|
121
|
+
// backend-supplied strings out of the shell line so copy-pastes stay safe.
|
|
122
|
+
function friendlyChatError(err) {
|
|
123
|
+
if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
|
|
124
|
+
if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound whoami` to check yours.';
|
|
125
|
+
if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
|
|
126
|
+
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderSuggestionsForHuman(suggestions) {
|
|
130
|
+
if (!suggestions?.length) return;
|
|
131
|
+
console.log('');
|
|
132
|
+
output.info('Suggested follow-ups (rerun `unbound chat -m "<your question>"` with one of these):');
|
|
133
|
+
suggestions.forEach((s, i) => {
|
|
134
|
+
const label = sanitizeForTerminal(s.label || `Suggestion ${i + 1}`);
|
|
135
|
+
const query = sanitizeForTerminal(s.query || '');
|
|
136
|
+
const desc = sanitizeForTerminal(s.description || '');
|
|
137
|
+
console.log(` ${i + 1}. ${bold(label)}`);
|
|
138
|
+
if (query) console.log(` ${query}`);
|
|
139
|
+
if (desc) console.log(` ${dim(desc)}`);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Print the assistant message + (if a chart) a one-line summary and a chart
|
|
145
|
+
* body. Suggestions are NOT printed here — caller decides (REPL picker vs
|
|
146
|
+
* one-shot numbered list).
|
|
147
|
+
*/
|
|
148
|
+
function renderResponse(resp) {
|
|
149
|
+
const intent = resp?.intent;
|
|
150
|
+
const message = sanitizeForTerminal(resp?.message || '');
|
|
151
|
+
|
|
152
|
+
if (intent === 'invalid') {
|
|
153
|
+
output.error(message || 'The request could not be processed.');
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (intent === 'conversational' || !resp.result) {
|
|
158
|
+
if (message) console.log(cyan('\u25cf ') + message);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (message) console.log(cyan('\u25cf ') + message);
|
|
163
|
+
const result = resp.result;
|
|
164
|
+
const summary = sanitizeForTerminal(chart.summarizeChart(result.chart_meta, result.row_count));
|
|
165
|
+
console.log(dim(' ' + summary));
|
|
166
|
+
|
|
167
|
+
const { columns, rows } = chart.reconstructRows(result.chart_option, result.chart_meta);
|
|
168
|
+
if (!rows.length) {
|
|
169
|
+
console.log(dim(' (no rows to render)'));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
console.log('');
|
|
174
|
+
const cap = chart.MAX_RENDER_ROWS;
|
|
175
|
+
const renderRows = rows.slice(0, cap);
|
|
176
|
+
const chartType = result.chart_meta?.chart_type;
|
|
177
|
+
const cols = process.stdout.columns || 80;
|
|
178
|
+
const tableCols = columns.map((k) => ({ key: k, header: k }));
|
|
179
|
+
|
|
180
|
+
if (cols < 40) {
|
|
181
|
+
output.table(renderRows, tableCols);
|
|
182
|
+
} else if (chartType === 'bar' && columns.length >= 2) {
|
|
183
|
+
const xKey = columns[0];
|
|
184
|
+
const yKeys = columns.slice(1);
|
|
185
|
+
const body = yKeys.length === 1
|
|
186
|
+
? chart.renderBarChart(renderRows, xKey, yKeys[0])
|
|
187
|
+
: chart.renderGroupedBarChart(renderRows, xKey, yKeys);
|
|
188
|
+
if (body) console.log(body); else output.table(renderRows, tableCols);
|
|
189
|
+
} else if (chartType === 'line' && columns.length >= 2) {
|
|
190
|
+
const yKey = columns[1];
|
|
191
|
+
const series = renderRows.map((r) => Number(r[yKey] ?? 0));
|
|
192
|
+
const spark = chart.renderSparkline(series);
|
|
193
|
+
if (spark) console.log(spark);
|
|
194
|
+
output.table(renderRows, tableCols);
|
|
195
|
+
} else {
|
|
196
|
+
output.table(renderRows, tableCols);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (rows.length > cap) {
|
|
200
|
+
console.log(dim(` \u2026 ${rows.length - cap} more row${rows.length - cap === 1 ? '' : 's'} hidden. Use --json or -o for full data.`));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runOneShot({ message, json, outputPath }) {
|
|
205
|
+
const body = buildBody(message, [], []);
|
|
206
|
+
|
|
207
|
+
if (json || outputPath) {
|
|
208
|
+
let resp;
|
|
209
|
+
try {
|
|
210
|
+
resp = await api.post(ENDPOINT, { body });
|
|
211
|
+
} catch (err) {
|
|
212
|
+
output.error(friendlyChatError(err));
|
|
213
|
+
process.exitCode = 1;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const serialized = JSON.stringify(resp, null, 2) + '\n';
|
|
217
|
+
// Write to stdout first (when requested) so a downstream `-o` write failure
|
|
218
|
+
// doesn't eat the response when both flags are combined.
|
|
219
|
+
if (json) {
|
|
220
|
+
process.stdout.write(serialized);
|
|
221
|
+
}
|
|
222
|
+
if (outputPath) {
|
|
223
|
+
try {
|
|
224
|
+
const resolved = safeWriteFile(outputPath, serialized);
|
|
225
|
+
output.success(`Wrote ${resolved}`);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
output.error(sanitizeForTerminal(err.message));
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (resp?.intent === 'invalid') process.exitCode = 1;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const spin = output.spinner('Thinking...');
|
|
237
|
+
let resp;
|
|
238
|
+
try {
|
|
239
|
+
resp = await api.post(ENDPOINT, { body });
|
|
240
|
+
spin.stop();
|
|
241
|
+
} catch (err) {
|
|
242
|
+
spin.fail(friendlyChatError(err));
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
renderResponse(resp);
|
|
248
|
+
renderSuggestionsForHuman(resp?.suggestions);
|
|
249
|
+
if (resp?.intent === 'invalid') process.exitCode = 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function printBanner() {
|
|
253
|
+
console.log(bold('unbound chat') + dim(' — ask questions about your usage data'));
|
|
254
|
+
console.log(dim('Type a question, or /help for commands. /exit (or Ctrl+C) to quit.'));
|
|
255
|
+
console.log('');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function printReplHelp() {
|
|
259
|
+
const lines = [
|
|
260
|
+
' /help show this help',
|
|
261
|
+
' /json print the last result as JSON to stdout',
|
|
262
|
+
' /export <path> write the last result to a new JSON file',
|
|
263
|
+
' (refuses to overwrite; supports ~/ expansion; mode 0600)',
|
|
264
|
+
' /clear reset the conversation in memory',
|
|
265
|
+
' /exit quit (Ctrl+C also works)',
|
|
266
|
+
];
|
|
267
|
+
console.log(lines.join('\n'));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handleSlash(line, state) {
|
|
271
|
+
const trimmed = line.trim();
|
|
272
|
+
const spaceIdx = trimmed.search(/\s/);
|
|
273
|
+
const cmd = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
|
|
274
|
+
const argStr = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
|
|
275
|
+
switch (cmd) {
|
|
276
|
+
case '/exit':
|
|
277
|
+
case '/quit':
|
|
278
|
+
return 'exit';
|
|
279
|
+
case '/help':
|
|
280
|
+
printReplHelp();
|
|
281
|
+
return 'continue';
|
|
282
|
+
case '/clear':
|
|
283
|
+
state.history.length = 0;
|
|
284
|
+
state.recent.length = 0;
|
|
285
|
+
state.lastResult = null;
|
|
286
|
+
output.success('Conversation cleared.');
|
|
287
|
+
return 'continue';
|
|
288
|
+
case '/json':
|
|
289
|
+
if (state.lastResult) console.log(JSON.stringify(state.lastResult, null, 2));
|
|
290
|
+
else console.log(dim('(no result yet)'));
|
|
291
|
+
return 'continue';
|
|
292
|
+
case '/export': {
|
|
293
|
+
if (!state.lastResult) { output.error('No result to export yet. Ask a question first.'); return 'continue'; }
|
|
294
|
+
if (!argStr) { output.error('Usage: /export <path>'); return 'continue'; }
|
|
295
|
+
try {
|
|
296
|
+
const resolved = safeWriteFile(argStr, JSON.stringify(state.lastResult, null, 2) + '\n');
|
|
297
|
+
output.success(`Wrote ${resolved}`);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
output.error(sanitizeForTerminal(err.message));
|
|
300
|
+
}
|
|
301
|
+
return 'continue';
|
|
302
|
+
}
|
|
303
|
+
default:
|
|
304
|
+
output.warn(`Unknown command: ${sanitizeForTerminal(cmd)}. Type /help for the list.`);
|
|
305
|
+
return 'continue';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function runRepl({ noSuggestions }) {
|
|
310
|
+
const state = { history: [], recent: [], lastResult: null };
|
|
311
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
312
|
+
let closed = false;
|
|
313
|
+
const setClosed = () => { closed = true; };
|
|
314
|
+
rl.on('SIGINT', () => { closed = true; rl.close(); });
|
|
315
|
+
rl.on('close', setClosed);
|
|
316
|
+
|
|
317
|
+
printBanner();
|
|
318
|
+
let pending = null;
|
|
319
|
+
|
|
320
|
+
while (!closed) {
|
|
321
|
+
let msg;
|
|
322
|
+
if (pending) {
|
|
323
|
+
msg = pending;
|
|
324
|
+
pending = null;
|
|
325
|
+
console.log(`${bold('chat>')} ${dim('(picked) ') + sanitizeForTerminal(msg)}`);
|
|
326
|
+
} else {
|
|
327
|
+
try {
|
|
328
|
+
msg = await rl.question(`${bold('chat>')} `);
|
|
329
|
+
} catch {
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (closed) break;
|
|
334
|
+
msg = (msg || '').trim();
|
|
335
|
+
if (!msg) continue;
|
|
336
|
+
if (msg.length > MAX_MESSAGE_LEN) {
|
|
337
|
+
output.warn(`Message truncated to ${MAX_MESSAGE_LEN} characters.`);
|
|
338
|
+
msg = msg.slice(0, MAX_MESSAGE_LEN);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (msg.startsWith('/')) {
|
|
342
|
+
const action = handleSlash(msg, state);
|
|
343
|
+
if (action === 'exit') break;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Build the request body BEFORE pushing the current turn into history —
|
|
348
|
+
// otherwise `message` and the last entry of `conversation_history` would
|
|
349
|
+
// both contain the same user text, making the backend process it twice.
|
|
350
|
+
const body = buildBody(msg, state.history, state.recent);
|
|
351
|
+
|
|
352
|
+
const spin = output.spinner('Thinking...');
|
|
353
|
+
let resp;
|
|
354
|
+
try {
|
|
355
|
+
resp = await api.post(ENDPOINT, { body });
|
|
356
|
+
spin.stop();
|
|
357
|
+
} catch (err) {
|
|
358
|
+
spin.fail(friendlyChatError(err));
|
|
359
|
+
if (err?.statusCode === 401 || err?.statusCode === 403) break;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Record the user turn only after a successful round-trip so history stays
|
|
364
|
+
// consistent with the server view.
|
|
365
|
+
pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
|
|
366
|
+
|
|
367
|
+
const assistantTurn = normalizeAssistantTurn(resp?.turn);
|
|
368
|
+
if (assistantTurn) pushHistory(state.history, assistantTurn);
|
|
369
|
+
if (resp?.result) {
|
|
370
|
+
pushRecent(state.recent, msg, resp.result);
|
|
371
|
+
state.lastResult = resp.result;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
renderResponse(resp);
|
|
375
|
+
|
|
376
|
+
const suggestions = Array.isArray(resp?.suggestions) ? resp.suggestions : [];
|
|
377
|
+
if (suggestions.length && !noSuggestions && process.stdin.isTTY && process.stdout.isTTY) {
|
|
378
|
+
const pickOptions = [
|
|
379
|
+
...suggestions.map((s, i) => ({
|
|
380
|
+
label: sanitizeForTerminal(s.label || `Suggestion ${i + 1}`)
|
|
381
|
+
+ (s.description ? ' ' + dim('— ' + sanitizeForTerminal(s.description)) : ''),
|
|
382
|
+
value: sanitizeForTerminal(s.query || ''),
|
|
383
|
+
})).filter((o) => o.value),
|
|
384
|
+
{ label: dim('(type your own)'), value: null },
|
|
385
|
+
{ label: dim('(exit)'), value: '__exit__' },
|
|
386
|
+
];
|
|
387
|
+
let pick;
|
|
388
|
+
try {
|
|
389
|
+
rl.pause();
|
|
390
|
+
pick = await output.select('Follow up?', pickOptions);
|
|
391
|
+
} catch {
|
|
392
|
+
pick = null;
|
|
393
|
+
} finally {
|
|
394
|
+
rl.resume();
|
|
395
|
+
}
|
|
396
|
+
if (pick === '__exit__') break;
|
|
397
|
+
if (pick) pending = pick;
|
|
398
|
+
} else if (suggestions.length && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
399
|
+
renderSuggestionsForHuman(suggestions);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
rl.close();
|
|
404
|
+
console.log('');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function register(program) {
|
|
408
|
+
program
|
|
409
|
+
.command('chat')
|
|
410
|
+
.description('Chat with your usage data. Opens an interactive session by default; use -m for one-shot.')
|
|
411
|
+
.option('-m, --message <text>', 'Ask a single question and exit (no interactive session).')
|
|
412
|
+
.option('--json', 'Write the raw JSON response to stdout. Use with -m.')
|
|
413
|
+
.option('-o, --output <path>', 'Write the raw JSON response to a file. Refuses to overwrite. Use with -m.')
|
|
414
|
+
.option('--no-suggestions', 'Skip the follow-up picker after each turn (interactive session only).')
|
|
415
|
+
.addHelpText('after', `
|
|
416
|
+
EXAMPLES
|
|
417
|
+
Start an interactive session:
|
|
418
|
+
$ unbound chat
|
|
419
|
+
|
|
420
|
+
Ask a single question and render a chart in the terminal:
|
|
421
|
+
$ unbound chat -m "show cost by provider for the last 30 days"
|
|
422
|
+
|
|
423
|
+
Write the raw JSON response to stdout (pipe to jq, a script, or another tool):
|
|
424
|
+
$ unbound chat -m "cost by provider last 30 days" --json
|
|
425
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq '.intent'
|
|
426
|
+
$ unbound chat -m "cost by provider last 30 days" --json | jq '.result.chart_option'
|
|
427
|
+
|
|
428
|
+
Write the raw JSON response to a file:
|
|
429
|
+
$ unbound chat -m "cost by provider last 30 days" -o cost.json
|
|
430
|
+
|
|
431
|
+
Combine --json and -o to pipe and archive at the same time:
|
|
432
|
+
$ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.intent'
|
|
433
|
+
|
|
434
|
+
OUTPUT FORMAT (--json and -o)
|
|
435
|
+
{ message, intent, result?, suggestions?, turn }
|
|
436
|
+
|
|
437
|
+
intent: "sql_visualise" | "conversational" | "invalid"
|
|
438
|
+
sql_visualise — a chart was generated; 'result' is populated.
|
|
439
|
+
conversational — the model wants clarification; pick a suggestions[i].query and re-run.
|
|
440
|
+
invalid — the prompt could not be answered; exit code is 1.
|
|
441
|
+
|
|
442
|
+
result (only when intent == "sql_visualise"):
|
|
443
|
+
columns string[] left-to-right column names
|
|
444
|
+
row_count number total rows in the answer
|
|
445
|
+
chart_meta { chart_type, x, y } chart_type is one of:
|
|
446
|
+
"bar" | "line" | "donut" | "pie"
|
|
447
|
+
| "kpi" | "scatter" | "table"
|
|
448
|
+
chart_option ECharts config with the data embedded.
|
|
449
|
+
|
|
450
|
+
suggestions (may be present with any intent):
|
|
451
|
+
[{ label, query, description }]
|
|
452
|
+
Each entry's 'query' is the exact text for a follow-up '-m "<query>"' call.
|
|
453
|
+
|
|
454
|
+
turn: { role, content, timestamp } — the assistant's turn record.
|
|
455
|
+
|
|
456
|
+
DATA EXTRACTION (rows are embedded in chart_option; no separate rows field)
|
|
457
|
+
bar, line chart_option.xAxis.data (category labels, ordered)
|
|
458
|
+
chart_option.series[i].data (values, parallel to xAxis.data)
|
|
459
|
+
donut, pie chart_option.series[0].data ([{name, value}, ...])
|
|
460
|
+
scatter chart_option.series[0].data ([[x, y], ...])
|
|
461
|
+
or chart_option.dataset.source
|
|
462
|
+
table chart_option.dataset.source (first row is the header)
|
|
463
|
+
kpi chart_option.series[0].data[0] (a single number; no axes)
|
|
464
|
+
|
|
465
|
+
EXIT CODES
|
|
466
|
+
0 success
|
|
467
|
+
1 intent=="invalid", auth/role/network failure, or a write error
|
|
468
|
+
|
|
469
|
+
INTERACTIVE SESSION
|
|
470
|
+
$ unbound chat start the session
|
|
471
|
+
$ unbound chat --no-suggestions skip the follow-up picker
|
|
472
|
+
|
|
473
|
+
Commands (type inside the session):
|
|
474
|
+
/help show these commands
|
|
475
|
+
/json print the last result as JSON
|
|
476
|
+
/export <path> write the last result to a file (refuses to overwrite;
|
|
477
|
+
supports ~/ expansion; file mode is 0600)
|
|
478
|
+
/clear reset the conversation in memory
|
|
479
|
+
/exit quit (Ctrl+C also works)
|
|
480
|
+
|
|
481
|
+
NOTES
|
|
482
|
+
* Requires Admin or Manager role in your organization. Run 'unbound whoami' to check.
|
|
483
|
+
* Conversation history is held in memory only; nothing is ever written to disk.
|
|
484
|
+
* Each -m invocation sends no history; keep each prompt self-contained.
|
|
485
|
+
* -o writes a new file with mode 0600 and refuses to overwrite existing files.
|
|
486
|
+
`)
|
|
487
|
+
.action(async (opts) => {
|
|
488
|
+
try {
|
|
489
|
+
await ensureLoggedIn();
|
|
490
|
+
} catch (err) {
|
|
491
|
+
if (!err.displayed) output.error(sanitizeForTerminal(err.message));
|
|
492
|
+
process.exitCode = 1;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const oneShotFlagsGiven = opts.message !== undefined || !!opts.json || !!opts.output;
|
|
497
|
+
if (oneShotFlagsGiven) {
|
|
498
|
+
const trimmed = typeof opts.message === 'string' ? opts.message.trim() : '';
|
|
499
|
+
if (!trimmed) {
|
|
500
|
+
output.error('`-m/--message` requires a non-empty message. For interactive chat, run `unbound chat` with no flags.');
|
|
501
|
+
process.exitCode = 1;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (trimmed.length > MAX_MESSAGE_LEN) {
|
|
505
|
+
output.error(`Message too long (max ${MAX_MESSAGE_LEN} characters).`);
|
|
506
|
+
process.exitCode = 1;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
await runOneShot({ message: trimmed, json: !!opts.json, outputPath: opts.output });
|
|
511
|
+
} catch (err) {
|
|
512
|
+
output.error(friendlyChatError(err));
|
|
513
|
+
process.exitCode = 1;
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
await runRepl({ noSuggestions: opts.suggestions === false });
|
|
520
|
+
} catch (err) {
|
|
521
|
+
output.error(friendlyChatError(err));
|
|
522
|
+
process.exitCode = 1;
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
module.exports = {
|
|
528
|
+
register,
|
|
529
|
+
// Exposed for unit tests:
|
|
530
|
+
_internals: { safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError },
|
|
531
|
+
};
|
package/src/commands/onboard.js
CHANGED
|
@@ -27,6 +27,8 @@ function register(program) {
|
|
|
27
27
|
.requiredOption('--api-key <key>', 'User API key (for tool setup and login)')
|
|
28
28
|
.requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
|
|
29
29
|
.option('--domain <url>', 'Backend URL for discovery', DEFAULT_DOMAIN)
|
|
30
|
+
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
31
|
+
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
30
32
|
.addHelpText('after', `
|
|
31
33
|
Runs the full onboarding flow for an end user:
|
|
32
34
|
1. Logs in with --api-key and stores credentials.
|
|
@@ -54,7 +56,7 @@ Examples:
|
|
|
54
56
|
|
|
55
57
|
console.log('');
|
|
56
58
|
output.info('Step 1/2: Installing tool bundle');
|
|
57
|
-
const ok = await runSetupAllBundle(apiKey);
|
|
59
|
+
const ok = await runSetupAllBundle(apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
58
60
|
if (!ok) return;
|
|
59
61
|
setupSucceeded = true;
|
|
60
62
|
|
|
@@ -87,7 +89,8 @@ Examples:
|
|
|
87
89
|
.requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
|
|
88
90
|
.requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
|
|
89
91
|
.option('--domain <url>', 'Backend URL for discovery', DEFAULT_DOMAIN)
|
|
90
|
-
.addOption(new Option('--url <url>', 'Override backend URL for setup scripts').hideHelp())
|
|
92
|
+
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
93
|
+
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
91
94
|
.addHelpText('after', `
|
|
92
95
|
Runs the full MDM onboarding flow for device enrollment:
|
|
93
96
|
1. Installs the MDM tool bundle: ${MDM_ALL_TOOLS.join(', ')}.
|
|
@@ -108,7 +111,7 @@ Examples:
|
|
|
108
111
|
|
|
109
112
|
console.log('');
|
|
110
113
|
output.info('Step 1/2: Installing MDM tool bundle');
|
|
111
|
-
const ok = await runMdmSetupAllBundle(opts.adminApiKey, {
|
|
114
|
+
const ok = await runMdmSetupAllBundle(opts.adminApiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
|
|
112
115
|
if (!ok) return;
|
|
113
116
|
setupSucceeded = true;
|
|
114
117
|
|