agentsw 0.9.2 → 0.10.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/dist/commands.js CHANGED
@@ -8,7 +8,7 @@ import prompts from "prompts";
8
8
  import { loadStore, saveStore, getProvider, configFile } from "./store.js";
9
9
  import { scanCandidates, normalizeUrl, findMatchingProvider } from "./import.js";
10
10
  import { loadCatalog, searchCatalog } from "./modelsdev.js";
11
- import { classifyUnresolved, enrichProviderModels, getMetadataMode, resolveMetadataOptions } from "./metadata.js";
11
+ import { classifyUnresolved, enrichProviderModels, resolveMetadataOptions } from "./metadata.js";
12
12
  import { loadGatewayCatalog } from "./gateway.js";
13
13
  import { resolveTargets, supportsProtocol, targets } from "./targets/index.js";
14
14
  import { discoverProviderModels, probeProtocols } from "./discover.js";
@@ -17,38 +17,16 @@ import { SafeConfigError, drainPendingWrites, readTextIfExists, setDryRun } from
17
17
  import { applyModelFilter } from "./filter.js";
18
18
  import { availableProviderId, providerIdFromBaseUrl, providerNameFromBaseUrl } from "./slug.js";
19
19
  import { t } from "./i18n.js";
20
+ import { cancelInteractive, emitJson, error, isJson, note, out, table } from "./ui.js";
21
+ import { withProgress } from "./progress.js";
22
+ import { appsReport, listReport, modelsQueryReport, providerMetadataReport, providerModelsReport, statusReport, } from "./report.js";
20
23
  /** Share successes and failures without fetching until a model actually needs the supplement. */
21
24
  function sharedGatewayLoader(refresh = false) {
22
25
  let pending;
23
- return () => pending ??= loadGatewayCatalog({ refresh }).then((catalog) => catalog ?? null);
26
+ return () => pending ??= withProgress(t("progress.gateway"), () => loadGatewayCatalog({ refresh }).then((catalog) => catalog ?? null));
24
27
  }
25
28
  function fail(message) {
26
- process.stderr.write(pc.red(`error: ${message}\n`));
27
- process.exit(1);
28
- }
29
- function table(rows, header) {
30
- const all = header ? [header, ...rows] : rows;
31
- if (all.length === 0)
32
- return "";
33
- const widths = [];
34
- for (const row of all) {
35
- row.forEach((cell, i) => {
36
- // eslint-disable-next-line no-control-regex
37
- const len = cell.replace(/\u001b\[[0-9;]*m/g, "").length;
38
- widths[i] = Math.max(widths[i] ?? 0, len);
39
- });
40
- }
41
- const render = (row) => row
42
- .map((cell, i) => {
43
- const len = cell.replace(/\u001b\[[0-9;]*m/g, "").length;
44
- return cell + " ".repeat((widths[i] ?? 0) - len);
45
- })
46
- .join(" ")
47
- .trimEnd();
48
- const lines = all.map(render);
49
- if (header)
50
- lines.splice(1, 0, widths.map((w) => "-".repeat(w)).join(" "));
51
- return lines.join("\n");
29
+ error(message);
52
30
  }
53
31
  function fmtTokens(n) {
54
32
  if (!n)
@@ -70,6 +48,12 @@ function modelRows(models) {
70
48
  ]);
71
49
  }
72
50
  const MODEL_HEADER = ["MODEL", "CTX", "IN", "OUT", "REASONING", "$IN/$OUT per M"];
51
+ /** Numbers right-align; the free-text columns shrink before ids on narrow terminals. */
52
+ const MODEL_TABLE_OPTIONS = { align: ["left", "right", "right", "right", "left", "right"], truncate: [4, 0] };
53
+ const MODEL_QUERY_OPTIONS = {
54
+ align: ["left", "left", "right", "right", "right", "left", "right"],
55
+ truncate: [5, 1, 0],
56
+ };
73
57
  /**
74
58
  * Explain why some models stayed bare: only ambiguous ids are worth the user's
75
59
  * attention, so those are named separately from ids nothing can resolve.
@@ -78,11 +62,11 @@ function printUncatalogedHint(models, catalog, hint) {
78
62
  const bare = models.filter((m) => m.contextWindow === undefined && !m.cost);
79
63
  if (bare.length === 0)
80
64
  return;
81
- console.log(pc.dim(t("meta.gap", { count: bare.length })));
65
+ out(pc.dim(t("meta.gap", { count: bare.length })));
82
66
  const { ambiguous, unknown, noCreatorRow } = classifyUnresolved(catalog, bare.map((m) => m.id), hint);
83
67
  for (const [key, ids] of [["meta.ambiguous", ambiguous], ["meta.noCreatorRow", noCreatorRow], ["meta.unknown", unknown]]) {
84
68
  if (ids.length)
85
- console.log(pc.dim(` ${t(key, { count: ids.length, ids: ids.join(", "), id: ids[0] })}`));
69
+ out(pc.dim(` ${t(key, { count: ids.length, ids: ids.join(", "), id: ids[0] })}`));
86
70
  }
87
71
  }
88
72
  /** Guess the models.dev provider whose API host matches the configured baseUrl. */
@@ -122,14 +106,14 @@ function parseFilterOpts(opts, previous) {
122
106
  function reportDropped(dropped) {
123
107
  if (!dropped.length)
124
108
  return;
125
- console.log(pc.dim(`filtered out ${dropped.length} model(s):`));
109
+ out(pc.dim(`filtered out ${dropped.length} model(s):`));
126
110
  for (const d of dropped)
127
- console.log(pc.dim(` - ${d.id} (${d.reason})`));
111
+ out(pc.dim(` - ${d.id} (${d.reason})`));
128
112
  }
129
113
  export async function cmdAdd(opts) {
130
114
  const store = loadStore();
131
115
  resolveMetadataOptions(opts);
132
- const interactive = process.stdin.isTTY && !opts.yes;
116
+ const interactive = process.stdin.isTTY === true && !opts.yes && !isJson();
133
117
  let answers = {};
134
118
  if (interactive) {
135
119
  prompts.override({
@@ -177,7 +161,7 @@ export async function cmdAdd(opts) {
177
161
  name: "models",
178
162
  message: t("add.models"),
179
163
  },
180
- ], { onCancel: () => fail(t("add.cancelled")) });
164
+ ], { onCancel: () => cancelInteractive() });
181
165
  }
182
166
  else {
183
167
  const required = opts.discover
@@ -211,7 +195,7 @@ export async function cmdAdd(opts) {
211
195
  const sameAccount = byId ? undefined : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
212
196
  const existing = byId ?? sameAccount;
213
197
  if (byId === undefined && explicitId && sameAccount) {
214
- console.log(pc.yellow(t("add.alreadyConfigured", { id: pc.bold(sameAccount.id) })));
198
+ out(pc.yellow(t("add.alreadyConfigured", { id: pc.bold(sameAccount.id) })));
215
199
  }
216
200
  const id = byId?.id ?? sameAccount?.id ?? explicitId ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
217
201
  const openaiApi = protocol === "openai" ? wire ?? existing?.openaiApi : undefined;
@@ -222,11 +206,30 @@ export async function cmdAdd(opts) {
222
206
  const shouldDiscover = opts.discover || manualIds.length === 0;
223
207
  if (shouldDiscover) {
224
208
  if (!opts.discover) {
225
- process.stderr.write(pc.dim(t("add.autoDiscover") + "\n"));
209
+ note(t("add.autoDiscover"));
210
+ }
211
+ const discover = () => withProgress(t("add.discovering", { url: baseUrl }), (progress) => discoverProviderModels({ baseUrl, apiKey: answers.apiKey, protocol }, {
212
+ onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: baseUrl, page, count })),
213
+ }));
214
+ let discovered = [];
215
+ for (let attempt = 0;; attempt++) {
216
+ try {
217
+ discovered = await discover();
218
+ break;
219
+ }
220
+ catch (err) {
221
+ // Unattended runs keep failing fast; a person at the terminal gets a bounded retry, then manual ids.
222
+ if (!interactive)
223
+ throw err;
224
+ if (attempt >= 2)
225
+ break;
226
+ const { retry } = await prompts({ type: "toggle", name: "retry", message: t("add.retryDiscovery", { error: err.message }), initial: true }, { onCancel: () => cancelInteractive() });
227
+ if (!retry)
228
+ break;
229
+ }
226
230
  }
227
- process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })}\n`));
228
- const discovered = await discoverProviderModels({ baseUrl, apiKey: answers.apiKey, protocol });
229
- console.log(t("add.providerLists", { count: discovered.length }));
231
+ if (discovered.length)
232
+ out(t("add.providerLists", { count: discovered.length }));
230
233
  modelIds = [...new Set([...manualIds, ...discovered])];
231
234
  }
232
235
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
@@ -237,9 +240,9 @@ export async function cmdAdd(opts) {
237
240
  modelIds = outcome.kept;
238
241
  if (modelIds.length === 0)
239
242
  fail(t("add.atLeastOne"));
240
- const catalog = await loadCatalog();
243
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
241
244
  const hint = opts.modelsDev ?? existing?.modelsDevId ?? guessProviderHint(catalog, answers.baseUrl);
242
- const models = await enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models });
245
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models }));
243
246
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
244
247
  const defaultModel = opts.defaultModel ?? existing?.defaultModel ?? modelIds[0];
245
248
  if (!modelIds.includes(defaultModel))
@@ -268,19 +271,23 @@ export async function cmdAdd(opts) {
268
271
  store.active = provider.id;
269
272
  saveStore(store);
270
273
  const savedStatus = t(existed ? "add.updated" : "add.added");
271
- console.log(pc.green(t("add.saved", { status: savedStatus, id: pc.bold(provider.id), protocol })));
272
- console.log(t("add.metadata", { matched, total: models.length }) + (hint ? t("add.providerHint", { hint }) : ""));
273
- console.log(table(modelRows(models), MODEL_HEADER));
274
+ if (isJson()) {
275
+ emitJson({ ...providerModelsReport(provider, false), created: !existed, matched });
276
+ return;
277
+ }
278
+ out(pc.green(t("add.saved", { status: savedStatus, id: pc.bold(provider.id), protocol })));
279
+ out(t("add.metadata", { matched, total: models.length }) + (hint ? t("add.providerHint", { hint }) : ""));
280
+ out(table(modelRows(models), MODEL_HEADER, MODEL_TABLE_OPTIONS));
274
281
  printUncatalogedHint(models, catalog, hint);
275
- console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
282
+ note(`\n${t("add.next", { id: provider.id })}`);
276
283
  }
277
284
  /** Shared logic to create and save a single provider from discovered models. */
278
285
  async function createProvider(opts) {
279
286
  const { store, id, name, protocol, openaiApi, baseUrl, apiKey, modelIds, modelFilter } = opts;
280
287
  const metadataOptions = resolveMetadataOptions(opts, opts.existing);
281
- const catalog = await loadCatalog();
288
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
282
289
  const hint = opts.modelsDev ?? opts.existing?.modelsDevId ?? guessProviderHint(catalog, baseUrl);
283
- const models = await enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, { gatewayLoader: opts.gatewayLoader });
290
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, { gatewayLoader: opts.gatewayLoader }));
284
291
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
285
292
  const defaultModel = opts.defaultModel ?? opts.existing?.defaultModel ?? modelIds[0];
286
293
  if (!modelIds.includes(defaultModel))
@@ -309,9 +316,9 @@ async function createProvider(opts) {
309
316
  if (!store.active)
310
317
  store.active = provider.id;
311
318
  const savedStatus = t(existed ? "add.updated" : "add.added");
312
- console.log(pc.green(t("add.saved", { status: savedStatus, id: pc.bold(provider.id), protocol })));
313
- console.log(t("add.metadata", { matched, total: models.length }) + (hint ? t("add.providerHint", { hint }) : ""));
314
- console.log(table(modelRows(models), MODEL_HEADER));
319
+ out(pc.green(t("add.saved", { status: savedStatus, id: pc.bold(provider.id), protocol })));
320
+ out(t("add.metadata", { matched, total: models.length }) + (hint ? t("add.providerHint", { hint }) : ""));
321
+ out(table(modelRows(models), MODEL_HEADER, MODEL_TABLE_OPTIONS));
315
322
  printUncatalogedHint(models, catalog, hint);
316
323
  return provider;
317
324
  }
@@ -323,14 +330,14 @@ async function createProvider(opts) {
323
330
  export async function cmdQuickAdd(opts) {
324
331
  const store = loadStore();
325
332
  resolveMetadataOptions(opts);
326
- const interactive = process.stdin.isTTY && !opts.yes;
333
+ const interactive = process.stdin.isTTY === true && !opts.yes && !isJson();
327
334
  let baseUrl;
328
335
  let apiKey;
329
336
  if (interactive) {
330
337
  const answers = await prompts([
331
338
  { type: "text", name: "baseUrl", message: t("add.baseUrl"), validate: (v) => (/^https?:\/\//.test(v) ? true : t("add.baseUrlInvalid")) },
332
339
  { type: "password", name: "apiKey", message: t("add.apiKey") },
333
- ], { onCancel: () => fail(t("add.cancelled")) });
340
+ ], { onCancel: () => cancelInteractive() });
334
341
  baseUrl = normalizeUrl(answers.baseUrl);
335
342
  apiKey = answers.apiKey;
336
343
  }
@@ -342,8 +349,25 @@ export async function cmdQuickAdd(opts) {
342
349
  baseUrl = normalizeUrl(opts.baseUrl);
343
350
  apiKey = opts.apiKey;
344
351
  }
345
- process.stderr.write(pc.dim(t("quick.probing", { url: baseUrl }) + "\n"));
346
- const protocols = await probeProtocols({ baseUrl, apiKey });
352
+ const probe = () => withProgress(t("quick.probing", { url: baseUrl }), (progress) => probeProtocols({ baseUrl, apiKey }, {
353
+ onAttempt: (protocol) => progress.update(t("progress.probe", { url: baseUrl, protocol })),
354
+ }));
355
+ let protocols = await probe();
356
+ // A typo in the endpoint is the common cause of "nothing answered"; let the user correct it instead of exiting.
357
+ for (let attempt = 0; interactive && protocols.length === 0 && attempt < 2; attempt++) {
358
+ const { retry } = await prompts({ type: "toggle", name: "retry", message: t("quick.retryProbe", { url: baseUrl }), initial: true }, { onCancel: () => cancelInteractive() });
359
+ if (!retry)
360
+ break;
361
+ const again = await prompts([
362
+ { type: "text", name: "baseUrl", message: t("add.baseUrl"), initial: baseUrl, validate: (v) => (/^https?:\/\//.test(v) ? true : t("add.baseUrlInvalid")) },
363
+ { type: "password", name: "apiKey", message: t("add.apiKey") },
364
+ ], { onCancel: () => cancelInteractive() });
365
+ if (!again.baseUrl || !again.apiKey)
366
+ break;
367
+ baseUrl = normalizeUrl(again.baseUrl);
368
+ apiKey = again.apiKey;
369
+ protocols = await probe();
370
+ }
347
371
  if (protocols.length === 0) {
348
372
  fail(t("quick.noProtocol"));
349
373
  }
@@ -358,14 +382,15 @@ export async function cmdQuickAdd(opts) {
358
382
  const sameAccount = byId ? undefined : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey });
359
383
  const existing = byId ?? sameAccount;
360
384
  if (byId === undefined && explicitId && sameAccount) {
361
- console.log(pc.yellow(t("add.alreadyConfigured", { id: pc.bold(sameAccount.id) })));
385
+ out(pc.yellow(t("add.alreadyConfigured", { id: pc.bold(sameAccount.id) })));
362
386
  }
363
387
  const id = byId?.id ?? sameAccount?.id ?? explicitId ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
364
388
  const name = opts.name ?? existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
365
389
  const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
366
- process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })} [${protocol}]\n`));
367
- const discovered = await discoverProviderModels({ baseUrl, apiKey, protocol });
368
- console.log(t("add.providerLists", { count: discovered.length }));
390
+ const discovered = await withProgress(`${t("add.discovering", { url: baseUrl })} [${protocol}]`, (progress) => discoverProviderModels({ baseUrl, apiKey, protocol }, {
391
+ onPage: ({ page, count }) => progress.update(`${t("progress.discoverPage", { url: baseUrl, page, count })} [${protocol}]`),
392
+ }));
393
+ out(t("add.providerLists", { count: discovered.length }));
369
394
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
370
395
  let modelIds = [...new Set([...discovered, ...retained])];
371
396
  const pinned = [...retained, ...(opts.defaultModel ? [opts.defaultModel] : []), ...(opts.smallModel ? [opts.smallModel] : [])];
@@ -373,7 +398,7 @@ export async function cmdQuickAdd(opts) {
373
398
  reportDropped(outcome.dropped);
374
399
  modelIds = outcome.kept;
375
400
  if (modelIds.length === 0) {
376
- console.log(pc.yellow(t("quick.noModelsAfterFilter", { id })));
401
+ out(pc.yellow(t("quick.noModelsAfterFilter", { id })));
377
402
  continue;
378
403
  }
379
404
  const provider = await createProvider({
@@ -397,18 +422,26 @@ export async function cmdQuickAdd(opts) {
397
422
  gatewayLoader,
398
423
  });
399
424
  createdIds.push(provider.id);
400
- console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
425
+ note(`\n${t("add.next", { id: provider.id })}`);
401
426
  }
402
427
  saveStore(store);
428
+ if (isJson()) {
429
+ emitJson({ providers: createdIds });
430
+ return;
431
+ }
403
432
  if (createdIds.length > 0) {
404
- console.log(pc.green(t("quick.summary", { count: createdIds.length, ids: createdIds.join(", ") })));
433
+ out(pc.green(t("quick.summary", { count: createdIds.length, ids: createdIds.join(", ") })));
405
434
  }
406
435
  }
407
436
  export function cmdList() {
408
437
  const store = loadStore();
438
+ if (isJson()) {
439
+ emitJson(listReport(store));
440
+ return;
441
+ }
409
442
  const ids = Object.keys(store.providers);
410
443
  if (ids.length === 0) {
411
- console.log(t("list.none", { file: configFile }));
444
+ out(t("list.none", { file: configFile }));
412
445
  return;
413
446
  }
414
447
  const rows = ids.map((id) => {
@@ -422,23 +455,37 @@ export function cmdList() {
422
455
  String(p.models.length),
423
456
  ];
424
457
  });
425
- console.log(table(rows, [" ", "ID", t("table.protocol"), "BASE URL", t("table.defaultModel"), t("table.models")]));
458
+ out(table(rows, [" ", "ID", t("table.protocol"), "BASE URL", t("table.defaultModel"), t("table.models")], {
459
+ truncate: [3, 1, 4],
460
+ align: ["left", "left", "left", "left", "left", "right"],
461
+ }));
426
462
  }
427
463
  export async function cmdPrune(id, opts) {
428
464
  const store = loadStore();
429
465
  const provider = getProvider(store, id);
430
- console.log(`${t("remove.pruning", { id: pc.bold(id) })}\n`);
431
- reportResults(await runTargets("prune", provider, opts.apps));
466
+ out(`${t("remove.pruning", { id: pc.bold(id) })}\n`);
467
+ const results = await runTargets("prune", provider, opts.apps);
468
+ if (isJson()) {
469
+ emitJson({
470
+ provider: id,
471
+ op: "prune",
472
+ apps: results.filter((r) => !r.skipped).map((r) => r.app),
473
+ files: results.flatMap((r) => (r.skipped ? [] : r.changed)),
474
+ skipped: results.filter((r) => r.skipped).map((r) => ({ app: r.app, reason: r.skipped })),
475
+ });
476
+ return;
477
+ }
478
+ reportResults(results);
432
479
  }
433
480
  function reportResults(results) {
434
481
  for (const r of results) {
435
482
  if (r.skipped) {
436
- console.log(`${pc.yellow(t("common.skip"))} ${r.app.padEnd(9)} ${pc.dim(r.skipped)}`);
483
+ out(`${pc.yellow(t("common.skip"))} ${r.app.padEnd(9)} ${pc.dim(r.skipped)}`);
437
484
  continue;
438
485
  }
439
- console.log(`${pc.green("ok ")} ${r.app.padEnd(9)} ${r.changed.join(", ")}`);
486
+ out(`${pc.green("ok ")} ${r.app.padEnd(9)} ${r.changed.join(", ")}`);
440
487
  for (const note of r.notes)
441
- console.log(` ${" ".repeat(9)} ${pc.dim(note)}`);
488
+ out(` ${" ".repeat(9)} ${pc.dim(note)}`);
442
489
  }
443
490
  }
444
491
  async function runTargets(op, provider, appsFilter, redactErrors = false) {
@@ -464,7 +511,7 @@ async function runTargets(op, provider, appsFilter, redactErrors = false) {
464
511
  }
465
512
  catch (err) {
466
513
  const safe = redactErrors && !(err instanceof SafeConfigError);
467
- results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${safe ? "configuration could not be previewed safely" : err.message}`) });
514
+ results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${safe ? t("preview.failedSafely") : err.message}`) });
468
515
  process.exitCode = 1;
469
516
  }
470
517
  }
@@ -501,10 +548,10 @@ function previewDocument(file, text) {
501
548
  function printFileDiff(file, next, apiKey) {
502
549
  const before = readTextIfExists(file) ?? "";
503
550
  if (before === next) {
504
- console.log(`${pc.dim("unchanged")} ${file}`);
551
+ out(`${pc.dim(t("preview.unchanged"))} ${file}`);
505
552
  return;
506
553
  }
507
- console.log(pc.bold(`--- ${file} (redacted configuration)`));
554
+ out(pc.bold(t("preview.header", { file })));
508
555
  let oldText;
509
556
  let newText;
510
557
  try {
@@ -554,7 +601,7 @@ function printFileDiff(file, next, apiKey) {
554
601
  newText = JSON.stringify(newDocument, redact, 2) ?? "";
555
602
  }
556
603
  catch {
557
- console.log(pc.dim("[content withheld: unsupported or malformed configuration]"));
604
+ out(pc.dim(t("preview.contentWithheld")));
558
605
  return;
559
606
  }
560
607
  const oldLines = oldText.split("\n");
@@ -570,22 +617,28 @@ function printFileDiff(file, next, apiKey) {
570
617
  if (count > 0)
571
618
  newSeen.set(line, count - 1);
572
619
  else
573
- console.log(pc.red(`- ${line}`));
620
+ out(pc.red(`- ${line}`));
574
621
  }
575
622
  for (const line of newLines) {
576
623
  const count = oldSeen.get(line) ?? 0;
577
624
  if (count > 0)
578
625
  oldSeen.set(line, count - 1);
579
626
  else
580
- console.log(pc.green(`+ ${line}`));
627
+ out(pc.green(`+ ${line}`));
581
628
  }
582
629
  if (oldText === newText)
583
- console.log(pc.dim("[only redacted values or formatting changed]"));
630
+ out(pc.dim(t("preview.onlyRedacted")));
584
631
  }
585
632
  async function runWithOptionalDryRun(op, provider, apps, dryRun) {
633
+ const summarize = (results, files) => ({
634
+ apps: results.map((r) => r.app),
635
+ files,
636
+ skipped: results.filter((r) => r.skipped).map((r) => ({ app: r.app, reason: r.skipped })),
637
+ });
586
638
  if (!dryRun) {
587
- reportResults(await runTargets(op, provider, apps));
588
- return;
639
+ const results = await runTargets(op, provider, apps);
640
+ reportResults(results);
641
+ return summarize(results, results.flatMap((r) => (r.skipped ? [] : r.changed)));
589
642
  }
590
643
  setDryRun(true);
591
644
  try {
@@ -593,11 +646,13 @@ async function runWithOptionalDryRun(op, provider, apps, dryRun) {
593
646
  const writes = drainPendingWrites();
594
647
  for (const r of results) {
595
648
  if (r.skipped)
596
- console.log(`${pc.yellow("skip")} ${r.app.padEnd(9)} ${pc.dim(r.skipped)}`);
649
+ out(`${pc.yellow(t("dryRun.skip"))} ${r.app.padEnd(9)} ${pc.dim(r.skipped)}`);
597
650
  }
598
- console.log(pc.bold(`\ndry run — ${writes.length} file(s) would be written:\n`));
651
+ out(pc.bold(`\n${t("dryRun.wouldWrite", { count: writes.length })}\n`));
599
652
  for (const w of writes)
600
653
  printFileDiff(w.file, w.content, provider.apiKey);
654
+ // Only paths: the staged content is redacted for humans but still configuration, not a machine interface.
655
+ return summarize(results, writes.map((w) => w.file));
601
656
  }
602
657
  finally {
603
658
  setDryRun(false);
@@ -617,8 +672,11 @@ export async function cmdUse(id, opts) {
617
672
  store.active = id;
618
673
  saveStore(store);
619
674
  }
620
- console.log(`${t("use.switching", { id: pc.bold(id), protocol: provider.protocol, model: provider.defaultModel })}\n`);
621
- await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
675
+ out(`${t("use.switching", { id: pc.bold(id), protocol: provider.protocol, model: provider.defaultModel })}\n`);
676
+ const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
677
+ if (isJson()) {
678
+ emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome });
679
+ }
622
680
  }
623
681
  export async function cmdSync(opts) {
624
682
  resolveTargets(opts.apps);
@@ -627,59 +685,85 @@ export async function cmdSync(opts) {
627
685
  if (!id)
628
686
  fail(t("sync.noActive"));
629
687
  const provider = getProvider(store, id);
630
- console.log(`${t("sync.syncing", { id: pc.bold(id), model: provider.defaultModel })}\n`);
631
- await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
688
+ out(`${t("sync.syncing", { id: pc.bold(id), model: provider.defaultModel })}\n`);
689
+ const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
690
+ if (isJson()) {
691
+ emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome });
692
+ }
632
693
  }
633
694
  export function cmdStatus() {
634
695
  const store = loadStore();
635
- console.log(t("status.config", { file: configFile }));
636
- console.log(`${t("status.active", { id: store.active ? pc.bold(store.active) : pc.dim(t("status.none")) })}\n`);
637
- const rows = targets.map((target) => [
638
- target.id,
639
- target.detect() ? pc.green(t("common.yes")) : pc.dim(t("common.no")),
640
- target.protocols.join("+"),
641
- target.current() ?? pc.dim("-"),
642
- pc.dim(target.configPaths[0] ?? ""),
696
+ const apps = targets.map((target) => ({
697
+ id: target.id,
698
+ detected: target.detect(),
699
+ protocols: [...target.protocols],
700
+ current: target.current() ?? null,
701
+ configPath: target.configPaths[0] ?? "",
702
+ }));
703
+ if (isJson()) {
704
+ emitJson(statusReport(configFile, store, apps));
705
+ return;
706
+ }
707
+ out(t("status.config", { file: configFile }));
708
+ out(`${t("status.active", { id: store.active ? pc.bold(store.active) : pc.dim(t("status.none")) })}\n`);
709
+ const rows = apps.map((app) => [
710
+ app.id,
711
+ app.detected ? pc.green(t("common.yes")) : pc.dim(t("common.no")),
712
+ app.protocols.join("+"),
713
+ app.current ?? pc.dim("-"),
714
+ pc.dim(app.configPath),
643
715
  ]);
644
- console.log(table(rows, ["APP", t("table.found"), t("table.protocols"), t("table.current"), t("table.config")]));
716
+ out(table(rows, ["APP", t("table.found"), t("table.protocols"), t("table.current"), t("table.config")], {
717
+ truncate: [4, 3],
718
+ }));
719
+ }
720
+ /** `--limit` is user input: a non-numeric or non-positive value must fail, not silently list everything. */
721
+ function parseLimit(value) {
722
+ const limit = value === undefined ? 30 : Number(value);
723
+ if (!Number.isInteger(limit) || limit < 1)
724
+ fail(t("error.limit"));
725
+ return limit;
645
726
  }
646
727
  export async function cmdModels(query, opts) {
647
728
  if (opts.metadata && !opts.provider)
648
- fail("--metadata requires --provider");
729
+ fail(t("models.metadataRequiresProvider"));
649
730
  if (opts.provider) {
650
731
  const store = loadStore();
651
732
  const provider = getProvider(store, opts.provider);
733
+ if (isJson()) {
734
+ emitJson(providerModelsReport(provider, opts.metadata === true));
735
+ return;
736
+ }
737
+ // Kept as a bare document without --json: existing scripts parse it directly.
652
738
  if (opts.metadata) {
653
- console.log(JSON.stringify({
654
- provider: provider.id,
655
- gatewayMetadata: provider.gatewayMetadata ?? "auto",
656
- metadataMode: getMetadataMode(provider),
657
- gatewayModelAliases: provider.gatewayModelAliases ?? {},
658
- models: provider.models.map(({ id, metadata }) => ({ id, metadata })),
659
- }, null, 2));
739
+ out(JSON.stringify(providerMetadataReport(provider), null, 2));
660
740
  return;
661
741
  }
662
- console.log(`${pc.bold(provider.id)} (${provider.protocol}) · ${provider.baseUrl}`);
663
- console.log(table(modelRows(provider.models), MODEL_HEADER));
664
- printUncatalogedHint(provider.models, await loadCatalog(), provider.modelsDevId);
742
+ out(`${pc.bold(provider.id)} (${provider.protocol}) · ${provider.baseUrl}`);
743
+ out(table(modelRows(provider.models), MODEL_HEADER, MODEL_TABLE_OPTIONS));
744
+ printUncatalogedHint(provider.models, await withProgress(t("progress.catalog"), () => loadCatalog()), provider.modelsDevId);
665
745
  return;
666
746
  }
667
- const catalog = await loadCatalog({ refresh: opts.refresh });
747
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog({ refresh: opts.refresh }));
668
748
  if (!catalog)
669
- fail("models.dev catalog unavailable (offline and no cache)");
749
+ fail(t("models.catalogUnavailable"));
670
750
  if (!query)
671
- fail("usage: agentsw models <query> | agentsw models --provider <id>");
672
- const limit = opts.limit ? Number(opts.limit) : 30;
751
+ fail(t("models.usage"));
752
+ const limit = parseLimit(opts.limit);
673
753
  const hits = searchCatalog(catalog, query, limit);
754
+ if (isJson()) {
755
+ emitJson(modelsQueryReport(query, hits));
756
+ return;
757
+ }
674
758
  if (hits.length === 0) {
675
- console.log(`no models.dev entries match "${query}"`);
759
+ out(t("models.noMatch", { query }));
676
760
  return;
677
761
  }
678
762
  const rows = hits.map((h) => {
679
763
  const row = modelRows([h.spec])[0];
680
764
  return [h.provider, ...row];
681
765
  });
682
- console.log(table(rows, ["PROVIDER", ...MODEL_HEADER]));
766
+ out(table(rows, ["PROVIDER", ...MODEL_HEADER], MODEL_QUERY_OPTIONS));
683
767
  }
684
768
  export async function cmdRefreshMeta(opts = {}) {
685
769
  const store = loadStore();
@@ -687,22 +771,26 @@ export async function cmdRefreshMeta(opts = {}) {
687
771
  resolveMetadataOptions(opts);
688
772
  for (const provider of providers)
689
773
  Object.assign(provider, resolveMetadataOptions(opts, provider));
690
- const catalog = await loadCatalog({ refresh: true });
774
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog({ refresh: true }));
691
775
  const gatewayLoader = sharedGatewayLoader(true);
692
776
  let updated = 0;
693
777
  for (const provider of providers) {
694
778
  const before = JSON.stringify(provider.models);
695
- provider.models = await enrichProviderModels(catalog, provider.models.map((m) => m.id), {
779
+ provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, provider.models.map((m) => m.id), {
696
780
  ...provider,
697
781
  modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl),
698
- }, { gatewayLoader });
782
+ }, { gatewayLoader }));
699
783
  if (JSON.stringify(provider.models) !== before)
700
784
  updated++;
701
785
  }
702
786
  saveStore(store);
703
- console.log(pc.green(t("refresh.checked", { changed: updated })));
787
+ if (isJson()) {
788
+ emitJson({ checked: providers.length, updated, providers: providers.map((p) => p.id) });
789
+ return;
790
+ }
791
+ out(pc.green(t("refresh.checked", { changed: updated })));
704
792
  if (updated > 0)
705
- console.log(pc.dim(t("refresh.next")));
793
+ note(t("refresh.next"));
706
794
  for (const provider of providers)
707
795
  printUncatalogedHint(provider.models, catalog, provider.modelsDevId);
708
796
  }
@@ -717,133 +805,182 @@ export async function cmdDiscover(id, opts) {
717
805
  provider.modelFilter = undefined;
718
806
  else if (flagFilter)
719
807
  provider.modelFilter = flagFilter;
720
- process.stderr.write(pc.dim(`discovering models from ${provider.baseUrl} ...\n`));
721
- const listed = await discoverProviderModels(provider);
808
+ const listed = await withProgress(t("add.discovering", { url: provider.baseUrl }), (progress) => discoverProviderModels(provider, {
809
+ onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: provider.baseUrl, page, count })),
810
+ }));
722
811
  const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
723
812
  const outcome = applyModelFilter(listed, provider.modelFilter, pinned);
724
813
  reportDropped(outcome.dropped);
725
814
  const ids = outcome.kept;
815
+ // Pinned ids survive a listing that dropped them, so they are not "removed upstream".
816
+ const retainedIds = [...new Set([...ids, ...pinned])];
817
+ const retained = new Set(retainedIds);
726
818
  const known = provider.models.map((m) => m.id);
727
819
  const added = ids.filter((m) => !known.includes(m));
728
- const gone = known.filter((m) => !ids.includes(m));
729
- const catalog = await loadCatalog();
730
- const retainedIds = [...new Set([...ids, ...pinned])];
731
- provider.models = await enrichProviderModels(catalog, retainedIds, { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) });
820
+ const gone = known.filter((m) => !retained.has(m));
821
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
822
+ provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, retainedIds, { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) }));
732
823
  if (!ids.includes(provider.defaultModel)) {
733
- console.log(pc.yellow(`default model ${provider.defaultModel} no longer listed; keeping it anyway`));
824
+ out(pc.yellow(t("discover.defaultMissing", { model: provider.defaultModel })));
734
825
  }
735
826
  saveStore(store);
736
- console.log(`${pc.bold(id)}: ${ids.length} models (${pc.green(`+${added.length}`)} / ${pc.red(`-${gone.length}`)})` +
827
+ if (isJson()) {
828
+ emitJson({ provider: id, models: ids.length, added, removed: gone });
829
+ return;
830
+ }
831
+ out(`${pc.bold(id)}: ${ids.length} models (${pc.green(`+${added.length}`)} / ${pc.red(`-${gone.length}`)})` +
737
832
  (added.length ? `\n new: ${added.join(", ")}` : "") +
738
833
  (gone.length ? `\n removed upstream: ${gone.join(", ")}` : ""));
739
- console.log(table(modelRows(provider.models), MODEL_HEADER));
834
+ out(table(modelRows(provider.models), MODEL_HEADER, MODEL_TABLE_OPTIONS));
740
835
  printUncatalogedHint(provider.models, catalog, provider.modelsDevId);
741
836
  if (opts.sync) {
742
- console.log("");
837
+ out("");
743
838
  await cmdSync({ provider: id, apps: opts.apps });
744
839
  }
745
840
  else {
746
- console.log(pc.dim("\nrun `agentsw sync` to push into app configs"));
841
+ note(`\n${t("discover.next")}`);
747
842
  }
748
843
  }
749
- async function collectAppRows() {
844
+ async function collectAppRows(onProgress) {
845
+ const total = appPackages.length;
846
+ let done = 0;
750
847
  return Promise.all(appPackages.map(async (app) => {
751
- const [installedResult, latestResult] = await Promise.allSettled([
752
- Promise.resolve().then(() => installedVersion(app)),
753
- latestVersion(app),
754
- ]);
755
- const installed = installedResult.status === "fulfilled" ? installedResult.value : "?";
756
- const latest = latestResult.status === "fulfilled" ? latestResult.value : undefined;
757
- const knownInstalled = normalizeAppVersion(installed);
758
- const knownLatest = normalizeAppVersion(latest);
759
- const checkFailed = installedResult.status === "rejected"
760
- ? "installed version check failed"
761
- : installed && !knownInstalled
762
- ? "installed version unknown"
763
- : installed && !knownLatest
764
- ? "latest version unavailable"
765
- : undefined;
766
- return {
767
- id: app.id,
768
- name: app.name,
769
- installed,
770
- latest,
771
- upgradable: !!knownInstalled && !!knownLatest && isNewer(knownInstalled, knownLatest),
772
- installable: !installed && !!appCommand(app, "install"),
773
- checkFailed,
774
- };
848
+ try {
849
+ const [installedResult, latestResult] = await Promise.allSettled([
850
+ Promise.resolve().then(() => installedVersion(app)),
851
+ latestVersion(app),
852
+ ]);
853
+ const installed = installedResult.status === "fulfilled" ? installedResult.value : "?";
854
+ const latest = latestResult.status === "fulfilled" ? latestResult.value : undefined;
855
+ const knownInstalled = normalizeAppVersion(installed);
856
+ const knownLatest = normalizeAppVersion(latest);
857
+ const checkFailed = installedResult.status === "rejected"
858
+ ? t("apps.checkFailedProbe")
859
+ : installed && !knownInstalled
860
+ ? t("apps.checkFailedUnknown")
861
+ : installed && !knownLatest
862
+ ? t("apps.checkFailedLatest")
863
+ : undefined;
864
+ return {
865
+ id: app.id,
866
+ name: app.name,
867
+ installed,
868
+ latest,
869
+ upgradable: !!knownInstalled && !!knownLatest && isNewer(knownInstalled, knownLatest),
870
+ installable: !installed && !!appCommand(app, "install"),
871
+ checkFailed,
872
+ };
873
+ }
874
+ finally {
875
+ onProgress?.(++done, total);
876
+ }
775
877
  }));
776
878
  }
879
+ function appStatus(row) {
880
+ if (row.upgradable)
881
+ return "upgradable";
882
+ if (!row.installed)
883
+ return row.installable ? "installable" : "not-installed";
884
+ return row.checkFailed || !normalizeAppVersion(row.latest) ? "unknown" : "up-to-date";
885
+ }
886
+ function appStatusLabel(status) {
887
+ switch (status) {
888
+ case "upgradable": return pc.yellow(t("apps.upgradeAvailable"));
889
+ case "installable": return pc.dim(t("apps.installable"));
890
+ case "not-installed": return pc.dim("-");
891
+ case "unknown": return pc.dim(t("apps.unknown"));
892
+ case "up-to-date": return pc.green(t("apps.upToDate"));
893
+ }
894
+ }
777
895
  export async function cmdApps() {
778
- process.stderr.write(pc.dim("checking installed and latest versions ...\n"));
779
- const rows = await collectAppRows();
780
- console.log(table(rows.map((r) => [
896
+ const rows = await withProgress(t("apps.checking"), (progress) => collectAppRows((done, total) => progress.update(t("progress.apps", { done, total }))));
897
+ if (isJson()) {
898
+ const report = rows.map((row) => ({
899
+ id: row.id,
900
+ name: row.name,
901
+ installed: row.installed,
902
+ latest: row.latest,
903
+ upgradable: row.upgradable,
904
+ installable: row.installable,
905
+ status: appStatus(row),
906
+ }));
907
+ emitJson(appsReport(report));
908
+ return;
909
+ }
910
+ out(table(rows.map((r) => [
781
911
  r.id,
782
- r.installed ?? pc.dim("not installed"),
912
+ r.installed ?? pc.dim(t("apps.notInstalled")),
783
913
  r.latest ?? pc.dim("?"),
784
- r.upgradable
785
- ? pc.yellow("upgrade available")
786
- : !r.installed
787
- ? r.installable
788
- ? pc.dim("installable")
789
- : pc.dim("-")
790
- : r.checkFailed || !normalizeAppVersion(r.latest)
791
- ? pc.dim("unknown")
792
- : pc.green("up to date"),
914
+ appStatusLabel(appStatus(r)),
793
915
  ]), ["APP", "INSTALLED", "LATEST", "STATUS"]));
794
916
  const upgradable = rows.filter((r) => r.upgradable).map((r) => r.id);
795
917
  if (upgradable.length)
796
- console.log(pc.dim(`\nupgrade with: agentsw upgrade ${upgradable.join(" ")}`));
918
+ note(`\n${t("apps.upgradeWith", { ids: upgradable.join(" ") })}`);
797
919
  }
798
920
  export async function cmdInstall(id) {
799
921
  const app = appPackages.find((a) => a.id === id);
800
922
  if (!app)
801
- fail(`unknown app "${id}" (supported: ${appPackages.map((a) => a.id).join(", ")})`);
923
+ fail(t("install.unknownApp", { value: id, apps: appPackages.map((a) => a.id).join(", ") }));
802
924
  const installCmd = appCommand(app, "install");
803
925
  if (!installCmd)
804
- fail(`${app.name} is not installable on ${process.platform} (or is managed by its desktop app)`);
926
+ fail(t("install.notInstallable", { name: app.name, platform: process.platform }));
805
927
  const installed = installedVersion(app);
806
928
  if (installed) {
807
- console.log(`${app.name} already installed (${installed}); use \`agentsw upgrade ${id}\``);
929
+ if (isJson()) {
930
+ emitJson({ app: id, status: "already-installed", version: installed });
931
+ return;
932
+ }
933
+ out(t("install.already", { name: app.name, version: installed, id }));
808
934
  return;
809
935
  }
810
- console.log(`installing ${app.name}: ${pc.dim(installCmd)}`);
936
+ out(t("install.installing", { name: app.name, command: pc.dim(installCmd) }));
811
937
  runShell(installCmd);
812
938
  const detected = installedVersion(app);
813
939
  if (!detected)
814
- throw new Error(`${app.name}: installer completed but the app is still not detected; check the installation and PATH`);
940
+ throw new Error(t("install.notDetected", { name: app.name }));
815
941
  const version = normalizeAppVersion(detected);
816
942
  if (!version) {
817
- console.log(pc.yellow(`${app.name}: installer completed; app detected but version unknown`));
943
+ if (isJson())
944
+ error(t("install.versionUnknown", { name: app.name }));
945
+ out(pc.yellow(t("install.versionUnknown", { name: app.name })));
818
946
  process.exitCode = 1;
819
947
  return;
820
948
  }
821
- console.log(pc.green(`${app.name} installed: ${version}`));
949
+ if (isJson()) {
950
+ emitJson({ app: id, status: "installed", version });
951
+ return;
952
+ }
953
+ out(pc.green(t("install.installed", { name: app.name, version })));
822
954
  }
823
955
  export async function cmdUpgrade(ids) {
824
956
  let selected = appPackages.filter((a) => ids.length === 0 || ids.includes(a.id));
825
957
  const unknown = ids.filter((id) => !appPackages.some((a) => a.id === id));
826
958
  if (unknown.length)
827
- fail(`unknown app(s): ${unknown.join(", ")}`);
959
+ fail(t("upgrade.unknownApps", { ids: unknown.join(", ") }));
828
960
  const expectedVersions = new Map();
961
+ const upgraded = [];
962
+ const skipped = [];
829
963
  if (ids.length === 0) {
830
964
  // no args: upgrade everything that is installed and outdated
831
- process.stderr.write(pc.dim("checking versions ...\n"));
832
- const rows = await collectAppRows();
965
+ const rows = await withProgress(t("upgrade.checking"), (progress) => collectAppRows((done, total) => progress.update(t("progress.apps", { done, total }))));
833
966
  const managed = rows.filter((row) => !!appCommand(appPackages.find((app) => app.id === row.id), "upgrade"));
834
967
  const failed = managed.filter((row) => row.checkFailed);
835
968
  for (const row of failed)
836
- console.log(pc.yellow(`${row.id}: ${row.checkFailed}; update status unknown`));
969
+ out(pc.yellow(t("upgrade.statusUnknown", { id: row.id, reason: row.checkFailed })));
837
970
  if (failed.length)
838
971
  process.exitCode = 1;
839
972
  const upgradable = managed.filter((row) => row.upgradable);
840
973
  if (upgradable.length === 0) {
974
+ if (isJson()) {
975
+ emitJson({ upgraded, skipped: failed.map((row) => ({ id: row.id, reason: row.checkFailed })) });
976
+ return;
977
+ }
841
978
  if (failed.length)
842
- console.log("could not determine update status for every installed app");
979
+ out(t("upgrade.cannotDetermine"));
843
980
  else if (managed.some((row) => row.installed))
844
- console.log("all checked apps are up to date");
981
+ out(t("upgrade.allCurrent"));
845
982
  else
846
- console.log("no installed CLI-managed apps to upgrade");
983
+ out(t("upgrade.none"));
847
984
  return;
848
985
  }
849
986
  for (const row of upgradable)
@@ -853,57 +990,68 @@ export async function cmdUpgrade(ids) {
853
990
  for (const app of selected) {
854
991
  const cmd = appCommand(app, "upgrade");
855
992
  if (!cmd) {
856
- console.log(`${pc.yellow("skip")} ${app.id}: not CLI-upgradable`);
993
+ out(`${pc.yellow(t("dryRun.skip"))} ${t("upgrade.notCli", { id: app.id })}`);
994
+ skipped.push({ id: app.id, reason: "not-cli-managed" });
857
995
  process.exitCode = 1;
858
996
  continue;
859
997
  }
860
998
  try {
861
999
  if (!installedVersion(app)) {
862
- console.log(`${pc.yellow("skip")} ${app.id}: not installed (use \`agentsw install ${app.id}\`)`);
1000
+ out(`${pc.yellow(t("dryRun.skip"))} ${t("upgrade.notInstalled", { id: app.id })}`);
1001
+ skipped.push({ id: app.id, reason: "not-installed" });
863
1002
  process.exitCode = 1;
864
1003
  continue;
865
1004
  }
866
- console.log(`upgrading ${app.name}: ${pc.dim(cmd)}`);
1005
+ out(t("upgrade.upgrading", { name: app.name, command: pc.dim(cmd) }));
867
1006
  runShell(cmd);
868
1007
  const version = normalizeAppVersion(installedVersion(app));
869
1008
  if (!version)
870
- throw new Error("upgrade command completed but installed version is unknown or app is not detected");
1009
+ throw new Error(t("upgrade.unknownVersion"));
871
1010
  const expected = expectedVersions.get(app.id);
872
1011
  if (expected && isNewer(version, expected))
873
- throw new Error(`upgrade command completed but ${version} is older than available ${expected}`);
874
- console.log(pc.green(`${app.id} -> ${version}`));
1012
+ throw new Error(t("upgrade.olderThanAvailable", { version, expected }));
1013
+ out(pc.green(t("upgrade.done", { id: app.id, version })));
1014
+ upgraded.push({ id: app.id, version });
875
1015
  }
876
1016
  catch (err) {
877
- console.log(pc.red(`${app.id} upgrade failed: ${err.message}`));
1017
+ out(pc.red(t("upgrade.failed", { id: app.id, message: err.message })));
1018
+ skipped.push({ id: app.id, reason: err.message });
878
1019
  process.exitCode = 1;
879
1020
  }
880
1021
  }
1022
+ if (isJson())
1023
+ emitJson({ upgraded, skipped });
881
1024
  }
882
1025
  /** Collect custom providers, dedupe by endpoint and credentials, and import what is new. */
883
1026
  export async function cmdImport(opts) {
1027
+ const interactive = process.stdin.isTTY === true && !opts.all && !isJson();
884
1028
  const metadataOptions = resolveMetadataOptions(opts);
885
1029
  const rows = scanCandidates();
886
1030
  const fresh = rows.filter((r) => !r.configured);
887
1031
  for (const r of rows) {
888
1032
  if (r.configured) {
889
- console.log(`${pc.yellow(t("import.skip"))} ${pc.bold(r.id)} · ${r.protocol} · ${r.baseUrl} — ${t("import.already", { id: pc.bold(r.configured) })}`);
1033
+ out(`${pc.yellow(t("import.skip"))} ${pc.bold(r.id)} · ${r.protocol} · ${r.baseUrl} — ${t("import.already", { id: pc.bold(r.configured) })}`);
890
1034
  }
891
1035
  }
892
1036
  if (fresh.length === 0) {
893
- console.log(rows.length ? t("import.noneNew") : t("import.noneFound"));
1037
+ if (isJson()) {
1038
+ emitJson({ providers: [], candidates: rows.length });
1039
+ return;
1040
+ }
1041
+ out(rows.length ? t("import.noneNew") : t("import.noneFound"));
894
1042
  return;
895
1043
  }
896
- console.log("");
897
- console.log(table(fresh.map((r) => [
1044
+ out("");
1045
+ out(table(fresh.map((r) => [
898
1046
  r.id,
899
1047
  r.protocol,
900
1048
  r.baseUrl,
901
1049
  String(r.models.length),
902
1050
  r.sources.join(","),
903
1051
  r.apiKey ? pc.green(t("import.keyYes")) : r.keyEnv ? pc.yellow(t("import.keyEnv", { name: r.keyEnv })) : pc.red(t("import.keyMissing")),
904
- ]), ["ID", t("table.protocol"), "BASE URL", t("table.models"), t("table.from"), t("table.key")]));
1052
+ ]), ["ID", t("table.protocol"), "BASE URL", t("table.models"), t("table.from"), t("table.key")], { truncate: [2, 4] }));
905
1053
  let chosen;
906
- if (process.stdin.isTTY && !opts.all) {
1054
+ if (interactive) {
907
1055
  const { pick } = await prompts({
908
1056
  type: "multiselect",
909
1057
  name: "pick",
@@ -914,9 +1062,9 @@ export async function cmdImport(opts) {
914
1062
  value: i,
915
1063
  selected: true,
916
1064
  })),
917
- }, { onCancel: () => fail(t("add.cancelled")) });
1065
+ }, { onCancel: () => cancelInteractive() });
918
1066
  if (!Array.isArray(pick) || pick.length === 0) {
919
- console.log(pc.dim(t("import.nothingSelected")));
1067
+ out(pc.dim(t("import.nothingSelected")));
920
1068
  return;
921
1069
  }
922
1070
  chosen = pick.map((i) => fresh[i]).filter((r) => r !== undefined);
@@ -925,14 +1073,14 @@ export async function cmdImport(opts) {
925
1073
  chosen = fresh;
926
1074
  }
927
1075
  const store = loadStore();
928
- const catalog = await loadCatalog();
1076
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
929
1077
  const gatewayLoader = sharedGatewayLoader();
930
1078
  const imported = [];
931
1079
  for (const c of chosen) {
932
1080
  let apiKey = c.apiKey;
933
1081
  if (!apiKey) {
934
1082
  const why = c.keyEnv ? t("import.keyRefMissing", { name: c.keyEnv }) : t("import.keyNotStored");
935
- if (!process.stdin.isTTY || opts.all) {
1083
+ if (!interactive) {
936
1084
  fail(t("import.missingKey", { id: c.id, why }));
937
1085
  }
938
1086
  const a = await prompts({
@@ -940,31 +1088,49 @@ export async function cmdImport(opts) {
940
1088
  name: "key",
941
1089
  message: t("import.keyPrompt", { id: c.id, url: c.baseUrl, why }),
942
1090
  validate: (v) => (v.trim() ? true : t("import.required")),
943
- }, { onCancel: () => fail(t("add.cancelled")) });
1091
+ }, { onCancel: () => cancelInteractive() });
944
1092
  apiKey = a.key;
945
1093
  }
946
1094
  // A prompted credential can identify a provider imported earlier in this same batch.
947
1095
  const existing = findMatchingProvider(Object.values(store.providers), { protocol: c.protocol, baseUrl: c.baseUrl, apiKey });
948
1096
  if (existing) {
949
- console.log(`${pc.yellow(t("import.skip"))} ${pc.bold(c.id)} · ${c.protocol} · ${c.baseUrl} — ${t("import.already", { id: pc.bold(existing.id) })}`);
1097
+ out(`${pc.yellow(t("import.skip"))} ${pc.bold(c.id)} · ${c.protocol} · ${c.baseUrl} — ${t("import.already", { id: pc.bold(existing.id) })}`);
950
1098
  continue;
951
1099
  }
952
1100
  let ids = [...c.models];
953
1101
  if (ids.length === 0) {
954
- process.stderr.write(pc.dim(`${t("import.discovering", { id: c.id })}\n`));
955
- try {
956
- ids = await discoverProviderModels({ baseUrl: c.baseUrl, apiKey: apiKey, protocol: c.protocol });
1102
+ const discover = () => withProgress(t("import.discovering", { id: c.id }), (progress) => discoverProviderModels({ baseUrl: c.baseUrl, apiKey: apiKey, protocol: c.protocol }, {
1103
+ onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: c.baseUrl, page, count })),
1104
+ }));
1105
+ let listed;
1106
+ for (let attempt = 0;; attempt++) {
1107
+ try {
1108
+ listed = await discover();
1109
+ break;
1110
+ }
1111
+ catch (err) {
1112
+ if (!interactive)
1113
+ fail(t("import.discoveryFailed", { id: c.id, error: err.message }));
1114
+ // Skipping one provider keeps the rest of the batch importable.
1115
+ if (attempt >= 2)
1116
+ break;
1117
+ const { retry } = await prompts({ type: "toggle", name: "retry", message: t("import.retryDiscovery", { id: c.id, error: err.message }), initial: true }, { onCancel: () => cancelInteractive() });
1118
+ if (!retry)
1119
+ break;
1120
+ }
957
1121
  }
958
- catch (err) {
959
- fail(t("import.discoveryFailed", { id: c.id, error: err.message }));
1122
+ if (listed === undefined) {
1123
+ out(pc.yellow(t("import.skippedAfterFailure", { id: c.id })));
1124
+ continue;
960
1125
  }
1126
+ ids = listed;
961
1127
  ids = applyModelFilter(ids, undefined, []).kept;
962
1128
  }
963
1129
  if (ids.length === 0)
964
1130
  fail(t("import.noModelsImport", { id: c.id }));
965
1131
  const id = availableProviderId(c.id, store.providers);
966
1132
  const hint = guessProviderHint(catalog, c.baseUrl);
967
- const models = await enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader });
1133
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader }));
968
1134
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
969
1135
  const existed = store.providers[id] !== undefined;
970
1136
  store.providers[id] = {
@@ -982,9 +1148,13 @@ export async function cmdImport(opts) {
982
1148
  if (!store.active)
983
1149
  store.active = id;
984
1150
  imported.push(id);
985
- console.log(`${pc.green(t(existed ? "import.updated" : "import.imported"))} ${pc.bold(id)} · ${c.protocol} · ${c.baseUrl} · ${t("import.modelsCount", { count: ids.length })} [${t("import.from")} ${c.sources.join(", ")}]`);
1151
+ out(`${pc.green(t(existed ? "import.updated" : "import.imported"))} ${pc.bold(id)} · ${c.protocol} · ${c.baseUrl} · ${t("import.modelsCount", { count: ids.length })} [${t("import.from")} ${c.sources.join(", ")}]`);
986
1152
  }
987
1153
  saveStore(store);
1154
+ if (isJson()) {
1155
+ emitJson({ providers: imported });
1156
+ return;
1157
+ }
988
1158
  if (imported.length)
989
- console.log(pc.dim(`\n${t("import.next", { id: imported[0] })}`));
1159
+ note(`\n${t("import.next", { id: imported[0] })}`);
990
1160
  }