unbound-cli 0.5.1 → 0.7.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/commands/discover.js +94 -0
- package/src/commands/setup.js +132 -6
- 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.7.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
|
+
};
|