unbound-cli 0.5.1 → 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.
@@ -0,0 +1,25 @@
1
+ name: Tests
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout PR branch
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Set up Node.js
16
+ uses: actions/setup-node@v4
17
+ with:
18
+ node-version: 20.x
19
+ cache: npm
20
+
21
+ - name: Install dependencies
22
+ run: npm ci
23
+
24
+ - name: Run tests
25
+ run: npm test
package/LOCAL_DEV.md CHANGED
@@ -61,6 +61,10 @@ node src/index.js setup claude-code --gateway --backend-url http://localhost:800
61
61
  # Default bundle
62
62
  node src/index.js setup --all --backend-url http://localhost:8000 --frontend-url http://localhost:3000
63
63
 
64
+ # With --api-key, the CLI verifies the key against its own base URL before running the setup scripts,
65
+ # so you must ALSO set UNBOUND_API_URL (or `config set-url`) or auth will hit prod and fail with 401.
66
+ UNBOUND_API_URL=http://localhost:8000 node src/index.js setup --all --api-key <key> --backend-url http://localhost:8000 --frontend-url http://localhost:3000
67
+
64
68
  # Multiple explicit tools
65
69
  node src/index.js setup cursor claude-code-gateway --backend-url http://localhost:8000 --frontend-url http://localhost:3000
66
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node src/index.js",
12
12
  "lint": "eslint src/",
13
- "test": "node --test test/"
13
+ "test": "node --test test/*.test.js"
14
14
  },
15
15
  "keywords": [
16
16
  "unbound",
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Render ECharts-style chart specs as ASCII for the terminal, and reconstruct
3
+ * tabular rows from a chart_option payload (the backend never returns rows
4
+ * separately — they live inside chart_option as xAxis.data + series[].data,
5
+ * series[].data of {name,value}, or dataset.source).
6
+ *
7
+ * Pure functions so they can be unit-tested without TTY mocking.
8
+ */
9
+
10
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
11
+ const c = {
12
+ cyan: (s) => useColor ? `\x1b[36m${s}\x1b[0m` : s,
13
+ green: (s) => useColor ? `\x1b[32m${s}\x1b[0m` : s,
14
+ yellow: (s) => useColor ? `\x1b[33m${s}\x1b[0m` : s,
15
+ red: (s) => useColor ? `\x1b[31m${s}\x1b[0m` : s,
16
+ dim: (s) => useColor ? `\x1b[2m${s}\x1b[0m` : s,
17
+ bold: (s) => useColor ? `\x1b[1m${s}\x1b[0m` : s,
18
+ };
19
+ const SERIES_COLORS = [c.cyan, c.green, c.yellow, c.red];
20
+ const MAX_RENDER_ROWS = 20;
21
+ // Upper bound on how many data points we'll materialize from a chart_option,
22
+ // regardless of what the backend returns. Guards against OOM from pathological
23
+ // or hostile payloads. MAX_RENDER_ROWS is a subset of this for display only.
24
+ const MAX_RECONSTRUCT_ROWS = 10000;
25
+
26
+ // Strip terminal control sequences and C0/C1 control characters from backend-
27
+ // sourced strings before printing. Prevents a hostile/compromised backend from
28
+ // hijacking the user's terminal via ANSI CSI, OSC (title, clipboard, hyperlinks),
29
+ // DCS, APC, or PM sequences. Callers should pipe every backend-origin string
30
+ // through this before echoing.
31
+ function sanitizeForTerminal(value) {
32
+ if (value === null || value === undefined) return '';
33
+ let s = typeof value === 'string' ? value : String(value);
34
+ // Strip OSC (\x1b]...\x07 or \x1b]...\x1b\\), DCS/APC/PM/SOS (\x1bP|X|_|^...\x1b\\).
35
+ s = s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
36
+ s = s.replace(/\x1b[PX_^][^\x1b]*\x1b\\/g, '');
37
+ // Strip CSI and other ESC-prefixed sequences.
38
+ s = s.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
39
+ s = s.replace(/\x1b[@-Z\\-_]/g, '');
40
+ // Strip 8-bit C1 control bytes (\x80-\x9f) — in 8-bit terminals \x9b means
41
+ // CSI, \x9d means OSC, \x90 means DCS, etc. Modern UTF-8 terminals mostly
42
+ // ignore these, but strip them so the sanitizer's guarantee is complete.
43
+ s = s.replace(/[\x80-\x9f]/g, '');
44
+ // Strip remaining C0 control chars except tab (\t); drop backspace, CR, LF,
45
+ // vertical tab, form feed, and the rest. Newlines in assistant messages are
46
+ // converted to spaces to preserve flow without breaking line-based layout.
47
+ s = s.replace(/[\r\n]+/g, ' ');
48
+ s = s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
49
+ return s;
50
+ }
51
+
52
+ function asArray(x) {
53
+ if (x === undefined || x === null) return [];
54
+ return Array.isArray(x) ? x : [x];
55
+ }
56
+
57
+ function pickXAxisData(chart_option) {
58
+ const axes = asArray(chart_option?.xAxis);
59
+ for (const a of axes) {
60
+ if (Array.isArray(a?.data)) return a.data;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ function formatNumber(v) {
66
+ if (typeof v !== 'number' || !isFinite(v)) return String(v ?? '');
67
+ if (Number.isInteger(v)) return v.toLocaleString('en-US');
68
+ return v.toLocaleString('en-US', { maximumFractionDigits: 2 });
69
+ }
70
+
71
+ /**
72
+ * Reconstruct {columns, rows} from a chart_option + chart_meta.
73
+ * Defensive — returns {columns: [], rows: []} if nothing usable.
74
+ */
75
+ function reconstructRows(chart_option, chart_meta) {
76
+ if (!chart_option || typeof chart_option !== 'object') {
77
+ return { columns: [], rows: [] };
78
+ }
79
+
80
+ const sanitizeLabel = (v) => {
81
+ if (typeof v === 'number' || typeof v === 'boolean') return v;
82
+ return sanitizeForTerminal(v);
83
+ };
84
+
85
+ // 1) dataset.source — preferred when present
86
+ const source = chart_option.dataset?.source;
87
+ if (Array.isArray(source) && source.length > 0) {
88
+ const cappedSource = source.slice(0, MAX_RECONSTRUCT_ROWS + 1);
89
+ if (Array.isArray(cappedSource[0])) {
90
+ const [header, ...body] = cappedSource;
91
+ const columns = header.map((h) => sanitizeForTerminal(String(h)));
92
+ const rows = body.map((r) => Object.fromEntries(columns.map((k, i) => [k, sanitizeLabel(r[i])])));
93
+ return { columns, rows };
94
+ }
95
+ if (typeof cappedSource[0] === 'object' && cappedSource[0] !== null) {
96
+ const columns = Object.keys(cappedSource[0]).map((k) => sanitizeForTerminal(k));
97
+ // Object-shape has no header row so re-cap to MAX_RECONSTRUCT_ROWS.
98
+ const rows = cappedSource.slice(0, MAX_RECONSTRUCT_ROWS)
99
+ .map((r) => Object.fromEntries(columns.map((k) => [k, sanitizeLabel(r[k])])));
100
+ return { columns, rows };
101
+ }
102
+ }
103
+
104
+ const series = asArray(chart_option.series).filter(Boolean);
105
+ const xData = pickXAxisData(chart_option);
106
+ const xKey = sanitizeForTerminal(chart_meta?.x || 'category');
107
+ const yKeys = asArray(chart_meta?.y).map((y) => sanitizeForTerminal(String(y)));
108
+
109
+ // 2) Pie / donut — series[0].data is [{name, value}, ...]
110
+ const pieSeries = series.find((s) => Array.isArray(s.data) && s.data.length > 0
111
+ && typeof s.data[0] === 'object' && s.data[0] !== null
112
+ && 'value' in s.data[0]);
113
+ if (pieSeries && !xData) {
114
+ const valueKey = yKeys[0] || 'value';
115
+ const nameKey = xKey || 'name';
116
+ const columns = [nameKey, valueKey];
117
+ const cappedPie = pieSeries.data.slice(0, MAX_RECONSTRUCT_ROWS);
118
+ const rows = cappedPie.map((d) => ({
119
+ [nameKey]: sanitizeLabel(d.name ?? ''),
120
+ [valueKey]: d.value,
121
+ }));
122
+ return { columns, rows };
123
+ }
124
+
125
+ // 3) Cartesian — xAxis.data + parallel series[i].data arrays
126
+ if (xData && series.length > 0) {
127
+ const seriesNames = series.map((s, i) => sanitizeForTerminal(s.name || yKeys[i] || `series_${i + 1}`));
128
+ const columns = [xKey, ...seriesNames];
129
+ const cappedX = xData.slice(0, MAX_RECONSTRUCT_ROWS);
130
+ const rows = cappedX.map((label, i) => {
131
+ const row = { [xKey]: sanitizeLabel(label) };
132
+ series.forEach((s, j) => {
133
+ const v = Array.isArray(s.data) ? s.data[i] : undefined;
134
+ row[seriesNames[j]] = (v && typeof v === 'object' && 'value' in v) ? v.value : v;
135
+ });
136
+ return row;
137
+ });
138
+ return { columns, rows };
139
+ }
140
+
141
+ // 4) KPI / single number — series[0].data[0]
142
+ const flat = series.find((s) => Array.isArray(s.data) && s.data.length === 1
143
+ && (typeof s.data[0] === 'number' || typeof s.data[0] === 'string'));
144
+ if (flat) {
145
+ const valueKey = yKeys[0] || sanitizeForTerminal(flat.name || 'value');
146
+ return { columns: [valueKey], rows: [{ [valueKey]: sanitizeLabel(flat.data[0]) }] };
147
+ }
148
+
149
+ return { columns: [], rows: [] };
150
+ }
151
+
152
+ /**
153
+ * One-line summary, e.g. "Bar chart, x=provider, y=cost, 4 rows".
154
+ */
155
+ function summarizeChart(chart_meta, row_count) {
156
+ const t = chart_meta?.chart_type ? String(chart_meta.chart_type) : 'unknown';
157
+ const cap = t.charAt(0).toUpperCase() + t.slice(1);
158
+ const parts = [`${cap} chart`];
159
+ if (chart_meta?.x) parts.push(`x=${chart_meta.x}`);
160
+ const ys = asArray(chart_meta?.y);
161
+ if (ys.length) parts.push(`y=${ys.join('+')}`);
162
+ if (typeof row_count === 'number') parts.push(`${row_count} row${row_count === 1 ? '' : 's'}`);
163
+ return parts.join(', ');
164
+ }
165
+
166
+ /**
167
+ * Render an ASCII bar chart for the given rows. Single-series only (use
168
+ * renderGroupedBarChart for multi-series). Returns the rendered string;
169
+ * caller decides where to print.
170
+ *
171
+ * @param {Array<object>} rows
172
+ * @param {string} xKey
173
+ * @param {string} yKey
174
+ * @param {{width?: number, color?: (s:string)=>string}} [opts]
175
+ */
176
+ function renderBarChart(rows, xKey, yKey, opts = {}) {
177
+ if (!rows?.length) return '';
178
+ const width = clampWidth(opts.width);
179
+ const color = opts.color || c.cyan;
180
+
181
+ const labels = rows.map((r) => String(r[xKey] ?? ''));
182
+ const values = rows.map((r) => Number(r[yKey] ?? 0));
183
+ const max = Math.max(...values.map((v) => isFinite(v) ? Math.abs(v) : 0));
184
+
185
+ const labelWidth = Math.min(24, Math.max(...labels.map((l) => l.length), 1));
186
+ const truncatedLabels = labels.map((l) => l.length > labelWidth ? l.slice(0, labelWidth - 1) + '\u2026' : l);
187
+ const valueStrs = values.map(formatNumber);
188
+ const valueWidth = Math.max(...valueStrs.map((s) => s.length), 1);
189
+ // Line layout: " " + label(W) + " " + bar + " " + value(W) = 6 padding chars + label + bar + value.
190
+ const barArea = Math.max(4, width - labelWidth - valueWidth - 6);
191
+
192
+ const lines = [];
193
+ for (let i = 0; i < rows.length; i++) {
194
+ const v = values[i];
195
+ const len = max > 0 && isFinite(v) ? Math.max(1, Math.round(Math.abs(v) / max * barArea)) : 0;
196
+ const bar = '\u2588'.repeat(len);
197
+ lines.push(
198
+ ` ${c.dim(truncatedLabels[i].padEnd(labelWidth))} ${color(bar)}${' '.repeat(barArea - len)} ${c.bold(valueStrs[i])}`
199
+ );
200
+ }
201
+ return lines.join('\n');
202
+ }
203
+
204
+ /**
205
+ * Render a multi-series grouped bar chart by stacking series under each label.
206
+ */
207
+ function renderGroupedBarChart(rows, xKey, yKeys, opts = {}) {
208
+ if (!rows?.length || !yKeys?.length) return '';
209
+ if (yKeys.length === 1) return renderBarChart(rows, xKey, yKeys[0], opts);
210
+
211
+ const width = clampWidth(opts.width);
212
+ const labels = rows.map((r) => String(r[xKey] ?? ''));
213
+ const labelWidth = Math.min(24, Math.max(...labels.map((l) => l.length), 1));
214
+ const allValues = rows.flatMap((r) => yKeys.map((k) => Number(r[k] ?? 0)));
215
+ const max = Math.max(...allValues.map((v) => isFinite(v) ? Math.abs(v) : 0));
216
+
217
+ // Build per-series strings to compute valueWidth uniformly.
218
+ const valueWidth = Math.max(
219
+ ...rows.flatMap((r) => yKeys.map((k) => formatNumber(Number(r[k] ?? 0)).length)),
220
+ 1
221
+ );
222
+ const seriesLabelWidth = Math.max(...yKeys.map((k) => k.length), 1);
223
+ // Per-series line: " " + seriesLabel(W) + " " + bar + " " + value(W) = 8 padding chars + label + bar + value.
224
+ const barArea = Math.max(4, width - seriesLabelWidth - valueWidth - 8);
225
+
226
+ const legend = ' ' + c.dim('Legend: ') + yKeys.map((k, i) => SERIES_COLORS[i % SERIES_COLORS.length]('\u2588') + ' ' + k).join(' ');
227
+ const blocks = [legend];
228
+ for (let i = 0; i < rows.length; i++) {
229
+ const truncated = labels[i].length > labelWidth ? labels[i].slice(0, labelWidth - 1) + '\u2026' : labels[i];
230
+ blocks.push(' ' + c.bold(truncated));
231
+ for (let j = 0; j < yKeys.length; j++) {
232
+ const v = Number(rows[i][yKeys[j]] ?? 0);
233
+ const len = max > 0 && isFinite(v) ? Math.max(1, Math.round(Math.abs(v) / max * barArea)) : 0;
234
+ const color = SERIES_COLORS[j % SERIES_COLORS.length];
235
+ blocks.push(
236
+ ` ${c.dim(yKeys[j].padEnd(seriesLabelWidth))} ${color('\u2588'.repeat(len))}${' '.repeat(barArea - len)} ${c.bold(formatNumber(v).padStart(valueWidth))}`
237
+ );
238
+ }
239
+ }
240
+ return blocks.join('\n');
241
+ }
242
+
243
+ /**
244
+ * One-line braille sparkline for a numeric series.
245
+ */
246
+ function renderSparkline(values, opts = {}) {
247
+ const nums = values.filter((v) => typeof v === 'number' && isFinite(v));
248
+ if (nums.length < 2) return '';
249
+ const width = Math.min(opts.width || clampWidth(), nums.length);
250
+ const min = Math.min(...nums);
251
+ const max = Math.max(...nums);
252
+ const span = max - min || 1;
253
+ // Sample/bucket if more values than width.
254
+ const buckets = [];
255
+ for (let i = 0; i < width; i++) {
256
+ const start = Math.floor(i * nums.length / width);
257
+ const end = Math.max(start + 1, Math.floor((i + 1) * nums.length / width));
258
+ const slice = nums.slice(start, end);
259
+ buckets.push(slice.reduce((a, b) => a + b, 0) / slice.length);
260
+ }
261
+ const chars = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
262
+ const rendered = buckets.map((v) => {
263
+ const idx = Math.round((v - min) / span * (chars.length - 1));
264
+ return chars[Math.max(0, Math.min(chars.length - 1, idx))];
265
+ }).join('');
266
+ return ` ${c.dim(formatNumber(min))} ${c.cyan(rendered)} ${c.dim(formatNumber(max))}`;
267
+ }
268
+
269
+ function clampWidth(w) {
270
+ const cols = w ?? process.stdout.columns ?? 80;
271
+ return Math.max(40, Math.min(cols, 120));
272
+ }
273
+
274
+ module.exports = {
275
+ reconstructRows,
276
+ renderBarChart,
277
+ renderGroupedBarChart,
278
+ renderSparkline,
279
+ summarizeChart,
280
+ formatNumber,
281
+ sanitizeForTerminal,
282
+ MAX_RENDER_ROWS,
283
+ MAX_RECONSTRUCT_ROWS,
284
+ };
@@ -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/index.js CHANGED
@@ -115,6 +115,14 @@ TOOL CONNECTIONS
115
115
  $ unbound tools connect <type> Connect a new tool
116
116
  $ unbound tools approved List approved tool types
117
117
 
118
+ CHAT (Admin/Manager)
119
+ $ unbound chat Interactive REPL (multi-turn, in-memory history)
120
+ $ unbound chat -m "show cost by provider for the last 30 days"
121
+ One-shot: render a chart in the terminal
122
+ $ unbound chat -m "..." --json One-shot: write the raw JSON response to stdout
123
+ $ unbound chat -m "..." -o report.json One-shot: write the raw JSON response to a file
124
+ See "unbound chat --help" for the response shape and REPL slash commands.
125
+
118
126
  CONFIGURATION
119
127
  $ unbound config show Show all settings
120
128
 
@@ -138,6 +146,7 @@ require('./commands/tools').register(program);
138
146
  require('./commands/setup').register(program);
139
147
  require('./commands/discover').register(program);
140
148
  require('./commands/onboard').register(program);
149
+ require('./commands/chat').register(program);
141
150
 
142
151
  // config command for managing CLI settings
143
152
  const configCmd = program
@@ -0,0 +1,205 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const chart = require('../src/chartRender');
5
+
6
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
7
+
8
+ test('reconstructRows: bar chart with single series', () => {
9
+ const chart_option = {
10
+ xAxis: { type: 'category', data: ['A', 'B', 'C'] },
11
+ yAxis: { type: 'value' },
12
+ series: [{ name: 'cost', type: 'bar', data: [10, 20, 30] }],
13
+ };
14
+ const chart_meta = { chart_type: 'bar', x: 'department', y: ['cost'] };
15
+ const { columns, rows } = chart.reconstructRows(chart_option, chart_meta);
16
+ assert.deepEqual(columns, ['department', 'cost']);
17
+ assert.deepEqual(rows, [
18
+ { department: 'A', cost: 10 },
19
+ { department: 'B', cost: 20 },
20
+ { department: 'C', cost: 30 },
21
+ ]);
22
+ });
23
+
24
+ test('reconstructRows: multi-series bar chart', () => {
25
+ const chart_option = {
26
+ xAxis: { data: ['A', 'B'] },
27
+ series: [
28
+ { name: 'opus', data: [10, 20] },
29
+ { name: 'sonnet', data: [5, 15] },
30
+ ],
31
+ };
32
+ const chart_meta = { chart_type: 'bar', x: 'department', y: ['opus', 'sonnet'] };
33
+ const { columns, rows } = chart.reconstructRows(chart_option, chart_meta);
34
+ assert.deepEqual(columns, ['department', 'opus', 'sonnet']);
35
+ assert.deepEqual(rows, [
36
+ { department: 'A', opus: 10, sonnet: 5 },
37
+ { department: 'B', opus: 20, sonnet: 15 },
38
+ ]);
39
+ });
40
+
41
+ test('reconstructRows: donut/pie with {name,value} data', () => {
42
+ const chart_option = {
43
+ series: [{
44
+ type: 'pie',
45
+ data: [
46
+ { name: 'Engineering', value: 100 },
47
+ { name: 'Sales', value: 50 },
48
+ ],
49
+ }],
50
+ };
51
+ const chart_meta = { chart_type: 'donut', x: 'department', y: ['cost'] };
52
+ const { columns, rows } = chart.reconstructRows(chart_option, chart_meta);
53
+ assert.deepEqual(columns, ['department', 'cost']);
54
+ assert.deepEqual(rows, [
55
+ { department: 'Engineering', cost: 100 },
56
+ { department: 'Sales', cost: 50 },
57
+ ]);
58
+ });
59
+
60
+ test('reconstructRows: dataset.source with header row (array of arrays)', () => {
61
+ const chart_option = {
62
+ dataset: {
63
+ source: [
64
+ ['provider', 'cost'],
65
+ ['anthropic', 1234],
66
+ ['openai', 567],
67
+ ],
68
+ },
69
+ };
70
+ const { columns, rows } = chart.reconstructRows(chart_option, { chart_type: 'table' });
71
+ assert.deepEqual(columns, ['provider', 'cost']);
72
+ assert.deepEqual(rows, [
73
+ { provider: 'anthropic', cost: 1234 },
74
+ { provider: 'openai', cost: 567 },
75
+ ]);
76
+ });
77
+
78
+ test('reconstructRows: dataset.source as array of objects', () => {
79
+ const chart_option = {
80
+ dataset: {
81
+ source: [
82
+ { provider: 'anthropic', cost: 1234 },
83
+ { provider: 'openai', cost: 567 },
84
+ ],
85
+ },
86
+ };
87
+ const { columns, rows } = chart.reconstructRows(chart_option, { chart_type: 'table' });
88
+ assert.deepEqual(columns, ['provider', 'cost']);
89
+ assert.equal(rows.length, 2);
90
+ });
91
+
92
+ test('reconstructRows: missing chart_option returns empty', () => {
93
+ assert.deepEqual(chart.reconstructRows(null, null), { columns: [], rows: [] });
94
+ assert.deepEqual(chart.reconstructRows(undefined, {}), { columns: [], rows: [] });
95
+ assert.deepEqual(chart.reconstructRows({}, {}), { columns: [], rows: [] });
96
+ });
97
+
98
+ test('reconstructRows: strips ANSI / control chars from backend-provided labels', () => {
99
+ const chart_option = {
100
+ xAxis: { data: ['\x1b]0;pwned\x07Anthropic', 'Open\nAI'] },
101
+ series: [{ name: 'cost', data: [100, 200] }],
102
+ };
103
+ const { rows, columns } = chart.reconstructRows(chart_option, { chart_type: 'bar', x: 'provider', y: ['cost'] });
104
+ assert.deepEqual(columns, ['provider', 'cost']);
105
+ assert.equal(rows[0].provider, 'Anthropic');
106
+ assert.equal(rows[1].provider, 'Open AI');
107
+ });
108
+
109
+ test('reconstructRows: dataset.source array-of-objects is capped at MAX_RECONSTRUCT_ROWS', () => {
110
+ const source = Array.from({ length: chart.MAX_RECONSTRUCT_ROWS + 10 }, (_, i) => ({ k: i, v: i * 2 }));
111
+ const { rows } = chart.reconstructRows({ dataset: { source } }, { chart_type: 'table' });
112
+ assert.equal(rows.length, chart.MAX_RECONSTRUCT_ROWS);
113
+ });
114
+
115
+ test('reconstructRows: caps allocation at MAX_RECONSTRUCT_ROWS', () => {
116
+ const huge = Array.from({ length: chart.MAX_RECONSTRUCT_ROWS + 5000 }, (_, i) => `row${i}`);
117
+ const hugeVals = huge.map((_, i) => i);
118
+ const chart_option = {
119
+ xAxis: { data: huge },
120
+ series: [{ name: 'v', data: hugeVals }],
121
+ };
122
+ const { rows } = chart.reconstructRows(chart_option, { chart_type: 'bar', x: 'p', y: ['v'] });
123
+ assert.equal(rows.length, chart.MAX_RECONSTRUCT_ROWS);
124
+ });
125
+
126
+ test('summarizeChart: bar with single y', () => {
127
+ const s = stripAnsi(chart.summarizeChart({ chart_type: 'bar', x: 'provider', y: ['cost'] }, 4));
128
+ assert.equal(s, 'Bar chart, x=provider, y=cost, 4 rows');
129
+ });
130
+
131
+ test('summarizeChart: handles singular row + multi y', () => {
132
+ const s = stripAnsi(chart.summarizeChart({ chart_type: 'line', x: 'date', y: ['cost', 'tokens'] }, 1));
133
+ assert.equal(s, 'Line chart, x=date, y=cost+tokens, 1 row');
134
+ });
135
+
136
+ test('summarizeChart: missing meta degrades gracefully', () => {
137
+ assert.equal(chart.summarizeChart({}, 0), 'Unknown chart, 0 rows');
138
+ assert.equal(chart.summarizeChart(null), 'Unknown chart');
139
+ });
140
+
141
+ test('renderBarChart: lines respect requested width', () => {
142
+ const rows = [
143
+ { dept: 'Engineering', cost: 30000 },
144
+ { dept: 'Sales', cost: 10000 },
145
+ { dept: 'Marketing', cost: 5000 },
146
+ ];
147
+ const out = chart.renderBarChart(rows, 'dept', 'cost', { width: 80 });
148
+ assert.ok(out.length > 0);
149
+ for (const line of out.split('\n')) {
150
+ assert.ok(stripAnsi(line).length <= 80, `line too long: ${stripAnsi(line)}`);
151
+ }
152
+ });
153
+
154
+ test('renderBarChart: empty rows returns empty string', () => {
155
+ assert.equal(chart.renderBarChart([], 'x', 'y'), '');
156
+ assert.equal(chart.renderBarChart(null, 'x', 'y'), '');
157
+ });
158
+
159
+ test('renderBarChart: scales the largest value to the full bar area', () => {
160
+ const rows = [{ x: 'A', y: 100 }, { x: 'B', y: 50 }];
161
+ const out = stripAnsi(chart.renderBarChart(rows, 'x', 'y', { width: 60 }));
162
+ const lines = out.split('\n');
163
+ const blocksA = (lines[0].match(/\u2588/g) || []).length;
164
+ const blocksB = (lines[1].match(/\u2588/g) || []).length;
165
+ assert.ok(blocksA > blocksB, 'larger value should produce more blocks');
166
+ assert.equal(blocksB, Math.max(1, Math.round(blocksA * 50 / 100)));
167
+ });
168
+
169
+ test('renderBarChart: all-zero values do not crash and produce empty bars', () => {
170
+ const rows = [{ x: 'A', y: 0 }, { x: 'B', y: 0 }];
171
+ const out = stripAnsi(chart.renderBarChart(rows, 'x', 'y', { width: 80 }));
172
+ assert.ok(out.length > 0);
173
+ assert.equal((out.match(/\u2588/g) || []).length, 0);
174
+ });
175
+
176
+ test('renderGroupedBarChart: emits a legend and one row per series per label', () => {
177
+ const rows = [
178
+ { dept: 'Eng', opus: 100, sonnet: 50 },
179
+ { dept: 'Sales', opus: 20, sonnet: 80 },
180
+ ];
181
+ const out = stripAnsi(chart.renderGroupedBarChart(rows, 'dept', ['opus', 'sonnet'], { width: 80 }));
182
+ assert.match(out, /Legend:/);
183
+ assert.match(out, /opus/);
184
+ assert.match(out, /sonnet/);
185
+ assert.match(out, /Eng/);
186
+ assert.match(out, /Sales/);
187
+ });
188
+
189
+ test('renderSparkline: returns a single-line braille bar between min and max', () => {
190
+ const out = stripAnsi(chart.renderSparkline([1, 2, 3, 4, 5, 4, 3], { width: 20 }));
191
+ assert.ok(out.includes('1'));
192
+ assert.ok(out.includes('5'));
193
+ assert.equal(out.split('\n').length, 1);
194
+ });
195
+
196
+ test('renderSparkline: too few points returns empty string', () => {
197
+ assert.equal(chart.renderSparkline([1]), '');
198
+ assert.equal(chart.renderSparkline([]), '');
199
+ });
200
+
201
+ test('formatNumber: integer thousands separator, decimal capped', () => {
202
+ assert.equal(chart.formatNumber(1234567), '1,234,567');
203
+ assert.equal(chart.formatNumber(12.3456), '12.35');
204
+ assert.equal(chart.formatNumber(0), '0');
205
+ });
@@ -0,0 +1,144 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const chat = require('../src/commands/chat');
8
+ const { sanitizeForTerminal } = require('../src/chartRender');
9
+
10
+ const { safeWriteFile, expandPath, normalizeAssistantTurn, friendlyChatError } = chat._internals;
11
+
12
+ function tmpFile(name) {
13
+ return path.join(os.tmpdir(), `unbound-cli-test-${process.pid}-${Date.now()}-${name}`);
14
+ }
15
+
16
+ test('sanitizeForTerminal: strips CSI, OSC, clipboard, title, hyperlink sequences', () => {
17
+ assert.equal(sanitizeForTerminal('\x1b[31mred\x1b[0m'), 'red');
18
+ assert.equal(sanitizeForTerminal('\x1b]0;PWNED\x07hi'), 'hi');
19
+ assert.equal(sanitizeForTerminal('\x1b]52;c;Y3VybHxzaA==\x07ok'), 'ok');
20
+ assert.equal(sanitizeForTerminal('\x1b]8;;https://evil/\x07click\x1b]8;;\x07'), 'click');
21
+ assert.equal(sanitizeForTerminal('a\x1bPdata\x1b\\b'), 'ab');
22
+ assert.equal(sanitizeForTerminal('plain text'), 'plain text');
23
+ });
24
+
25
+ test('sanitizeForTerminal: strips 8-bit C1 control bytes', () => {
26
+ // \x9b = 8-bit CSI, \x9d = 8-bit OSC, \x90 = 8-bit DCS. Stripping the
27
+ // trigger byte neutralizes the sequence; residual parameter text is
28
+ // harmless without the preceding control byte.
29
+ const a = sanitizeForTerminal('\x9b31mhello\x9b0m');
30
+ assert.ok(!/[\x80-\x9f]/.test(a), 'should not contain any C1 bytes');
31
+ assert.ok(a.includes('hello'));
32
+ const b = sanitizeForTerminal('a\x9dtitle\x07b');
33
+ assert.ok(!/[\x80-\x9f]/.test(b));
34
+ for (let code = 0x80; code <= 0x9f; code++) {
35
+ assert.equal(sanitizeForTerminal(String.fromCharCode(code)), '');
36
+ }
37
+ });
38
+
39
+ test('sanitizeForTerminal: removes control chars but preserves tab; collapses newlines', () => {
40
+ assert.equal(sanitizeForTerminal('a\x00b\x07c\x7fd'), 'abcd');
41
+ assert.equal(sanitizeForTerminal('line1\nline2\r\nline3'), 'line1 line2 line3');
42
+ assert.equal(sanitizeForTerminal('tab\there'), 'tab\there');
43
+ });
44
+
45
+ test('sanitizeForTerminal: handles non-string inputs safely', () => {
46
+ assert.equal(sanitizeForTerminal(null), '');
47
+ assert.equal(sanitizeForTerminal(undefined), '');
48
+ assert.equal(sanitizeForTerminal(42), '42');
49
+ assert.equal(sanitizeForTerminal({ a: 1 }), '[object Object]');
50
+ });
51
+
52
+ test('expandPath: expands leading ~/ to homedir, passes through absolute paths', () => {
53
+ assert.equal(expandPath('~'), os.homedir());
54
+ assert.equal(expandPath('~/foo/bar'), path.join(os.homedir(), 'foo/bar'));
55
+ assert.equal(expandPath('/etc/passwd'), '/etc/passwd');
56
+ });
57
+
58
+ test('safeWriteFile: happy path writes a new file with mode 0600', () => {
59
+ const target = tmpFile('happy.json');
60
+ try {
61
+ const resolved = safeWriteFile(target, '{"ok":true}\n');
62
+ assert.equal(resolved, path.resolve(target));
63
+ assert.equal(fs.readFileSync(target, 'utf8'), '{"ok":true}\n');
64
+ const stat = fs.statSync(target);
65
+ assert.equal(stat.mode & 0o777, 0o600);
66
+ } finally {
67
+ try { fs.unlinkSync(target); } catch { /* ignore */ }
68
+ }
69
+ });
70
+
71
+ test('safeWriteFile: refuses to overwrite existing file', () => {
72
+ const target = tmpFile('noclobber.json');
73
+ try {
74
+ fs.writeFileSync(target, 'original');
75
+ assert.throws(() => safeWriteFile(target, 'hijacked'), /Refusing to overwrite/);
76
+ assert.equal(fs.readFileSync(target, 'utf8'), 'original');
77
+ } finally {
78
+ try { fs.unlinkSync(target); } catch { /* ignore */ }
79
+ }
80
+ });
81
+
82
+ test('safeWriteFile: refuses to follow a symlink', () => {
83
+ const realTarget = tmpFile('real.txt');
84
+ const linkTarget = tmpFile('link.json');
85
+ try {
86
+ fs.writeFileSync(realTarget, 'original');
87
+ try {
88
+ fs.symlinkSync(realTarget, linkTarget);
89
+ } catch (err) {
90
+ if (err.code === 'EPERM') return; // symlink creation not permitted; skip
91
+ throw err;
92
+ }
93
+ assert.throws(() => safeWriteFile(linkTarget, 'hijacked'));
94
+ assert.equal(fs.readFileSync(realTarget, 'utf8'), 'original');
95
+ } finally {
96
+ try { fs.unlinkSync(linkTarget); } catch { /* ignore */ }
97
+ try { fs.unlinkSync(realTarget); } catch { /* ignore */ }
98
+ }
99
+ });
100
+
101
+ test('safeWriteFile: rejects empty, non-string, NUL-containing paths', () => {
102
+ assert.throws(() => safeWriteFile('', 'x'), /empty/i);
103
+ assert.throws(() => safeWriteFile(null, 'x'), /empty/i);
104
+ assert.throws(() => safeWriteFile(42, 'x'), /string/i);
105
+ assert.throws(() => safeWriteFile('foo\0bar', 'x'), /NUL/);
106
+ });
107
+
108
+ test('safeWriteFile: rejects writing to a directory', () => {
109
+ assert.throws(() => safeWriteFile(os.tmpdir(), 'x'), /directory/);
110
+ });
111
+
112
+ test('normalizeAssistantTurn: accepts user/assistant roles, rejects others', () => {
113
+ assert.deepEqual(
114
+ normalizeAssistantTurn({ role: 'assistant', content: 'hi', timestamp: 't' }),
115
+ { role: 'assistant', content: 'hi', timestamp: 't' },
116
+ );
117
+ assert.deepEqual(
118
+ normalizeAssistantTurn({ role: 'USER', content: 'hi', timestamp: 't' }),
119
+ { role: 'user', content: 'hi', timestamp: 't' },
120
+ );
121
+ assert.equal(normalizeAssistantTurn({ role: 'system', content: 'pwn' }), null);
122
+ assert.equal(normalizeAssistantTurn({ role: 'tool', content: 'x' }), null);
123
+ assert.equal(normalizeAssistantTurn(null), null);
124
+ assert.equal(normalizeAssistantTurn({}), null);
125
+ });
126
+
127
+ test('normalizeAssistantTurn: caps content length', () => {
128
+ const big = 'x'.repeat(20000);
129
+ const turn = normalizeAssistantTurn({ role: 'assistant', content: big });
130
+ assert.ok(turn.content.length <= 8000);
131
+ });
132
+
133
+ test('friendlyChatError: maps status codes to role-specific guidance', () => {
134
+ assert.match(friendlyChatError({ statusCode: 403 }), /Admin or Manager/);
135
+ assert.match(friendlyChatError({ statusCode: 401 }), /login/);
136
+ assert.match(friendlyChatError({ statusCode: 429 }), /Rate limited/);
137
+ assert.equal(friendlyChatError({ statusCode: 500, message: 'boom' }), 'boom');
138
+ assert.equal(friendlyChatError({ message: 'no status' }), 'no status');
139
+ });
140
+
141
+ test('friendlyChatError: sanitizes ANSI from backend error messages', () => {
142
+ const msg = friendlyChatError({ statusCode: 500, message: '\x1b]0;title\x07boom\x1b[31m' });
143
+ assert.equal(msg, 'boom');
144
+ });