opencode-auto-agent 1.2.0 → 1.3.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": "opencode-auto-agent",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Scaffold and run an AI agent team powered by Ralphy + OpenCode. Presets for Java, Spring Boot, Next.js, and more.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,8 +15,8 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "dev": "node bin/cli.js",
18
- "test": "node --test src/**/*.test.js 2>/dev/null || true",
19
- "verify": "node bin/cli.js help"
18
+ "test": "node --test src/**/*.test.js",
19
+ "verify": "npm run test && node bin/cli.js help"
20
20
  },
21
21
  "keywords": [
22
22
  "ai",
@@ -0,0 +1,42 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { parseModelsOutput, findModel, isValidModel } from "./models.js";
5
+
6
+ test("parseModelsOutput parses slash-style model lines", () => {
7
+ const raw = [
8
+ "openai/gpt-5.1-codex",
9
+ "anthropic/claude-sonnet-4-5 Fast and reliable",
10
+ "google/gemini-3-pro",
11
+ ].join("\n");
12
+
13
+ const models = parseModelsOutput(raw);
14
+ assert.equal(models.length, 3);
15
+ assert.equal(models[0].id, "openai/gpt-5.1-codex");
16
+ assert.equal(models[1].provider, "anthropic");
17
+ });
18
+
19
+ test("parseModelsOutput parses table-like provider/model lines", () => {
20
+ const raw = [
21
+ "provider model",
22
+ "openai gpt-5.1-codex",
23
+ "google gemini-3-pro",
24
+ ].join("\n");
25
+
26
+ const models = parseModelsOutput(raw);
27
+ assert.equal(models.length, 2);
28
+ assert.equal(models[0].id, "openai/gpt-5.1-codex");
29
+ assert.equal(models[1].id, "google/gemini-3-pro");
30
+ });
31
+
32
+ test("findModel falls back from exact to model-name match", () => {
33
+ const models = [
34
+ { id: "openrouter/gpt-5.1-codex", provider: "openrouter", model: "gpt-5.1-codex" },
35
+ { id: "google/gemini-3-pro", provider: "google", model: "gemini-3-pro" },
36
+ ];
37
+
38
+ const found = findModel(models, "openai/gpt-5.1-codex");
39
+ assert.ok(found);
40
+ assert.equal(found.id, "openrouter/gpt-5.1-codex");
41
+ assert.equal(isValidModel(models, "openai/gpt-5.1-codex"), true);
42
+ });
package/src/lib/ui.js CHANGED
@@ -240,36 +240,33 @@ export async function input(question, defaultValue = "") {
240
240
  export async function select(title, items) {
241
241
  if (!items.length) throw new Error("select: no items provided");
242
242
 
243
+ if (!supportsInteractiveMenu()) {
244
+ return selectFallback(title, items);
245
+ }
246
+
243
247
  return new Promise((resolve) => {
244
248
  let cursor = 0;
249
+ const totalLines = items.length + 1;
250
+ let hasRendered = false;
245
251
 
246
252
  const render = () => {
247
- // Move cursor up to overwrite previous render
248
- if (cursor !== -1) {
249
- stdout.write(`\x1b[${items.length + 1}A`);
253
+ if (hasRendered) {
254
+ stdout.write(`\x1b[${totalLines}A`);
250
255
  }
251
256
 
252
- stdout.write(` ${c.cyan("?")} ${c.bold(title)}\n`);
257
+ stdout.write(`\x1b[2K ${c.cyan("?")} ${c.bold(title)}\n`);
253
258
  for (let i = 0; i < items.length; i++) {
254
259
  const item = items[i];
255
- const selected = i === cursor;
256
- const pointer = selected ? c.cyan("\u25B6") : " ";
257
- const label = selected ? c.cyan(item.label) : c.white(item.label);
260
+ const isSelected = i === cursor;
261
+ const pointer = isSelected ? c.cyan("\u25B6") : " ";
262
+ const label = isSelected ? c.cyan(item.label) : c.white(item.label);
258
263
  const hint = item.hint ? c.gray(` ${item.hint}`) : "";
259
- stdout.write(` ${pointer} ${label}${hint}\n`);
264
+ stdout.write(`\x1b[2K ${pointer} ${label}${hint}\n`);
260
265
  }
266
+ hasRendered = true;
261
267
  };
262
268
 
263
- // Initial render
264
- stdout.write(` ${c.cyan("?")} ${c.bold(title)}\n`);
265
- for (let i = 0; i < items.length; i++) {
266
- const item = items[i];
267
- const selected = i === cursor;
268
- const pointer = selected ? c.cyan("\u25B6") : " ";
269
- const label = selected ? c.cyan(item.label) : c.white(item.label);
270
- const hint = item.hint ? c.gray(` ${item.hint}`) : "";
271
- stdout.write(` ${pointer} ${label}${hint}\n`);
272
- }
269
+ render();
273
270
 
274
271
  stdin.setRawMode(true);
275
272
  stdin.resume();
@@ -288,13 +285,12 @@ export async function select(title, items) {
288
285
  stdin.setRawMode(false);
289
286
  stdin.removeListener("data", onData);
290
287
  stdin.pause();
291
- // Clear and reprint final selection
292
- stdout.write(`\x1b[${items.length + 1}A`);
293
- stdout.write(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[cursor].label)}\n`);
294
- for (let i = 0; i < items.length; i++) {
295
- stdout.write(`\x1b[2K\n`); // clear each line
288
+ stdout.write(`\x1b[${totalLines}A`);
289
+ for (let i = 0; i < totalLines; i++) {
290
+ stdout.write("\x1b[2K\n");
296
291
  }
297
- stdout.write(`\x1b[${items.length}A`); // move back up
292
+ stdout.write(`\x1b[${totalLines}A`);
293
+ stdout.write(`\x1b[2K ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[cursor].label)}\n`);
298
294
  resolve(items[cursor]);
299
295
  return;
300
296
  }
@@ -325,15 +321,22 @@ export async function select(title, items) {
325
321
  export async function multiSelect(title, items) {
326
322
  if (!items.length) throw new Error("multiSelect: no items provided");
327
323
 
324
+ if (!supportsInteractiveMenu()) {
325
+ return multiSelectFallback(title, items);
326
+ }
327
+
328
328
  return new Promise((resolve) => {
329
329
  let cursor = 0;
330
- const selected = new Set(
331
- items.filter((it) => it.selected).map((_, i) => i)
332
- );
330
+ const selected = buildSelectedIndexSet(items);
331
+ const totalLines = items.length + 2;
332
+ let hasRendered = false;
333
333
 
334
334
  const render = () => {
335
- stdout.write(`\x1b[${items.length + 2}A`);
336
- stdout.write(` ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle, enter to confirm)")}\n`);
335
+ if (hasRendered) {
336
+ stdout.write(`\x1b[${totalLines}A`);
337
+ }
338
+
339
+ stdout.write(`\x1b[2K ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle, enter to confirm)")}\n`);
337
340
  for (let i = 0; i < items.length; i++) {
338
341
  const item = items[i];
339
342
  const isCursor = i === cursor;
@@ -345,21 +348,10 @@ export async function multiSelect(title, items) {
345
348
  stdout.write(`\x1b[2K ${pointer} ${check} ${label}${hint}\n`);
346
349
  }
347
350
  stdout.write("\x1b[2K\n");
351
+ hasRendered = true;
348
352
  };
349
353
 
350
- // Initial render
351
- stdout.write(` ${c.cyan("?")} ${c.bold(title)} ${c.gray("(space to toggle, enter to confirm)")}\n`);
352
- for (let i = 0; i < items.length; i++) {
353
- const item = items[i];
354
- const isCursor = i === cursor;
355
- const isSelected = selected.has(i);
356
- const pointer = isCursor ? c.cyan("\u25B6") : " ";
357
- const check = isSelected ? c.green("\u25C9") : c.gray("\u25CB");
358
- const label = isCursor ? c.cyan(item.label) : c.white(item.label);
359
- const hint = item.hint ? c.gray(` ${item.hint}`) : "";
360
- stdout.write(` ${pointer} ${check} ${label}${hint}\n`);
361
- }
362
- stdout.write("\n");
354
+ render();
363
355
 
364
356
  stdin.setRawMode(true);
365
357
  stdin.resume();
@@ -376,14 +368,14 @@ export async function multiSelect(title, items) {
376
368
  stdin.setRawMode(false);
377
369
  stdin.removeListener("data", onData);
378
370
  stdin.pause();
379
- const result = [...selected].map((i) => items[i]);
380
- // Clear and show final
381
- stdout.write(`\x1b[${items.length + 2}A`);
382
- stdout.write(`\x1b[2K ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(result.map((r) => r.label).join(", "))}\n`);
383
- for (let i = 0; i < items.length + 1; i++) {
384
- stdout.write(`\x1b[2K\n`);
371
+ const orderedIndexes = [...selected].sort((a, b) => a - b);
372
+ const result = orderedIndexes.map((i) => items[i]);
373
+ stdout.write(`\x1b[${totalLines}A`);
374
+ for (let i = 0; i < totalLines; i++) {
375
+ stdout.write("\x1b[2K\n");
385
376
  }
386
- stdout.write(`\x1b[${items.length + 1}A`);
377
+ stdout.write(`\x1b[${totalLines}A`);
378
+ stdout.write(`\x1b[2K ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(result.map((r) => r.label).join(", "))}\n`);
387
379
  resolve(result);
388
380
  return;
389
381
  }
@@ -444,3 +436,66 @@ export function truncate(str, maxLen = 40) {
444
436
  if (str.length <= maxLen) return str;
445
437
  return str.slice(0, maxLen - 1) + "\u2026";
446
438
  }
439
+
440
+ /**
441
+ * Build selected index set preserving original item indexes.
442
+ * @param {Array<{selected?: boolean}>} items
443
+ * @returns {Set<number>}
444
+ */
445
+ export function buildSelectedIndexSet(items) {
446
+ const indexes = new Set();
447
+ for (let i = 0; i < items.length; i++) {
448
+ if (items[i]?.selected) indexes.add(i);
449
+ }
450
+ return indexes;
451
+ }
452
+
453
+ function supportsInteractiveMenu() {
454
+ return Boolean(stdin.isTTY && stdout.isTTY && typeof stdin.setRawMode === "function");
455
+ }
456
+
457
+ async function selectFallback(title, items) {
458
+ console.log(` ${c.cyan("?")} ${c.bold(title)}`);
459
+ for (let i = 0; i < items.length; i++) {
460
+ const item = items[i];
461
+ const hint = item.hint ? ` ${c.gray(item.hint)}` : "";
462
+ console.log(` ${String(i + 1).padStart(2, " ")}) ${item.label}${hint}`);
463
+ }
464
+ const answer = await input("Choose an option number", "1");
465
+ const idx = clampIndex(parseInt(answer, 10) - 1, items.length);
466
+ console.log(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(items[idx].label)}`);
467
+ return items[idx];
468
+ }
469
+
470
+ async function multiSelectFallback(title, items) {
471
+ console.log(` ${c.cyan("?")} ${c.bold(title)}`);
472
+ for (let i = 0; i < items.length; i++) {
473
+ const item = items[i];
474
+ const mark = item.selected ? "*" : " ";
475
+ const hint = item.hint ? ` ${c.gray(item.hint)}` : "";
476
+ console.log(` ${String(i + 1).padStart(2, " ")}) [${mark}] ${item.label}${hint}`);
477
+ }
478
+ const answer = await input("Choose option numbers (comma-separated)", "1");
479
+ const indexes = parseMultiNumberInput(answer, items.length);
480
+ const selected = indexes.map((i) => items[i]);
481
+ console.log(` ${c.green("\u2713")} ${c.bold(title)} ${c.cyan(selected.map((it) => it.label).join(", "))}`);
482
+ return selected;
483
+ }
484
+
485
+ function clampIndex(idx, length) {
486
+ if (Number.isNaN(idx) || idx < 0) return 0;
487
+ if (idx >= length) return length - 1;
488
+ return idx;
489
+ }
490
+
491
+ function parseMultiNumberInput(text, length) {
492
+ const indexes = new Set();
493
+ for (const rawPart of text.split(",")) {
494
+ const n = parseInt(rawPart.trim(), 10);
495
+ if (Number.isNaN(n)) continue;
496
+ const idx = n - 1;
497
+ if (idx >= 0 && idx < length) indexes.add(idx);
498
+ }
499
+ if (indexes.size === 0) indexes.add(0);
500
+ return [...indexes].sort((a, b) => a - b);
501
+ }
@@ -0,0 +1,26 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { buildSelectedIndexSet } from "./ui.js";
5
+
6
+ test("buildSelectedIndexSet keeps original indexes", () => {
7
+ const items = [
8
+ { label: "a", selected: false },
9
+ { label: "b", selected: true },
10
+ { label: "c", selected: false },
11
+ { label: "d", selected: true },
12
+ ];
13
+
14
+ const selected = buildSelectedIndexSet(items);
15
+ assert.deepEqual([...selected], [1, 3]);
16
+ });
17
+
18
+ test("buildSelectedIndexSet handles empty selection", () => {
19
+ const items = [
20
+ { label: "a" },
21
+ { label: "b", selected: false },
22
+ ];
23
+
24
+ const selected = buildSelectedIndexSet(items);
25
+ assert.equal(selected.size, 0);
26
+ });