unbound-cli 1.12.0 → 1.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -162,6 +162,17 @@ function reconstructRows(chart_option, chart_meta) {
162
162
  return { columns: [valueKey], rows: [{ [valueKey]: sanitizeLabel(flat.data[0]) }] };
163
163
  }
164
164
 
165
+ // 5) KPI as the builder actually emits it: series is [] and the number lives,
166
+ // already formatted, in a graphic text element. Nothing above can see it, so a
167
+ // single-number answer decoded to nothing and read as an empty result.
168
+ const graphicText = asArray(chart_option.graphic)
169
+ .map((g) => g?.style?.text)
170
+ .find((t) => typeof t === 'string' && t.trim());
171
+ if (graphicText) {
172
+ const valueKey = yKeys[0] || 'value';
173
+ return { columns: [valueKey], rows: [{ [valueKey]: sanitizeLabel(graphicText.trim()) }] };
174
+ }
175
+
165
176
  return { columns: [], rows: [] };
166
177
  }
167
178
 
@@ -85,6 +85,8 @@ function safeWriteFile(target, data) {
85
85
  return resolved;
86
86
  }
87
87
 
88
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
89
+
88
90
  function buildBody(message, history, conversationId) {
89
91
  const body = {
90
92
  message,
@@ -119,6 +121,10 @@ function toJsonPayload(resp) {
119
121
  log_id: resp?.log_id ?? null,
120
122
  // Ran out of budget mid-answer; the blocks already here are real.
121
123
  truncated: resp?.truncated === true,
124
+ // False when -c named a conversation the server found nothing for. A mistyped
125
+ // id is still well-formed, so without this the turn reads as a success that
126
+ // quietly lost the thread.
127
+ resumed: resp?.resumed === true,
122
128
  blocks: blocks.map((b) => {
123
129
  if (b.type === 'text') return { type: 'text', text: b.text };
124
130
  const result = b.result || {};
@@ -257,8 +263,17 @@ function renderResponse(resp) {
257
263
  }
258
264
  }
259
265
 
260
- async function runOneShot({ message, json, outputPath }) {
261
- const body = buildBody(message, [], null);
266
+ function warnIfNotResumed(conversationId, payload) {
267
+ if (conversationId && !payload.resumed) {
268
+ process.stderr.write(
269
+ `Nothing to continue for ${conversationId} — answering without it. `
270
+ + 'Check the conversation_id from the previous reply.\n',
271
+ );
272
+ }
273
+ }
274
+
275
+ async function runOneShot({ message, json, outputPath, conversationId }) {
276
+ const body = buildBody(message, [], conversationId);
262
277
 
263
278
  if (json || outputPath) {
264
279
  let resp;
@@ -270,6 +285,12 @@ async function runOneShot({ message, json, outputPath }) {
270
285
  return;
271
286
  }
272
287
  const payload = toJsonPayload(resp);
288
+ warnIfNotResumed(conversationId, payload);
289
+ // On stderr: stdout is the transcript, and a reader piping it to jq must not
290
+ // find this in the stream. It is still on screen for whoever ran the command.
291
+ if (payload.conversation_id && payload.blocks.length) {
292
+ process.stderr.write(`continue with: -c ${sanitizeForTerminal(String(payload.conversation_id))}\n`);
293
+ }
273
294
  const serialized = JSON.stringify(payload, null, 2) + '\n';
274
295
  // Write to stdout first (when requested) so a downstream `-o` write failure
275
296
  // doesn't eat the response when both flags are combined.
@@ -279,7 +300,11 @@ async function runOneShot({ message, json, outputPath }) {
279
300
  if (outputPath) {
280
301
  try {
281
302
  const resolved = safeWriteFile(outputPath, serialized);
282
- output.success(`Wrote ${resolved}`);
303
+ // With --json the transcript owns stdout. jq parses the whole stream, so a
304
+ // confirmation printed after it makes the output unparseable and exits 5 —
305
+ // while still emitting the right answer, which is worse than failing outright.
306
+ if (json) process.stderr.write(`Wrote ${resolved}\n`);
307
+ else output.success(`Wrote ${resolved}`);
283
308
  } catch (err) {
284
309
  output.error(sanitizeForTerminal(err.message));
285
310
  process.exitCode = 1;
@@ -301,7 +326,15 @@ async function runOneShot({ message, json, outputPath }) {
301
326
  return;
302
327
  }
303
328
 
329
+ warnIfNotResumed(conversationId, toJsonPayload(resp));
304
330
  renderResponse(resp);
331
+ // Without this the id only exists in --json, so continuing a conversation from
332
+ // the terminal would mean asking the same question again just to read it back.
333
+ const id = resp?.conversation_id;
334
+ if (id && (resp?.blocks || []).length) {
335
+ console.log('');
336
+ console.log(dim(` continue: unbound chat -m "..." -c ${sanitizeForTerminal(String(id))}`));
337
+ }
305
338
  if (resp?.success === false || !(resp?.blocks || []).length) process.exitCode = 1;
306
339
  }
307
340
 
@@ -448,6 +481,7 @@ function register(program) {
448
481
  .option('-m, --message <text>', 'Ask a single question and exit (no interactive session).')
449
482
  .option('--json', 'Write the raw JSON response to stdout. Use with -m.')
450
483
  .option('-o, --output <path>', 'Write the raw JSON response to a file. Refuses to overwrite. Use with -m.')
484
+ .option('-c, --continue <conversation_id>', 'Continue the conversation with this id. Use with -m.')
451
485
  .addHelpText('after', `
452
486
  EXAMPLES
453
487
  Start an interactive session:
@@ -458,9 +492,19 @@ EXAMPLES
458
492
 
459
493
  Write the JSON transcript to stdout (pipe to jq, a script, or a coding agent):
460
494
  $ unbound chat -m "cost by provider last 30 days" --json
461
- $ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[].text'
495
+ $ unbound chat -m "cost by provider last 30 days" --json | jq -r '.blocks[] | select(.type=="text") | .text'
462
496
  $ unbound chat -m "cost by provider last 30 days" --json | jq '.blocks[] | select(.type=="chart") | .rows'
463
497
 
498
+ Continue a conversation across separate commands. Every one-shot reply prints
499
+ the id to continue with; --json carries it as conversation_id:
500
+ $ unbound chat -m "cost by provider last 7 days"
501
+ ... continue: unbound chat -m "..." -c <conversation_id>
502
+ $ unbound chat -m "now just the top 2" -c <conversation_id>
503
+
504
+ Scripted, taking the id from the JSON:
505
+ $ id=$(unbound chat -m "cost by provider last 7 days" --json | jq -r .conversation_id)
506
+ $ unbound chat -m "now just the top 2" -c "$id"
507
+
464
508
  Write it to a file:
465
509
  $ unbound chat -m "cost by provider last 30 days" -o cost.json
466
510
 
@@ -468,7 +512,7 @@ EXAMPLES
468
512
  $ unbound chat -m "cost by provider last 30 days" --json -o cost.json | jq '.blocks | length'
469
513
 
470
514
  OUTPUT FORMAT (--json and -o)
471
- { success, conversation_id, log_id, truncated, blocks[] }
515
+ { success, error, conversation_id, log_id, truncated, resumed, blocks[] }
472
516
 
473
517
  A turn is an ordered transcript. The agent answers one thing at a time, so a
474
518
  single question can return several charts with prose between them, and blocks[]
@@ -482,17 +526,24 @@ OUTPUT FORMAT (--json and -o)
482
526
  { type: "chart", title, view_spec, chart_type, row_count, columns, rows }
483
527
  columns string[] left-to-right column names
484
528
  rows [{ <column>: value, ... }] the data, already decoded
485
- chart_type "bar" | "line" | "donut" | "pie" | "kpi" | "scatter" | "table"
529
+ chart_type "bar" | "line" | "area" | "stacked_bar" | "pie" | "donut"
530
+ | "scatter" | "heatmap" | "kpi" | "table"
531
+ decode_failed present and true only when the chart data could not be read,
532
+ so empty rows from a broken payload are not mistaken for an
533
+ empty answer
486
534
  view_spec the query that produced it, for reuse or refinement
487
535
 
536
+ error carries the reason when success is false, and is null otherwise.
537
+
488
538
  truncated: true means the agent ran out of budget partway. The blocks present
489
539
  are real and complete; ask a follow-up for the rest.
490
540
 
491
541
  log_id identifies the stored turn, for feedback and for support to trace.
492
542
 
493
543
  EXIT CODES
494
- 0 success
495
- 1 no blocks returned, auth/role/network failure, or a write error
544
+ 0 success, including a turn that ends by asking you a question
545
+ 1 no blocks returned, auth/role/network failure, a message over 4000
546
+ characters, or a write error
496
547
 
497
548
  INTERACTIVE SESSION
498
549
  $ unbound chat start the session
@@ -507,8 +558,17 @@ INTERACTIVE SESSION
507
558
 
508
559
  NOTES
509
560
  * Requires Admin or Manager role in your organization. Run 'unbound status' to check.
510
- * Conversation history is held in memory only; nothing is ever written to disk.
511
- * Each -m invocation sends no history; keep each prompt self-contained.
561
+ * A question the assistant asks back is a normal reply, not an error: the turn
562
+ exits 0, and you answer it in the next one with -c.
563
+ * Each -m invocation is its own process and sends no history. -c carries the
564
+ conversation instead; the turns are read back from the server, and this
565
+ command still writes no history of its own to disk.
566
+ * resumed is false when -c named a conversation that was not found. The question
567
+ is still answered, just without the earlier context.
568
+ * A single turn can answer several things at once and will say what it could not
569
+ do. Splitting a broad request across turns with -c tends to give sharper
570
+ answers though. Messages are capped at 4000 characters.
571
+ * With --json the transcript owns stdout; everything else goes to stderr.
512
572
  * -o writes a new file with mode 0600 and refuses to overwrite existing files.
513
573
  `)
514
574
  .action(async (opts) => {
@@ -520,7 +580,8 @@ NOTES
520
580
  return;
521
581
  }
522
582
 
523
- const oneShotFlagsGiven = opts.message !== undefined || !!opts.json || !!opts.output;
583
+ const oneShotFlagsGiven =
584
+ opts.message !== undefined || !!opts.json || !!opts.output || !!opts.continue;
524
585
  if (oneShotFlagsGiven) {
525
586
  const trimmed = typeof opts.message === 'string' ? opts.message.trim() : '';
526
587
  if (!trimmed) {
@@ -533,8 +594,18 @@ NOTES
533
594
  process.exitCode = 1;
534
595
  return;
535
596
  }
597
+ if (opts.continue !== undefined && !UUID_RE.test(String(opts.continue).trim())) {
598
+ output.error('`-c/--continue` needs the conversation_id from a previous reply.');
599
+ process.exitCode = 1;
600
+ return;
601
+ }
536
602
  try {
537
- await runOneShot({ message: trimmed, json: !!opts.json, outputPath: opts.output });
603
+ await runOneShot({
604
+ message: trimmed,
605
+ json: !!opts.json,
606
+ outputPath: opts.output,
607
+ conversationId: opts.continue ? String(opts.continue).trim() : null,
608
+ });
538
609
  } catch (err) {
539
610
  output.error(friendlyChatError(err));
540
611
  process.exitCode = 1;
@@ -227,3 +227,24 @@ test('reconstructRows: table rows may be arrays aligned to columns', () => {
227
227
  test('reconstructRows: a malformed table falls through instead of throwing', () => {
228
228
  assert.deepEqual(chart.reconstructRows({ table: { columns: 'nope' } }, {}), { columns: [], rows: [] });
229
229
  });
230
+
231
+ test('reconstructRows: a KPI decodes from the graphic text the builder puts it in', () => {
232
+ // KPI_TEMPLATE ships series: [] and writes the formatted number into a graphic
233
+ // element, so every branch keyed on series/dataset/xAxis missed it and a
234
+ // single-number answer rendered as "(no rows to render)".
235
+ const option = {
236
+ series: [],
237
+ graphic: [{ type: 'text', style: { text: '$372.81', fontSize: 48 } }],
238
+ };
239
+
240
+ const { columns, rows } = chart.reconstructRows(option, { chart_type: 'kpi', y: ['total_cost'] });
241
+
242
+ assert.deepEqual(columns, ['total_cost']);
243
+ assert.deepEqual(rows, [{ total_cost: '$372.81' }]);
244
+ });
245
+
246
+ test('reconstructRows: an empty graphic text is not mistaken for a value', () => {
247
+ const option = { series: [], graphic: [{ type: 'text', style: { text: ' ' } }] };
248
+
249
+ assert.deepEqual(chart.reconstructRows(option, { chart_type: 'kpi' }), { columns: [], rows: [] });
250
+ });
@@ -256,3 +256,19 @@ test('toJsonPayload: an empty chart and an unreadable one are not the same block
256
256
  // A genuinely empty answer carries no such marker.
257
257
  assert.equal('decode_failed' in payload.blocks[1], false);
258
258
  });
259
+
260
+ test('buildBody: a conversation id travels, and its absence is not a null field', () => {
261
+ const resumed = buildBody('now just the top 2', [], '00000000-0000-4000-8000-000000000000');
262
+ assert.equal(resumed.conversation_id, '00000000-0000-4000-8000-000000000000');
263
+ // One-shot sends no history — the server reads the turns back from the id alone.
264
+ assert.deepEqual(resumed.conversation_history, []);
265
+
266
+ assert.equal('conversation_id' in buildBody('first question', [], null), false);
267
+ });
268
+
269
+ test('toJsonPayload: resumed is only true when the server says it resumed', () => {
270
+ assert.equal(toJsonPayload({ blocks: [], resumed: true }).resumed, true);
271
+ // A mistyped id is well-formed, so silence here would read as success.
272
+ assert.equal(toJsonPayload({ blocks: [] }).resumed, false);
273
+ assert.equal(toJsonPayload({ blocks: [], resumed: 'yes' }).resumed, false);
274
+ });
@@ -0,0 +1,85 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { Command } = require('commander');
7
+
8
+ // `--json -o file` printed "Wrote <path>" to stdout, right after the transcript.
9
+ // jq parses the whole stream, so it emitted the right answer AND exited 5 — a
10
+ // failure that looks like a success until something checks $?.
11
+
12
+ function freshChat({ post }) {
13
+ for (const m of ['../src/commands/chat', '../src/api', '../src/auth', '../src/output', '../src/chartRender']) {
14
+ delete require.cache[require.resolve(m)];
15
+ }
16
+ const api = require('../src/api');
17
+ api.post = post;
18
+ const auth = require('../src/auth');
19
+ auth.ensureLoggedIn = async () => {};
20
+ return require('../src/commands/chat');
21
+ }
22
+
23
+ const REPLY = {
24
+ success: true,
25
+ conversation_id: 'c-1',
26
+ log_id: 'l-1',
27
+ blocks: [{ type: 'text', text: 'Here you go.' }],
28
+ };
29
+
30
+ async function runChat(args) {
31
+ const chat = freshChat({ post: async () => REPLY });
32
+ const out = [];
33
+ const err = [];
34
+ const realOut = process.stdout.write.bind(process.stdout);
35
+ const realErr = process.stderr.write.bind(process.stderr);
36
+ process.stdout.write = (chunk) => { out.push(String(chunk)); return true; };
37
+ process.stderr.write = (chunk) => { err.push(String(chunk)); return true; };
38
+ try {
39
+ const program = new Command();
40
+ program.exitOverride();
41
+ chat.register(program);
42
+ await program.parseAsync(['node', 'unbound', 'chat', ...args]);
43
+ } finally {
44
+ process.stdout.write = realOut;
45
+ process.stderr.write = realErr;
46
+ }
47
+ return { stdout: out.join(''), stderr: err.join('') };
48
+ }
49
+
50
+ function tmpPath() {
51
+ return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-chat-')), 'out.json');
52
+ }
53
+
54
+ test('chat: --json with -o keeps stdout parseable as JSON', async () => {
55
+ const target = tmpPath();
56
+ const { stdout, stderr } = await runChat(['-m', 'anything', '--json', '-o', target]);
57
+
58
+ // The whole stream, not just a prefix — this is what jq consumes.
59
+ const parsed = JSON.parse(stdout);
60
+ assert.equal(parsed.blocks.length, 1);
61
+ assert.equal(parsed.conversation_id, 'c-1');
62
+
63
+ // The confirmation still happens, just not where a parser will trip on it.
64
+ assert.match(stderr, /Wrote /);
65
+ assert.ok(!stdout.includes('Wrote '), 'stdout must carry the transcript and nothing else');
66
+
67
+ assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).blocks.length, 1);
68
+ });
69
+
70
+ test('chat: -o without --json still confirms on stdout for a human', async () => {
71
+ const target = tmpPath();
72
+ const { stdout } = await runChat(['-m', 'anything', '-o', target]);
73
+
74
+ assert.match(stdout, /Wrote /);
75
+ assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).blocks.length, 1);
76
+ });
77
+
78
+ test('chat: --json puts the transcript on stdout and everything else on stderr', async () => {
79
+ const { stdout, stderr } = await runChat(['-m', 'anything', '--json']);
80
+
81
+ // stdout is what a reader pipes to jq, so it has to parse whole.
82
+ assert.equal(JSON.parse(stdout).blocks.length, 1);
83
+ assert.ok(!stdout.includes('continue with'), 'the hint must not reach stdout');
84
+ assert.match(stderr, /continue with: -c c-1/);
85
+ });