agentsw 0.10.2 → 0.11.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/README.md CHANGED
@@ -27,7 +27,8 @@ without touching the settings you hand-tuned.
27
27
  `npx agentsw use myproxy -a codex,omp` is one line in a provisioning script; nine GUI
28
28
  clicks are not.
29
29
  - **`--dry-run` prints redacted diffs.** Preview changes without writing files or creating
30
- backups. Existing configuration files are backed up when actual changes are committed.
30
+ backups — the provider store included, so a previewed `sync` does not leave its refreshed
31
+ model list behind. Existing configuration files are backed up when changes are committed.
31
32
  - **Non-destructive by design.** Only the fields agentsw owns are rewritten. Provider-level
32
33
  keys it does not model — `authHeader`, `headers`, `compat`, `discovery` — per-model extras
33
34
  like `thinkingLevelMap`, and YAML comments all survive a re-sync.
@@ -94,9 +95,10 @@ agentsw # once installed globally; the short alias `asw` is id
94
95
 
95
96
  **Run it with no arguments and you get a menu — that is the whole tool.** Every action lives
96
97
  there: add a provider (auto-detect or manual setup), import the providers already configured in
97
- your agents, switch provider and default model, re-sync, update a model list, configure model
98
- metadata, rename or delete, install/upgrade an agent, change language. On the first run it asks
99
- for English or 简体中文 and offers to adopt what your agents are already using.
98
+ your agents, switch provider and default model, sync a provider (fetch its current model list,
99
+ then write it to your agents), configure model metadata, rename or delete, install/upgrade an
100
+ agent, change language. On the first run it asks for English or 简体中文 and offers to adopt what
101
+ your agents are already using.
100
102
 
101
103
  Each menu action has a command equivalent for scripts, CI and headless machines — see
102
104
  [Commands](#commands). Examples below are written as `asw`; without a global install, run them
@@ -213,6 +215,10 @@ asw add -y --discover --exclude "*embedding*,*video*" --id myproxy ...
213
215
  asw add -y --discover --no-dedup ... # keep duplicates
214
216
  asw discover myproxy --include "gpt-*,glm-*" # update the persisted filter
215
217
  asw discover myproxy --no-filter # clear it
218
+
219
+ # Refresh several providers' model lists without writing any agent config.
220
+ asw discover --all
221
+ asw discover myproxy backup-proxy
216
222
  ```
217
223
 
218
224
  Explicit `--models` entries and the default model are never filtered out.
@@ -249,9 +255,16 @@ asw refresh --provider myproxy --metadata-mode off
249
255
  # Return an explicitly disabled provider to automatic mode
250
256
  asw refresh --provider myproxy --metadata-mode auto
251
257
 
252
- # Preview and apply separately; sync does not query catalogs
258
+ # Preview and apply separately. Sync fetches the model list first;
259
+ # --dry-run still fetches, so the preview matches a real sync, but saves nothing;
260
+ # --no-refresh writes the saved list without touching the network.
253
261
  asw sync --provider myproxy --dry-run
254
262
  asw sync --provider myproxy
263
+ asw sync --provider myproxy --no-refresh
264
+
265
+ # Refresh and push several providers in one run.
266
+ asw sync --all
267
+ asw sync --provider myproxy,backup-proxy
255
268
  ```
256
269
 
257
270
  The menu's **Configure and refresh model metadata** action offers **Automatic (recommended)**,
@@ -286,9 +299,28 @@ Import still skips already configured accounts.
286
299
  Gateway has a separate 24-hour cache; a failed request is retried once before falling back to stale cached data
287
300
  (or skipping supplementation when there is none). `AGENTSW_DEBUG=1` reports why an attempt failed, and
288
301
  `AGENTSW_GATEWAY_TIMEOUT_MS` overrides the 15-second whole-request budget.
289
- - **Automatic lookup is not automatic sync:** enrichment updates saved metadata during add/quick/discover/import/refresh.
290
- It does not add an automatic push to agents. Sync behavior is unchanged: ordinary `sync` only writes saved settings
291
- and fetches neither model lists nor metadata catalogs.
302
+ - **A model the endpoint dropped is reported, not hidden.** A refresh keeps the default and small
303
+ model even when the listing stops carrying them — one flaky listing must not delete the model you
304
+ chose — but being kept also keeps them out of the "removed upstream" line, so they are named in a
305
+ `warning:` after the apply and in `--json` as `refreshed.missing`. That is what a renamed model
306
+ looks like from the client side: the agents keep working with an id the endpoint now rejects.
307
+ - **One selection everywhere:** `--all` and a comma-separated `-p a,b` mean the same thing on `sync`, `discover`
308
+ and `refresh`, and `discover` also takes its ids positionally (`discover alpha beta`). Every id is resolved before
309
+ the first request, so a typo fails before half the run is on disk. Omitting the selection keeps each command's own
310
+ default: the active provider for `sync` and `discover`, every provider for `refresh`.
311
+ - **Syncing several providers:** `sync --all` takes every configured provider and `-p a,b` takes a list; each is
312
+ refreshed and written in turn, finishing with the active provider so whatever "current" pointer an app keeps ends
313
+ up where the store says it should. An app that names one provider instead of a map — Claude Code's env block — is
314
+ written once rather than once per provider: it gets the active provider when it can accept it, or the single
315
+ provider in the run that it can (Claude Code takes anthropic endpoints only, so an openai active provider does not
316
+ starve it). With several candidates and the active provider not among them the choice is yours, so the app keeps
317
+ pointing where it already does: syncing is not switching. A provider whose endpoint cannot be reached is reported
318
+ and skipped, the rest still sync, and the run exits non-zero.
319
+ - **Automatic lookup is not an automatic push:** enrichment updates saved metadata during add/quick/discover/import/refresh
320
+ and never writes agent configs by itself. `sync` is the push, and it refreshes first: it fetches the provider's model
321
+ list, re-enriches it from the catalogs, saves the store, then applies it. `sync --dry-run` fetches the same list so
322
+ the preview is accurate but saves neither it nor the configs. `sync --no-refresh` writes the saved list without
323
+ fetching, and `discover` stops after the refresh without writing any agent.
292
324
 
293
325
  ## Commands
294
326
 
@@ -301,11 +333,11 @@ Import still skips already configured accounts.
301
333
  | `list` / `status` | configured providers / what each agent points at |
302
334
  | `list --apps omp,prime` | list agent-local provider IDs, including entries absent from agentsw |
303
335
  | `rename <id> <new-id>` | back up and migrate the ID and config references; supports `--dry-run` |
304
- | `use <id>` | switch every detected agent; `-a codex,omp`, `-m <model>`, `--dry-run` |
305
- | `sync` | re-apply the active provider (after an agent update, say) |
306
- | `discover <id> [--sync]` | refresh the model list + metadata from `/v1/models` |
336
+ | `use <id>` | switch every detected agent; `-a codex,omp`, `-m <model>`, `--small-model <id>` / `--no-small-model`, `--dry-run` |
337
+ | `sync` | refresh each provider's model list, then re-apply it to the agent configs; `--all` or `-p a,b` for several, `--no-refresh` writes the saved list |
338
+ | `discover [ids...] [--sync]` | refresh the model list + metadata from `/v1/models`; `--all` or several ids for more than one, no id for the active provider; `--sync` pushes them to the agents |
307
339
  | `models [query]` | search the models.dev catalog (`-r` refresh the cache, `-l <n>` limit rows) |
308
- | `refresh [--provider <id>]` | refresh saved metadata and optionally configure Gateway; keep the model list |
340
+ | `refresh [--provider <ids>]` | refresh saved metadata and optionally configure Gateway; keep the model list; every provider unless `-p a,b` narrows it |
309
341
  | `models --provider <id> --metadata` | inspect field sources, conflicts and reference pricing as JSON |
310
342
  | `prune <id>` / `remove <id> [--prune]` | remove from app configs / from the store |
311
343
  | `remove <id> --apps omp` | delete only the selected app's entry; supports `--dry-run` |
package/README.zh-CN.md CHANGED
@@ -25,8 +25,8 @@
25
25
 
26
26
  - **它能去桌面应用去不了的地方。** 无头服务器、容器、devcontainer、CI。
27
27
  `npx agentsw use myproxy -a codex,omp` 在开机脚本里是一行;GUI 点九次不是。
28
- - **`--dry-run` 输出脱敏 diff。** 写盘前先预览;预览不写文件、不创建备份。
29
- 正式提交变更时,已有配置文件才会先备份。
28
+ - **`--dry-run` 输出脱敏 diff。** 写盘前先预览;预览不写文件、不创建备份,供应商 store 也一样——
29
+ 预览 `sync` 不会把刷新到的模型列表留在配置里。正式提交变更时,已有配置文件才会先备份。
30
30
  - **默认不破坏现有配置。** 只覆盖 agentsw 自己管理的字段:它不建模的供应商级键
31
31
  (`authHeader`、`headers`、`compat`、`discovery`)、`thinkingLevelMap` 之类的模型级字段、
32
32
  以及 YAML 注释,都会在重新同步后原样保留。
@@ -87,7 +87,7 @@ agentsw # 全局安装后可用;短名 `asw` 完全等价
87
87
  ```
88
88
 
89
89
  **不带参数运行就是一个菜单——整个工具都在里面。** 添加供应商(自动识别或手动设置)、导入各智能体
90
- 已有的供应商、切换供应商和默认模型、重新同步、更新模型列表、设置模型参数补充源、重命名或删除、
90
+ 已有的供应商、切换供应商和默认模型、同步供应商(重新获取模型列表再写入)、设置模型参数补充源、重命名或删除、
91
91
  安装/升级智能体、切换语言。首次运行会先问你要 English 还是简体中文,并提议接管你各智能体已经在用的配置。
92
92
 
93
93
  菜单里的每项操作都有对应的命令,方便脚本、CI 和无头机器——见 [命令](#命令)。
@@ -200,6 +200,10 @@ asw add -y --discover --exclude "*embedding*,*video*" --id myproxy ...
200
200
  asw add -y --discover --no-dedup ... # 保留重复项
201
201
  asw discover myproxy --include "gpt-*,glm-*" # 更新持久化过滤规则
202
202
  asw discover myproxy --no-filter # 清除规则
203
+
204
+ # 只刷新模型列表、不写任何智能体配置,可一次多个。
205
+ asw discover --all
206
+ asw discover myproxy backup-proxy
203
207
  ```
204
208
 
205
209
  手动 `--models` 指定的 id 和默认模型永不被过滤。
@@ -234,9 +238,16 @@ asw refresh --provider myproxy --metadata-mode off
234
238
  # 将显式关闭的供应商恢复为自动模式
235
239
  asw refresh --provider myproxy --metadata-mode auto
236
240
 
237
- # 单独预览、同步;sync 不查询目录
241
+ # 单独预览、应用。sync 会先重新获取模型列表;
242
+ # --dry-run 同样会获取,预览因此与真实 sync 一致,但什么都不保存;
243
+ # --no-refresh 则完全不联网,只写入已保存的列表。
238
244
  asw sync --provider myproxy --dry-run
239
245
  asw sync --provider myproxy
246
+ asw sync --provider myproxy --no-refresh
247
+
248
+ # 一次刷新并推送多个供应商。
249
+ asw sync --all
250
+ asw sync --provider myproxy,backup-proxy
240
251
  ```
241
252
 
242
253
  交互菜单的“设置并刷新模型参数补充源”提供“自动按需(推荐)”“始终补充”“关闭”,
@@ -266,8 +277,25 @@ asw sync --provider myproxy
266
277
  审计信息仅保存在 agentsw,不写入智能体运行配置。Gateway 目录独立缓存 24 小时;请求失败会先重试一次,
267
278
  仍失败才使用旧缓存,无缓存时只跳过补充。`AGENTSW_DEBUG=1` 会说明该次尝试失败的原因,
268
279
  `AGENTSW_GATEWAY_TIMEOUT_MS` 可覆盖默认 15 秒的整体请求预算。
269
- - **自动查询不等于自动同步:** 添加、快速添加、发现、导入、刷新时补全保存的元数据,并不新增自动推送。
270
- 同步行为不变:普通 `sync` 仅写入已保存的设置,既不获取模型列表,也不请求元数据目录。
280
+ - **端点下掉的模型会被明确报出,而不是悄悄保留。** 刷新时即使列表里不再有默认模型和小模型,也会保留它们——
281
+ 一次抖动的列表不该删掉你选定的模型——但「removed upstream」里就不会出现它们了,所以改为在写入之后用
282
+ `warning:` 点名,`--json` 里对应 `refreshed.missing`。模型被改名时从客户端看到的就是这个现象:
283
+ 配置照旧,而端点已经不认这个 id 了。
284
+ - **选择供应商的方式统一:** `sync`、`discover`、`refresh` 都支持 `--all` 和逗号分隔的 `-p a,b`,
285
+ `discover` 还可以直接写位置参数(`discover alpha beta`)。所有 id 在第一次请求前就全部解析,
286
+ 写错一个会在任何东西落盘之前失败。不指定时各命令保持自己的默认:`sync` 和 `discover` 取当前供应商,
287
+ `refresh` 取全部。
288
+ - **一次同步多个供应商:** `sync --all` 取全部已配置的供应商,`-p a,b` 取指定列表;逐个刷新并写入,
289
+ 并把当前供应商放在最后,这样各智能体记录的“当前指向”最终就停在 store 所说的那一个。
290
+ 只记录单个供应商(而非供应商表)的应用——例如 Claude Code 的 env 块——只写一次而不是每个供应商写一遍:
291
+ 能接受当前供应商时就写它,不能接受时(Claude Code 只收 anthropic 端点,当前供应商却可能是 openai 的)
292
+ 就写本次同步里它唯一能接受的那个。若有多个候选且当前供应商不在其中,选择权在你,它保持原有指向:
293
+ 同步不是切换。
294
+ 某个供应商的端点不可达时会被报告并跳过,其余照常同步,整个运行以非零状态结束。
295
+ - **自动查询不等于自动推送:** 添加、快速添加、发现、导入、刷新时补全保存的元数据,永远不会自行写入智能体配置。
296
+ 推送是 `sync`,而且它先刷新:先获取供应商模型列表、按目录补全、保存到 store,再写入各智能体。
297
+ `sync --dry-run` 会获取同一份列表以保证预览准确,但既不保存列表也不写配置;
298
+ `sync --no-refresh` 不联网,只写入已保存的列表;`discover` 刷新完就停,不写任何智能体。
271
299
 
272
300
  ## 命令
273
301
 
@@ -280,11 +308,11 @@ asw sync --provider myproxy
280
308
  | `list` / `status` | 已配置的供应商 / 各智能体当前指向 |
281
309
  | `list --apps omp,prime` | 列出智能体本地供应商 ID,包括未导入 agentsw 的条目 |
282
310
  | `rename <id> <new-id>` | 备份并迁移 ID 和配置引用,支持 `--dry-run` |
283
- | `use <id>` | 切换所有检测到的智能体;`-a codex,omp`、`-m <model>`、`--dry-run` |
284
- | `sync` | 重新应用当前供应商(比如某个智能体升级之后) |
285
- | `discover <id> [--sync]` | 从 `/v1/models` 刷新模型列表与元数据 |
311
+ | `use <id>` | 切换所有检测到的智能体;`-a codex,omp`、`-m <model>`、`--small-model <id>` / `--no-small-model`、`--dry-run` |
312
+ | `sync` | 先刷新各供应商的模型列表,再写入各智能体配置;`--all` 或 `-p a,b` 可一次处理多个,`--no-refresh` 只写已保存的列表 |
313
+ | `discover [ids...] [--sync]` | 从 `/v1/models` 刷新模型列表与元数据;`--all` 或多个 id 可一次处理多个,不给 id 则取当前供应商;`--sync` 再推送到各智能体 |
286
314
  | `models [query]` | 搜索 models.dev 目录(`-r` 强制刷新缓存,`-l <n>` 限制条数) |
287
- | `refresh [--provider <id>]` | 刷新已有模型参数,可设置 Gateway 补充源,不改变模型列表 |
315
+ | `refresh [--provider <ids>]` | 刷新已有模型参数,可设置 Gateway 补充源,不改变模型列表;默认全部,`-p a,b` 可缩小范围 |
288
316
  | `models --provider <id> --metadata` | 以 JSON 查看字段来源、冲突和参考价格 |
289
317
  | `prune <id>` / `remove <id> [--prune]` | 从各应用配置中清除 / 从存储中删除 |
290
318
  | `remove <id> --apps omp` | 仅删除指定智能体内的条目,支持 `--dry-run` |
package/dist/commands.js CHANGED
@@ -17,7 +17,7 @@ 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";
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. */
@@ -491,7 +491,7 @@ function reportResults(results) {
491
491
  out(` ${" ".repeat(9)} ${pc.dim(note)}`);
492
492
  }
493
493
  }
494
- async function runTargets(op, provider, appsFilter, redactErrors = false) {
494
+ async function runTargets(op, provider, appsFilter, opts = {}) {
495
495
  const selected = resolveTargets(appsFilter);
496
496
  const explicit = appsFilter !== undefined && appsFilter !== "all";
497
497
  const results = [];
@@ -505,6 +505,18 @@ async function runTargets(op, provider, appsFilter, redactErrors = false) {
505
505
  });
506
506
  continue;
507
507
  }
508
+ if (op === "apply" && opts.oneSlot && target.singleProvider && provider.id !== opts.oneSlot.winners.get(target.id)) {
509
+ const winner = opts.oneSlot.winners.get(target.id);
510
+ results.push({
511
+ app: target.id,
512
+ changed: [],
513
+ notes: [],
514
+ skipped: winner === undefined
515
+ ? `${target.name} holds one provider at a time; left as it is because this run does not say which`
516
+ : `${target.name} holds one provider at a time; written for ${winner}`,
517
+ });
518
+ continue;
519
+ }
508
520
  try {
509
521
  if (!explicit && !target.detect()) {
510
522
  results.push({ app: target.id, changed: [], notes: [], skipped: `${target.name} not detected (pass --apps ${target.id} to force)` });
@@ -513,7 +525,7 @@ async function runTargets(op, provider, appsFilter, redactErrors = false) {
513
525
  results.push(await target[op](provider));
514
526
  }
515
527
  catch (err) {
516
- const safe = redactErrors && !(err instanceof SafeConfigError);
528
+ const safe = opts.redactErrors === true && !(err instanceof SafeConfigError);
517
529
  results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${safe ? t("preview.failedSafely") : err.message}`) });
518
530
  process.exitCode = 1;
519
531
  }
@@ -632,20 +644,20 @@ function printFileDiff(file, next, apiKey) {
632
644
  if (oldText === newText)
633
645
  out(pc.dim(t("preview.onlyRedacted")));
634
646
  }
635
- async function runWithOptionalDryRun(op, provider, apps, dryRun) {
647
+ async function runWithOptionalDryRun(op, provider, apps, dryRun, oneSlot) {
636
648
  const summarize = (results, files) => ({
637
649
  apps: results.map((r) => r.app),
638
650
  files,
639
651
  skipped: results.filter((r) => r.skipped).map((r) => ({ app: r.app, reason: r.skipped })),
640
652
  });
641
653
  if (!dryRun) {
642
- const results = await runTargets(op, provider, apps);
654
+ const results = await runTargets(op, provider, apps, { oneSlot });
643
655
  reportResults(results);
644
656
  return summarize(results, results.flatMap((r) => (r.skipped ? [] : r.changed)));
645
657
  }
646
658
  setDryRun(true);
647
659
  try {
648
- const results = await runTargets(op, provider, apps, true);
660
+ const results = await runTargets(op, provider, apps, { redactErrors: true, oneSlot });
649
661
  const writes = drainPendingWrites();
650
662
  for (const r of results) {
651
663
  if (r.skipped)
@@ -665,12 +677,25 @@ export async function cmdUse(id, opts) {
665
677
  resolveTargets(opts.apps);
666
678
  const store = loadStore();
667
679
  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(", ") }));
680
+ const requireConfigured = (model) => {
681
+ if (!provider.models.some((m) => m.id === model)) {
682
+ fail(t("use.modelMissing", { model, id, have: provider.models.map((m) => m.id).join(", ") }));
671
683
  }
684
+ };
685
+ if (opts.model) {
686
+ requireConfigured(opts.model);
672
687
  provider.defaultModel = opts.model;
673
688
  }
689
+ // The slot agents reach for cheap background work — Claude Code's haiku variables. Leaving it
690
+ // unset means "same as the default model", so an expensive default silently becomes the
691
+ // background model too; `--no-small-model` returns the slot to that fallback.
692
+ if (typeof opts.smallModel === "string") {
693
+ requireConfigured(opts.smallModel);
694
+ provider.smallModel = opts.smallModel;
695
+ }
696
+ else if (opts.smallModel === false) {
697
+ delete provider.smallModel;
698
+ }
674
699
  if (!opts.dryRun) {
675
700
  store.active = id;
676
701
  saveStore(store);
@@ -681,18 +706,189 @@ export async function cmdUse(id, opts) {
681
706
  emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome });
682
707
  }
683
708
  }
709
+ /**
710
+ * Shared by `discover` and `sync`: fetch the live model list, re-enrich from the catalog, persist.
711
+ * `pinned` ids survive a listing that dropped them, so they are not reported as "removed upstream".
712
+ * `save: false` keeps the refreshed list in memory for the caller to preview: a dry run shows what a
713
+ * real sync would write without leaving the fetched list behind in the store.
714
+ */
715
+ async function refreshProviderModels(store, provider, save = true) {
716
+ const listed = await withProgress(t("add.discovering", { url: provider.baseUrl }), (progress) => discoverProviderModels(provider, {
717
+ onPage: ({ page, count }) => progress.update(t("progress.discoverPage", { url: provider.baseUrl, page, count })),
718
+ }));
719
+ const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
720
+ const outcome = applyModelFilter(listed, provider.modelFilter, pinned);
721
+ reportDropped(outcome.dropped);
722
+ const ids = outcome.kept;
723
+ const retained = new Set([...ids, ...pinned]);
724
+ const known = provider.models.map((m) => m.id);
725
+ const added = ids.filter((m) => !known.includes(m));
726
+ const removed = known.filter((m) => !retained.has(m));
727
+ const catalog = await withProgress(t("progress.catalog"), () => loadCatalog());
728
+ provider.models = await withProgress(t("progress.metadata"), () => enrichProviderModels(catalog, [...retained], { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) }));
729
+ // Reported by the caller, where it reads after the table or the per-app results instead of
730
+ // scrolling away above them.
731
+ const missing = [...new Set(pinned)].filter((model) => !ids.includes(model));
732
+ if (save)
733
+ saveStore(store);
734
+ return { ids, added, removed, missing, catalog };
735
+ }
736
+ function printModelRefresh(provider, refresh) {
737
+ out(`${pc.bold(provider.id)}: ${refresh.ids.length} models (${pc.green(`+${refresh.added.length}`)} / ${pc.red(`-${refresh.removed.length}`)})` +
738
+ (refresh.added.length ? `\n new: ${refresh.added.join(", ")}` : "") +
739
+ (refresh.removed.length ? `\n removed upstream: ${refresh.removed.join(", ")}` : ""));
740
+ out(table(modelRows(provider.models), MODEL_HEADER, MODEL_TABLE_OPTIONS));
741
+ printUncatalogedHint(provider.models, refresh.catalog, provider.modelsDevId);
742
+ }
743
+ /** Comma-separated ids, the same shape `--apps` takes. Repeats collapse; blanks are ignored. */
744
+ function splitProviderList(value) {
745
+ return [...new Set(value.split(",").map((id) => id.trim()).filter(Boolean))];
746
+ }
747
+ /**
748
+ * One provider selection for every command that acts on providers: `--all` takes every configured
749
+ * one, `--provider` takes a comma-separated list, and `fallback` says what no selection means —
750
+ * `"active"` for the commands that write (sync), `"all"` for the ones that only read and refresh.
751
+ * Every id is resolved up front so an unknown one fails before the first network call, never after
752
+ * half a run has been saved. An empty store is an error only when `--all` asked for it by name; the
753
+ * implicit all-providers fallback keeps reporting zero, as it did before there was a flag.
754
+ */
755
+ function resolveProviderIds(store, opts, fallback) {
756
+ if (opts.all && opts.provider !== undefined)
757
+ fail(t("select.allAndProvider"));
758
+ let ids;
759
+ if (opts.all) {
760
+ ids = Object.keys(store.providers);
761
+ if (!ids.length)
762
+ fail(t("select.noProviders"));
763
+ }
764
+ else if (opts.provider !== undefined) {
765
+ ids = splitProviderList(opts.provider);
766
+ if (!ids.length)
767
+ fail(t("select.noneSelected"));
768
+ }
769
+ else if (fallback === "all") {
770
+ ids = Object.keys(store.providers);
771
+ }
772
+ else {
773
+ if (!store.active)
774
+ fail(t("sync.noActive"));
775
+ ids = [store.active];
776
+ }
777
+ for (const id of ids)
778
+ getProvider(store, id);
779
+ return ids;
780
+ }
781
+ /**
782
+ * Apps that name a single provider are written once (see `RunTargetsOptions.oneSlot`); for the rest
783
+ * the last writer owns whatever "current" pointer the app keeps, so a multi-provider sync finishes
784
+ * on the active provider and leaves the agents where the store says they already are.
785
+ */
786
+ function activeLast(store, ids) {
787
+ const active = store.active;
788
+ if (ids.length < 2 || !active || !ids.includes(active))
789
+ return ids;
790
+ return [...ids.filter((id) => id !== active), active];
791
+ }
792
+ /**
793
+ * Which provider each single-slot app should receive. The active provider wins where the app can
794
+ * accept it; where it cannot — Claude Code takes anthropic endpoints only, and the active provider
795
+ * may well be an openai one — the single compatible provider in the run wins instead, because
796
+ * writing the only candidate takes nothing away. With several compatible candidates and no active
797
+ * provider among them the choice is the user's, so the app is left pointing where it already does.
798
+ */
799
+ function oneSlotWinners(selected, store, ids) {
800
+ const winners = new Map();
801
+ for (const target of selected) {
802
+ if (!target.singleProvider)
803
+ continue;
804
+ const usable = ids.filter((id) => supportsProtocol(target, store.providers[id].protocol));
805
+ const winner = store.active !== undefined && usable.includes(store.active)
806
+ ? store.active
807
+ : usable.length === 1 ? usable[0] : undefined;
808
+ if (winner !== undefined)
809
+ winners.set(target.id, winner);
810
+ }
811
+ return winners;
812
+ }
813
+ /** Section separator plus the tail line that says how many of several providers came through. */
814
+ function printMultiSummary(count, failed, key) {
815
+ const done = count - failed;
816
+ out(`\n${failed
817
+ ? pc.yellow(t(key === "sync" ? "sync.summaryFailed" : "discover.summaryFailed", { ok: done, count }))
818
+ : t(key === "sync" ? "sync.summaryOk" : "discover.summaryOk", { count })}`);
819
+ }
684
820
  export async function cmdSync(opts) {
685
- resolveTargets(opts.apps);
821
+ const selectedTargets = resolveTargets(opts.apps);
686
822
  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);
823
+ const ids = activeLast(store, resolveProviderIds(store, opts, "active"));
824
+ const many = ids.length > 1;
825
+ const oneSlot = many ? { winners: oneSlotWinners(selectedTargets, store, ids) } : undefined;
826
+ const saveRefresh = opts.dryRun !== true;
827
+ const reports = [];
828
+ let failed = 0;
829
+ for (const [index, id] of ids.entries()) {
830
+ const provider = getProvider(store, id);
831
+ if (index)
832
+ out("");
833
+ let refresh;
834
+ if (opts.refresh !== false) {
835
+ try {
836
+ refresh = await refreshProviderModels(store, provider, saveRefresh);
837
+ }
838
+ catch (err) {
839
+ // One unreachable provider must not strand the others: report it, write nothing for it,
840
+ // and fail the run. A single-provider sync has nothing else to do, so it still throws.
841
+ if (!many)
842
+ throw new Error(`${err.message}\n${t("sync.refreshFailed")}`);
843
+ warn(t("sync.refreshSkipped", { id, message: err.message }));
844
+ process.exitCode = 1;
845
+ failed += 1;
846
+ reports.push({ provider: id, refreshFailed: true });
847
+ continue;
848
+ }
849
+ if (!isJson()) {
850
+ printModelRefresh(provider, refresh);
851
+ // --dry-run promises to write nothing, so say the fetched list only lived long enough to preview.
852
+ if (!saveRefresh)
853
+ note(t("sync.refreshNotSaved"));
854
+ }
855
+ }
856
+ out(`${t("sync.syncing", { id: pc.bold(id), model: provider.defaultModel })}\n`);
857
+ const outcome = await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun, oneSlot);
858
+ if (refresh?.missing.length) {
859
+ const models = refresh.missing.join(", ");
860
+ warn(t(opts.dryRun ? "refresh.pinnedMissing" : "sync.pinnedMissingWritten", { models, id }));
861
+ }
862
+ reports.push({
863
+ provider: id,
864
+ model: provider.defaultModel,
865
+ ...(refresh
866
+ ? {
867
+ refreshed: {
868
+ models: refresh.ids.length,
869
+ added: refresh.added,
870
+ removed: refresh.removed,
871
+ missing: refresh.missing,
872
+ saved: saveRefresh,
873
+ },
874
+ }
875
+ : {}),
876
+ ...outcome,
877
+ });
878
+ }
693
879
  if (isJson()) {
694
- emitJson({ provider: id, model: provider.defaultModel, dryRun: opts.dryRun === true, ...outcome });
880
+ const first = reports[0] ?? {};
881
+ emitJson(many
882
+ ? {
883
+ dryRun: opts.dryRun === true,
884
+ providers: reports,
885
+ files: [...new Set(reports.flatMap((report) => report.files ?? []))],
886
+ }
887
+ : { ...first, dryRun: opts.dryRun === true });
888
+ return;
695
889
  }
890
+ if (many)
891
+ printMultiSummary(ids.length, failed, "sync");
696
892
  }
697
893
  export function cmdStatus() {
698
894
  const store = loadStore();
@@ -770,7 +966,8 @@ export async function cmdModels(query, opts) {
770
966
  }
771
967
  export async function cmdRefreshMeta(opts = {}) {
772
968
  const store = loadStore();
773
- const providers = opts.provider ? [getProvider(store, opts.provider)] : Object.values(store.providers);
969
+ // Omitting the selection still means every provider: this command only reads catalogs.
970
+ const providers = resolveProviderIds(store, opts, "all").map((id) => getProvider(store, id));
774
971
  resolveMetadataOptions(opts);
775
972
  for (const provider of providers)
776
973
  Object.assign(provider, resolveMetadataOptions(opts, provider));
@@ -797,50 +994,61 @@ export async function cmdRefreshMeta(opts = {}) {
797
994
  for (const provider of providers)
798
995
  printUncatalogedHint(provider.models, catalog, provider.modelsDevId);
799
996
  }
800
- export async function cmdDiscover(id, opts) {
997
+ export async function cmdDiscover(ids, opts) {
801
998
  resolveTargets(opts.apps);
802
999
  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 })));
1000
+ // Ids may arrive as positional arguments, as one comma-separated list, or through `-p` for
1001
+ // symmetry with sync; all three say the same thing, so accept them together.
1002
+ const listed = [...(Array.isArray(ids) ? ids : [ids ?? ""]), opts.provider ?? ""].filter(Boolean).join(",");
1003
+ const selected = resolveProviderIds(store, { provider: listed || undefined, all: opts.all }, "active");
1004
+ // Reject bad metadata options once, before the first provider is contacted.
1005
+ resolveMetadataOptions(opts);
1006
+ const many = selected.length > 1;
1007
+ const reports = [];
1008
+ const refreshed = [];
1009
+ for (const [index, id] of selected.entries()) {
1010
+ const provider = getProvider(store, id);
1011
+ if (index)
1012
+ out("");
1013
+ Object.assign(provider, resolveMetadataOptions(opts, provider));
1014
+ // flags override and re-persist the filter; --no-filter clears it
1015
+ const flagFilter = parseFilterOpts(opts, provider.modelFilter);
1016
+ if (opts.filter === false)
1017
+ provider.modelFilter = undefined;
1018
+ else if (flagFilter)
1019
+ provider.modelFilter = flagFilter;
1020
+ let refresh;
1021
+ try {
1022
+ refresh = await refreshProviderModels(store, provider);
1023
+ }
1024
+ catch (err) {
1025
+ // One unreachable endpoint must not strand the others; a single provider still throws.
1026
+ if (!many)
1027
+ throw err;
1028
+ warn(t("sync.refreshSkipped", { id, message: err.message }));
1029
+ process.exitCode = 1;
1030
+ reports.push({ provider: id, refreshFailed: true });
1031
+ continue;
1032
+ }
1033
+ refreshed.push(id);
1034
+ reports.push({ provider: id, models: refresh.ids.length, added: refresh.added, removed: refresh.removed, missing: refresh.missing });
1035
+ if (!isJson())
1036
+ printModelRefresh(provider, refresh);
1037
+ if (refresh.missing.length)
1038
+ warn(t("refresh.pinnedMissing", { models: refresh.missing.join(", "), id }));
828
1039
  }
829
- saveStore(store);
830
1040
  if (isJson()) {
831
- emitJson({ provider: id, models: ids.length, added, removed: gone });
1041
+ emitJson(many ? { providers: reports } : (reports[0] ?? {}));
832
1042
  return;
833
1043
  }
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) {
1044
+ if (many)
1045
+ printMultiSummary(selected.length, selected.length - refreshed.length, "discover");
1046
+ if (opts.sync && refreshed.length) {
840
1047
  out("");
841
- await cmdSync({ provider: id, apps: opts.apps });
1048
+ // The lists are already fresh: write them without a second fetch.
1049
+ await cmdSync({ provider: refreshed.join(","), apps: opts.apps, refresh: false });
842
1050
  }
843
- else {
1051
+ else if (!opts.sync) {
844
1052
  note(`\n${t("discover.next")}`);
845
1053
  }
846
1054
  }
package/dist/i18n.js CHANGED
@@ -14,10 +14,10 @@ const messages = {
14
14
  "cmd.prune": { en: "remove a provider's entries from app configs (keeps it in agentsw)", "zh-CN": "从应用配置移除供应商条目(仍保留在 agentsw)" },
15
15
  "cmd.rename": { en: "rename a provider and migrate config references with backups", "zh-CN": "重命名供应商并迁移配置引用(自动备份)" },
16
16
  "cmd.use": { en: "set the active provider and write it into app configs", "zh-CN": "设为当前供应商并写入应用配置" },
17
- "cmd.sync": { en: "re-apply the active provider (or --provider) to app configs", "zh-CN": "将当前供应商(或 --provider)重新应用到应用配置" },
17
+ "cmd.sync": { en: "refresh a provider's model list and re-apply it to app configs (--no-refresh writes the saved list)", "zh-CN": "刷新供应商模型列表并重新应用到应用配置(--no-refresh 只写入已保存的列表)" },
18
18
  "cmd.status": { en: "show detected apps and what they currently point at", "zh-CN": "显示检测到的应用及当前供应商" },
19
19
  "cmd.models": { en: "search models.dev, or show a configured provider's models", "zh-CN": "搜索 models.dev 或显示已配置供应商的模型" },
20
- "cmd.discover": { en: "refresh a provider's model list and configured metadata sources", "zh-CN": "获取供应商模型列表,并通过已启用的目录补全参数" },
20
+ "cmd.discover": { en: "refresh model lists and configured metadata sources for one or more providers", "zh-CN": "刷新一个或多个供应商的模型列表,并通过已启用的目录补全参数" },
21
21
  "cmd.import": { en: "scan app configs, preview and dedupe existing custom providers, then import", "zh-CN": "扫描应用配置,预览并去重已有自定义供应商后导入" },
22
22
  "cmd.apps": { en: "check installed agents: current version vs latest", "zh-CN": "检查智能体已安装版本与最新版本" },
23
23
  "cmd.install": { en: "install an agent CLI", "zh-CN": "安装智能体 CLI" },
@@ -35,6 +35,7 @@ const messages = {
35
35
  "opt.models": { en: "comma-separated model ids", "zh-CN": "逗号分隔的模型 id" },
36
36
  "opt.defaultModel": { en: "default model (defaults to first)", "zh-CN": "默认模型(默认取第一个)" },
37
37
  "opt.smallModel": { en: "small/fast model (Claude Code haiku slot)", "zh-CN": "小型/快速模型(Claude Code haiku 槽位)" },
38
+ "opt.noSmallModel": { en: "clear the small/fast model so the default model fills that slot", "zh-CN": "清除小型/快速模型,该槽位改用默认模型" },
38
39
  "opt.reasoning": { en: "preferred reasoning effort (codex): minimal|low|medium|high", "zh-CN": "Codex 首选推理等级: minimal|low|medium|high" },
39
40
  "opt.discover": { en: "list model ids from the provider's /v1/models", "zh-CN": "从供应商 /v1/models 获取模型 id" },
40
41
  "opt.include": { en: "keep only models matching comma-separated globs", "zh-CN": "仅保留匹配逗号分隔 glob 的模型" },
@@ -47,7 +48,7 @@ const messages = {
47
48
  "opt.noGatewayMetadata": { en: "disable Gateway fetching; keep saved parameters (same as --metadata-mode off)", "zh-CN": "关闭 Gateway 获取,保留已保存参数(等同 --metadata-mode off)" },
48
49
  "opt.gatewayModels": { en: 'replace exact aliases as JSON: {"local-id":"creator/model"}; {} clears them', "zh-CN": '用 JSON 替换精确映射:{"本地模型ID":"creator/model"};{} 清空' },
49
50
  "opt.metadataDetails": { en: "show field sources, conflicts and Gateway reference pricing as JSON (requires --provider)", "zh-CN": "以 JSON 查看字段来源、冲突和 Gateway 参考价格(需 --provider)" },
50
- "opt.refreshProvider": { en: "refresh/configure only this saved provider (default: all)", "zh-CN": "仅刷新和设置该供应商(默认全部)" },
51
+ "opt.refreshProvider": { en: "refresh/configure only these saved providers, comma-separated (default: all)", "zh-CN": "仅刷新和设置这些供应商,逗号分隔(默认全部)" },
51
52
  "opt.yes": { en: "non-interactive; require all flags", "zh-CN": "非交互模式;要求提供全部参数" },
52
53
  "opt.prune": { en: "also remove entries from app configs", "zh-CN": "同时从应用配置中移除条目" },
53
54
  "opt.apps": { en: "comma-separated apps or 'all'", "zh-CN": "逗号分隔的应用或 'all'" },
@@ -56,9 +57,12 @@ const messages = {
56
57
  "opt.appsDetailed": { en: "comma-separated apps (claude,codex,omp,pi,prime,opencode,hermes,workbuddy,dsh) or 'all'", "zh-CN": "逗号分隔的应用(claude,codex,omp,pi,prime,opencode,hermes,workbuddy,dsh)或 'all'" },
57
58
  "opt.model": { en: "override default model while switching", "zh-CN": "切换时覆盖默认模型" },
58
59
  "opt.dryRun": { en: "preview config diff without writing", "zh-CN": "仅预览配置差异,不写入" },
59
- "opt.provider": { en: "sync this provider instead of the active one", "zh-CN": "同步指定供应商而非当前供应商" },
60
+ "opt.provider": { en: "sync these providers instead of the active one (comma-separated)", "zh-CN": "同步指定供应商而非当前供应商(逗号分隔)" },
61
+ "opt.syncAll": { en: "sync every configured provider", "zh-CN": "同步全部已配置的供应商" },
62
+ "opt.refreshAll": { en: "refresh every configured provider", "zh-CN": "刷新全部已配置的供应商" },
60
63
  "opt.showProvider": { en: "show models of a configured provider", "zh-CN": "显示已配置供应商的模型" },
61
64
  "opt.refresh": { en: "force-refresh models.dev cache", "zh-CN": "强制刷新 models.dev 缓存" },
65
+ "opt.noRefresh": { en: "skip the model-list refresh; write the saved list only", "zh-CN": "跳过模型列表刷新;只写入已保存的列表" },
62
66
  "opt.limit": { en: "maximum results", "zh-CN": "最大结果数" },
63
67
  "opt.syncAfter": { en: "push refreshed provider into app configs", "zh-CN": "刷新后推送到应用配置" },
64
68
  "opt.appsSync": { en: "apps to sync when --sync is set", "zh-CN": "--sync 时要同步的应用" },
@@ -80,8 +84,7 @@ const messages = {
80
84
  "menu.use": { en: "Switch provider and default model", "zh-CN": "切换供应商和默认模型" },
81
85
  "menu.status": { en: "View each agent's current configuration", "zh-CN": "查看各智能体当前配置" },
82
86
  "menu.list": { en: "View providers saved in agentsw", "zh-CN": "查看 agentsw 供应商列表" },
83
- "menu.sync": { en: "Re-sync the current provider to agents", "zh-CN": "重新同步当前供应商" },
84
- "menu.discover": { en: "Update a provider's model list", "zh-CN": "更新供应商模型列表" },
87
+ "menu.sync": { en: "Sync providers", "zh-CN": "同步供应商" },
85
88
  "menu.metadata": { en: "Configure and refresh model metadata", "zh-CN": "设置并刷新模型参数补充源" },
86
89
  "menu.metadataHelp": { en: "Keep the model list; choose automatic, always-on or disabled AI Gateway lookup, then refresh saved parameters only.", "zh-CN": "保留模型列表,选择 AI Gateway 自动按需、始终补充或关闭,仅刷新保存的参数。" },
87
90
  "menu.metadataProvider": { en: "which provider's metadata settings should be updated?", "zh-CN": "选择要设置模型参数补充源的供应商" },
@@ -128,8 +131,8 @@ const messages = {
128
131
  "menu.appsAll": { en: "all {count} detected apps", "zh-CN": "全部 {count} 个检测到的应用" },
129
132
  "menu.appNotDetected": { en: "(not detected)", "zh-CN": "(未检测到)" },
130
133
  "menu.appsHint": { en: "↑/↓ move · Space toggle · Enter confirm", "zh-CN": "↑/↓ 移动 · 空格切换 · Enter 确认" },
131
- "menu.discoverFor": { en: "which provider's model list should be updated?", "zh-CN": "选择要更新模型列表的供应商" },
132
- "menu.pushRefresh": { en: "also sync this provider to agent configs after updating its model list?", "zh-CN": "更新模型列表后,是否同时将该供应商同步到智能体配置?" },
134
+ "menu.syncFor": { en: "which providers should be synced?", "zh-CN": "选择要同步的供应商(可多选)" },
135
+ "menu.providersAll": { en: "all {count} configured providers", "zh-CN": "全部 {count} 个已配置供应商" },
133
136
  "menu.removeProvider": { en: "which provider configuration should be deleted?", "zh-CN": "选择要删除配置的供应商" },
134
137
  "menu.upgrade": { en: "upgrade all installed agents that have updates?", "zh-CN": "是否升级所有有新版本的已安装智能体?" },
135
138
  "menu.installApp": { en: "Install a new coding agent", "zh-CN": "安装新的智能体" },
@@ -205,8 +208,7 @@ const messages = {
205
208
  "menu.useHelp": { en: "Select a provider and default model, then write them to compatible agent configs.", "zh-CN": "选择供应商和默认模型,然后写入支持该协议的智能体配置。" },
206
209
  "menu.statusHelp": { en: "Show detected agents, their current providers and config file locations.", "zh-CN": "查看检测到的智能体、所用供应商及配置文件位置。" },
207
210
  "menu.listHelp": { en: "Show only the providers, protocols and default models saved in agentsw.", "zh-CN": "只查看 agentsw 已保存的供应商、协议和默认模型。" },
208
- "menu.syncHelp": { en: "Write the current agentsw provider to agent configs without fetching a model list or metadata catalogs.", "zh-CN": "将 agentsw 当前供应商写入智能体配置,不重新获取模型列表或元数据目录。" },
209
- "menu.discoverHelp": { en: "Fetch models from the provider, fill in model metadata, and optionally sync to agents.", "zh-CN": "从供应商接口重新获取模型并补全模型参数,可选择同步到智能体。" },
211
+ "menu.syncHelp": { en: "Fetch each selected provider's current model list, fill in saved parameters, then write them to the selected agent configs.", "zh-CN": "依次获取所选供应商的最新模型列表并补全保存的参数,然后写入所选智能体配置。" },
210
212
  "menu.renameHelp": { en: "Change the config ID and update references; keep custom display names.", "zh-CN": "修改配置中的标识并更新关联引用,保留自定义显示名称。" },
211
213
  "menu.removeHelp": { en: "Choose where to delete: agentsw only, one agent only, or both.", "zh-CN": "先选择删除范围:仅 agentsw、仅某个智能体,或两处一起。" },
212
214
  "menu.appsHelp": { en: "Compare installed and latest versions, then choose whether to upgrade.", "zh-CN": "对比已安装版本和最新版本,再选择是否升级。" },
@@ -241,6 +243,14 @@ const messages = {
241
243
  "use.switching": { en: "switching to {id} ({protocol}) · default model {model}", "zh-CN": "正在切换到 {id} ({protocol})· 默认模型 {model}" },
242
244
  "sync.noActive": { en: "no active provider; run agentsw use <id> first", "zh-CN": "没有当前供应商;请先运行 agentsw use <id>" },
243
245
  "sync.syncing": { en: "syncing provider {id} · default model {model}", "zh-CN": "正在同步供应商 {id} · 默认模型 {model}" },
246
+ "select.allAndProvider": { en: "--all cannot be combined with a provider selection", "zh-CN": "--all 不能与指定的供应商同时使用" },
247
+ "select.noProviders": { en: "no providers configured; run agentsw add first", "zh-CN": "尚未配置任何供应商;请先运行 agentsw add" },
248
+ "select.noneSelected": { en: "the provider selection is empty; name at least one id", "zh-CN": "未指定任何供应商,至少需要一个 id" },
249
+ "sync.refreshSkipped": { en: "skipping {id}: {message}", "zh-CN": "已跳过 {id}:{message}" },
250
+ "sync.summaryOk": { en: "synced {count} providers", "zh-CN": "已同步 {count} 个供应商" },
251
+ "sync.summaryFailed": { en: "synced {ok} of {count} providers; the rest were skipped", "zh-CN": "已同步 {count} 个中的 {ok} 个供应商,其余已跳过" },
252
+ "sync.refreshNotSaved": { en: "dry run: the refreshed model list was not saved", "zh-CN": "试运行:刷新到的模型列表未保存" },
253
+ "sync.refreshFailed": { en: "could not refresh the model list; pass --no-refresh to write the saved list instead", "zh-CN": "无法刷新模型列表;可以加 --no-refresh 只写入已保存的列表" },
244
254
  "status.config": { en: "config: {file}", "zh-CN": "配置:{file}" },
245
255
  "status.active": { en: "active provider: {id}", "zh-CN": "当前供应商:{id}" },
246
256
  "status.none": { en: "(none)", "zh-CN": "(无)" },
@@ -375,9 +385,15 @@ const messages = {
375
385
  },
376
386
  "upgrade.done": { en: "{id} -> {version}", "zh-CN": "{id} -> {version}" },
377
387
  "upgrade.failed": { en: "{id} upgrade failed: {message}", "zh-CN": "{id} 升级失败:{message}" },
378
- "discover.defaultMissing": {
379
- en: "default model {model} no longer listed; keeping it anyway",
380
- "zh-CN": "默认模型 {model} 已不在列表中;仍然保留",
388
+ "discover.summaryOk": { en: "refreshed {count} providers", "zh-CN": "已刷新 {count} 个供应商" },
389
+ "discover.summaryFailed": { en: "refreshed {ok} of {count} providers; the rest were skipped", "zh-CN": "已刷新 {count} 个中的 {ok} 个供应商,其余已跳过" },
390
+ "refresh.pinnedMissing": {
391
+ en: "{models} no longer listed by the endpoint; kept as configured — pick a listed model with: agentsw use {id} -m <model>",
392
+ "zh-CN": "{models} 已不在端点的模型列表中;仍按原配置保留 —— 可用 agentsw use {id} -m <model> 换成列表中的模型",
393
+ },
394
+ "sync.pinnedMissingWritten": {
395
+ en: "{models} no longer listed by the endpoint but was written to the agents anyway; pick a listed model with: agentsw use {id} -m <model>",
396
+ "zh-CN": "{models} 已不在端点的模型列表中,但仍写入了各智能体;可用 agentsw use {id} -m <model> 换成列表中的模型",
381
397
  },
382
398
  "discover.next": {
383
399
  en: "run `agentsw sync` to push into app configs",
package/dist/index.js CHANGED
@@ -133,14 +133,18 @@ program
133
133
  .description(t("cmd.use"))
134
134
  .option("-a, --apps <apps>", t("opt.appsDetailed"))
135
135
  .option("-m, --model <id>", t("opt.model"))
136
+ .option("--small-model <id>", t("opt.smallModel"))
137
+ .option("--no-small-model", t("opt.noSmallModel"))
136
138
  .option("-n, --dry-run", t("opt.dryRun"))
137
139
  .action(cmdUse);
138
140
  program
139
141
  .command("sync")
140
142
  .description(t("cmd.sync"))
141
143
  .option("-a, --apps <apps>", t("opt.apps"))
142
- .option("-p, --provider <id>", t("opt.provider"))
144
+ .option("-p, --provider <ids>", t("opt.provider"))
145
+ .option("--all", t("opt.syncAll"))
143
146
  .option("-n, --dry-run", t("opt.dryRun"))
147
+ .option("--no-refresh", t("opt.noRefresh"))
144
148
  .action(cmdSync);
145
149
  program.command("status").description(t("cmd.status")).action(cmdStatus);
146
150
  program
@@ -152,8 +156,10 @@ program
152
156
  .option("-l, --limit <n>", t("opt.limit"), "30")
153
157
  .action(cmdModels);
154
158
  program
155
- .command("discover <id>")
159
+ .command("discover [ids...]")
156
160
  .description(t("cmd.discover"))
161
+ .option("-p, --provider <ids>", t("opt.refreshProvider"))
162
+ .option("--all", t("opt.refreshAll"))
157
163
  .option("-s, --sync", t("opt.syncAfter"))
158
164
  .option("-a, --apps <apps>", t("opt.appsSync"))
159
165
  .option("--include <globs>", t("opt.setInclude"))
@@ -181,7 +187,8 @@ program.command("upgrade [apps...]").description(t("cmd.upgrade")).action(cmdUpg
181
187
  program
182
188
  .command("refresh")
183
189
  .description(t("cmd.refresh"))
184
- .option("-p, --provider <id>", t("opt.refreshProvider"))
190
+ .option("-p, --provider <ids>", t("opt.refreshProvider"))
191
+ .option("--all", t("opt.refreshAll"))
185
192
  .option("--metadata-mode <auto|on|off>", t("opt.metadataMode"))
186
193
  .option("--gateway-metadata", t("opt.gatewayMetadata"))
187
194
  .option("--no-gateway-metadata", t("opt.noGatewayMetadata"))
package/dist/menu.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import pc from "picocolors";
2
2
  import prompts from "prompts";
3
- import { cmdAdd, cmdQuickAdd, cmdApps, cmdInstall, cmdDiscover, cmdImport, cmdRefreshMeta, cmdList, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
3
+ import { cmdAdd, cmdQuickAdd, cmdApps, cmdInstall, cmdImport, cmdRefreshMeta, cmdList, cmdStatus, cmdSync, cmdUpgrade, cmdUse, } from "./commands.js";
4
4
  import { getLocale, setLocale, t } from "./i18n.js";
5
5
  import { getMetadataMode } from "./metadata.js";
6
6
  import { loadStore, saveStore } from "./store.js";
@@ -66,6 +66,41 @@ async function pickProvider(message) {
66
66
  const p = store.providers[id];
67
67
  return { id, defaultModel: p.defaultModel, models: p.models.map((m) => m.id) };
68
68
  }
69
+ /**
70
+ * Multi-select providers for one sync. The first row takes every configured provider and maps to
71
+ * the CLI's `--all`; toggling everything off means "back", not "all", because a sync writes files.
72
+ */
73
+ async function pickProviders(message) {
74
+ const store = loadStore();
75
+ const ids = Object.keys(store.providers);
76
+ if (ids.length === 0) {
77
+ out(pc.yellow(t("menu.noProvidersHint")));
78
+ return undefined;
79
+ }
80
+ const { providers } = await prompts({
81
+ type: "multiselect",
82
+ name: "providers",
83
+ message,
84
+ hint: t("menu.appsHint"),
85
+ instructions: false,
86
+ choices: [
87
+ { title: t("menu.providersAll", { count: ids.length }), value: "__all__" },
88
+ ...ids.map((pid) => {
89
+ const p = store.providers[pid];
90
+ return {
91
+ title: `${pid} · ${p.protocol} · ${t("menu.defaultModel")} ${p.defaultModel}${store.active === pid ? ` (${t("menu.active")})` : ""}`,
92
+ value: pid,
93
+ selected: store.active === pid,
94
+ };
95
+ }),
96
+ ],
97
+ }, cancel);
98
+ const chosen = Array.isArray(providers) ? providers : [];
99
+ if (chosen.includes("__all__"))
100
+ return { all: true, ids };
101
+ const selected = chosen.filter((value) => value !== "__all__");
102
+ return selected.length ? { all: false, ids: selected } : undefined;
103
+ }
69
104
  /**
70
105
  * Multi-select target apps, remembering the previous choice. The first row selects
71
106
  * every detected app; picking it (or toggling everything off) returns undefined,
@@ -212,7 +247,6 @@ export async function cmdMenu() {
212
247
  { title: t("menu.status"), description: t("menu.statusHelp"), value: "status" },
213
248
  { title: t("menu.list"), description: t("menu.listHelp"), value: "list" },
214
249
  { title: t("menu.sync"), description: t("menu.syncHelp"), value: "sync" },
215
- { title: t("menu.discover"), description: t("menu.discoverHelp"), value: "discover" },
216
250
  { title: t("menu.metadata"), description: t("menu.metadataHelp"), value: "metadata" },
217
251
  { title: t("menu.rename"), description: t("menu.renameHelp"), value: "rename" },
218
252
  { title: t("menu.remove"), description: t("menu.removeHelp"), value: "remove" },
@@ -271,14 +305,11 @@ export async function cmdMenu() {
271
305
  cmdList();
272
306
  }
273
307
  else if (action === "sync") {
274
- await cmdSync({ apps: await pickApps(t("menu.pickApps")) });
275
- }
276
- else if (action === "discover") {
277
- const picked = await pickProvider(t("menu.discoverFor"));
308
+ const picked = await pickProviders(t("menu.syncFor"));
278
309
  if (!picked)
279
310
  continue;
280
- const sync = await askToggle(t("menu.pushRefresh"));
281
- await cmdDiscover(picked.id, { sync, apps: sync ? await pickApps(t("menu.pickApps")) : undefined });
311
+ const apps = await pickApps(t("menu.pickApps"));
312
+ await cmdSync(picked.all ? { all: true, apps } : { provider: picked.ids.join(","), apps });
282
313
  }
283
314
  else if (action === "metadata") {
284
315
  const picked = await pickProvider(t("menu.metadataProvider"));
@@ -14,6 +14,8 @@ export const claudecode = transactionalTarget({
14
14
  id: "claude",
15
15
  name: "Claude Code",
16
16
  protocols: ["anthropic"],
17
+ // The env block names one endpoint: a second provider replaces the first rather than joining it.
18
+ singleProvider: true,
17
19
  get configPaths() {
18
20
  return [claudeSettingsFile()];
19
21
  },
@@ -5,7 +5,7 @@ import { dshCredentialsFile, dshDir, dshSettingsFile } from "../app-paths.js";
5
5
  import { isManagedCredentialRef, legacyManagedCredentialRef, managedCredentialRef } from "../provider-identity.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
7
  import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
8
- import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl } from "./wire.js";
8
+ import { apiValue, classifyApi, entryApi, hasModelMetadata, mergeModels, sdkBaseUrl } from "./wire.js";
9
9
  /** Layout version of `.credentials.yaml` this build reads and writes. */
10
10
  const CREDENTIALS_VERSION = 1;
11
11
  /** Thinking levels a `reasoningEfforts` entry may offer (llm-pi-ai THINKING_LEVEL_GATE). */
@@ -29,7 +29,8 @@ function modelEntry(m) {
29
29
  ...(m.name ? { name: m.name } : {}),
30
30
  ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
31
31
  ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
32
- input: m.imageInput ? ["text", "image"] : ["text"],
32
+ // `input` is a guess for a spec that says nothing; see hasModelMetadata.
33
+ ...(hasModelMetadata(m) ? { input: m.imageInput ? ["text", "image"] : ["text"] } : {}),
33
34
  ...(m.reasoning === false
34
35
  ? { reasoningEfforts: false }
35
36
  : m.reasoning && m.reasoningEfforts?.length
@@ -5,9 +5,7 @@ import { ompAgentDir, ompModelsFiles } from "../app-paths.js";
5
5
  import { looksLikeEnvName } from "../slug.js";
6
6
  import { transactionalTarget } from "../target-transaction.js";
7
7
  import { parseYamlMapping, serializeYamlMapping } from "../yaml.js";
8
- import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
9
- /** Per-model keys this adapter writes; one that stops being emitted is cleared, not inherited. */
10
- const OWNED_MODEL_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
8
+ import { apiValue, applyDeepseekCompat, classifyApi, entryApi, mergeModels, OWNED_MODEL_METADATA_KEYS, ownedModelMetadata, sdkBaseUrl, stripConflictingOverrides, } from "./wire.js";
11
9
  /** omp has no directory override; the file set is shared with remove/rename. */
12
10
  function modelFiles() {
13
11
  const [yml, yaml] = ompModelsFiles();
@@ -43,27 +41,12 @@ export const omp = transactionalTarget({
43
41
  const anthropic = provider.protocol === "anthropic";
44
42
  const api = apiValue(provider.protocol, provider.openaiApi, prev?.api ?? entryApi(prev ?? {}));
45
43
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
46
- const models = mergeModels(prev?.models, provider.models.map((m) => ({
47
- id: m.id,
48
- ...(m.name ? { name: m.name } : {}),
49
- ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
50
- input: m.imageInput ? ["text", "image"] : ["text"],
51
- ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
52
- ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
53
- ...(m.cost
54
- ? {
55
- cost: {
56
- input: m.cost.input ?? 0,
57
- output: m.cost.output ?? 0,
58
- cacheRead: m.cost.cacheRead ?? 0,
59
- cacheWrite: m.cost.cacheWrite ?? 0,
60
- },
61
- }
62
- : {}),
63
- })), OWNED_MODEL_KEYS);
44
+ const models = mergeModels(prev?.models, provider.models.map(ownedModelMetadata), OWNED_MODEL_METADATA_KEYS);
64
45
  const conflicts = stripConflictingOverrides(models, api, baseUrl);
65
46
  if (conflicts.length)
66
47
  notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
48
+ if (!anthropic)
49
+ applyDeepseekCompat(models, { requiresReasoningContentForToolCalls: true });
67
50
  const entry = {
68
51
  baseUrl,
69
52
  apiKey: provider.apiKey, // omp treats value as env-var name first, then literal
@@ -5,18 +5,9 @@ import { piDir, piModelsFile, piSettingsFile } from "../app-paths.js";
5
5
  import { editJsoncObject, isJsonObject, readJsoncObject } from "../jsonc.js";
6
6
  import { looksLikeEnvName } from "../slug.js";
7
7
  import { transactionalTarget } from "../target-transaction.js";
8
- import { apiValue, classifyApi, entryApi, mergeModels, sdkBaseUrl, stripConflictingOverrides } from "./wire.js";
9
- /** Per-model keys this adapter writes; one a metadata-bearing entry stops emitting is cleared, not inherited. */
10
- const OWNED_MODEL_KEYS = [
11
- "id",
12
- "name",
13
- "reasoning",
14
- "thinkingLevelMap",
15
- "input",
16
- "contextWindow",
17
- "maxTokens",
18
- "cost",
19
- ];
8
+ import { apiValue, applyDeepseekCompat, classifyApi, entryApi, mergeModels, OWNED_MODEL_METADATA_KEYS, ownedModelMetadata, sdkBaseUrl, stripConflictingOverrides, } from "./wire.js";
9
+ /** pi writes the shared metadata plus its own thinking-level map. */
10
+ const OWNED_MODEL_KEYS = [...OWNED_MODEL_METADATA_KEYS, "thinkingLevelMap"];
20
11
  function readModels(file) {
21
12
  const document = readJsoncObject(file);
22
13
  const providers = document.value.providers;
@@ -84,26 +75,17 @@ export function piStyleTarget(opts) {
84
75
  const api = apiValue(provider.protocol, provider.openaiApi, prev.api ?? entryApi(prev));
85
76
  const baseUrl = sdkBaseUrl(provider.protocol, provider.baseUrl);
86
77
  const models = mergeModels(prev.models, provider.models.map((m) => ({
87
- id: m.id,
88
- ...(m.name ? { name: m.name } : {}),
89
- ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
78
+ ...ownedModelMetadata(m),
90
79
  ...(m.reasoning && m.reasoningEfforts?.length
91
80
  ? { thinkingLevelMap: thinkingLevelMap(m.reasoningEfforts) }
92
81
  : {}),
93
- input: m.imageInput ? ["text", "image"] : ["text"],
94
- ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
95
- ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
96
- ...(m.cost
97
- ? {
98
- cost: {
99
- input: m.cost.input ?? 0,
100
- output: m.cost.output ?? 0,
101
- cacheRead: m.cost.cacheRead ?? 0,
102
- cacheWrite: m.cost.cacheWrite ?? 0,
103
- },
104
- }
105
- : {}),
106
82
  })), OWNED_MODEL_KEYS);
83
+ // pi/prime key their DeepSeek wire rules by provider name, so a gateway
84
+ // entry never picks them up; the flags below are what their own deepseek
85
+ // catalog entries carry. Anthropic-wire entries replay differently.
86
+ if (provider.protocol === "openai") {
87
+ applyDeepseekCompat(models, { requiresReasoningContentOnAssistantMessages: true, thinkingFormat: "deepseek" });
88
+ }
107
89
  const conflicts = stripConflictingOverrides(models, api, baseUrl);
108
90
  if (conflicts.length)
109
91
  notes.push(`dropped model overrides pointing elsewhere: ${conflicts.join(", ")}`);
@@ -99,6 +99,11 @@ derived = ["input"]) {
99
99
  const old = prev.get(m.id);
100
100
  if (!old)
101
101
  return m;
102
+ // An entry that carries nothing but its id says nothing about the model: it
103
+ // is no evidence that the config's metadata went stale, so clearing owned
104
+ // keys on its behalf would delete what a richer sync or the user put there.
105
+ if (Object.keys(m).length === 1)
106
+ return { ...m, ...old };
102
107
  const kept = { ...old };
103
108
  const known = informative.some((key) => key in m);
104
109
  if (known)
@@ -108,6 +113,66 @@ derived = ["input"]) {
108
113
  return { ...kept, ...m };
109
114
  });
110
115
  }
116
+ /** Per-model keys every pi-family adapter owns; see `ownedModelMetadata`. */
117
+ export const OWNED_MODEL_METADATA_KEYS = ["id", "name", "reasoning", "input", "contextWindow", "maxTokens", "cost"];
118
+ const DEEPSEEK_MODEL_ID = /deepseek/i;
119
+ /**
120
+ * omp/pi/prime key their DeepSeek wire rules by provider name ("deepseek",
121
+ * "opencode-go", ...), so a gateway or reseller entry matches none of them: its
122
+ * reasoning replay then breaks and DeepSeek answers 400. Match on the model id
123
+ * instead and write the adapter's own flags per model; an explicit user value wins.
124
+ */
125
+ export function applyDeepseekCompat(models, flags) {
126
+ for (const model of models) {
127
+ if (!DEEPSEEK_MODEL_ID.test(String(model.id)))
128
+ continue;
129
+ const compat = (model.compat ?? {});
130
+ model.compat = { ...flags, ...compat };
131
+ }
132
+ }
133
+ /**
134
+ * Whether a store entry describes the model at all. A bare id is ignorance,
135
+ * not a catalog statement, so adapters must not clear what the app config
136
+ * already holds on its behalf.
137
+ */
138
+ export function hasModelMetadata(m) {
139
+ return (m.name !== undefined ||
140
+ m.reasoning !== undefined ||
141
+ m.reasoningEfforts !== undefined ||
142
+ m.imageInput !== undefined ||
143
+ m.contextWindow !== undefined ||
144
+ m.maxInput !== undefined ||
145
+ m.maxOutput !== undefined ||
146
+ m.cost !== undefined);
147
+ }
148
+ /**
149
+ * The per-model metadata agentsw writes into a pi-family models config. omp and
150
+ * the pi/prime adapters share the shape; only the extras around it differ, so
151
+ * keeping one builder is what stops the two from drifting apart.
152
+ */
153
+ export function ownedModelMetadata(m) {
154
+ // `input` is a guess for a spec that says nothing; writing it would make the
155
+ // entry look authoritative to mergeModels and clear the config's own values.
156
+ const known = hasModelMetadata(m);
157
+ return {
158
+ id: m.id,
159
+ ...(m.name ? { name: m.name } : {}),
160
+ ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
161
+ ...(known ? { input: m.imageInput ? ["text", "image"] : ["text"] } : {}),
162
+ ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
163
+ ...(m.maxOutput ? { maxTokens: m.maxOutput } : {}),
164
+ ...(m.cost
165
+ ? {
166
+ cost: {
167
+ input: m.cost.input ?? 0,
168
+ output: m.cost.output ?? 0,
169
+ cacheRead: m.cost.cacheRead ?? 0,
170
+ cacheWrite: m.cost.cacheWrite ?? 0,
171
+ },
172
+ }
173
+ : {}),
174
+ };
175
+ }
111
176
  /**
112
177
  * Drop per-model `api`/`baseUrl` overrides that contradict the route being
113
178
  * written. A model-level override wins over the provider entry in omp and pi,
@@ -6,6 +6,7 @@ import { workbuddyDir } from "../app-paths.js";
6
6
  import { localProviderId } from "../provider-identity.js";
7
7
  import { providerIdFromBaseUrl, providerNameFromBaseUrl } from "../slug.js";
8
8
  import { transactionalTarget } from "../target-transaction.js";
9
+ import { hasModelMetadata } from "./wire.js";
9
10
  function configDir() {
10
11
  return workbuddyDir();
11
12
  }
@@ -101,12 +102,15 @@ export const workbuddy = transactionalTarget({
101
102
  const previous = new Map(owned.map((row) => [row.id, row]));
102
103
  const ours = provider.models.map((model) => {
103
104
  const custom = { ...previous.get(model.id) };
104
- for (const key of OWNED_MODEL_KEYS)
105
- delete custom[key];
105
+ // A bare id says nothing about the model; the row keeps what it already has.
106
+ const known = hasModelMetadata(model);
107
+ if (known)
108
+ for (const key of OWNED_MODEL_KEYS)
109
+ delete custom[key];
106
110
  return {
107
111
  ...custom,
108
112
  id: model.id,
109
- name: model.name ?? model.id,
113
+ name: model.name ?? (known ? undefined : custom.name) ?? model.id,
110
114
  vendor: provider.name,
111
115
  url,
112
116
  apiKey: provider.apiKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentsw",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "CLI provider switcher for coding agents: manage OpenAI/Anthropic-protocol providers in one place and sync them, with models.dev metadata, into Claude Code, Codex, omp, pi, prime-agent, opencode, Hermes, WorkBuddy and DeepSeek Harness",
5
5
  "type": "module",
6
6
  "repository": {