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.
- package/.github/workflows/test.yml +25 -0
- package/LOCAL_DEV.md +4 -0
- package/package.json +2 -2
- package/src/chartRender.js +284 -0
- package/src/commands/chat.js +531 -0
- package/src/index.js +9 -0
- package/test/chart-render.test.js +205 -0
- package/test/chat-internals.test.js +144 -0
|
@@ -0,0 +1,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.
|
|
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
|
+
});
|