agentsw 0.9.1 → 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 { 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,11 +48,25 @@ function modelRows(models) {
70
48
  ]);
71
49
  }
72
50
  const MODEL_HEADER = ["MODEL", "CTX", "IN", "OUT", "REASONING", "$IN/$OUT per M"];
73
- /** Print a dim hint when some models have no catalog metadata. */
74
- function printUncatalogedHint(models) {
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
+ };
57
+ /**
58
+ * Explain why some models stayed bare: only ambiguous ids are worth the user's
59
+ * attention, so those are named separately from ids nothing can resolve.
60
+ */
61
+ function printUncatalogedHint(models, catalog, hint) {
75
62
  const bare = models.filter((m) => m.contextWindow === undefined && !m.cost);
76
- if (bare.length > 0) {
77
- console.log(pc.dim(`${bare.length} model(s) have no catalog metadata (shown as "-")`));
63
+ if (bare.length === 0)
64
+ return;
65
+ out(pc.dim(t("meta.gap", { count: bare.length })));
66
+ const { ambiguous, unknown, noCreatorRow } = classifyUnresolved(catalog, bare.map((m) => m.id), hint);
67
+ for (const [key, ids] of [["meta.ambiguous", ambiguous], ["meta.noCreatorRow", noCreatorRow], ["meta.unknown", unknown]]) {
68
+ if (ids.length)
69
+ out(pc.dim(` ${t(key, { count: ids.length, ids: ids.join(", "), id: ids[0] })}`));
78
70
  }
79
71
  }
80
72
  /** Guess the models.dev provider whose API host matches the configured baseUrl. */
@@ -114,14 +106,14 @@ function parseFilterOpts(opts, previous) {
114
106
  function reportDropped(dropped) {
115
107
  if (!dropped.length)
116
108
  return;
117
- console.log(pc.dim(`filtered out ${dropped.length} model(s):`));
109
+ out(pc.dim(`filtered out ${dropped.length} model(s):`));
118
110
  for (const d of dropped)
119
- console.log(pc.dim(` - ${d.id} (${d.reason})`));
111
+ out(pc.dim(` - ${d.id} (${d.reason})`));
120
112
  }
121
113
  export async function cmdAdd(opts) {
122
114
  const store = loadStore();
123
115
  resolveMetadataOptions(opts);
124
- const interactive = process.stdin.isTTY && !opts.yes;
116
+ const interactive = process.stdin.isTTY === true && !opts.yes && !isJson();
125
117
  let answers = {};
126
118
  if (interactive) {
127
119
  prompts.override({
@@ -169,7 +161,7 @@ export async function cmdAdd(opts) {
169
161
  name: "models",
170
162
  message: t("add.models"),
171
163
  },
172
- ], { onCancel: () => fail(t("add.cancelled")) });
164
+ ], { onCancel: () => cancelInteractive() });
173
165
  }
174
166
  else {
175
167
  const required = opts.discover
@@ -197,8 +189,15 @@ export async function cmdAdd(opts) {
197
189
  fail(t("add.openaiApiInvalid"));
198
190
  const baseUrl = normalizeUrl(answers.baseUrl);
199
191
  const explicitId = answers.id || undefined;
200
- const existing = explicitId ? store.providers[explicitId] : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
201
- const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
192
+ const byId = explicitId ? store.providers[explicitId] : undefined;
193
+ // Endpoint plus credential is the account identity, so an explicit id never
194
+ // licenses a second entry for an account that is already configured.
195
+ const sameAccount = byId ? undefined : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
196
+ const existing = byId ?? sameAccount;
197
+ if (byId === undefined && explicitId && sameAccount) {
198
+ out(pc.yellow(t("add.alreadyConfigured", { id: pc.bold(sameAccount.id) })));
199
+ }
200
+ const id = byId?.id ?? sameAccount?.id ?? explicitId ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
202
201
  const openaiApi = protocol === "openai" ? wire ?? existing?.openaiApi : undefined;
203
202
  const metadataOptions = resolveMetadataOptions(opts, existing);
204
203
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -207,11 +206,30 @@ export async function cmdAdd(opts) {
207
206
  const shouldDiscover = opts.discover || manualIds.length === 0;
208
207
  if (shouldDiscover) {
209
208
  if (!opts.discover) {
210
- 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
+ }
211
230
  }
212
- process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })}\n`));
213
- const discovered = await discoverProviderModels({ baseUrl, apiKey: answers.apiKey, protocol });
214
- console.log(t("add.providerLists", { count: discovered.length }));
231
+ if (discovered.length)
232
+ out(t("add.providerLists", { count: discovered.length }));
215
233
  modelIds = [...new Set([...manualIds, ...discovered])];
216
234
  }
217
235
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
@@ -222,9 +240,9 @@ export async function cmdAdd(opts) {
222
240
  modelIds = outcome.kept;
223
241
  if (modelIds.length === 0)
224
242
  fail(t("add.atLeastOne"));
225
- const catalog = await loadCatalog();
243
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
226
244
  const hint = opts.modelsDev ?? existing?.modelsDevId ?? guessProviderHint(catalog, answers.baseUrl);
227
- 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 }));
228
246
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
229
247
  const defaultModel = opts.defaultModel ?? existing?.defaultModel ?? modelIds[0];
230
248
  if (!modelIds.includes(defaultModel))
@@ -253,19 +271,23 @@ export async function cmdAdd(opts) {
253
271
  store.active = provider.id;
254
272
  saveStore(store);
255
273
  const savedStatus = t(existed ? "add.updated" : "add.added");
256
- console.log(pc.green(t("add.saved", { status: savedStatus, id: pc.bold(provider.id), protocol })));
257
- console.log(t("add.metadata", { matched, total: models.length }) + (hint ? t("add.providerHint", { hint }) : ""));
258
- console.log(table(modelRows(models), MODEL_HEADER));
259
- printUncatalogedHint(models);
260
- console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
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));
281
+ printUncatalogedHint(models, catalog, hint);
282
+ note(`\n${t("add.next", { id: provider.id })}`);
261
283
  }
262
284
  /** Shared logic to create and save a single provider from discovered models. */
263
285
  async function createProvider(opts) {
264
286
  const { store, id, name, protocol, openaiApi, baseUrl, apiKey, modelIds, modelFilter } = opts;
265
287
  const metadataOptions = resolveMetadataOptions(opts, opts.existing);
266
- const catalog = await loadCatalog();
288
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
267
289
  const hint = opts.modelsDev ?? opts.existing?.modelsDevId ?? guessProviderHint(catalog, baseUrl);
268
- 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 }));
269
291
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
270
292
  const defaultModel = opts.defaultModel ?? opts.existing?.defaultModel ?? modelIds[0];
271
293
  if (!modelIds.includes(defaultModel))
@@ -294,10 +316,10 @@ async function createProvider(opts) {
294
316
  if (!store.active)
295
317
  store.active = provider.id;
296
318
  const savedStatus = t(existed ? "add.updated" : "add.added");
297
- console.log(pc.green(t("add.saved", { status: savedStatus, id: pc.bold(provider.id), protocol })));
298
- console.log(t("add.metadata", { matched, total: models.length }) + (hint ? t("add.providerHint", { hint }) : ""));
299
- console.log(table(modelRows(models), MODEL_HEADER));
300
- printUncatalogedHint(models);
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));
322
+ printUncatalogedHint(models, catalog, hint);
301
323
  return provider;
302
324
  }
303
325
  /**
@@ -308,14 +330,14 @@ async function createProvider(opts) {
308
330
  export async function cmdQuickAdd(opts) {
309
331
  const store = loadStore();
310
332
  resolveMetadataOptions(opts);
311
- const interactive = process.stdin.isTTY && !opts.yes;
333
+ const interactive = process.stdin.isTTY === true && !opts.yes && !isJson();
312
334
  let baseUrl;
313
335
  let apiKey;
314
336
  if (interactive) {
315
337
  const answers = await prompts([
316
338
  { type: "text", name: "baseUrl", message: t("add.baseUrl"), validate: (v) => (/^https?:\/\//.test(v) ? true : t("add.baseUrlInvalid")) },
317
339
  { type: "password", name: "apiKey", message: t("add.apiKey") },
318
- ], { onCancel: () => fail(t("add.cancelled")) });
340
+ ], { onCancel: () => cancelInteractive() });
319
341
  baseUrl = normalizeUrl(answers.baseUrl);
320
342
  apiKey = answers.apiKey;
321
343
  }
@@ -327,8 +349,25 @@ export async function cmdQuickAdd(opts) {
327
349
  baseUrl = normalizeUrl(opts.baseUrl);
328
350
  apiKey = opts.apiKey;
329
351
  }
330
- process.stderr.write(pc.dim(t("quick.probing", { url: baseUrl }) + "\n"));
331
- 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
+ }
332
371
  if (protocols.length === 0) {
333
372
  fail(t("quick.noProtocol"));
334
373
  }
@@ -339,13 +378,19 @@ export async function cmdQuickAdd(opts) {
339
378
  const gatewayLoader = sharedGatewayLoader();
340
379
  for (const protocol of protocols) {
341
380
  const explicitId = opts.id === undefined ? undefined : `${opts.id}${multi ? `-${protocol}` : ""}`;
342
- const existing = explicitId === undefined ? findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey }) : store.providers[explicitId];
343
- const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
381
+ const byId = explicitId ? store.providers[explicitId] : undefined;
382
+ const sameAccount = byId ? undefined : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey });
383
+ const existing = byId ?? sameAccount;
384
+ if (byId === undefined && explicitId && sameAccount) {
385
+ out(pc.yellow(t("add.alreadyConfigured", { id: pc.bold(sameAccount.id) })));
386
+ }
387
+ const id = byId?.id ?? sameAccount?.id ?? explicitId ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
344
388
  const name = opts.name ?? existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
345
389
  const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
346
- process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })} [${protocol}]\n`));
347
- const discovered = await discoverProviderModels({ baseUrl, apiKey, protocol });
348
- 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 }));
349
394
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
350
395
  let modelIds = [...new Set([...discovered, ...retained])];
351
396
  const pinned = [...retained, ...(opts.defaultModel ? [opts.defaultModel] : []), ...(opts.smallModel ? [opts.smallModel] : [])];
@@ -353,7 +398,7 @@ export async function cmdQuickAdd(opts) {
353
398
  reportDropped(outcome.dropped);
354
399
  modelIds = outcome.kept;
355
400
  if (modelIds.length === 0) {
356
- console.log(pc.yellow(t("quick.noModelsAfterFilter", { id })));
401
+ out(pc.yellow(t("quick.noModelsAfterFilter", { id })));
357
402
  continue;
358
403
  }
359
404
  const provider = await createProvider({
@@ -377,18 +422,26 @@ export async function cmdQuickAdd(opts) {
377
422
  gatewayLoader,
378
423
  });
379
424
  createdIds.push(provider.id);
380
- console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
425
+ note(`\n${t("add.next", { id: provider.id })}`);
381
426
  }
382
427
  saveStore(store);
428
+ if (isJson()) {
429
+ emitJson({ providers: createdIds });
430
+ return;
431
+ }
383
432
  if (createdIds.length > 0) {
384
- 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(", ") })));
385
434
  }
386
435
  }
387
436
  export function cmdList() {
388
437
  const store = loadStore();
438
+ if (isJson()) {
439
+ emitJson(listReport(store));
440
+ return;
441
+ }
389
442
  const ids = Object.keys(store.providers);
390
443
  if (ids.length === 0) {
391
- console.log(t("list.none", { file: configFile }));
444
+ out(t("list.none", { file: configFile }));
392
445
  return;
393
446
  }
394
447
  const rows = ids.map((id) => {
@@ -402,23 +455,37 @@ export function cmdList() {
402
455
  String(p.models.length),
403
456
  ];
404
457
  });
405
- 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
+ }));
406
462
  }
407
463
  export async function cmdPrune(id, opts) {
408
464
  const store = loadStore();
409
465
  const provider = getProvider(store, id);
410
- console.log(`${t("remove.pruning", { id: pc.bold(id) })}\n`);
411
- 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);
412
479
  }
413
480
  function reportResults(results) {
414
481
  for (const r of results) {
415
482
  if (r.skipped) {
416
- 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)}`);
417
484
  continue;
418
485
  }
419
- console.log(`${pc.green("ok ")} ${r.app.padEnd(9)} ${r.changed.join(", ")}`);
486
+ out(`${pc.green("ok ")} ${r.app.padEnd(9)} ${r.changed.join(", ")}`);
420
487
  for (const note of r.notes)
421
- console.log(` ${" ".repeat(9)} ${pc.dim(note)}`);
488
+ out(` ${" ".repeat(9)} ${pc.dim(note)}`);
422
489
  }
423
490
  }
424
491
  async function runTargets(op, provider, appsFilter, redactErrors = false) {
@@ -444,7 +511,7 @@ async function runTargets(op, provider, appsFilter, redactErrors = false) {
444
511
  }
445
512
  catch (err) {
446
513
  const safe = redactErrors && !(err instanceof SafeConfigError);
447
- 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}`) });
448
515
  process.exitCode = 1;
449
516
  }
450
517
  }
@@ -481,10 +548,10 @@ function previewDocument(file, text) {
481
548
  function printFileDiff(file, next, apiKey) {
482
549
  const before = readTextIfExists(file) ?? "";
483
550
  if (before === next) {
484
- console.log(`${pc.dim("unchanged")} ${file}`);
551
+ out(`${pc.dim(t("preview.unchanged"))} ${file}`);
485
552
  return;
486
553
  }
487
- console.log(pc.bold(`--- ${file} (redacted configuration)`));
554
+ out(pc.bold(t("preview.header", { file })));
488
555
  let oldText;
489
556
  let newText;
490
557
  try {
@@ -534,7 +601,7 @@ function printFileDiff(file, next, apiKey) {
534
601
  newText = JSON.stringify(newDocument, redact, 2) ?? "";
535
602
  }
536
603
  catch {
537
- console.log(pc.dim("[content withheld: unsupported or malformed configuration]"));
604
+ out(pc.dim(t("preview.contentWithheld")));
538
605
  return;
539
606
  }
540
607
  const oldLines = oldText.split("\n");
@@ -550,22 +617,28 @@ function printFileDiff(file, next, apiKey) {
550
617
  if (count > 0)
551
618
  newSeen.set(line, count - 1);
552
619
  else
553
- console.log(pc.red(`- ${line}`));
620
+ out(pc.red(`- ${line}`));
554
621
  }
555
622
  for (const line of newLines) {
556
623
  const count = oldSeen.get(line) ?? 0;
557
624
  if (count > 0)
558
625
  oldSeen.set(line, count - 1);
559
626
  else
560
- console.log(pc.green(`+ ${line}`));
627
+ out(pc.green(`+ ${line}`));
561
628
  }
562
629
  if (oldText === newText)
563
- console.log(pc.dim("[only redacted values or formatting changed]"));
630
+ out(pc.dim(t("preview.onlyRedacted")));
564
631
  }
565
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
+ });
566
638
  if (!dryRun) {
567
- reportResults(await runTargets(op, provider, apps));
568
- return;
639
+ const results = await runTargets(op, provider, apps);
640
+ reportResults(results);
641
+ return summarize(results, results.flatMap((r) => (r.skipped ? [] : r.changed)));
569
642
  }
570
643
  setDryRun(true);
571
644
  try {
@@ -573,11 +646,13 @@ async function runWithOptionalDryRun(op, provider, apps, dryRun) {
573
646
  const writes = drainPendingWrites();
574
647
  for (const r of results) {
575
648
  if (r.skipped)
576
- 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)}`);
577
650
  }
578
- 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`));
579
652
  for (const w of writes)
580
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));
581
656
  }
582
657
  finally {
583
658
  setDryRun(false);
@@ -597,8 +672,11 @@ export async function cmdUse(id, opts) {
597
672
  store.active = id;
598
673
  saveStore(store);
599
674
  }
600
- console.log(`${t("use.switching", { id: pc.bold(id), protocol: provider.protocol, model: provider.defaultModel })}\n`);
601
- 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
+ }
602
680
  }
603
681
  export async function cmdSync(opts) {
604
682
  resolveTargets(opts.apps);
@@ -607,59 +685,85 @@ export async function cmdSync(opts) {
607
685
  if (!id)
608
686
  fail(t("sync.noActive"));
609
687
  const provider = getProvider(store, id);
610
- console.log(`${t("sync.syncing", { id: pc.bold(id), model: provider.defaultModel })}\n`);
611
- 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
+ }
612
693
  }
613
694
  export function cmdStatus() {
614
695
  const store = loadStore();
615
- console.log(t("status.config", { file: configFile }));
616
- console.log(`${t("status.active", { id: store.active ? pc.bold(store.active) : pc.dim(t("status.none")) })}\n`);
617
- const rows = targets.map((target) => [
618
- target.id,
619
- target.detect() ? pc.green(t("common.yes")) : pc.dim(t("common.no")),
620
- target.protocols.join("+"),
621
- target.current() ?? pc.dim("-"),
622
- 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),
623
715
  ]);
624
- 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;
625
726
  }
626
727
  export async function cmdModels(query, opts) {
627
728
  if (opts.metadata && !opts.provider)
628
- fail("--metadata requires --provider");
729
+ fail(t("models.metadataRequiresProvider"));
629
730
  if (opts.provider) {
630
731
  const store = loadStore();
631
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.
632
738
  if (opts.metadata) {
633
- console.log(JSON.stringify({
634
- provider: provider.id,
635
- gatewayMetadata: provider.gatewayMetadata ?? "auto",
636
- metadataMode: getMetadataMode(provider),
637
- gatewayModelAliases: provider.gatewayModelAliases ?? {},
638
- models: provider.models.map(({ id, metadata }) => ({ id, metadata })),
639
- }, null, 2));
739
+ out(JSON.stringify(providerMetadataReport(provider), null, 2));
640
740
  return;
641
741
  }
642
- console.log(`${pc.bold(provider.id)} (${provider.protocol}) · ${provider.baseUrl}`);
643
- console.log(table(modelRows(provider.models), MODEL_HEADER));
644
- printUncatalogedHint(provider.models);
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);
645
745
  return;
646
746
  }
647
- const catalog = await loadCatalog({ refresh: opts.refresh });
747
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog({ refresh: opts.refresh }));
648
748
  if (!catalog)
649
- fail("models.dev catalog unavailable (offline and no cache)");
749
+ fail(t("models.catalogUnavailable"));
650
750
  if (!query)
651
- fail("usage: agentsw models <query> | agentsw models --provider <id>");
652
- const limit = opts.limit ? Number(opts.limit) : 30;
751
+ fail(t("models.usage"));
752
+ const limit = parseLimit(opts.limit);
653
753
  const hits = searchCatalog(catalog, query, limit);
754
+ if (isJson()) {
755
+ emitJson(modelsQueryReport(query, hits));
756
+ return;
757
+ }
654
758
  if (hits.length === 0) {
655
- console.log(`no models.dev entries match "${query}"`);
759
+ out(t("models.noMatch", { query }));
656
760
  return;
657
761
  }
658
762
  const rows = hits.map((h) => {
659
763
  const row = modelRows([h.spec])[0];
660
764
  return [h.provider, ...row];
661
765
  });
662
- console.log(table(rows, ["PROVIDER", ...MODEL_HEADER]));
766
+ out(table(rows, ["PROVIDER", ...MODEL_HEADER], MODEL_QUERY_OPTIONS));
663
767
  }
664
768
  export async function cmdRefreshMeta(opts = {}) {
665
769
  const store = loadStore();
@@ -667,22 +771,28 @@ export async function cmdRefreshMeta(opts = {}) {
667
771
  resolveMetadataOptions(opts);
668
772
  for (const provider of providers)
669
773
  Object.assign(provider, resolveMetadataOptions(opts, provider));
670
- const catalog = await loadCatalog({ refresh: true });
774
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog({ refresh: true }));
671
775
  const gatewayLoader = sharedGatewayLoader(true);
672
776
  let updated = 0;
673
777
  for (const provider of providers) {
674
778
  const before = JSON.stringify(provider.models);
675
- 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), {
676
780
  ...provider,
677
781
  modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl),
678
- }, { gatewayLoader });
782
+ }, { gatewayLoader }));
679
783
  if (JSON.stringify(provider.models) !== before)
680
784
  updated++;
681
785
  }
682
786
  saveStore(store);
683
- console.log(pc.green(`checked model metadata (${updated} provider(s) changed)`));
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 })));
684
792
  if (updated > 0)
685
- console.log(pc.dim("run `agentsw sync` to push updated metadata into app configs"));
793
+ note(t("refresh.next"));
794
+ for (const provider of providers)
795
+ printUncatalogedHint(provider.models, catalog, provider.modelsDevId);
686
796
  }
687
797
  export async function cmdDiscover(id, opts) {
688
798
  resolveTargets(opts.apps);
@@ -695,133 +805,182 @@ export async function cmdDiscover(id, opts) {
695
805
  provider.modelFilter = undefined;
696
806
  else if (flagFilter)
697
807
  provider.modelFilter = flagFilter;
698
- process.stderr.write(pc.dim(`discovering models from ${provider.baseUrl} ...\n`));
699
- 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
+ }));
700
811
  const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
701
812
  const outcome = applyModelFilter(listed, provider.modelFilter, pinned);
702
813
  reportDropped(outcome.dropped);
703
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);
704
818
  const known = provider.models.map((m) => m.id);
705
819
  const added = ids.filter((m) => !known.includes(m));
706
- const gone = known.filter((m) => !ids.includes(m));
707
- const catalog = await loadCatalog();
708
- const retainedIds = [...new Set([...ids, ...pinned])];
709
- 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) }));
710
823
  if (!ids.includes(provider.defaultModel)) {
711
- console.log(pc.yellow(`default model ${provider.defaultModel} no longer listed; keeping it anyway`));
824
+ out(pc.yellow(t("discover.defaultMissing", { model: provider.defaultModel })));
712
825
  }
713
826
  saveStore(store);
714
- 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}`)})` +
715
832
  (added.length ? `\n new: ${added.join(", ")}` : "") +
716
833
  (gone.length ? `\n removed upstream: ${gone.join(", ")}` : ""));
717
- console.log(table(modelRows(provider.models), MODEL_HEADER));
718
- printUncatalogedHint(provider.models);
834
+ out(table(modelRows(provider.models), MODEL_HEADER, MODEL_TABLE_OPTIONS));
835
+ printUncatalogedHint(provider.models, catalog, provider.modelsDevId);
719
836
  if (opts.sync) {
720
- console.log("");
837
+ out("");
721
838
  await cmdSync({ provider: id, apps: opts.apps });
722
839
  }
723
840
  else {
724
- console.log(pc.dim("\nrun `agentsw sync` to push into app configs"));
841
+ note(`\n${t("discover.next")}`);
725
842
  }
726
843
  }
727
- async function collectAppRows() {
844
+ async function collectAppRows(onProgress) {
845
+ const total = appPackages.length;
846
+ let done = 0;
728
847
  return Promise.all(appPackages.map(async (app) => {
729
- const [installedResult, latestResult] = await Promise.allSettled([
730
- Promise.resolve().then(() => installedVersion(app)),
731
- latestVersion(app),
732
- ]);
733
- const installed = installedResult.status === "fulfilled" ? installedResult.value : "?";
734
- const latest = latestResult.status === "fulfilled" ? latestResult.value : undefined;
735
- const knownInstalled = normalizeAppVersion(installed);
736
- const knownLatest = normalizeAppVersion(latest);
737
- const checkFailed = installedResult.status === "rejected"
738
- ? "installed version check failed"
739
- : installed && !knownInstalled
740
- ? "installed version unknown"
741
- : installed && !knownLatest
742
- ? "latest version unavailable"
743
- : undefined;
744
- return {
745
- id: app.id,
746
- name: app.name,
747
- installed,
748
- latest,
749
- upgradable: !!knownInstalled && !!knownLatest && isNewer(knownInstalled, knownLatest),
750
- installable: !installed && !!appCommand(app, "install"),
751
- checkFailed,
752
- };
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
+ }
753
877
  }));
754
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
+ }
755
895
  export async function cmdApps() {
756
- process.stderr.write(pc.dim("checking installed and latest versions ...\n"));
757
- const rows = await collectAppRows();
758
- 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) => [
759
911
  r.id,
760
- r.installed ?? pc.dim("not installed"),
912
+ r.installed ?? pc.dim(t("apps.notInstalled")),
761
913
  r.latest ?? pc.dim("?"),
762
- r.upgradable
763
- ? pc.yellow("upgrade available")
764
- : !r.installed
765
- ? r.installable
766
- ? pc.dim("installable")
767
- : pc.dim("-")
768
- : r.checkFailed || !normalizeAppVersion(r.latest)
769
- ? pc.dim("unknown")
770
- : pc.green("up to date"),
914
+ appStatusLabel(appStatus(r)),
771
915
  ]), ["APP", "INSTALLED", "LATEST", "STATUS"]));
772
916
  const upgradable = rows.filter((r) => r.upgradable).map((r) => r.id);
773
917
  if (upgradable.length)
774
- console.log(pc.dim(`\nupgrade with: agentsw upgrade ${upgradable.join(" ")}`));
918
+ note(`\n${t("apps.upgradeWith", { ids: upgradable.join(" ") })}`);
775
919
  }
776
920
  export async function cmdInstall(id) {
777
921
  const app = appPackages.find((a) => a.id === id);
778
922
  if (!app)
779
- 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(", ") }));
780
924
  const installCmd = appCommand(app, "install");
781
925
  if (!installCmd)
782
- 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 }));
783
927
  const installed = installedVersion(app);
784
928
  if (installed) {
785
- 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 }));
786
934
  return;
787
935
  }
788
- console.log(`installing ${app.name}: ${pc.dim(installCmd)}`);
936
+ out(t("install.installing", { name: app.name, command: pc.dim(installCmd) }));
789
937
  runShell(installCmd);
790
938
  const detected = installedVersion(app);
791
939
  if (!detected)
792
- 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 }));
793
941
  const version = normalizeAppVersion(detected);
794
942
  if (!version) {
795
- 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 })));
796
946
  process.exitCode = 1;
797
947
  return;
798
948
  }
799
- 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 })));
800
954
  }
801
955
  export async function cmdUpgrade(ids) {
802
956
  let selected = appPackages.filter((a) => ids.length === 0 || ids.includes(a.id));
803
957
  const unknown = ids.filter((id) => !appPackages.some((a) => a.id === id));
804
958
  if (unknown.length)
805
- fail(`unknown app(s): ${unknown.join(", ")}`);
959
+ fail(t("upgrade.unknownApps", { ids: unknown.join(", ") }));
806
960
  const expectedVersions = new Map();
961
+ const upgraded = [];
962
+ const skipped = [];
807
963
  if (ids.length === 0) {
808
964
  // no args: upgrade everything that is installed and outdated
809
- process.stderr.write(pc.dim("checking versions ...\n"));
810
- const rows = await collectAppRows();
965
+ const rows = await withProgress(t("upgrade.checking"), (progress) => collectAppRows((done, total) => progress.update(t("progress.apps", { done, total }))));
811
966
  const managed = rows.filter((row) => !!appCommand(appPackages.find((app) => app.id === row.id), "upgrade"));
812
967
  const failed = managed.filter((row) => row.checkFailed);
813
968
  for (const row of failed)
814
- console.log(pc.yellow(`${row.id}: ${row.checkFailed}; update status unknown`));
969
+ out(pc.yellow(t("upgrade.statusUnknown", { id: row.id, reason: row.checkFailed })));
815
970
  if (failed.length)
816
971
  process.exitCode = 1;
817
972
  const upgradable = managed.filter((row) => row.upgradable);
818
973
  if (upgradable.length === 0) {
974
+ if (isJson()) {
975
+ emitJson({ upgraded, skipped: failed.map((row) => ({ id: row.id, reason: row.checkFailed })) });
976
+ return;
977
+ }
819
978
  if (failed.length)
820
- console.log("could not determine update status for every installed app");
979
+ out(t("upgrade.cannotDetermine"));
821
980
  else if (managed.some((row) => row.installed))
822
- console.log("all checked apps are up to date");
981
+ out(t("upgrade.allCurrent"));
823
982
  else
824
- console.log("no installed CLI-managed apps to upgrade");
983
+ out(t("upgrade.none"));
825
984
  return;
826
985
  }
827
986
  for (const row of upgradable)
@@ -831,57 +990,68 @@ export async function cmdUpgrade(ids) {
831
990
  for (const app of selected) {
832
991
  const cmd = appCommand(app, "upgrade");
833
992
  if (!cmd) {
834
- 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" });
835
995
  process.exitCode = 1;
836
996
  continue;
837
997
  }
838
998
  try {
839
999
  if (!installedVersion(app)) {
840
- 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" });
841
1002
  process.exitCode = 1;
842
1003
  continue;
843
1004
  }
844
- console.log(`upgrading ${app.name}: ${pc.dim(cmd)}`);
1005
+ out(t("upgrade.upgrading", { name: app.name, command: pc.dim(cmd) }));
845
1006
  runShell(cmd);
846
1007
  const version = normalizeAppVersion(installedVersion(app));
847
1008
  if (!version)
848
- throw new Error("upgrade command completed but installed version is unknown or app is not detected");
1009
+ throw new Error(t("upgrade.unknownVersion"));
849
1010
  const expected = expectedVersions.get(app.id);
850
1011
  if (expected && isNewer(version, expected))
851
- throw new Error(`upgrade command completed but ${version} is older than available ${expected}`);
852
- 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 });
853
1015
  }
854
1016
  catch (err) {
855
- 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 });
856
1019
  process.exitCode = 1;
857
1020
  }
858
1021
  }
1022
+ if (isJson())
1023
+ emitJson({ upgraded, skipped });
859
1024
  }
860
1025
  /** Collect custom providers, dedupe by endpoint and credentials, and import what is new. */
861
1026
  export async function cmdImport(opts) {
1027
+ const interactive = process.stdin.isTTY === true && !opts.all && !isJson();
862
1028
  const metadataOptions = resolveMetadataOptions(opts);
863
1029
  const rows = scanCandidates();
864
1030
  const fresh = rows.filter((r) => !r.configured);
865
1031
  for (const r of rows) {
866
1032
  if (r.configured) {
867
- 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) })}`);
868
1034
  }
869
1035
  }
870
1036
  if (fresh.length === 0) {
871
- 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"));
872
1042
  return;
873
1043
  }
874
- console.log("");
875
- console.log(table(fresh.map((r) => [
1044
+ out("");
1045
+ out(table(fresh.map((r) => [
876
1046
  r.id,
877
1047
  r.protocol,
878
1048
  r.baseUrl,
879
1049
  String(r.models.length),
880
1050
  r.sources.join(","),
881
1051
  r.apiKey ? pc.green(t("import.keyYes")) : r.keyEnv ? pc.yellow(t("import.keyEnv", { name: r.keyEnv })) : pc.red(t("import.keyMissing")),
882
- ]), ["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] }));
883
1053
  let chosen;
884
- if (process.stdin.isTTY && !opts.all) {
1054
+ if (interactive) {
885
1055
  const { pick } = await prompts({
886
1056
  type: "multiselect",
887
1057
  name: "pick",
@@ -892,9 +1062,9 @@ export async function cmdImport(opts) {
892
1062
  value: i,
893
1063
  selected: true,
894
1064
  })),
895
- }, { onCancel: () => fail(t("add.cancelled")) });
1065
+ }, { onCancel: () => cancelInteractive() });
896
1066
  if (!Array.isArray(pick) || pick.length === 0) {
897
- console.log(pc.dim(t("import.nothingSelected")));
1067
+ out(pc.dim(t("import.nothingSelected")));
898
1068
  return;
899
1069
  }
900
1070
  chosen = pick.map((i) => fresh[i]).filter((r) => r !== undefined);
@@ -903,14 +1073,14 @@ export async function cmdImport(opts) {
903
1073
  chosen = fresh;
904
1074
  }
905
1075
  const store = loadStore();
906
- const catalog = await loadCatalog();
1076
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
907
1077
  const gatewayLoader = sharedGatewayLoader();
908
1078
  const imported = [];
909
1079
  for (const c of chosen) {
910
1080
  let apiKey = c.apiKey;
911
1081
  if (!apiKey) {
912
1082
  const why = c.keyEnv ? t("import.keyRefMissing", { name: c.keyEnv }) : t("import.keyNotStored");
913
- if (!process.stdin.isTTY || opts.all) {
1083
+ if (!interactive) {
914
1084
  fail(t("import.missingKey", { id: c.id, why }));
915
1085
  }
916
1086
  const a = await prompts({
@@ -918,31 +1088,49 @@ export async function cmdImport(opts) {
918
1088
  name: "key",
919
1089
  message: t("import.keyPrompt", { id: c.id, url: c.baseUrl, why }),
920
1090
  validate: (v) => (v.trim() ? true : t("import.required")),
921
- }, { onCancel: () => fail(t("add.cancelled")) });
1091
+ }, { onCancel: () => cancelInteractive() });
922
1092
  apiKey = a.key;
923
1093
  }
924
1094
  // A prompted credential can identify a provider imported earlier in this same batch.
925
1095
  const existing = findMatchingProvider(Object.values(store.providers), { protocol: c.protocol, baseUrl: c.baseUrl, apiKey });
926
1096
  if (existing) {
927
- 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) })}`);
928
1098
  continue;
929
1099
  }
930
1100
  let ids = [...c.models];
931
1101
  if (ids.length === 0) {
932
- process.stderr.write(pc.dim(`${t("import.discovering", { id: c.id })}\n`));
933
- try {
934
- 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
+ }
935
1121
  }
936
- catch (err) {
937
- 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;
938
1125
  }
1126
+ ids = listed;
939
1127
  ids = applyModelFilter(ids, undefined, []).kept;
940
1128
  }
941
1129
  if (ids.length === 0)
942
1130
  fail(t("import.noModelsImport", { id: c.id }));
943
1131
  const id = availableProviderId(c.id, store.providers);
944
1132
  const hint = guessProviderHint(catalog, c.baseUrl);
945
- 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 }));
946
1134
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
947
1135
  const existed = store.providers[id] !== undefined;
948
1136
  store.providers[id] = {
@@ -960,9 +1148,13 @@ export async function cmdImport(opts) {
960
1148
  if (!store.active)
961
1149
  store.active = id;
962
1150
  imported.push(id);
963
- 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(", ")}]`);
964
1152
  }
965
1153
  saveStore(store);
1154
+ if (isJson()) {
1155
+ emitJson({ providers: imported });
1156
+ return;
1157
+ }
966
1158
  if (imported.length)
967
- console.log(pc.dim(`\n${t("import.next", { id: imported[0] })}`));
1159
+ note(`\n${t("import.next", { id: imported[0] })}`);
968
1160
  }