unbound-cli 0.5.0 → 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
@@ -43,6 +43,51 @@ node src/index.js config reset-url
43
43
  node src/index.js config reset-frontend-url
44
44
  ```
45
45
 
46
+ ## Point setup scripts to a local backend / frontend
47
+
48
+ The `setup`, `setup mdm`, `onboard`, and `onboard-mdm` commands invoke Python setup scripts from the `setup` repo. Those scripts:
49
+
50
+ - Ping `https://backend.getunbound.ai/api/v1/setup/complete/` when a tool is configured (override with `--backend-url`).
51
+ - Use the frontend URL for the browser-auth callback if `--api-key` is not already stored (override with `--frontend-url`, passed through to the scripts as `--domain`).
52
+
53
+ Both flags are hidden from `--help` (dev-only) and work on every setup-invoking command:
54
+
55
+ ```bash
56
+ # Single tool — override backend only, or both
57
+ node src/index.js setup cursor --backend-url http://localhost:8000
58
+ node src/index.js setup cursor --backend-url http://localhost:8000 --frontend-url http://localhost:3000
59
+ node src/index.js setup claude-code --gateway --backend-url http://localhost:8000 --frontend-url http://localhost:3000
60
+
61
+ # Default bundle
62
+ node src/index.js setup --all --backend-url http://localhost:8000 --frontend-url http://localhost:3000
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
+
68
+ # Multiple explicit tools
69
+ node src/index.js setup cursor claude-code-gateway --backend-url http://localhost:8000 --frontend-url http://localhost:3000
70
+
71
+ # Interactive mode (select tools, then apply overrides to all selected)
72
+ node src/index.js setup --backend-url http://localhost:8000 --frontend-url http://localhost:3000
73
+
74
+ # MDM (requires sudo; MDM scripts ignore --frontend-url since they don't use browser auth)
75
+ sudo node src/index.js setup mdm --admin-api-key <ADMIN_KEY> --all --backend-url http://localhost:8000
76
+
77
+ # Onboarding (combined setup + discover)
78
+ node src/index.js onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> \
79
+ --backend-url http://localhost:8000 --frontend-url http://localhost:3000
80
+ sudo node src/index.js onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> \
81
+ --backend-url http://localhost:8000
82
+ ```
83
+
84
+ When omitted, scripts default to `https://backend.getunbound.ai` and the stored frontend URL (or `https://gateway.getunbound.ai`).
85
+
86
+ Notes:
87
+ - `--backend-url` / `--frontend-url` only affect the setup scripts. They do not change the CLI's own API calls (use `config set-url` / `UNBOUND_API_URL` / `config set-frontend-url` / `UNBOUND_FRONTEND_URL` for that).
88
+ - `onboard` and `onboard-mdm` also take a visible `--domain <url>` flag — that one is for the **discovery** backend (a separate repo), not the setup scripts' frontend. The two flags don't conflict.
89
+ - MDM setup scripts (`setup mdm`, `onboard-mdm`) don't do browser auth, so `--frontend-url` is a no-op there; only `--backend-url` is meaningful.
90
+
46
91
  ## Verify config
47
92
 
48
93
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "0.5.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
+ };