agentsw 0.10.2 → 0.11.1

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
@@ -11,13 +11,13 @@ import { loadCatalog, searchCatalog } from "./modelsdev.js";
11
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
- import { discoverProviderModels, probeProtocols } from "./discover.js";
14
+ import { discoverProviderCatalog, probeProtocols } from "./discover.js";
15
15
  import { appCommand, appPackages, installedVersion, isNewer, latestVersion, normalizeAppVersion, runShell } from "./apps.js";
16
- import { SafeConfigError, drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
16
+ import { SafeConfigError, drainPendingWrites, isDryRun, readTextIfExists, setDryRun } from "./fsutil.js";
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";
20
+ import { cancelInteractive, emitJson, error, isJson, note, out, table, warn } from "./ui.js";
21
21
  import { withProgress } from "./progress.js";
22
22
  import { appsReport, listReport, modelsQueryReport, providerMetadataReport, providerModelsReport, statusReport, } from "./report.js";
23
23
  /** Share successes and failures without fetching until a model actually needs the supplement. */
@@ -202,16 +202,18 @@ export async function cmdAdd(opts) {
202
202
  const metadataOptions = resolveMetadataOptions(opts, existing);
203
203
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
204
204
  let modelIds = manualIds;
205
+ // Limits the endpoint declares for its own models, kept from the discovery pass.
206
+ let endpointLimits = new Map();
205
207
  const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
206
208
  const shouldDiscover = opts.discover || manualIds.length === 0;
207
209
  if (shouldDiscover) {
208
210
  if (!opts.discover) {
209
211
  note(t("add.autoDiscover"));
210
212
  }
211
- const discover = () => withProgress(t("add.discovering", { url: baseUrl }), (progress) => discoverProviderModels({ baseUrl, apiKey: answers.apiKey, protocol }, {
213
+ const discover = () => withProgress(t("add.discovering", { url: baseUrl }), (progress) => discoverProviderCatalog({ baseUrl, apiKey: answers.apiKey, protocol }, {
212
214
  onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: baseUrl, page, count })),
213
215
  }));
214
- let discovered = [];
216
+ let discovered = { ids: [], limits: new Map() };
215
217
  for (let attempt = 0;; attempt++) {
216
218
  try {
217
219
  discovered = await discover();
@@ -228,9 +230,10 @@ export async function cmdAdd(opts) {
228
230
  break;
229
231
  }
230
232
  }
231
- if (discovered.length)
232
- out(t("add.providerLists", { count: discovered.length }));
233
- modelIds = [...new Set([...manualIds, ...discovered])];
233
+ if (discovered.ids.length)
234
+ out(t("add.providerLists", { count: discovered.ids.length }));
235
+ modelIds = [...new Set([...manualIds, ...discovered.ids])];
236
+ endpointLimits = discovered.limits;
234
237
  }
235
238
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
236
239
  modelIds = [...new Set([...modelIds, ...retained])];
@@ -242,7 +245,7 @@ export async function cmdAdd(opts) {
242
245
  fail(t("add.atLeastOne"));
243
246
  const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
244
247
  const hint = opts.modelsDev ?? existing?.modelsDevId ?? guessProviderHint(catalog, answers.baseUrl);
245
- const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models }));
248
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models }, { endpointLimits }));
246
249
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
247
250
  const defaultModel = opts.defaultModel ?? existing?.defaultModel ?? modelIds[0];
248
251
  if (!modelIds.includes(defaultModel))
@@ -287,7 +290,10 @@ async function createProvider(opts) {
287
290
  const metadataOptions = resolveMetadataOptions(opts, opts.existing);
288
291
  const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
289
292
  const hint = opts.modelsDev ?? opts.existing?.modelsDevId ?? guessProviderHint(catalog, baseUrl);
290
- const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, { gatewayLoader: opts.gatewayLoader }));
293
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, {
294
+ gatewayLoader: opts.gatewayLoader,
295
+ endpointLimits: opts.endpointLimits,
296
+ }));
291
297
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
292
298
  const defaultModel = opts.defaultModel ?? opts.existing?.defaultModel ?? modelIds[0];
293
299
  if (!modelIds.includes(defaultModel))
@@ -387,12 +393,12 @@ export async function cmdQuickAdd(opts) {
387
393
  const id = byId?.id ?? sameAccount?.id ?? explicitId ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
388
394
  const name = opts.name ?? existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
389
395
  const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
390
- const discovered = await withProgress(`${t("add.discovering", { url: baseUrl })} [${protocol}]`, (progress) => discoverProviderModels({ baseUrl, apiKey, protocol }, {
396
+ const discovered = await withProgress(`${t("add.discovering", { url: baseUrl })} [${protocol}]`, (progress) => discoverProviderCatalog({ baseUrl, apiKey, protocol }, {
391
397
  onPage: ({ page, count }) => progress.update(`${t("progress.discoverPage", { url: baseUrl, page, count })} [${protocol}]`),
392
398
  }));
393
- out(t("add.providerLists", { count: discovered.length }));
399
+ out(t("add.providerLists", { count: discovered.ids.length }));
394
400
  const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
395
- let modelIds = [...new Set([...discovered, ...retained])];
401
+ let modelIds = [...new Set([...discovered.ids, ...retained])];
396
402
  const pinned = [...retained, ...(opts.defaultModel ? [opts.defaultModel] : []), ...(opts.smallModel ? [opts.smallModel] : [])];
397
403
  const outcome = applyModelFilter(modelIds, modelFilter, pinned);
398
404
  reportDropped(outcome.dropped);
@@ -420,6 +426,7 @@ export async function cmdQuickAdd(opts) {
420
426
  gatewayModels: opts.gatewayModels,
421
427
  metadataMode: opts.metadataMode,
422
428
  gatewayLoader,
429
+ endpointLimits: discovered.limits,
423
430
  });
424
431
  createdIds.push(provider.id);
425
432
  note(`\n${t("add.next", { id: provider.id })}`);
@@ -466,13 +473,14 @@ export async function cmdPrune(id, opts) {
466
473
  out(`${t("remove.pruning", { id: pc.bold(id) })}\n`);
467
474
  const results = await runTargets("prune", provider, opts.apps);
468
475
  if (isJson()) {
476
+ const failed = results.filter((r) => r.failed).map((r) => r.app);
469
477
  emitJson({
470
478
  provider: id,
471
479
  op: "prune",
472
480
  apps: results.filter((r) => !r.skipped).map((r) => r.app),
473
481
  files: results.flatMap((r) => (r.skipped ? [] : r.changed)),
474
482
  skipped: results.filter((r) => r.skipped).map((r) => ({ app: r.app, reason: r.skipped })),
475
- });
483
+ }, failed.length ? t("targets.failed", { apps: failed.join(", ") }) : undefined);
476
484
  return;
477
485
  }
478
486
  reportResults(results);
@@ -491,7 +499,7 @@ function reportResults(results) {
491
499
  out(` ${" ".repeat(9)} ${pc.dim(note)}`);
492
500
  }
493
501
  }
494
- async function runTargets(op, provider, appsFilter, redactErrors = false) {
502
+ async function runTargets(op, provider, appsFilter, opts = {}) {
495
503
  const selected = resolveTargets(appsFilter);
496
504
  const explicit = appsFilter !== undefined && appsFilter !== "all";
497
505
  const results = [];
@@ -505,16 +513,29 @@ async function runTargets(op, provider, appsFilter, redactErrors = false) {
505
513
  });
506
514
  continue;
507
515
  }
516
+ if (op === "apply" && opts.oneSlot && target.singleProvider && provider.id !== opts.oneSlot.winners.get(target.id)) {
517
+ const winner = opts.oneSlot.winners.get(target.id);
518
+ results.push({
519
+ app: target.id,
520
+ changed: [],
521
+ notes: [],
522
+ skipped: winner === undefined
523
+ ? `${target.name} holds one provider at a time; left as it is because this run does not say which`
524
+ : `${target.name} holds one provider at a time; written for ${winner}`,
525
+ });
526
+ continue;
527
+ }
508
528
  try {
509
529
  if (!explicit && !target.detect()) {
510
530
  results.push({ app: target.id, changed: [], notes: [], skipped: `${target.name} not detected (pass --apps ${target.id} to force)` });
511
531
  continue;
512
532
  }
513
- results.push(await target[op](provider));
533
+ results.push(await (op === "apply" ? target.apply(provider, opts.applyOptions) : target.prune(provider)));
514
534
  }
515
535
  catch (err) {
516
- const safe = redactErrors && !(err instanceof SafeConfigError);
517
- results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${safe ? t("preview.failedSafely") : err.message}`) });
536
+ const safe = (opts.redactErrors === true || isJson()) && !(err instanceof SafeConfigError);
537
+ const message = safe ? t(opts.redactErrors ? "preview.failedSafely" : "targets.failedSafely") : err.message;
538
+ results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${message}`), failed: true });
518
539
  process.exitCode = 1;
519
540
  }
520
541
  }
@@ -632,20 +653,22 @@ function printFileDiff(file, next, apiKey) {
632
653
  if (oldText === newText)
633
654
  out(pc.dim(t("preview.onlyRedacted")));
634
655
  }
635
- async function runWithOptionalDryRun(op, provider, apps, dryRun) {
656
+ async function runWithOptionalDryRun(op, provider, apps, dryRun, oneSlot, applyOptions) {
636
657
  const summarize = (results, files) => ({
658
+ ...(results.some((r) => r.failed) ? { failed: results.filter((r) => r.failed).map((r) => r.app) } : {}),
637
659
  apps: results.map((r) => r.app),
638
660
  files,
639
661
  skipped: results.filter((r) => r.skipped).map((r) => ({ app: r.app, reason: r.skipped })),
640
662
  });
641
663
  if (!dryRun) {
642
- const results = await runTargets(op, provider, apps);
664
+ const results = await runTargets(op, provider, apps, { oneSlot, applyOptions });
643
665
  reportResults(results);
644
666
  return summarize(results, results.flatMap((r) => (r.skipped ? [] : r.changed)));
645
667
  }
668
+ const wasDryRun = isDryRun();
646
669
  setDryRun(true);
647
670
  try {
648
- const results = await runTargets(op, provider, apps, true);
671
+ const results = await runTargets(op, provider, apps, { redactErrors: true, oneSlot, applyOptions });
649
672
  const writes = drainPendingWrites();
650
673
  for (const r of results) {
651
674
  if (r.skipped)
@@ -658,19 +681,32 @@ async function runWithOptionalDryRun(op, provider, apps, dryRun) {
658
681
  return summarize(results, writes.map((w) => w.file));
659
682
  }
660
683
  finally {
661
- setDryRun(false);
684
+ setDryRun(wasDryRun);
662
685
  }
663
686
  }
664
687
  export async function cmdUse(id, opts) {
665
688
  resolveTargets(opts.apps);
666
689
  const store = loadStore();
667
690
  const provider = getProvider(store, id);
668
- if (opts.model) {
669
- if (!provider.models.some((m) => m.id === opts.model)) {
670
- fail(t("use.modelMissing", { model: opts.model, id, have: provider.models.map((m) => m.id).join(", ") }));
691
+ const requireConfigured = (model) => {
692
+ if (!provider.models.some((m) => m.id === model)) {
693
+ fail(t("use.modelMissing", { model, id, have: provider.models.map((m) => m.id).join(", ") }));
671
694
  }
695
+ };
696
+ if (opts.model) {
697
+ requireConfigured(opts.model);
672
698
  provider.defaultModel = opts.model;
673
699
  }
700
+ // The slot agents reach for cheap background work — Claude Code's haiku variables. Leaving it
701
+ // unset means "same as the default model", so an expensive default silently becomes the
702
+ // background model too; `--no-small-model` returns the slot to that fallback.
703
+ if (typeof opts.smallModel === "string") {
704
+ requireConfigured(opts.smallModel);
705
+ provider.smallModel = opts.smallModel;
706
+ }
707
+ else if (opts.smallModel === false) {
708
+ delete provider.smallModel;
709
+ }
674
710
  if (!opts.dryRun) {
675
711
  store.active = id;
676
712
  saveStore(store);
@@ -678,20 +714,225 @@ export async function cmdUse(id, opts) {
678
714
  out(`${t("use.switching", { id: pc.bold(id), protocol: provider.protocol, model: provider.defaultModel })}\n`);
679
715
  const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
680
716
  if (isJson()) {
681
- emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome });
717
+ emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome }, outcome.failed?.length ? t("targets.failed", { apps: outcome.failed.join(", ") }) : undefined);
718
+ }
719
+ }
720
+ function saveRefreshedProvider(store, provider) {
721
+ const previous = getProvider(store, provider.id);
722
+ store.providers[provider.id] = provider;
723
+ try {
724
+ saveStore(store);
725
+ }
726
+ catch (err) {
727
+ store.providers[provider.id] = previous;
728
+ throw err;
729
+ }
730
+ }
731
+ /**
732
+ * Shared by `discover` and `sync`: fetch the live model list, re-enrich from the catalog, persist.
733
+ * `pinned` ids survive a listing that dropped them, so they are not reported as "removed upstream".
734
+ * `save: false` keeps the refreshed list in memory for the caller to preview: a dry run shows what a
735
+ * real sync would write without leaving the fetched list behind in the store.
736
+ */
737
+ async function refreshProviderModels(store, provider, save = true) {
738
+ const listed = await withProgress(t("add.discovering", { url: provider.baseUrl }), (progress) => discoverProviderCatalog(provider, {
739
+ onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: provider.baseUrl, page, count })),
740
+ }));
741
+ const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
742
+ const outcome = applyModelFilter(listed.ids, provider.modelFilter, pinned);
743
+ reportDropped(outcome.dropped);
744
+ const ids = outcome.kept;
745
+ const retained = new Set([...ids, ...pinned]);
746
+ const known = provider.models.map((m) => m.id);
747
+ const added = ids.filter((m) => !known.includes(m));
748
+ const removed = known.filter((m) => !retained.has(m));
749
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
750
+ provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, [...retained], { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) }, { endpointLimits: listed.limits }));
751
+ // Reported by the caller, where it reads after the table or the per-app results instead of
752
+ // scrolling away above them.
753
+ const missing = [...new Set(pinned)].filter((model) => !ids.includes(model));
754
+ if (save)
755
+ saveRefreshedProvider(store, provider);
756
+ return { ids, added, removed, missing, catalog };
757
+ }
758
+ function printModelRefresh(provider, refresh) {
759
+ out(`${pc.bold(provider.id)}: ${refresh.ids.length} models (${pc.green(`+${refresh.added.length}`)} / ${pc.red(`-${refresh.removed.length}`)})` +
760
+ (refresh.added.length ? `\n new: ${refresh.added.join(", ")}` : "") +
761
+ (refresh.removed.length ? `\n removed upstream: ${refresh.removed.join(", ")}` : ""));
762
+ out(table(modelRows(provider.models), MODEL_HEADER, MODEL_TABLE_OPTIONS));
763
+ printUncatalogedHint(provider.models, refresh.catalog, provider.modelsDevId);
764
+ }
765
+ /** Comma-separated ids, the same shape `--apps` takes. Repeats collapse; blanks are ignored. */
766
+ function splitProviderList(value) {
767
+ return [...new Set(value.split(",").map((id) => id.trim()).filter(Boolean))];
768
+ }
769
+ /**
770
+ * One provider selection for every command that acts on providers: `--all` takes every configured
771
+ * one, `--provider` takes a comma-separated list, and `fallback` says what no selection means —
772
+ * `"active"` for the commands that write (sync), `"all"` for the ones that only read and refresh.
773
+ * Every id is resolved up front so an unknown one fails before the first network call, never after
774
+ * half a run has been saved. An empty store is an error only when `--all` asked for it by name; the
775
+ * implicit all-providers fallback keeps reporting zero, as it did before there was a flag.
776
+ */
777
+ function resolveProviderIds(store, opts, fallback) {
778
+ if (opts.all && opts.provider !== undefined)
779
+ fail(t("select.allAndProvider"));
780
+ let ids;
781
+ if (opts.all) {
782
+ ids = Object.keys(store.providers);
783
+ if (!ids.length)
784
+ fail(t("select.noProviders"));
785
+ }
786
+ else if (opts.provider !== undefined) {
787
+ ids = splitProviderList(opts.provider);
788
+ if (!ids.length)
789
+ fail(t("select.noneSelected"));
790
+ }
791
+ else if (fallback === "all") {
792
+ ids = Object.keys(store.providers);
793
+ }
794
+ else {
795
+ if (!store.active)
796
+ fail(t("sync.noActive"));
797
+ ids = [store.active];
798
+ }
799
+ for (const id of ids)
800
+ getProvider(store, id);
801
+ return ids;
802
+ }
803
+ /**
804
+ * Apps that name a single provider are written once (see `RunTargetsOptions.oneSlot`); for the rest
805
+ * the last writer owns whatever "current" pointer the app keeps, so a multi-provider sync finishes
806
+ * on the active provider and leaves the agents where the store says they already are.
807
+ */
808
+ function activeLast(store, ids) {
809
+ const active = store.active;
810
+ if (ids.length < 2 || !active || !ids.includes(active))
811
+ return ids;
812
+ return [...ids.filter((id) => id !== active), active];
813
+ }
814
+ /**
815
+ * Which provider each single-slot app should receive. The active provider wins where the app can
816
+ * accept it; where it cannot — Claude Code takes anthropic endpoints only, and the active provider
817
+ * may well be an openai one — the single compatible provider in the run wins instead, because
818
+ * writing the only candidate takes nothing away. With several compatible candidates and no active
819
+ * provider among them the choice is the user's, so the app is left pointing where it already does.
820
+ */
821
+ function oneSlotWinners(selected, store, ids) {
822
+ const winners = new Map();
823
+ for (const target of selected) {
824
+ if (!target.singleProvider)
825
+ continue;
826
+ const usable = ids.filter((id) => supportsProtocol(target, store.providers[id].protocol));
827
+ const winner = store.active !== undefined && usable.includes(store.active)
828
+ ? store.active
829
+ : usable.length === 1 ? usable[0] : undefined;
830
+ if (winner !== undefined)
831
+ winners.set(target.id, winner);
832
+ }
833
+ return winners;
834
+ }
835
+ /** Section separator plus the tail line that says how many of several providers came through. */
836
+ function printMultiSummary(count, failed, key) {
837
+ const done = count - failed;
838
+ out(`\n${failed
839
+ ? pc.yellow(t(key === "sync" ? "sync.summaryFailed" : "discover.summaryFailed", { ok: done, count }))
840
+ : t(key === "sync" ? "sync.summaryOk" : "discover.summaryOk", { count })}`);
841
+ }
842
+ function syncTargets(opts) {
843
+ const selected = resolveTargets(opts.apps);
844
+ if (opts.keepDefault) {
845
+ const unsupported = selected.filter((target) => !target.supportsKeepDefault);
846
+ if (unsupported.length)
847
+ fail(t("sync.keepDefaultUnsupported", { apps: unsupported.map((target) => target.id).join(", ") }));
848
+ }
849
+ return selected;
850
+ }
851
+ async function syncProviders(opts, sourceStore) {
852
+ const selectedTargets = syncTargets(opts);
853
+ const store = sourceStore ?? loadStore();
854
+ const ids = activeLast(store, resolveProviderIds(store, opts, "active"));
855
+ const many = ids.length > 1;
856
+ const oneSlot = many ? { winners: oneSlotWinners(selectedTargets, store, ids) } : undefined;
857
+ const saveRefresh = opts.dryRun !== true;
858
+ const reports = [];
859
+ const failed = [];
860
+ for (const [index, id] of ids.entries()) {
861
+ const provider = structuredClone(getProvider(store, id));
862
+ if (index)
863
+ out("");
864
+ let refresh;
865
+ if (opts.refresh !== false) {
866
+ try {
867
+ const wasDryRun = isDryRun();
868
+ try {
869
+ if (opts.dryRun)
870
+ setDryRun(true);
871
+ refresh = await refreshProviderModels(store, provider, saveRefresh);
872
+ }
873
+ finally {
874
+ // Metadata cache writes are not target configuration previews.
875
+ if (opts.dryRun)
876
+ setDryRun(wasDryRun);
877
+ }
878
+ }
879
+ catch (err) {
880
+ // One unreachable provider must not strand the others: report it, write nothing for it,
881
+ // and fail the run. Text mode keeps the single-provider exception contract.
882
+ if (!many && !isJson())
883
+ throw new Error(`${err.message}\n${t("sync.refreshFailed")}`);
884
+ warn(t("sync.refreshSkipped", { id, message: err.message }));
885
+ process.exitCode = 1;
886
+ failed.push(id);
887
+ reports.push({ provider: id, refreshFailed: true });
888
+ continue;
889
+ }
890
+ if (!isJson()) {
891
+ printModelRefresh(provider, refresh);
892
+ // --dry-run promises to write nothing, so say the fetched list only lived long enough to preview.
893
+ if (!saveRefresh)
894
+ note(t("sync.refreshNotSaved"));
895
+ }
896
+ }
897
+ out(`${t(opts.keepDefault ? "sync.syncingKeepDefault" : "sync.syncing", { id: pc.bold(id), model: provider.defaultModel })}\n`);
898
+ const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun, oneSlot, { keepDefault: opts.keepDefault });
899
+ if (outcome.failed?.length)
900
+ failed.push(id);
901
+ if (refresh?.missing.length) {
902
+ const models = refresh.missing.join(", ");
903
+ warn(t(opts.dryRun ? "refresh.pinnedMissing" : "sync.pinnedMissingWritten", { models, id }));
904
+ }
905
+ reports.push({
906
+ provider: id,
907
+ model: provider.defaultModel,
908
+ ...(refresh
909
+ ? {
910
+ refreshed: {
911
+ models: refresh.ids.length,
912
+ added: refresh.added,
913
+ removed: refresh.removed,
914
+ missing: refresh.missing,
915
+ saved: saveRefresh,
916
+ },
917
+ }
918
+ : {}),
919
+ ...outcome,
920
+ });
682
921
  }
922
+ if (many)
923
+ printMultiSummary(ids.length, failed.length, "sync");
924
+ return { reports, failed };
683
925
  }
684
926
  export async function cmdSync(opts) {
685
- resolveTargets(opts.apps);
686
- const store = loadStore();
687
- const id = opts.provider ?? store.active;
688
- if (!id)
689
- fail(t("sync.noActive"));
690
- const provider = getProvider(store, id);
691
- out(`${t("sync.syncing", { id: pc.bold(id), model: provider.defaultModel })}\n`);
692
- const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
927
+ const { reports, failed } = await syncProviders(opts);
693
928
  if (isJson()) {
694
- emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome });
929
+ emitJson(reports.length > 1
930
+ ? {
931
+ dryRun: opts.dryRun === true,
932
+ providers: reports,
933
+ files: [...new Set(reports.flatMap((report) => report.files ?? []))],
934
+ }
935
+ : { ...(reports[0] ?? {}), dryRun: opts.dryRun === true }, failed.length ? t("sync.failed", { ids: failed.join(", ") }) : undefined);
695
936
  }
696
937
  }
697
938
  export function cmdStatus() {
@@ -770,7 +1011,8 @@ export async function cmdModels(query, opts) {
770
1011
  }
771
1012
  export async function cmdRefreshMeta(opts = {}) {
772
1013
  const store = loadStore();
773
- const providers = opts.provider ? [getProvider(store, opts.provider)] : Object.values(store.providers);
1014
+ // Omitting the selection still means every provider: this command only reads catalogs.
1015
+ const providers = resolveProviderIds(store, opts, "all").map((id) => getProvider(store, id));
774
1016
  resolveMetadataOptions(opts);
775
1017
  for (const provider of providers)
776
1018
  Object.assign(provider, resolveMetadataOptions(opts, provider));
@@ -797,50 +1039,88 @@ export async function cmdRefreshMeta(opts = {}) {
797
1039
  for (const provider of providers)
798
1040
  printUncatalogedHint(provider.models, catalog, provider.modelsDevId);
799
1041
  }
800
- export async function cmdDiscover(id, opts) {
801
- resolveTargets(opts.apps);
1042
+ export async function cmdDiscover(ids, opts) {
1043
+ if (opts.keepDefault && !opts.sync)
1044
+ fail(t("discover.keepDefaultRequiresSync"));
1045
+ syncTargets(opts);
802
1046
  const store = loadStore();
803
- const provider = getProvider(store, id);
804
- Object.assign(provider, resolveMetadataOptions(opts, provider));
805
- // flags override and re-persist the filter; --no-filter clears it
806
- const flagFilter = parseFilterOpts(opts, provider.modelFilter);
807
- if (opts.filter === false)
808
- provider.modelFilter = undefined;
809
- else if (flagFilter)
810
- provider.modelFilter = flagFilter;
811
- const listed = await withProgress(t("add.discovering", { url: provider.baseUrl }), (progress) => discoverProviderModels(provider, {
812
- onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: provider.baseUrl, page, count })),
813
- }));
814
- const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
815
- const outcome = applyModelFilter(listed, provider.modelFilter, pinned);
816
- reportDropped(outcome.dropped);
817
- const ids = outcome.kept;
818
- // Pinned ids survive a listing that dropped them, so they are not "removed upstream".
819
- const retainedIds = [...new Set([...ids, ...pinned])];
820
- const retained = new Set(retainedIds);
821
- const known = provider.models.map((m) => m.id);
822
- const added = ids.filter((m) => !known.includes(m));
823
- const gone = known.filter((m) => !retained.has(m));
824
- const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
825
- provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, retainedIds, { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) }));
826
- if (!ids.includes(provider.defaultModel)) {
827
- out(pc.yellow(t("discover.defaultMissing", { model: provider.defaultModel })));
1047
+ // Ids may arrive as positional arguments, as one comma-separated list, or through `-p` for
1048
+ // symmetry with sync; all three say the same thing, so accept them together.
1049
+ const listed = [...(Array.isArray(ids) ? ids : [ids ?? ""]), opts.provider ?? ""].filter(Boolean).join(",");
1050
+ const selected = resolveProviderIds(store, { provider: listed || undefined, all: opts.all }, "active");
1051
+ // Reject bad metadata options once, before the first provider is contacted.
1052
+ resolveMetadataOptions(opts);
1053
+ const many = selected.length > 1;
1054
+ const reports = [];
1055
+ const refreshed = [];
1056
+ const failed = [];
1057
+ for (const [index, id] of selected.entries()) {
1058
+ const provider = structuredClone(getProvider(store, id));
1059
+ if (index)
1060
+ out("");
1061
+ Object.assign(provider, resolveMetadataOptions(opts, provider));
1062
+ // flags override and re-persist the filter; --no-filter clears it
1063
+ const flagFilter = parseFilterOpts(opts, provider.modelFilter);
1064
+ if (opts.filter === false)
1065
+ provider.modelFilter = undefined;
1066
+ else if (flagFilter)
1067
+ provider.modelFilter = flagFilter;
1068
+ let refresh;
1069
+ try {
1070
+ const wasDryRun = isDryRun();
1071
+ try {
1072
+ if (opts.dryRun)
1073
+ setDryRun(true);
1074
+ refresh = await refreshProviderModels(store, provider, false);
1075
+ if (opts.dryRun)
1076
+ store.providers[id] = provider;
1077
+ else
1078
+ saveRefreshedProvider(store, provider);
1079
+ }
1080
+ finally {
1081
+ if (opts.dryRun)
1082
+ setDryRun(wasDryRun);
1083
+ }
1084
+ }
1085
+ catch (err) {
1086
+ // One unreachable endpoint must not strand the others; text mode still throws for one.
1087
+ if (!many && !isJson())
1088
+ throw err;
1089
+ warn(t("sync.refreshSkipped", { id, message: err.message }));
1090
+ process.exitCode = 1;
1091
+ failed.push(id);
1092
+ reports.push({ provider: id, refreshFailed: true });
1093
+ continue;
1094
+ }
1095
+ refreshed.push(id);
1096
+ reports.push({ provider: id, models: refresh.ids.length, added: refresh.added, removed: refresh.removed, missing: refresh.missing });
1097
+ if (!isJson())
1098
+ printModelRefresh(provider, refresh);
1099
+ if (refresh.missing.length)
1100
+ warn(t("refresh.pinnedMissing", { models: refresh.missing.join(", "), id }));
828
1101
  }
829
- saveStore(store);
830
1102
  if (isJson()) {
831
- emitJson({ provider: id, models: ids.length, added, removed: gone });
1103
+ if (opts.sync && refreshed.length) {
1104
+ // Collect application results without emitting or fetching the discovered lists again.
1105
+ const synced = await syncProviders({ provider: refreshed.join(","), apps: opts.apps, refresh: false, keepDefault: opts.keepDefault, dryRun: opts.dryRun }, store);
1106
+ failed.push(...synced.failed);
1107
+ for (const report of reports)
1108
+ Object.assign(report, synced.reports.find((entry) => entry.provider === report.provider));
1109
+ }
1110
+ const files = [...new Set(reports.flatMap((report) => report.files ?? []))];
1111
+ emitJson(many
1112
+ ? { providers: reports, ...(opts.sync ? { files, dryRun: opts.dryRun === true } : {}) }
1113
+ : { ...(reports[0] ?? {}), ...(opts.sync ? { dryRun: opts.dryRun === true } : {}) }, failed.length ? t("discover.failed", { ids: failed.join(", ") }) : undefined);
832
1114
  return;
833
1115
  }
834
- out(`${pc.bold(id)}: ${ids.length} models (${pc.green(`+${added.length}`)} / ${pc.red(`-${gone.length}`)})` +
835
- (added.length ? `\n new: ${added.join(", ")}` : "") +
836
- (gone.length ? `\n removed upstream: ${gone.join(", ")}` : ""));
837
- out(table(modelRows(provider.models), MODEL_HEADER, MODEL_TABLE_OPTIONS));
838
- printUncatalogedHint(provider.models, catalog, provider.modelsDevId);
839
- if (opts.sync) {
1116
+ if (many)
1117
+ printMultiSummary(selected.length, selected.length - refreshed.length, "discover");
1118
+ if (opts.sync && refreshed.length) {
840
1119
  out("");
841
- await cmdSync({ provider: id, apps: opts.apps });
1120
+ // The lists are already fresh: write them without a second fetch.
1121
+ await syncProviders({ provider: refreshed.join(","), apps: opts.apps, refresh: false, keepDefault: opts.keepDefault, dryRun: opts.dryRun }, store);
842
1122
  }
843
- else {
1123
+ else if (!opts.sync) {
844
1124
  note(`\n${t("discover.next")}`);
845
1125
  }
846
1126
  }
@@ -1101,8 +1381,9 @@ export async function cmdImport(opts) {
1101
1381
  continue;
1102
1382
  }
1103
1383
  let ids = [...c.models];
1384
+ let endpointLimits = new Map();
1104
1385
  if (ids.length === 0) {
1105
- const discover = () => withProgress(t("import.discovering", { id: c.id }), (progress) => discoverProviderModels({ baseUrl: c.baseUrl, apiKey: apiKey, protocol: c.protocol }, {
1386
+ const discover = () => withProgress(t("import.discovering", { id: c.id }), (progress) => discoverProviderCatalog({ baseUrl: c.baseUrl, apiKey: apiKey, protocol: c.protocol }, {
1106
1387
  onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: c.baseUrl, page, count })),
1107
1388
  }));
1108
1389
  let listed;
@@ -1126,14 +1407,15 @@ export async function cmdImport(opts) {
1126
1407
  out(pc.yellow(t("import.skippedAfterFailure", { id: c.id })));
1127
1408
  continue;
1128
1409
  }
1129
- ids = listed;
1410
+ ids = listed.ids;
1411
+ endpointLimits = listed.limits;
1130
1412
  ids = applyModelFilter(ids, undefined, []).kept;
1131
1413
  }
1132
1414
  if (ids.length === 0)
1133
1415
  fail(t("import.noModelsImport", { id: c.id }));
1134
1416
  const id = availableProviderId(c.id, store.providers);
1135
1417
  const hint = guessProviderHint(catalog, c.baseUrl);
1136
- const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader }));
1418
+ const models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader, endpointLimits }));
1137
1419
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
1138
1420
  const existed = store.providers[id] !== undefined;
1139
1421
  store.providers[id] = {