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.
@@ -1,11 +1,21 @@
1
- const { execSync, spawn } = require('child_process');
1
+ const { execSync, spawn, spawnSync } = require('child_process');
2
2
  const { Option } = require('commander');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const https = require('https');
3
7
  const config = require('../config');
4
8
  const output = require('../output');
5
9
  const { ensureLoggedIn } = require('../auth');
6
10
 
7
11
  const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
8
12
 
13
+ // WSL reports as Linux via uname; only native Windows (cmd.exe / PowerShell)
14
+ // takes the Windows code path. WSL keeps using the Linux curl|python3 pipe.
15
+ function isWindowsNative() {
16
+ return process.platform === 'win32';
17
+ }
18
+
9
19
  const SETUP_TOOLS = [
10
20
  { label: 'Cursor', value: 'cursor', script: 'cursor/setup.py' },
11
21
  { label: 'Claude Code \u2014 subscription (hooks)', value: 'claude-sub', script: 'claude-code/hooks/setup.py', group: 'claude-code' },
@@ -81,21 +91,134 @@ function shellEscape(str) {
81
91
 
82
92
  /**
83
93
  * Builds a shell command that curls a setup script and pipes it to python3.
94
+ * Used on macOS/Linux/WSL. Native Windows takes the runPythonScriptWindows path.
84
95
  */
85
96
  function buildSetupCommand(scriptPath, args) {
86
97
  const url = `${SETUP_BASE_URL}/${scriptPath}`;
87
98
  return `curl -fsSL "${url}" | python3 - ${args}`;
88
99
  }
89
100
 
101
+ /**
102
+ * Reverses shellEscape back into an argv list so the Windows path can call
103
+ * spawn() directly without going through a shell. Handles unquoted tokens and
104
+ * single-quoted strings with '\'' for embedded quotes (the exact format
105
+ * shellEscape produces).
106
+ */
107
+ function parsePosixArgs(s) {
108
+ const out = [];
109
+ let cur = '', inQ = false, i = 0;
110
+ while (i < s.length) {
111
+ const c = s[i];
112
+ if (inQ) {
113
+ if (c === "'" && s.substr(i, 4) === "'\\''") { cur += "'"; i += 4; continue; }
114
+ if (c === "'") { inQ = false; i++; continue; }
115
+ cur += c; i++;
116
+ } else {
117
+ if (c === ' ') { if (cur) { out.push(cur); cur = ''; } i++; continue; }
118
+ if (c === "'") { inQ = true; i++; continue; }
119
+ cur += c; i++;
120
+ }
121
+ }
122
+ if (cur) out.push(cur);
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Downloads a URL to a local file. Follows one level of redirect (GitHub raw
128
+ * occasionally 302s). Used only by the Windows execution path.
129
+ */
130
+ function downloadToFile(url, destPath) {
131
+ return new Promise((resolve, reject) => {
132
+ const request = (u, remaining) => {
133
+ https.get(u, (res) => {
134
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && remaining > 0) {
135
+ res.resume();
136
+ return request(res.headers.location, remaining - 1);
137
+ }
138
+ if (res.statusCode !== 200) {
139
+ res.resume();
140
+ return reject(new Error(`Failed to download ${u}: HTTP ${res.statusCode}`));
141
+ }
142
+ const file = fs.createWriteStream(destPath);
143
+ res.pipe(file);
144
+ file.on('finish', () => file.close(resolve));
145
+ file.on('error', reject);
146
+ }).on('error', reject);
147
+ };
148
+ request(url, 3);
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Resolves the Python launcher to use on native Windows.
154
+ * Prefers the `py` launcher (installed by python.org at C:\Windows\py.exe —
155
+ * no spaces in PATH), falls back to `python` on PATH.
156
+ */
157
+ function resolveWindowsPython() {
158
+ const probe = (cmd, args) => {
159
+ try {
160
+ const r = spawnSync(cmd, args, { stdio: 'ignore', shell: false, windowsHide: true });
161
+ return r.status === 0;
162
+ } catch { return false; }
163
+ };
164
+ if (probe('py', ['-3', '--version'])) return { cmd: 'py', prefix: ['-3'] };
165
+ if (probe('python', ['--version'])) return { cmd: 'python', prefix: [] };
166
+ if (probe('python3', ['--version'])) return { cmd: 'python3', prefix: [] };
167
+ throw new Error(
168
+ 'Python 3 not found. Install Python 3 from https://www.python.org/downloads/ ' +
169
+ 'and make sure the "Add python.exe to PATH" option is checked during install.'
170
+ );
171
+ }
172
+
173
+ /**
174
+ * Windows counterpart to the curl|python3 pipe. Downloads the script to a
175
+ * temp file and invokes Python directly — no shell required.
176
+ */
177
+ async function runPythonScriptWindows(scriptPath, args, { capture }) {
178
+ const url = `${SETUP_BASE_URL}/${scriptPath}`;
179
+ const tmp = path.join(os.tmpdir(), `unbound-${Date.now()}-${Math.random().toString(36).slice(2)}.py`);
180
+ await downloadToFile(url, tmp);
181
+ const py = resolveWindowsPython();
182
+ try {
183
+ await new Promise((resolve, reject) => {
184
+ const child = spawn(py.cmd, [...py.prefix, tmp, ...parsePosixArgs(args)], {
185
+ stdio: capture ? ['pipe', 'pipe', 'pipe'] : 'inherit',
186
+ shell: false,
187
+ windowsHide: true,
188
+ });
189
+ let out = '';
190
+ if (capture) {
191
+ child.stdin.on('error', () => {});
192
+ child.stdin.end();
193
+ child.stdout.on('data', (d) => { out += d.toString(); });
194
+ child.stderr.on('data', (d) => { out += d.toString(); });
195
+ }
196
+ child.on('close', (code) => {
197
+ if (code === 0) return resolve();
198
+ const err = new Error(out.trim() || `Setup script failed with exit code ${code}`);
199
+ if (capture) err.setupOutput = out.trim();
200
+ reject(err);
201
+ });
202
+ child.on('error', reject);
203
+ });
204
+ } finally {
205
+ try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
206
+ }
207
+ }
208
+
90
209
  /**
91
210
  * Runs a Python setup script from the setup repo with inherited stdio (live output).
92
211
  */
93
- function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl } = {}) {
212
+ async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl } = {}) {
94
213
  let args = `--api-key ${shellEscape(apiKey)}`;
95
214
  if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
96
215
  if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
97
216
  if (clear) args += ' --clear';
98
217
  console.log('');
218
+ if (isWindowsNative()) {
219
+ await runPythonScriptWindows(scriptPath, args, { capture: false });
220
+ return;
221
+ }
99
222
  try {
100
223
  execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
101
224
  } catch (err) {
@@ -108,6 +231,9 @@ function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, fronten
108
231
  * Returns a promise that resolves on success, rejects with captured output on failure.
109
232
  */
110
233
  function runScriptPiped(scriptPath, args) {
234
+ if (isWindowsNative()) {
235
+ return runPythonScriptWindows(scriptPath, args, { capture: true });
236
+ }
111
237
  return new Promise((resolve, reject) => {
112
238
  const child = spawn(buildSetupCommand(scriptPath, args), {
113
239
  shell: true,
@@ -326,13 +452,13 @@ automatically to authenticate before proceeding.
326
452
  const toolName = tools[0];
327
453
 
328
454
  if (SETUP_TOOL_MAP[toolName]) {
329
- runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
455
+ await runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
330
456
  } else if (MODE_TOOLS[toolName]) {
331
457
  const mode = MODE_TOOLS[toolName];
332
458
  if (opts.clear) {
333
459
  // Clear both modes
334
- runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
335
- runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
460
+ await runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
461
+ await runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
336
462
  } else {
337
463
  let useSubscription = opts.subscription;
338
464
  if (!opts.subscription && !opts.gateway) {
@@ -340,7 +466,7 @@ automatically to authenticate before proceeding.
340
466
  useSubscription = choice === 'subscription';
341
467
  }
342
468
  const resolved = useSubscription ? mode.subscription : mode.gateway;
343
- runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
469
+ await runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
344
470
  }
345
471
  } else if (INSTRUCTION_TOOLS[toolName]) {
346
472
  output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
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
+ });