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.
@@ -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.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
+ };