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.
@@ -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
- // Above the server's own 300s cap, so a turn is never abandoned while it works.
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
- if (block?.type === 'text') {
254
- if (block.text) console.log(cyan('\u25cf ') + sanitizeForTerminal(block.text));
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
- if (resp?.truncated) {
261
- console.log('');
262
- output.warn('That is as far as it got in one turn. Ask for the rest to continue.');
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
- if (conversationId && !payload.resumed) {
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
- resp = await api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
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 api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
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
- renderResponse(resp);
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 id = resp?.conversation_id;
334
- if (id && (resp?.blocks || []).length) {
430
+ const command = (resp?.blocks || []).length ? continueCommand(resp?.conversation_id) : null;
431
+ if (command) {
335
432
  console.log('');
336
- console.log(dim(` continue: unbound chat -m "..." -c ${sanitizeForTerminal(String(id))}`));
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 api.post(ENDPOINT, { body, timeoutMs: REQUEST_TIMEOUT_MS });
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
- renderResponse(resp);
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
- renderResponse(resp);
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
- renderResponse(resp);
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, resumed, blocks[] }
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);