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.
@@ -90,8 +90,11 @@ function buildSetupCommand(scriptPath, args) {
90
90
  /**
91
91
  * Runs a Python setup script from the setup repo with inherited stdio (live output).
92
92
  */
93
- function runSetupScript(scriptPath, apiKey, { clear = false } = {}) {
94
- const args = `--api-key ${shellEscape(apiKey)}${clear ? ' --clear' : ''}`;
93
+ function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl } = {}) {
94
+ let args = `--api-key ${shellEscape(apiKey)}`;
95
+ if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
96
+ if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
97
+ if (clear) args += ' --clear';
95
98
  console.log('');
96
99
  try {
97
100
  execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
@@ -177,6 +180,8 @@ function register(program) {
177
180
  .option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
178
181
  .option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
179
182
  .option('--all', 'Set up the default bundle: Cursor, Claude Code (hooks), Codex (hooks)')
183
+ .addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
184
+ .addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
180
185
  .addHelpText('after', `
181
186
  Available tools:
182
187
  cursor Cursor IDE
@@ -252,8 +257,11 @@ automatically to authenticate before proceeding.
252
257
  const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
253
258
  console.log('');
254
259
 
260
+ let interactiveArgs = `--api-key ${shellEscape(apiKey)}`;
261
+ if (opts.backendUrl) interactiveArgs += ` --backend-url ${shellEscape(opts.backendUrl)}`;
262
+ if (opts.frontendUrl) interactiveArgs += ` --domain ${shellEscape(opts.frontendUrl)}`;
255
263
  const ok = await runBatch(selectedTools, (tool) =>
256
- runScriptPiped(tool.script, `--api-key ${shellEscape(apiKey)}`)
264
+ runScriptPiped(tool.script, interactiveArgs)
257
265
  );
258
266
  if (!ok) return;
259
267
 
@@ -318,13 +326,13 @@ automatically to authenticate before proceeding.
318
326
  const toolName = tools[0];
319
327
 
320
328
  if (SETUP_TOOL_MAP[toolName]) {
321
- runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear });
329
+ runSetupScript(SETUP_TOOL_MAP[toolName].script, apiKey, { clear: opts.clear, backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
322
330
  } else if (MODE_TOOLS[toolName]) {
323
331
  const mode = MODE_TOOLS[toolName];
324
332
  if (opts.clear) {
325
333
  // Clear both modes
326
- runSetupScript(SETUP_TOOL_MAP[mode.subscription].script, apiKey, { clear: true });
327
- runSetupScript(SETUP_TOOL_MAP[mode.gateway].script, apiKey, { clear: true });
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 });
328
336
  } else {
329
337
  let useSubscription = opts.subscription;
330
338
  if (!opts.subscription && !opts.gateway) {
@@ -332,7 +340,7 @@ automatically to authenticate before proceeding.
332
340
  useSubscription = choice === 'subscription';
333
341
  }
334
342
  const resolved = useSubscription ? mode.subscription : mode.gateway;
335
- runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, {});
343
+ runSetupScript(SETUP_TOOL_MAP[resolved].script, apiKey, { backendUrl: opts.backendUrl, frontendUrl: opts.frontendUrl });
336
344
  }
337
345
  } else if (INSTRUCTION_TOOLS[toolName]) {
338
346
  output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
@@ -370,7 +378,10 @@ automatically to authenticate before proceeding.
370
378
  // Run automated tools with spinners
371
379
  if (resolvedScripts.length > 0) {
372
380
  console.log('');
373
- const args = `--api-key ${shellEscape(apiKey)}${opts.clear ? ' --clear' : ''}`;
381
+ let args = `--api-key ${shellEscape(apiKey)}`;
382
+ if (opts.backendUrl) args += ` --backend-url ${shellEscape(opts.backendUrl)}`;
383
+ if (opts.frontendUrl) args += ` --domain ${shellEscape(opts.frontendUrl)}`;
384
+ if (opts.clear) args += ' --clear';
374
385
  const ok = await runBatch(resolvedScripts, (tool) =>
375
386
  runScriptPiped(tool.script, args)
376
387
  , { clear: opts.clear });
@@ -409,7 +420,6 @@ automatically to authenticate before proceeding.
409
420
  .requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
410
421
  .option('--clear', 'Remove Unbound configuration for the specified tools')
411
422
  .option('--all', 'Set up all available tools')
412
- .addOption(new Option('--url <url>', 'Override backend URL').hideHelp())
413
423
  .addHelpText('after', `
414
424
  Available tools:
415
425
  cursor Cursor IDE
@@ -429,18 +439,22 @@ Examples:
429
439
  $ sudo unbound setup mdm --admin-api-key KEY --all
430
440
  $ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
431
441
  `)
432
- .action(async (tools, opts) => {
442
+ .action(async (tools, opts, command) => {
433
443
  try {
434
444
  checkRoot();
445
+ // --all and --clear are defined on both this command and the parent `setup` command;
446
+ // --backend-url and --frontend-url are defined only on the parent `setup` command.
447
+ // Use optsWithGlobals() so all four work regardless of position relative to `mdm`.
448
+ const globalOpts = command.optsWithGlobals();
435
449
 
436
- if (opts.all && tools.length > 0) {
450
+ if (globalOpts.all && tools.length > 0) {
437
451
  output.error('Cannot combine --all with specific tool names. Use one or the other.');
438
452
  process.exitCode = 1;
439
453
  return;
440
454
  }
441
455
 
442
456
  let toolNames;
443
- if (opts.all) {
457
+ if (globalOpts.all) {
444
458
  toolNames = MDM_ALL_TOOLS;
445
459
  } else if (tools.length > 0) {
446
460
  toolNames = tools;
@@ -476,20 +490,21 @@ Examples:
476
490
 
477
491
  const mdmArgs = (tool) => {
478
492
  let args = `--api-key ${shellEscape(opts.adminApiKey)}`;
479
- if (opts.url) args += ` --url ${shellEscape(opts.url)}`;
480
- if (opts.clear) args += ' --clear';
493
+ if (globalOpts.backendUrl) args += ` --backend-url ${shellEscape(globalOpts.backendUrl)}`;
494
+ if (globalOpts.frontendUrl) args += ` --domain ${shellEscape(globalOpts.frontendUrl)}`;
495
+ if (globalOpts.clear) args += ' --clear';
481
496
  return args;
482
497
  };
483
498
 
484
499
  const ok = await runBatch(
485
500
  resolvedTools,
486
501
  (tool) => runScriptPiped(tool.script, mdmArgs(tool)),
487
- { clear: opts.clear }
502
+ { clear: globalOpts.clear }
488
503
  );
489
504
  if (!ok) return;
490
505
 
491
506
  console.log('');
492
- output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
507
+ output.success(globalOpts.clear ? 'All tools cleared' : 'All tools configured');
493
508
  } catch (err) {
494
509
  output.error(err.message);
495
510
  process.exitCode = 1;
@@ -502,9 +517,11 @@ Examples:
502
517
  * Assumes the caller has already ensured the user is logged in.
503
518
  * Returns true on success, false on failure.
504
519
  */
505
- async function runSetupAllBundle(apiKey) {
520
+ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl } = {}) {
506
521
  const resolvedTools = ALL_TOOLS.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
507
- const args = `--api-key ${shellEscape(apiKey)}`;
522
+ let args = `--api-key ${shellEscape(apiKey)}`;
523
+ if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
524
+ if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
508
525
  return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
509
526
  }
510
527
 
@@ -513,10 +530,11 @@ async function runSetupAllBundle(apiKey) {
513
530
  * Caller must ensure the process is running as root.
514
531
  * Returns true on success, false on failure.
515
532
  */
516
- async function runMdmSetupAllBundle(adminApiKey, { url } = {}) {
533
+ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl } = {}) {
517
534
  const resolvedTools = MDM_ALL_TOOLS.map(name => ({ name, ...MDM_TOOLS[name] }));
518
535
  let args = `--api-key ${shellEscape(adminApiKey)}`;
519
- if (url) args += ` --url ${shellEscape(url)}`;
536
+ if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
537
+ if (frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
520
538
  return runBatch(resolvedTools, (tool) => runScriptPiped(tool.script, args));
521
539
  }
522
540
 
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
+ });