unbound-cli 1.13.0 → 1.15.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/package.json +1 -1
- package/src/api.js +217 -0
- package/src/commands/analytics.js +344 -0
- package/src/commands/chat.js +140 -31
- package/src/commands/download.js +245 -0
- package/src/index.js +14 -0
- package/src/lib/analytics-query.js +216 -0
- package/src/lib/downloads.js +343 -0
- package/src/output.js +75 -3
- package/test/analytics-columns.test.js +68 -0
- package/test/analytics-export-job.test.js +141 -0
- package/test/analytics-log-table.test.js +77 -0
- package/test/analytics-paging-footer.test.js +34 -0
- package/test/analytics-query.test.js +142 -0
- package/test/chat-json-stdout.test.js +101 -12
- package/test/chat-stream.test.js +255 -0
- package/test/download-flag-collision.test.js +52 -0
- package/test/download-query.test.js +79 -0
- package/test/download-safety.test.js +201 -0
package/src/commands/chat.js
CHANGED
|
@@ -22,7 +22,9 @@ const chart = require('../chartRender');
|
|
|
22
22
|
const { sanitizeForTerminal } = chart;
|
|
23
23
|
|
|
24
24
|
const ENDPOINT = '/api/v1/query_builder/agent/converse/';
|
|
25
|
-
//
|
|
25
|
+
// Silence, not total time — the turn streams, so this bounds the gap between
|
|
26
|
+
// frames. A step is one model call plus its queries, capped server-side well
|
|
27
|
+
// under this.
|
|
26
28
|
const REQUEST_TIMEOUT_MS = 300000;
|
|
27
29
|
const HISTORY_CAP = 20;
|
|
28
30
|
const MAX_MESSAGE_LEN = 4000;
|
|
@@ -118,6 +120,10 @@ function toJsonPayload(resp) {
|
|
|
118
120
|
// Without it a --json consumer gets exit 1 and no reason.
|
|
119
121
|
error: resp?.error ?? null,
|
|
120
122
|
conversation_id: resp?.conversation_id ?? null,
|
|
123
|
+
// The command to run next, spelled out. A model reading this transcript can
|
|
124
|
+
// act on it without being told how to assemble one from conversation_id.
|
|
125
|
+
// Null when the turn produced nothing, because there is nothing to continue.
|
|
126
|
+
continue_command: blocks.length ? continueCommand(resp?.conversation_id) : null,
|
|
121
127
|
log_id: resp?.log_id ?? null,
|
|
122
128
|
// Ran out of budget mid-answer; the blocks already here are real.
|
|
123
129
|
truncated: resp?.truncated === true,
|
|
@@ -175,9 +181,58 @@ function friendlyChatError(err) {
|
|
|
175
181
|
if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
|
|
176
182
|
if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
|
|
177
183
|
if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
|
|
184
|
+
if (err?.statusCode === 504) return 'The turn took too long and the connection was closed. Try narrowing the question.';
|
|
178
185
|
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
179
186
|
}
|
|
180
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Run one turn, handing each block to `onBlock` as it lands.
|
|
190
|
+
*
|
|
191
|
+
* Streamed because the proxy in front of the API drops a connection that goes
|
|
192
|
+
* 60s without a byte, and a turn can run for minutes — so this is what keeps a
|
|
193
|
+
* long request alive, not just what makes it render sooner.
|
|
194
|
+
*
|
|
195
|
+
* Returns the shape the non-streaming endpoint returns, so callers below do not
|
|
196
|
+
* care which one answered.
|
|
197
|
+
*/
|
|
198
|
+
async function converse(body, onBlock) {
|
|
199
|
+
const blocks = [];
|
|
200
|
+
let tail = null;
|
|
201
|
+
let failure = null;
|
|
202
|
+
|
|
203
|
+
let unstreamed;
|
|
204
|
+
try {
|
|
205
|
+
unstreamed = await api.postStream(ENDPOINT, {
|
|
206
|
+
body: { ...body, stream: true },
|
|
207
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
208
|
+
onEvent: (event, data) => {
|
|
209
|
+
if (event === 'block' && data) {
|
|
210
|
+
blocks.push(data);
|
|
211
|
+
if (onBlock) onBlock(data);
|
|
212
|
+
} else if (event === 'done') {
|
|
213
|
+
tail = data;
|
|
214
|
+
} else if (event === 'error') {
|
|
215
|
+
failure = data;
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// The socket can still fail while tearing down after the closing frame. The
|
|
221
|
+
// turn reported itself finished, and in the terminal its charts are already
|
|
222
|
+
// printed — discarding it here would contradict what is on screen.
|
|
223
|
+
if (!tail && !failure) throw err;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (unstreamed) return unstreamed;
|
|
227
|
+
// Blocks are kept on a mid-stream failure: they were built, and in the terminal
|
|
228
|
+
// they have already been printed.
|
|
229
|
+
if (failure) return { success: false, error: failure.error, blocks };
|
|
230
|
+
if (!tail) {
|
|
231
|
+
throw new Error('The connection dropped before the reply finished. Ask again, or narrow the question.');
|
|
232
|
+
}
|
|
233
|
+
return { success: true, blocks, ...tail };
|
|
234
|
+
}
|
|
235
|
+
|
|
181
236
|
/** Print one chart: its title, a one-line summary, then a body or a table. */
|
|
182
237
|
function renderChartBlock(block) {
|
|
183
238
|
const result = block.result || {};
|
|
@@ -233,6 +288,28 @@ function renderChartBlock(block) {
|
|
|
233
288
|
}
|
|
234
289
|
}
|
|
235
290
|
|
|
291
|
+
function renderBlock(block) {
|
|
292
|
+
if (block?.type === 'text') {
|
|
293
|
+
if (block.text) console.log(cyan('\u25cf ') + sanitizeForTerminal(block.text));
|
|
294
|
+
} else if (block?.type === 'chart') {
|
|
295
|
+
renderChartBlock(block);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* What belongs after the blocks. Called on its own when they were printed as
|
|
301
|
+
* they streamed, so this is all that is still owed.
|
|
302
|
+
*/
|
|
303
|
+
function renderTail(resp) {
|
|
304
|
+
if (resp?.success === false) {
|
|
305
|
+
output.error(sanitizeForTerminal(resp?.error || 'The request could not be processed.'));
|
|
306
|
+
}
|
|
307
|
+
if (resp?.truncated) {
|
|
308
|
+
console.log('');
|
|
309
|
+
output.warn('That is as far as it got in one turn. Ask for the rest to continue.');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
236
313
|
/**
|
|
237
314
|
* Print the turn in the order the agent produced it — prose where it wrote
|
|
238
315
|
* prose, a chart where it ran one. A turn can hold several of each.
|
|
@@ -249,22 +326,30 @@ function renderResponse(resp) {
|
|
|
249
326
|
return;
|
|
250
327
|
}
|
|
251
328
|
|
|
252
|
-
for (const block of blocks)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
} else if (block?.type === 'chart') {
|
|
256
|
-
renderChartBlock(block);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
329
|
+
for (const block of blocks) renderBlock(block);
|
|
330
|
+
renderTail(resp);
|
|
331
|
+
}
|
|
259
332
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
333
|
+
// Null unless the id is a canonical UUID. This string is meant to be run — by a
|
|
334
|
+
// person pasting it or by a model acting on the payload — and it is assembled
|
|
335
|
+
// from a response field, which is not a trusted source of command text. The same
|
|
336
|
+
// shape is already required of `-c`; requiring it in both directions is what
|
|
337
|
+
// keeps a hostile or broken backend from getting a shell metacharacter in here.
|
|
338
|
+
function continueCommand(conversationId) {
|
|
339
|
+
const id = String(conversationId ?? '');
|
|
340
|
+
return UUID_RE.test(id) ? `unbound chat -m "..." -c ${id}` : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// The same line either way, so what a --json run prints is what a plain run
|
|
344
|
+
// prints — a command to run, not an id to assemble one from.
|
|
345
|
+
function continueHint(command) {
|
|
346
|
+
return ` continue: ${command}`;
|
|
264
347
|
}
|
|
265
348
|
|
|
266
349
|
function warnIfNotResumed(conversationId, payload) {
|
|
267
|
-
|
|
350
|
+
// Only on a turn that ran. A failed turn carries no `resumed` at all, so saying
|
|
351
|
+
// the conversation was missing sends them after a problem they do not have.
|
|
352
|
+
if (conversationId && payload.success && !payload.resumed) {
|
|
268
353
|
process.stderr.write(
|
|
269
354
|
`Nothing to continue for ${conversationId} — answering without it. `
|
|
270
355
|
+ 'Check the conversation_id from the previous reply.\n',
|
|
@@ -278,19 +363,14 @@ async function runOneShot({ message, json, outputPath, conversationId }) {
|
|
|
278
363
|
if (json || outputPath) {
|
|
279
364
|
let resp;
|
|
280
365
|
try {
|
|
281
|
-
|
|
366
|
+
// No per-block callback: stdout carries the transcript and nothing else.
|
|
367
|
+
resp = await converse(body);
|
|
282
368
|
} catch (err) {
|
|
283
369
|
output.error(friendlyChatError(err));
|
|
284
370
|
process.exitCode = 1;
|
|
285
371
|
return;
|
|
286
372
|
}
|
|
287
373
|
const payload = toJsonPayload(resp);
|
|
288
|
-
warnIfNotResumed(conversationId, payload);
|
|
289
|
-
// On stderr: stdout is the transcript, and a reader piping it to jq must not
|
|
290
|
-
// find this in the stream. It is still on screen for whoever ran the command.
|
|
291
|
-
if (payload.conversation_id && payload.blocks.length) {
|
|
292
|
-
process.stderr.write(`continue with: -c ${sanitizeForTerminal(String(payload.conversation_id))}\n`);
|
|
293
|
-
}
|
|
294
374
|
const serialized = JSON.stringify(payload, null, 2) + '\n';
|
|
295
375
|
// Write to stdout first (when requested) so a downstream `-o` write failure
|
|
296
376
|
// doesn't eat the response when both flags are combined.
|
|
@@ -311,14 +391,30 @@ async function runOneShot({ message, json, outputPath, conversationId }) {
|
|
|
311
391
|
return;
|
|
312
392
|
}
|
|
313
393
|
}
|
|
394
|
+
// Last, after the transcript has finished printing. A turn can be hundreds of
|
|
395
|
+
// kilobytes of JSON, so anything written before it has scrolled out of reach
|
|
396
|
+
// by the time the command ends — which is where someone is looking for it.
|
|
397
|
+
warnIfNotResumed(conversationId, payload);
|
|
398
|
+
// stderr, and only to a terminal: stdout is the transcript and a reader piping
|
|
399
|
+
// it to jq must not find this in the stream, while a captured stderr is a log
|
|
400
|
+
// rather than something a person is reading. A script takes the command off
|
|
401
|
+
// `continue_command` in the payload instead.
|
|
402
|
+
if (payload.continue_command && process.stderr.isTTY) {
|
|
403
|
+
process.stderr.write(continueHint(payload.continue_command) + '\n');
|
|
404
|
+
}
|
|
314
405
|
if (!payload.success || !payload.blocks.length) process.exitCode = 1;
|
|
315
406
|
return;
|
|
316
407
|
}
|
|
317
408
|
|
|
318
409
|
const spin = output.spinner('Thinking...');
|
|
319
410
|
let resp;
|
|
411
|
+
let printed = 0;
|
|
320
412
|
try {
|
|
321
|
-
resp = await
|
|
413
|
+
resp = await converse(body, (block) => {
|
|
414
|
+
if (!printed) spin.stop();
|
|
415
|
+
printed += 1;
|
|
416
|
+
renderBlock(block);
|
|
417
|
+
});
|
|
322
418
|
spin.stop();
|
|
323
419
|
} catch (err) {
|
|
324
420
|
spin.fail(friendlyChatError(err));
|
|
@@ -327,13 +423,14 @@ async function runOneShot({ message, json, outputPath, conversationId }) {
|
|
|
327
423
|
}
|
|
328
424
|
|
|
329
425
|
warnIfNotResumed(conversationId, toJsonPayload(resp));
|
|
330
|
-
|
|
426
|
+
// A server that answered without streaming printed nothing along the way.
|
|
427
|
+
if (printed) renderTail(resp); else renderResponse(resp);
|
|
331
428
|
// Without this the id only exists in --json, so continuing a conversation from
|
|
332
429
|
// the terminal would mean asking the same question again just to read it back.
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
430
|
+
const command = (resp?.blocks || []).length ? continueCommand(resp?.conversation_id) : null;
|
|
431
|
+
if (command) {
|
|
335
432
|
console.log('');
|
|
336
|
-
console.log(dim(
|
|
433
|
+
console.log(dim(continueHint(command)));
|
|
337
434
|
}
|
|
338
435
|
if (resp?.success === false || !(resp?.blocks || []).length) process.exitCode = 1;
|
|
339
436
|
}
|
|
@@ -433,20 +530,27 @@ async function runRepl() {
|
|
|
433
530
|
|
|
434
531
|
const spin = output.spinner('Thinking...');
|
|
435
532
|
let resp;
|
|
533
|
+
let printed = 0;
|
|
436
534
|
try {
|
|
437
|
-
resp = await
|
|
535
|
+
resp = await converse(body, (block) => {
|
|
536
|
+
if (!printed) spin.stop();
|
|
537
|
+
printed += 1;
|
|
538
|
+
renderBlock(block);
|
|
539
|
+
});
|
|
438
540
|
spin.stop();
|
|
439
541
|
} catch (err) {
|
|
440
542
|
spin.fail(friendlyChatError(err));
|
|
441
543
|
if (err?.statusCode === 401 || err?.statusCode === 403) break;
|
|
442
544
|
continue;
|
|
443
545
|
}
|
|
546
|
+
// A server that answered without streaming printed nothing along the way.
|
|
547
|
+
const render = () => { if (printed) renderTail(resp); else renderResponse(resp); };
|
|
444
548
|
|
|
445
549
|
// No answer means no turn: recording the question would send the assistant
|
|
446
550
|
// something it never replied to. The id stays so the server's rows group.
|
|
447
551
|
if (resp?.conversation_id) state.conversationId = resp.conversation_id;
|
|
448
552
|
if (resp?.success === false || !(resp?.blocks || []).length) {
|
|
449
|
-
|
|
553
|
+
render();
|
|
450
554
|
continue;
|
|
451
555
|
}
|
|
452
556
|
|
|
@@ -459,14 +563,14 @@ async function runRepl() {
|
|
|
459
563
|
// would leave the question in history with no answer beside it, and the next
|
|
460
564
|
// request would send the model two user turns in a row.
|
|
461
565
|
if (!assistantTurn?.content) {
|
|
462
|
-
|
|
566
|
+
render();
|
|
463
567
|
continue;
|
|
464
568
|
}
|
|
465
569
|
pushHistory(state.history, { role: 'user', content: msg, timestamp: nowIso() });
|
|
466
570
|
pushHistory(state.history, assistantTurn);
|
|
467
571
|
state.lastPayload = toJsonPayload(resp);
|
|
468
572
|
|
|
469
|
-
|
|
573
|
+
render();
|
|
470
574
|
|
|
471
575
|
}
|
|
472
576
|
|
|
@@ -512,7 +616,8 @@ EXAMPLES
|
|
|
512
616
|
$ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.blocks | length'
|
|
513
617
|
|
|
514
618
|
OUTPUT FORMAT (--json and -o)
|
|
515
|
-
{ success, error, conversation_id, log_id, truncated,
|
|
619
|
+
{ success, error, conversation_id, continue_command, log_id, truncated,
|
|
620
|
+
resumed, blocks[] }
|
|
516
621
|
|
|
517
622
|
A turn is an ordered transcript. The agent answers one thing at a time, so a
|
|
518
623
|
single question can return several charts with prose between them, and blocks[]
|
|
@@ -538,6 +643,10 @@ OUTPUT FORMAT (--json and -o)
|
|
|
538
643
|
truncated: true means the agent ran out of budget partway. The blocks present
|
|
539
644
|
are real and complete; ask a follow-up for the rest.
|
|
540
645
|
|
|
646
|
+
continue_command is the next command spelled out, for a reader that should act
|
|
647
|
+
on it rather than assemble one from conversation_id. Null when the turn
|
|
648
|
+
produced nothing.
|
|
649
|
+
|
|
541
650
|
log_id identifies the stored turn, for feedback and for support to trace.
|
|
542
651
|
|
|
543
652
|
EXIT CODES
|
|
@@ -627,6 +736,6 @@ module.exports = {
|
|
|
627
736
|
// Exposed for unit tests:
|
|
628
737
|
_internals: {
|
|
629
738
|
safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError,
|
|
630
|
-
toJsonPayload, flattenBlocks, buildBody,
|
|
739
|
+
toJsonPayload, flattenBlocks, buildBody, converse,
|
|
631
740
|
},
|
|
632
741
|
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const config = require('../config');
|
|
2
|
+
const output = require('../output');
|
|
3
|
+
const downloads = require('../lib/downloads');
|
|
4
|
+
|
|
5
|
+
const { KINDS, MAX_EXPORT_ROWS, RETENTION_HOURS } = downloads;
|
|
6
|
+
|
|
7
|
+
function requireLogin() {
|
|
8
|
+
if (!config.isLoggedIn()) {
|
|
9
|
+
throw new Error('Not logged in. Run `unbound login` first.');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const KIND_LIST = Object.entries(KINDS)
|
|
14
|
+
.map(([kind, meta]) => ` ${kind.padEnd(23)} ${meta.summary}`)
|
|
15
|
+
.join('\n');
|
|
16
|
+
|
|
17
|
+
const AGENT_HELP = `
|
|
18
|
+
AGENTS / SCRIPTING
|
|
19
|
+
A CSV export is prepared by a background worker, so it is three steps. This
|
|
20
|
+
command does all three and blocks until the file is on disk:
|
|
21
|
+
|
|
22
|
+
1. POST starts a job and returns its ID
|
|
23
|
+
2. GET polls the job until it is completed
|
|
24
|
+
3. GET signs a short-lived URL and streams the file down
|
|
25
|
+
|
|
26
|
+
Exit code is 0 on success and 1 on any error. Diagnostics go to stderr, so
|
|
27
|
+
stdout carries only the CSV and is safe to pipe.
|
|
28
|
+
|
|
29
|
+
Check before you commit — this counts rows without starting anything:
|
|
30
|
+
|
|
31
|
+
$ unbound download start logs-export --check --last 7d --json
|
|
32
|
+
{"row_count":18204,"limit":25000,"exceeds_limit":false,"exact":true}
|
|
33
|
+
|
|
34
|
+
A selection over ${MAX_EXPORT_ROWS.toLocaleString()} rows is refused up front with the real count, so
|
|
35
|
+
narrow the range and retry rather than paging.
|
|
36
|
+
|
|
37
|
+
If you stop waiting, the job keeps running. Fetch it within ${RETENTION_HOURS} hours:
|
|
38
|
+
|
|
39
|
+
$ unbound download get <job-id> -o out.csv
|
|
40
|
+
|
|
41
|
+
Only 3 exports may be preparing at once per organization; a 4th returns an
|
|
42
|
+
error telling you to wait. Do not poll by re-running the export.
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const FILTER_HELP = `
|
|
46
|
+
TIME RANGE
|
|
47
|
+
Omit it and the server applies its own default window. --start/--end may be at
|
|
48
|
+
most 90 days apart, and cannot start earlier than 90 days ago.
|
|
49
|
+
|
|
50
|
+
FILTERS
|
|
51
|
+
Repeat --filter with the same key to pass a list; each is sent as its own
|
|
52
|
+
value, which is how the server reads list filters:
|
|
53
|
+
|
|
54
|
+
--filter tool_types=CLAUDE_CODE --filter tool_types=CURSOR
|
|
55
|
+
|
|
56
|
+
An unknown filter KEY is silently ignored, so a typo widens the export rather
|
|
57
|
+
than failing — 'tool_type=CURSOR' returns every tool. An invalid VALUE for a
|
|
58
|
+
known key IS rejected, with the supported values listed. Run --check first and
|
|
59
|
+
confirm the count moved.
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
/** `--last 7d` / `--last 24h` -> an absolute window the server understands. */
|
|
63
|
+
function resolveLast(value) {
|
|
64
|
+
const match = String(value).trim().match(/^(\d+)\s*([dh])$/i);
|
|
65
|
+
if (!match) {
|
|
66
|
+
throw new Error(`Invalid --last "${value}". Use a number followed by d or h, e.g. 7d or 24h.`);
|
|
67
|
+
}
|
|
68
|
+
const amount = Number(match[1]);
|
|
69
|
+
if (amount < 1) throw new Error('--last must be at least 1.');
|
|
70
|
+
const hours = match[2].toLowerCase() === 'd' ? amount * 24 : amount;
|
|
71
|
+
const end = new Date();
|
|
72
|
+
const start = new Date(end.getTime() - hours * 3600 * 1000);
|
|
73
|
+
return { start, end };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildQueryString(opts) {
|
|
77
|
+
const params = new URLSearchParams();
|
|
78
|
+
|
|
79
|
+
if (opts.last && (opts.start || opts.end)) {
|
|
80
|
+
throw new Error('Use --last or --start/--end, not both.');
|
|
81
|
+
}
|
|
82
|
+
if (Boolean(opts.start) !== Boolean(opts.end)) {
|
|
83
|
+
throw new Error('--start and --end must be used together.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (opts.last) {
|
|
87
|
+
const { start, end } = resolveLast(opts.last);
|
|
88
|
+
params.set('start_date', start.toISOString());
|
|
89
|
+
params.set('end_date', end.toISOString());
|
|
90
|
+
} else if (opts.start) {
|
|
91
|
+
params.set('start_date', opts.start);
|
|
92
|
+
params.set('end_date', opts.end);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (opts.search) params.set('search', opts.search);
|
|
96
|
+
|
|
97
|
+
// Repeated keys are appended, not replaced: every list filter on the server
|
|
98
|
+
// reads getlist(), so collapsing duplicates would silently drop all but one.
|
|
99
|
+
for (const entry of opts.filter || []) {
|
|
100
|
+
const index = String(entry).indexOf('=');
|
|
101
|
+
if (index <= 0) {
|
|
102
|
+
throw new Error(`Invalid --filter "${entry}". Use key=value, e.g. --filter tool_types=CURSOR.`);
|
|
103
|
+
}
|
|
104
|
+
params.append(entry.slice(0, index).trim(), entry.slice(index + 1));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return params.toString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runDownload(kind, opts) {
|
|
111
|
+
const query = buildQueryString(opts);
|
|
112
|
+
|
|
113
|
+
if (opts.check) {
|
|
114
|
+
const result = await downloads.checkCount(kind, query);
|
|
115
|
+
if (opts.json) {
|
|
116
|
+
output.json(result);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const rows = Number(result.row_count);
|
|
120
|
+
if (!Number.isFinite(rows)) {
|
|
121
|
+
output.info('This export does not report a row count up front.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (result.exceeds_limit) {
|
|
125
|
+
output.warn(
|
|
126
|
+
`${rows.toLocaleString()} rows, over the ${Number(result.limit).toLocaleString()}-row limit. ` +
|
|
127
|
+
'Narrow the range or add filters before exporting.'
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
output.info(`${rows.toLocaleString()} row(s) would be exported.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (opts.wait === false) {
|
|
136
|
+
const job = await downloads.startJob(kind, query);
|
|
137
|
+
if (opts.json) {
|
|
138
|
+
output.json(job);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
output.success(`Export started. ${downloads.fetchHint(job.id)}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await downloads.runExport(kind, query, { output: opts.output });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function register(program) {
|
|
149
|
+
// A group with no arguments and no options of its own. Commander binds a
|
|
150
|
+
// repeated flag on the parent, so `-o` declared on both here and on `get`
|
|
151
|
+
// would leave the subcommand's value undefined and silently write to stdout.
|
|
152
|
+
const group = program
|
|
153
|
+
.command('download')
|
|
154
|
+
.description('Export org data as CSV (Logs, Tasks, Users, Tool Use).')
|
|
155
|
+
.addHelpText('after', `
|
|
156
|
+
KINDS
|
|
157
|
+
${KIND_LIST}
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
$ unbound download kinds
|
|
161
|
+
$ unbound download start logs-export --last 7d
|
|
162
|
+
$ unbound download start logs-export --check --last 90d --json
|
|
163
|
+
$ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6
|
|
164
|
+
|
|
165
|
+
Admin role required. Exports are kept for ${RETENTION_HOURS} hours.
|
|
166
|
+
`);
|
|
167
|
+
|
|
168
|
+
group
|
|
169
|
+
.command('kinds')
|
|
170
|
+
.description('List what can be exported, and the filters each kind accepts.')
|
|
171
|
+
.option('--json', 'Machine-readable output')
|
|
172
|
+
.action((opts) => {
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
output.json(Object.entries(KINDS).map(([kind, meta]) => ({ kind, ...meta })));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const [kind, meta] of Object.entries(KINDS)) {
|
|
178
|
+
console.log(`${kind}\n ${meta.summary}\n filters: ${meta.filters.join(', ')}\n`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const command = group
|
|
183
|
+
.command('start <kind>')
|
|
184
|
+
.description('Start an export and wait for the file.')
|
|
185
|
+
.option('--last <window>', 'Relative range, e.g. 7d or 24h')
|
|
186
|
+
.option('--start <date>', 'Range start, YYYY-MM-DD (use with --end)')
|
|
187
|
+
.option('--end <date>', 'Range end, YYYY-MM-DD (use with --start)')
|
|
188
|
+
.option('--search <text>', 'Free-text filter (logs kinds only)')
|
|
189
|
+
.option('--filter <key=value>', 'Server-side filter, repeatable', (value, previous) => (previous || []).concat(value))
|
|
190
|
+
.option('-o, --output <file>', 'Write to a file instead of the server-chosen name')
|
|
191
|
+
.option('--check', 'Print how many rows would be exported, then exit')
|
|
192
|
+
.option('--json', 'Machine-readable output')
|
|
193
|
+
.option('--no-wait', 'Start the export and print its job ID instead of waiting')
|
|
194
|
+
.addHelpText('after', `
|
|
195
|
+
KINDS
|
|
196
|
+
${KIND_LIST}
|
|
197
|
+
|
|
198
|
+
Examples:
|
|
199
|
+
$ unbound download start logs-export --last 7d
|
|
200
|
+
$ unbound download start logs-export --last 24h --filter tool_types=CLAUDE_CODE -o today.csv
|
|
201
|
+
$ unbound download start logs-tasks-export --start 2026-07-01 --end 2026-07-31
|
|
202
|
+
$ unbound download start users-export --last 30d
|
|
203
|
+
$ unbound download start analytics-export --filter dataset=terminal-runs --last 7d
|
|
204
|
+
$ unbound download start logs-export --check --last 90d --json
|
|
205
|
+
|
|
206
|
+
Admin role required. Exports are kept for ${RETENTION_HOURS} hours.
|
|
207
|
+
${FILTER_HELP}${AGENT_HELP}`)
|
|
208
|
+
.action(async (kind, opts) => {
|
|
209
|
+
try {
|
|
210
|
+
requireLogin();
|
|
211
|
+
await runDownload(kind, opts);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
output.error(downloads.describeError(err, `${kind} export`));
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
group
|
|
219
|
+
.command('get <job-id>')
|
|
220
|
+
.description('Download an export that was already started, by its job ID.')
|
|
221
|
+
.option('-o, --output <file>', 'Write to a file instead of the server-chosen name')
|
|
222
|
+
.addHelpText('after', `
|
|
223
|
+
Exports run in the background. If you interrupted the wait, or used --no-wait,
|
|
224
|
+
this fetches the finished file. If the job is still running, it waits for it.
|
|
225
|
+
|
|
226
|
+
Exports are kept for ${RETENTION_HOURS} hours.
|
|
227
|
+
|
|
228
|
+
Examples:
|
|
229
|
+
$ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6
|
|
230
|
+
$ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6 -o july.csv
|
|
231
|
+
`)
|
|
232
|
+
.action(async (jobId, opts) => {
|
|
233
|
+
try {
|
|
234
|
+
requireLogin();
|
|
235
|
+
await downloads.fetchExisting(jobId, { output: opts.output });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
output.error(downloads.describeError(err, 'export', { byJobId: true }));
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return command;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { register, buildQueryString, resolveLast };
|
package/src/index.js
CHANGED
|
@@ -155,6 +155,18 @@ TOOL CONNECTIONS
|
|
|
155
155
|
$ unbound tools connect <type> Connect a new tool
|
|
156
156
|
$ unbound tools approved List approved tool types
|
|
157
157
|
|
|
158
|
+
ANALYTICS (Admin/Manager; CSV export is Admin-only)
|
|
159
|
+
$ unbound analytics tool-use terminal Terminal commands agents ran
|
|
160
|
+
$ unbound analytics tool-use mcp-servers MCP servers seen (find --server values here)
|
|
161
|
+
$ unbound analytics tool-use mcp-actions --server Notion
|
|
162
|
+
MCP tool calls for one service
|
|
163
|
+
$ unbound analytics tool-use unsanctioned Blocked/warned unsanctioned MCP usage
|
|
164
|
+
|
|
165
|
+
Every dataset takes --format table|json|csv, -o <file>, --limit/--offset,
|
|
166
|
+
--time-window or --start/--end, and filters (--tool --user --risk --policy).
|
|
167
|
+
csv exports every matching row, not just the page.
|
|
168
|
+
See "unbound analytics tool-use --help" for the JSON contract used by agents.
|
|
169
|
+
|
|
158
170
|
CHAT (Admin/Manager)
|
|
159
171
|
$ unbound chat Interactive REPL (multi-turn, in-memory history)
|
|
160
172
|
$ unbound chat -m "show cost by provider for the last 30 days"
|
|
@@ -196,6 +208,8 @@ require('./commands/policy').register(program);
|
|
|
196
208
|
require('./commands/users').register(program);
|
|
197
209
|
require('./commands/user-groups').register(program);
|
|
198
210
|
require('./commands/tools').register(program);
|
|
211
|
+
require('./commands/analytics').register(program);
|
|
212
|
+
require('./commands/download').register(program);
|
|
199
213
|
require('./commands/setup').register(program);
|
|
200
214
|
require('./commands/discover').register(program);
|
|
201
215
|
require('./commands/onboard').register(program);
|