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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Flag parsing and query-string construction for `unbound analytics tool-use`.
3
+ * Pure functions — no network, no process state — so they are unit-testable.
4
+ */
5
+
6
+ const TIME_WINDOWS = [
7
+ 'ONE_DAY',
8
+ 'THREE_DAYS',
9
+ 'SEVEN_DAYS',
10
+ 'FOURTEEN_DAYS',
11
+ 'THIRTY_DAYS',
12
+ 'THREE_MONTHS',
13
+ ];
14
+
15
+ const FORMATS = ['table', 'json', 'csv'];
16
+
17
+ const RISK_LEVELS = ['Low', 'Medium', 'High'];
18
+
19
+ const INITIATORS = {
20
+ user: 'USER_INTENTIONAL',
21
+ agent: 'AGENT_INITIATED',
22
+ unknown: 'UNKNOWN',
23
+ };
24
+
25
+ // The backend rejects a custom range wider than this.
26
+ const MAX_RANGE_DAYS = 90;
27
+
28
+ const DEFAULT_TIME_WINDOW = 'THIRTY_DAYS';
29
+ const DEFAULT_LIMIT = 20;
30
+ const MAX_LIMIT = 1000;
31
+ // Lower for the table: past roughly a screenful nobody reads the rows, they
32
+ // scroll past them. json is parsed rather than read, so it keeps the full cap.
33
+ const MAX_TABLE_LIMIT = 100;
34
+
35
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
36
+
37
+ function parseList(value) {
38
+ if (value === undefined || value === null) return [];
39
+ const items = Array.isArray(value) ? value : String(value).split(',');
40
+ return items.map((v) => String(v).trim()).filter(Boolean);
41
+ }
42
+
43
+ function resolveFormat(opts) {
44
+ if (opts.json && opts.format && opts.format !== 'json') {
45
+ throw new Error(`Cannot combine --json with --format ${opts.format}.`);
46
+ }
47
+ const format = opts.json ? 'json' : (opts.format || 'table');
48
+ if (!FORMATS.includes(format)) {
49
+ throw new Error(`Invalid --format "${format}". Must be one of: ${FORMATS.join(', ')}.`);
50
+ }
51
+ return format;
52
+ }
53
+
54
+ function resolveWindow(opts) {
55
+ const hasStart = Boolean(opts.start);
56
+ const hasEnd = Boolean(opts.end);
57
+
58
+ if (hasStart !== hasEnd) {
59
+ throw new Error('--start and --end must be used together.');
60
+ }
61
+
62
+ if (hasStart) {
63
+ if (opts.timeWindow) {
64
+ throw new Error('Cannot combine --time-window with --start/--end. Pick one.');
65
+ }
66
+ for (const [flag, value] of [['--start', opts.start], ['--end', opts.end]]) {
67
+ if (!DATE_RE.test(value)) {
68
+ throw new Error(`Invalid ${flag} "${value}". Expected YYYY-MM-DD.`);
69
+ }
70
+ }
71
+ const start = new Date(`${opts.start}T00:00:00Z`);
72
+ const end = new Date(`${opts.end}T00:00:00Z`);
73
+ // Round-tripped, not just checked for NaN: Date rolls an impossible day
74
+ // into the next month, so 2026-02-30 parses happily as March 2.
75
+ const isRealDate = (value, parsed) =>
76
+ !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
77
+ if (!isRealDate(opts.start, start) || !isRealDate(opts.end, end)) {
78
+ throw new Error('--start and --end must be real calendar dates (YYYY-MM-DD).');
79
+ }
80
+ if (start > end) {
81
+ throw new Error('--start must be on or before --end.');
82
+ }
83
+ const spanDays = Math.round((end - start) / 86400000);
84
+ if (spanDays > MAX_RANGE_DAYS) {
85
+ throw new Error(`Date range is ${spanDays} days; the maximum is ${MAX_RANGE_DAYS}.`);
86
+ }
87
+ return { start_date: opts.start, end_date: opts.end };
88
+ }
89
+
90
+ const timeWindow = (opts.timeWindow || DEFAULT_TIME_WINDOW).toUpperCase().replace(/-/g, '_');
91
+ if (!TIME_WINDOWS.includes(timeWindow)) {
92
+ throw new Error(
93
+ `Invalid --time-window "${opts.timeWindow}". Must be one of: ${TIME_WINDOWS.join(', ')}.`
94
+ );
95
+ }
96
+ return { time_window: timeWindow };
97
+ }
98
+
99
+ function resolveLimit(opts, format = 'json') {
100
+ if (opts.limit === undefined) return DEFAULT_LIMIT;
101
+ const limit = Number(opts.limit);
102
+ if (!Number.isInteger(limit) || limit < 1) {
103
+ throw new Error(`Invalid --limit "${opts.limit}". Must be a positive integer.`);
104
+ }
105
+ const max = format === 'table' ? MAX_TABLE_LIMIT : MAX_LIMIT;
106
+ if (limit > max) {
107
+ throw new Error(
108
+ `--limit cannot exceed ${max} for ${format} output. ` +
109
+ (format === 'table'
110
+ ? `Use --format json for up to ${MAX_LIMIT}, or --format csv to export everything.`
111
+ : 'Use --format csv to export everything.')
112
+ );
113
+ }
114
+ return limit;
115
+ }
116
+
117
+ function resolveOffset(opts) {
118
+ if (opts.offset === undefined) return 0;
119
+ const offset = Number(opts.offset);
120
+ if (!Number.isInteger(offset) || offset < 0) {
121
+ throw new Error(`Invalid --offset "${opts.offset}". Must be zero or a positive integer.`);
122
+ }
123
+ return offset;
124
+ }
125
+
126
+ function resolveRiskLevels(opts) {
127
+ const wanted = parseList(opts.risk);
128
+ return wanted.map((raw) => {
129
+ const match = RISK_LEVELS.find((level) => level.toLowerCase() === raw.toLowerCase());
130
+ if (!match) {
131
+ throw new Error(`Invalid --risk "${raw}". Must be one of: ${RISK_LEVELS.join(', ')}.`);
132
+ }
133
+ return match;
134
+ });
135
+ }
136
+
137
+ function resolveInitiators(opts) {
138
+ const wanted = parseList(opts.initiator);
139
+ return wanted.map((raw) => {
140
+ const mapped = INITIATORS[raw.toLowerCase()];
141
+ if (!mapped) {
142
+ throw new Error(
143
+ `Invalid --initiator "${raw}". Must be one of: ${Object.keys(INITIATORS).join(', ')}.`
144
+ );
145
+ }
146
+ return mapped;
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Turn parsed commander options into the export endpoint's query object.
152
+ * Repeated keys are represented as arrays; buildQueryString expands them.
153
+ */
154
+ function buildQuery(opts, { format, extra = {} } = {}) {
155
+ const query = {
156
+ format: format === 'table' ? 'json' : format,
157
+ ...resolveWindow(opts),
158
+ ...extra,
159
+ };
160
+
161
+ if (query.format === 'json') {
162
+ // `format` is the caller's intent; query.format is what the server speaks,
163
+ // and the table asks the server for json. The cap follows the intent.
164
+ query.limit = resolveLimit(opts, format);
165
+ const offset = resolveOffset(opts);
166
+ if (offset) query.offset = offset;
167
+ }
168
+
169
+ const lists = {
170
+ tool_types: parseList(opts.tool).map((t) => t.toUpperCase().replace(/-/g, '_')),
171
+ user_ids: parseList(opts.user),
172
+ policy_ids: parseList(opts.policy),
173
+ command_families: parseList(opts.family),
174
+ server: parseList(opts.server),
175
+ risk_levels: resolveRiskLevels(opts),
176
+ intent_attribution: resolveInitiators(opts),
177
+ };
178
+
179
+ for (const [key, values] of Object.entries(lists)) {
180
+ if (values.length) query[key] = values;
181
+ }
182
+
183
+ return query;
184
+ }
185
+
186
+ function buildQueryString(query) {
187
+ const params = new URLSearchParams();
188
+ for (const [key, value] of Object.entries(query)) {
189
+ if (value === undefined || value === null || value === '') continue;
190
+ if (Array.isArray(value)) {
191
+ for (const item of value) params.append(key, String(item));
192
+ } else {
193
+ params.append(key, String(value));
194
+ }
195
+ }
196
+ return params.toString();
197
+ }
198
+
199
+ module.exports = {
200
+ DEFAULT_LIMIT,
201
+ DEFAULT_TIME_WINDOW,
202
+ FORMATS,
203
+ INITIATORS,
204
+ MAX_LIMIT,
205
+ MAX_TABLE_LIMIT,
206
+ MAX_RANGE_DAYS,
207
+ RISK_LEVELS,
208
+ TIME_WINDOWS,
209
+ buildQuery,
210
+ buildQueryString,
211
+ parseList,
212
+ resolveFormat,
213
+ resolveLimit,
214
+ resolveOffset,
215
+ resolveWindow,
216
+ };
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The generated-download job flow: start, poll, fetch.
3
+ *
4
+ * A CSV export is prepared by a background worker and served from object
5
+ * storage, so every caller does the same three steps. They live here so
6
+ * `unbound download` and `analytics tool-use --format csv` cannot drift apart.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const api = require('../api');
13
+ const output = require('../output');
14
+
15
+ const DOWNLOADS_PATH = '/api/v1/downloads';
16
+ const MAX_EXPORT_ROWS = 25000;
17
+
18
+ const POLL_INTERVAL_MS = 2000;
19
+ // The worker's own soft limit is 30 minutes. Waiting slightly past it means a
20
+ // job that dies at that limit surfaces as its own failure rather than as ours.
21
+ const MAX_WAIT_MS = 1_860_000;
22
+ // How long a finished export stays fetchable, matching the server's retention.
23
+ const RETENTION_HOURS = 6;
24
+
25
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
+
27
+ /**
28
+ * The download kinds the server offers, for help text and tab-completion.
29
+ *
30
+ * Advisory only — an unlisted kind is still sent through, so a producer added
31
+ * to the backend is usable without waiting for a CLI release. The server is the
32
+ * authority on what exists and answers 400 for anything it does not know.
33
+ */
34
+ const KINDS = {
35
+ 'logs-export': {
36
+ summary: 'Every LLM call: prompt, model, tokens, status, user and task ID.',
37
+ filters: ['model_ids', 'application_ids', 'status_codes', 'application_owner_email',
38
+ 'application_types', 'tool_types'],
39
+ },
40
+ 'logs-tasks-export': {
41
+ summary: 'One row per task (agent session), with its thread ID.',
42
+ filters: ['model_ids', 'tool_types', 'application_owner_email', 'user_ids'],
43
+ },
44
+ 'analytics-export': {
45
+ summary: 'Tool Use rows. Needs dataset=terminal-runs|mcp-actions|unsanctioned|mcp-servers.',
46
+ filters: ['dataset', 'risk_levels', 'command_families', 'server', 'intent_attribution',
47
+ 'policy_ids', 'tool_types', 'user_ids'],
48
+ },
49
+ 'analytics-tasks-export': {
50
+ summary: 'Analytics > Tasks, optionally narrowed to one heatmap day.',
51
+ filters: ['user_ids', 'tool_types', 'date', 'tz'],
52
+ },
53
+ 'users-export': {
54
+ summary: 'Analytics > Users: per-user cost, tokens and activity.',
55
+ filters: ['user_ids', 'sort_field', 'sort_order'],
56
+ },
57
+ };
58
+
59
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
60
+
61
+ function fetchHint(jobId) {
62
+ return `Fetch it within ${RETENTION_HOURS} hours:\n unbound download get ${jobId}`;
63
+ }
64
+
65
+ function isJobId(value) {
66
+ return UUID_RE.test(String(value || '').trim());
67
+ }
68
+
69
+ // The server names the file, but it must not be able to choose where the file
70
+ // lands. Keep the basename only, so "../../.zshrc" cannot escape the cwd.
71
+ function safeFilename(name, fallback) {
72
+ const base = path.basename(String(name || '').trim());
73
+ return base && base !== '.' && base !== '..' ? base : fallback;
74
+ }
75
+
76
+ function formatBytes(bytes) {
77
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
78
+ return `${Math.max(1, Math.round(bytes / 1024))} KB`;
79
+ }
80
+
81
+ // Counting rows happens synchronously inside both calls below, and on a wide
82
+ // window it measured ~4s against production. The 30s default left too little
83
+ // headroom, so an export that was merely slow to admit failed as a timeout.
84
+ const ADMISSION_TIMEOUT_MS = 120000;
85
+
86
+ /** Ask how many rows a selection holds, without starting anything. */
87
+ function checkCount(kind, queryString) {
88
+ return api.get(`${DOWNLOADS_PATH}/${kind}/count/${queryString ? `?${queryString}` : ''}`,
89
+ { timeoutMs: ADMISSION_TIMEOUT_MS });
90
+ }
91
+
92
+ /** Start a job. Resolves the job row; the file does not exist yet. */
93
+ function startJob(kind, queryString) {
94
+ return api.post(`${DOWNLOADS_PATH}/${kind}/${queryString ? `?${queryString}` : ''}`,
95
+ { timeoutMs: ADMISSION_TIMEOUT_MS });
96
+ }
97
+
98
+ /**
99
+ * Start a job at a producer's own endpoint rather than the generic one.
100
+ *
101
+ * The analytics export lives behind its own path but enqueues the same job, and
102
+ * its admission runs the same synchronous count — so it needs the same timeout.
103
+ */
104
+ function startExportAt(path) {
105
+ return api.post(path, { timeoutMs: ADMISSION_TIMEOUT_MS });
106
+ }
107
+
108
+ function getJob(jobId) {
109
+ return api.get(`${DOWNLOADS_PATH}/${jobId}/`);
110
+ }
111
+
112
+ /**
113
+ * Poll a job until it stops being queued or running.
114
+ *
115
+ * The interrupt handler is registered before the spinner's, which exits the
116
+ * process on its own SIGINT — registering after would mean never printing the
117
+ * job id, and an unreachable export is the whole reason `download get` exists.
118
+ */
119
+ async function waitForJob(jobId, title) {
120
+ // A caller with no dataset to name degrades the label to just "export".
121
+ const label = title ? `${title} export` : 'export';
122
+ let spin = null;
123
+ const onInterrupt = () => {
124
+ if (spin) spin.stop();
125
+ output.info(`The ${label} is still being prepared. ${fetchHint(jobId)}`);
126
+ process.exit(130);
127
+ };
128
+ process.on('SIGINT', onInterrupt);
129
+ // Printed before the wait, not only on interrupt: a hard kill (a script's
130
+ // timeout, a closed terminal) never runs the handler, and without the id the
131
+ // finished file is unreachable for its whole retention window.
132
+ output.info(`Export ${jobId} started.`);
133
+ spin = output.spinner(`Preparing ${label}…`);
134
+
135
+ try {
136
+ const deadline = Date.now() + MAX_WAIT_MS;
137
+ for (;;) {
138
+ const job = await getJob(jobId);
139
+
140
+ if (job.status === 'completed') {
141
+ spin.stop();
142
+ return job;
143
+ }
144
+ if (job.status === 'failed') {
145
+ throw new Error(job.error || 'The export failed while it was being prepared.');
146
+ }
147
+ if (job.status === 'expired') {
148
+ throw new Error(`This export has expired. Exports are kept for ${RETENTION_HOURS} hours.`);
149
+ }
150
+ if (Date.now() >= deadline) {
151
+ throw new Error(
152
+ `The ${label} is taking longer than expected and is still running.\n ${fetchHint(jobId)}`
153
+ );
154
+ }
155
+
156
+ const rows = Number(job.row_count) || 0;
157
+ spin.update(rows > 0 ? `Preparing ${label}… ${rows.toLocaleString()} rows` : `Preparing ${label}…`);
158
+ await sleep(POLL_INTERVAL_MS);
159
+ }
160
+ } catch (err) {
161
+ spin.stop();
162
+ throw err;
163
+ } finally {
164
+ process.removeListener('SIGINT', onInterrupt);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Sign a URL for a finished job and stream it to disk or stdout.
170
+ *
171
+ * The presigned URL is a bearer capability for as long as it lives, so it is
172
+ * never printed, logged, or written anywhere — only piped straight through.
173
+ */
174
+ /**
175
+ * Move the finished download onto its final path.
176
+ *
177
+ * A server-chosen name is linked rather than renamed. rename() replaces
178
+ * silently, and the whole transfer sits between the existence check and this
179
+ * point, so anything created in the meantime would be overwritten. link() fails
180
+ * with EEXIST instead, which is the guarantee this is supposed to make.
181
+ *
182
+ * An explicit -o is the user naming the file, so there rename is correct.
183
+ */
184
+ function placeFile(partial, target, { replace, name }) {
185
+ if (replace) {
186
+ fs.renameSync(partial, target);
187
+ return;
188
+ }
189
+ try {
190
+ fs.linkSync(partial, target);
191
+ } catch (err) {
192
+ if (err.code === 'EEXIST') {
193
+ throw new Error(
194
+ `${name} already exists here. Pass -o <file> to choose where this export goes.`
195
+ );
196
+ }
197
+ throw err;
198
+ }
199
+ fs.unlinkSync(partial);
200
+ }
201
+
202
+ async function downloadJob(job, destinationPath, title) {
203
+ const { url, filename } = await api.get(`${DOWNLOADS_PATH}/${job.id}/url/`);
204
+ const name = safeFilename(filename || job.filename, `unbound-export-${job.id}.csv`);
205
+ const destination = destinationPath || (process.stdout.isTTY ? name : null);
206
+
207
+ // The server picks this name, so it must not land on top of anything: a
208
+ // backend could otherwise drop a file called `.zshrc` into whatever directory
209
+ // the command ran in. Checked up front only to fail before a long download;
210
+ // placeFile is what actually enforces it. An explicit -o is the user's own
211
+ // choice and may replace a file, but only once the download has succeeded.
212
+ if (destination && !destinationPath && fs.existsSync(path.resolve(destination))) {
213
+ throw new Error(
214
+ `${name} already exists here. Pass -o <file> to choose where this export goes.`
215
+ );
216
+ }
217
+
218
+ let target = null;
219
+ let partial = null;
220
+ try {
221
+ const { bytes } = await api.fetchToStream(url, {
222
+ onHeaders: () => {
223
+ if (!destination) return process.stdout;
224
+ target = path.resolve(destination);
225
+ // Written to a sibling temp file and renamed on success. Streaming
226
+ // straight to `target` would truncate an existing file the moment the
227
+ // stream opened, so a transfer that failed halfway would destroy it.
228
+ // 'wx' fails rather than following a symlink or reusing a leftover.
229
+ partial = `${target}.part-${process.pid}`;
230
+ return fs.createWriteStream(partial, { flags: 'wx', mode: 0o600 });
231
+ },
232
+ });
233
+
234
+ if (target) {
235
+ placeFile(partial, target, { replace: Boolean(destinationPath), name });
236
+ partial = null;
237
+ const rows = Number(job.row_count) || 0;
238
+ const what = title ? `${title} row(s)` : 'row(s)';
239
+ output.success(`Exported ${rows.toLocaleString()} ${what} to ${target} (${formatBytes(bytes)})`);
240
+ }
241
+ return target;
242
+ } catch (err) {
243
+ // A half-written export parses as valid CSV and reads as a complete one, so
244
+ // drop it. Only ever the temp file — the user's own file is untouched.
245
+ if (partial) {
246
+ try {
247
+ fs.unlinkSync(partial);
248
+ } catch {
249
+ // Nothing was written, or it is already gone.
250
+ }
251
+ }
252
+ throw err;
253
+ }
254
+ }
255
+
256
+ /** Start, wait, and save in one call. */
257
+ async function runExport(kind, queryString, { output: destination, title } = {}) {
258
+ const job = await startJob(kind, queryString);
259
+ const finished = await waitForJob(job.id, title);
260
+ await downloadJob(finished, destination, title);
261
+ return finished;
262
+ }
263
+
264
+ /** Fetch an already-started job, waiting first if it is still running. */
265
+ async function fetchExisting(jobId, { output: destination, title } = {}) {
266
+ if (!isJobId(jobId)) {
267
+ throw new Error(`"${jobId}" is not a valid export id. It looks like 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6.`);
268
+ }
269
+ const job = await getJob(jobId);
270
+
271
+ if (job.status === 'failed') {
272
+ throw new Error(job.error || 'This export failed while it was being prepared.');
273
+ }
274
+ if (job.status === 'expired') {
275
+ throw new Error(`This export has expired. Exports are kept for ${RETENTION_HOURS} hours.`);
276
+ }
277
+ const ready = job.status === 'completed' ? job : await waitForJob(jobId, title);
278
+ await downloadJob(ready, destination, title);
279
+ return ready;
280
+ }
281
+
282
+ /**
283
+ * Turn a server refusal into something a human or an agent can act on.
284
+ *
285
+ * `byJobId` changes what a 404 means: looking up a job that does not exist, has
286
+ * been swept, or belongs to another organization is indistinguishable by
287
+ * design, and none of those are "the backend is too old".
288
+ */
289
+ function describeError(err, what = 'export', { byJobId = false, listing = false } = {}) {
290
+ if (err instanceof api.ApiError) {
291
+ if (err.statusCode === 404) {
292
+ if (byJobId) {
293
+ return `No export with that ID. It may have expired — exports are kept for ${RETENTION_HOURS} hours.`;
294
+ }
295
+ // Reading a table and preparing a file fail for different reasons, and
296
+ // telling someone their backend lacks background exports when they only
297
+ // asked to list rows sends them after the wrong thing.
298
+ return listing
299
+ ? `This Unbound backend does not serve ${what} yet.`
300
+ : `This Unbound backend does not serve the ${what} yet. ` +
301
+ 'It needs a backend version that includes background CSV exports.';
302
+ }
303
+ if (err.statusCode === 405) {
304
+ return `This Unbound backend is too old for this CLI: it still streams the ${what} ` +
305
+ 'instead of preparing it in the background. Upgrade the backend, or pin the CLI to 1.14.0.';
306
+ }
307
+ if (err.statusCode === 403) {
308
+ return listing
309
+ ? 'Admin role required. Analytics > Tool Use is restricted to organization admins.'
310
+ : 'Admin role required. Exporting data is restricted to organization admins.';
311
+ }
312
+ if (err.statusCode === 422) {
313
+ const rows = Number(err.body?.row_count);
314
+ const limit = Number(err.body?.limit) || MAX_EXPORT_ROWS;
315
+ if (Number.isFinite(rows) && rows > 0) {
316
+ return `This selection holds ${rows.toLocaleString()} rows, over the ${limit.toLocaleString()}-row export limit. ` +
317
+ 'Narrow the date range or add filters.';
318
+ }
319
+ }
320
+ }
321
+ return err.message;
322
+ }
323
+
324
+ module.exports = {
325
+ DOWNLOADS_PATH,
326
+ KINDS,
327
+ MAX_EXPORT_ROWS,
328
+ RETENTION_HOURS,
329
+ checkCount,
330
+ describeError,
331
+ downloadJob,
332
+ fetchExisting,
333
+ fetchHint,
334
+ formatBytes,
335
+ getJob,
336
+ isJobId,
337
+ placeFile,
338
+ runExport,
339
+ safeFilename,
340
+ startExportAt,
341
+ startJob,
342
+ waitForJob,
343
+ };
package/src/output.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { sanitizeForTerminal } = require('./chartRender');
2
+
1
3
  /**
2
4
  * Output formatting utilities for the Unbound CLI.
3
5
  * Provides table, JSON, and key-value output for terminal display.
@@ -49,6 +51,70 @@ function table(rows, columns) {
49
51
  }
50
52
  }
51
53
 
54
+ const MIN_COLUMN_WIDTH = 6;
55
+
56
+ function clip(value, width) {
57
+ // Sanitize before measuring: these cells carry commands and prompts recorded
58
+ // from agents, so an OSC or CSI sequence in one would otherwise reach the
59
+ // terminal and could rewrite the title, the clipboard, or earlier output.
60
+ const text = sanitizeForTerminal(value).replace(/\s+/g, ' ').trim();
61
+ if (text.length <= width) return text;
62
+ if (width <= 1) return text.slice(0, width);
63
+ return `${text.slice(0, width - 1)}…`;
64
+ }
65
+
66
+ /**
67
+ * Render log rows as a table that fits the terminal.
68
+ *
69
+ * Unlike table(), every cell is collapsed to one line and clipped to a share of
70
+ * the available width, so a 4KB prompt or a long command cannot flood the
71
+ * screen. Pass { wide: true } to print values in full.
72
+ *
73
+ * @param {string[]} headers
74
+ * @param {Array<string[]>} rows - cells aligned to headers
75
+ * @param {{wide?: boolean, maxWidth?: number}} [opts]
76
+ */
77
+ function logTable(headers, rows, opts = {}) {
78
+ if (!rows || rows.length === 0) {
79
+ console.log(c.dim('No results found.'));
80
+ return;
81
+ }
82
+
83
+ const natural = headers.map((header, i) =>
84
+ Math.max(String(header).length, ...rows.map((row) => clip(row[i], Number.POSITIVE_INFINITY).length))
85
+ );
86
+
87
+ let widths = natural;
88
+ if (!opts.wide) {
89
+ // process.stdout.columns is undefined when piped, so fall back to the
90
+ // COLUMNS convention before the default.
91
+ const envColumns = Number.parseInt(process.env.COLUMNS, 10);
92
+ const terminalWidth =
93
+ opts.maxWidth || process.stdout.columns || (Number.isInteger(envColumns) && envColumns > 0 ? envColumns : 120);
94
+ const gutters = 2 * (headers.length - 1);
95
+ const budget = Math.max(terminalWidth - gutters, headers.length * MIN_COLUMN_WIDTH);
96
+ const total = natural.reduce((a, b) => a + b, 0);
97
+
98
+ if (total > budget) {
99
+ // Give every column its minimum first, then share what's left in
100
+ // proportion to how much each column actually wants.
101
+ const spare = Math.max(budget - headers.length * MIN_COLUMN_WIDTH, 0);
102
+ const over = natural.map((w) => Math.max(w - MIN_COLUMN_WIDTH, 0));
103
+ const overTotal = over.reduce((a, b) => a + b, 0) || 1;
104
+ widths = natural.map((w, i) =>
105
+ Math.min(w, MIN_COLUMN_WIDTH + Math.floor((over[i] / overTotal) * spare))
106
+ );
107
+ }
108
+ }
109
+
110
+ const line = (cells) =>
111
+ cells.map((cell, i) => clip(cell, widths[i]).padEnd(widths[i])).join(' ').trimEnd();
112
+
113
+ console.log(headers.map((h, i) => c.bold(c.dim(clip(h, widths[i]).padEnd(widths[i])))).join(' ').trimEnd());
114
+ console.log(widths.map((w) => '─'.repeat(w)).join(' '));
115
+ for (const row of rows) console.log(line(row));
116
+ }
117
+
52
118
  function json(data) {
53
119
  console.log(JSON.stringify(data, null, 2));
54
120
  }
@@ -80,13 +146,18 @@ function info(msg) {
80
146
  function spinner(message) {
81
147
  if (!process.stderr.isTTY) {
82
148
  console.error(message);
83
- return { stop: () => {}, succeed: (msg) => success(msg), fail: (msg) => error(msg) };
149
+ // update() stays silent when piped: a progress line reprinted every poll
150
+ // would bury the real output in a log.
151
+ return { stop: () => {}, update: () => {}, succeed: (msg) => success(msg), fail: (msg) => error(msg) };
84
152
  }
85
153
 
86
154
  const frames = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
87
155
  let i = 0;
156
+ let text = message;
88
157
  const interval = setInterval(() => {
89
- process.stderr.write(`\r${c.cyan(frames[i++ % frames.length])} ${message}`);
158
+ // Clear to end of line first: a shorter message must not leave the tail of
159
+ // a longer one behind.
160
+ process.stderr.write(`\r\x1b[K${c.cyan(frames[i++ % frames.length])} ${text}`);
90
161
  }, 80);
91
162
 
92
163
  function clear() {
@@ -99,6 +170,7 @@ function spinner(message) {
99
170
 
100
171
  return {
101
172
  stop: clear,
173
+ update(msg) { text = msg; },
102
174
  succeed(msg) { clear(); success(msg); },
103
175
  fail(msg) { clear(); error(msg); },
104
176
  };
@@ -349,4 +421,4 @@ function multiSelect(message, options) {
349
421
  });
350
422
  }
351
423
 
352
- module.exports = { table, json, keyValue, success, error, warn, info, spinner, select, multiSelect, colors: c };
424
+ module.exports = { table, logTable, json, keyValue, success, error, warn, info, spinner, select, multiSelect, colors: c };