agentsw 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -1,15 +1,27 @@
1
+ import path from "node:path";
2
+ import { parse as parseJsonc } from "jsonc-parser";
3
+ import { parse as parseToml } from "smol-toml";
4
+ import { parseDocument } from "yaml";
5
+ import { envAssignments } from "./envfile.js";
1
6
  import pc from "picocolors";
2
7
  import prompts from "prompts";
3
8
  import { loadStore, saveStore, getProvider, configFile } from "./store.js";
4
9
  import { scanCandidates, normalizeUrl, findMatchingProvider } from "./import.js";
5
- import { enrichModels, loadCatalog, searchCatalog } from "./modelsdev.js";
10
+ import { loadCatalog, searchCatalog } from "./modelsdev.js";
11
+ import { enrichProviderModels, getMetadataMode, resolveMetadataOptions } from "./metadata.js";
12
+ import { loadGatewayCatalog } from "./gateway.js";
6
13
  import { resolveTargets, supportsProtocol, targets } from "./targets/index.js";
7
14
  import { discoverProviderModels, probeProtocols } from "./discover.js";
8
- import { appCommand, appPackages, installedVersion, isNewer, latestVersion, runShell } from "./apps.js";
15
+ import { appCommand, appPackages, installedVersion, isNewer, latestVersion, normalizeAppVersion, runShell } from "./apps.js";
9
16
  import { drainPendingWrites, readTextIfExists, setDryRun } from "./fsutil.js";
10
17
  import { applyModelFilter } from "./filter.js";
11
18
  import { availableProviderId, providerIdFromBaseUrl, providerNameFromBaseUrl } from "./slug.js";
12
19
  import { t } from "./i18n.js";
20
+ /** Share successes and failures without fetching until a model actually needs the supplement. */
21
+ function sharedGatewayLoader(refresh = false) {
22
+ let pending;
23
+ return () => pending ??= loadGatewayCatalog({ refresh }).then((catalog) => catalog ?? null);
24
+ }
13
25
  function fail(message) {
14
26
  process.stderr.write(pc.red(`error: ${message}\n`));
15
27
  process.exit(1);
@@ -53,16 +65,16 @@ function modelRows(models) {
53
65
  fmtTokens(m.contextWindow),
54
66
  fmtTokens(m.maxInput),
55
67
  fmtTokens(m.maxOutput),
56
- m.reasoning ? (m.reasoningEfforts?.join("/") ?? "yes") : "no",
68
+ m.reasoning === undefined ? "-" : m.reasoning ? (m.reasoningEfforts?.length ? m.reasoningEfforts.join("/") : "yes") : "no",
57
69
  m.cost ? `$${m.cost.input ?? "?"}/$${m.cost.output ?? "?"}` : "-",
58
70
  ]);
59
71
  }
60
72
  const MODEL_HEADER = ["MODEL", "CTX", "IN", "OUT", "REASONING", "$IN/$OUT per M"];
61
- /** Print a dim hint when some models have no models.dev metadata. */
73
+ /** Print a dim hint when some models have no catalog metadata. */
62
74
  function printUncatalogedHint(models) {
63
75
  const bare = models.filter((m) => m.contextWindow === undefined && !m.cost);
64
76
  if (bare.length > 0) {
65
- console.log(pc.dim(`${bare.length} model(s) have no models.dev metadata (shown as "-")`));
77
+ console.log(pc.dim(`${bare.length} model(s) have no catalog metadata (shown as "-")`));
66
78
  }
67
79
  }
68
80
  /** Guess the models.dev provider whose API host matches the configured baseUrl. */
@@ -89,14 +101,15 @@ function guessProviderHint(catalog, baseUrl) {
89
101
  }
90
102
  return undefined;
91
103
  }
92
- function parseFilterOpts(opts) {
93
- const split = (s) => s?.split(",").map((x) => x.trim()).filter(Boolean);
94
- const include = split(opts.include);
95
- const exclude = split(opts.exclude);
96
- // dedup (dropping snapshot duplicates) is on by default; persist only the opt-out
97
- if (!include?.length && !exclude?.length && opts.dedup !== false)
98
- return undefined;
99
- return { include, exclude, ...(opts.dedup === false ? { dedup: false } : {}) };
104
+ function parseFilterOpts(opts, previous) {
105
+ if (opts.include === undefined && opts.exclude === undefined && opts.dedup === undefined)
106
+ return previous;
107
+ return {
108
+ ...previous,
109
+ ...(opts.include !== undefined ? { include: opts.include.split(",").map((x) => x.trim()).filter(Boolean) } : {}),
110
+ ...(opts.exclude !== undefined ? { exclude: opts.exclude.split(",").map((x) => x.trim()).filter(Boolean) } : {}),
111
+ ...(opts.dedup !== undefined ? { dedup: opts.dedup } : {}),
112
+ };
100
113
  }
101
114
  function reportDropped(dropped) {
102
115
  if (!dropped.length)
@@ -107,6 +120,7 @@ function reportDropped(dropped) {
107
120
  }
108
121
  export async function cmdAdd(opts) {
109
122
  const store = loadStore();
123
+ resolveMetadataOptions(opts);
110
124
  const interactive = process.stdin.isTTY && !opts.yes;
111
125
  let answers = {};
112
126
  if (interactive) {
@@ -179,13 +193,17 @@ export async function cmdAdd(opts) {
179
193
  if (protocol !== "openai" && protocol !== "anthropic")
180
194
  fail(t("add.protocolInvalid"));
181
195
  const wire = answers.openaiApi ?? opts.openaiApi;
182
- if (wire && wire !== "completions" && wire !== "responses")
196
+ if (wire !== undefined && wire !== "completions" && wire !== "responses")
183
197
  fail(t("add.openaiApiInvalid"));
184
- const openaiApi = protocol === "openai" ? wire : undefined;
185
198
  const baseUrl = normalizeUrl(answers.baseUrl);
199
+ const explicitId = answers.id || undefined;
200
+ const existing = explicitId ? store.providers[explicitId] : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
201
+ const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
202
+ const openaiApi = protocol === "openai" ? wire ?? existing?.openaiApi : undefined;
203
+ const metadataOptions = resolveMetadataOptions(opts, existing);
186
204
  const manualIds = (answers.models ?? "").split(",").map((s) => s.trim()).filter(Boolean);
187
205
  let modelIds = manualIds;
188
- const modelFilter = parseFilterOpts(opts);
206
+ const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
189
207
  const shouldDiscover = opts.discover || manualIds.length === 0;
190
208
  if (shouldDiscover) {
191
209
  if (!opts.discover) {
@@ -196,36 +214,37 @@ export async function cmdAdd(opts) {
196
214
  console.log(t("add.providerLists", { count: discovered.length }));
197
215
  modelIds = [...new Set([...manualIds, ...discovered])];
198
216
  }
199
- const pinned = [...manualIds, ...(opts.defaultModel ? [opts.defaultModel] : [])];
217
+ const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
218
+ modelIds = [...new Set([...modelIds, ...retained])];
219
+ const pinned = [...manualIds, ...retained, ...(opts.defaultModel ? [opts.defaultModel] : []), ...(opts.smallModel ? [opts.smallModel] : [])];
200
220
  const outcome = applyModelFilter(modelIds, modelFilter, pinned);
201
221
  reportDropped(outcome.dropped);
202
222
  modelIds = outcome.kept;
203
223
  if (modelIds.length === 0)
204
224
  fail(t("add.atLeastOne"));
205
225
  const catalog = await loadCatalog();
206
- const hint = opts.modelsDev ?? guessProviderHint(catalog, answers.baseUrl);
207
- const models = enrichModels(catalog, modelIds, hint);
226
+ const hint = opts.modelsDev ?? existing?.modelsDevId ?? guessProviderHint(catalog, answers.baseUrl);
227
+ const models = await enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: existing?.models });
208
228
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
209
- const defaultModel = opts.defaultModel ?? modelIds[0];
229
+ const defaultModel = opts.defaultModel ?? existing?.defaultModel ?? modelIds[0];
210
230
  if (!modelIds.includes(defaultModel))
211
231
  fail(t("add.defaultMissing", { model: defaultModel }));
212
232
  if (opts.smallModel && !modelIds.includes(opts.smallModel))
213
233
  fail(t("add.smallMissing", { model: opts.smallModel }));
214
- const explicitId = answers.id || undefined;
215
- const existing = explicitId ? undefined : findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey: answers.apiKey });
216
- const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
217
234
  const provider = {
235
+ ...existing,
218
236
  id,
219
237
  name: answers.name || existing?.name || (explicitId ? explicitId : providerNameFromBaseUrl(baseUrl, protocol)),
220
238
  protocol,
221
- ...(openaiApi ? { openaiApi } : {}),
239
+ openaiApi,
222
240
  baseUrl,
223
241
  apiKey: answers.apiKey,
224
242
  models,
225
243
  defaultModel,
226
- smallModel: opts.smallModel,
227
- reasoningEffort: opts.reasoningEffort,
244
+ smallModel: opts.smallModel ?? existing?.smallModel,
245
+ reasoningEffort: opts.reasoningEffort ?? existing?.reasoningEffort,
228
246
  modelsDevId: hint,
247
+ ...metadataOptions,
229
248
  modelFilter,
230
249
  };
231
250
  const existed = store.providers[provider.id] !== undefined;
@@ -243,25 +262,31 @@ export async function cmdAdd(opts) {
243
262
  /** Shared logic to create and save a single provider from discovered models. */
244
263
  async function createProvider(opts) {
245
264
  const { store, id, name, protocol, openaiApi, baseUrl, apiKey, modelIds, modelFilter } = opts;
265
+ const metadataOptions = resolveMetadataOptions(opts, opts.existing);
246
266
  const catalog = await loadCatalog();
247
- const hint = guessProviderHint(catalog, baseUrl);
248
- const models = enrichModels(catalog, modelIds, hint);
267
+ const hint = opts.modelsDev ?? opts.existing?.modelsDevId ?? guessProviderHint(catalog, baseUrl);
268
+ const models = await enrichProviderModels(catalog, modelIds, { ...metadataOptions, modelsDevId: hint, models: opts.existing?.models }, { gatewayLoader: opts.gatewayLoader });
249
269
  const matched = models.filter((m) => m.contextWindow !== undefined).length;
250
- const defaultModel = opts.defaultModel ?? modelIds[0];
270
+ const defaultModel = opts.defaultModel ?? opts.existing?.defaultModel ?? modelIds[0];
251
271
  if (!modelIds.includes(defaultModel))
252
272
  fail(t("add.defaultMissing", { model: defaultModel }));
273
+ const smallModel = opts.smallModel ?? opts.existing?.smallModel;
274
+ if (smallModel && !modelIds.includes(smallModel))
275
+ fail(t("add.smallMissing", { model: smallModel }));
253
276
  const provider = {
277
+ ...opts.existing,
254
278
  id,
255
279
  name,
256
280
  protocol,
257
- ...(openaiApi ? { openaiApi } : {}),
281
+ openaiApi: protocol === "openai" ? openaiApi ?? opts.existing?.openaiApi : undefined,
258
282
  baseUrl,
259
283
  apiKey,
260
284
  models,
261
285
  defaultModel,
262
- smallModel: opts.smallModel,
263
- reasoningEffort: opts.reasoningEffort,
286
+ smallModel,
287
+ reasoningEffort: opts.reasoningEffort ?? opts.existing?.reasoningEffort,
264
288
  modelsDevId: hint,
289
+ ...metadataOptions,
265
290
  modelFilter,
266
291
  };
267
292
  const existed = store.providers[provider.id] !== undefined;
@@ -282,6 +307,7 @@ async function createProvider(opts) {
282
307
  */
283
308
  export async function cmdQuickAdd(opts) {
284
309
  const store = loadStore();
310
+ resolveMetadataOptions(opts);
285
311
  const interactive = process.stdin.isTTY && !opts.yes;
286
312
  let baseUrl;
287
313
  let apiKey;
@@ -306,19 +332,23 @@ export async function cmdQuickAdd(opts) {
306
332
  if (protocols.length === 0) {
307
333
  fail(t("quick.noProtocol"));
308
334
  }
309
- const modelFilter = parseFilterOpts(opts);
335
+ if (opts.openaiApi !== undefined && opts.openaiApi !== "completions" && opts.openaiApi !== "responses")
336
+ fail(t("add.openaiApiInvalid"));
310
337
  const multi = protocols.length > 1;
311
338
  const createdIds = [];
339
+ const gatewayLoader = sharedGatewayLoader();
312
340
  for (const protocol of protocols) {
313
341
  const explicitId = opts.id === undefined ? undefined : `${opts.id}${multi ? `-${protocol}` : ""}`;
314
- const existing = explicitId === undefined ? findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey }) : undefined;
342
+ const existing = explicitId === undefined ? findMatchingProvider(Object.values(store.providers), { protocol, baseUrl, apiKey }) : store.providers[explicitId];
315
343
  const id = explicitId ?? existing?.id ?? availableProviderId(providerIdFromBaseUrl(baseUrl, protocol), store.providers);
316
- const name = existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
344
+ const name = opts.name ?? existing?.name ?? (opts.id === undefined ? providerNameFromBaseUrl(baseUrl, protocol) : multi ? `${opts.id} (${protocol})` : opts.id);
345
+ const modelFilter = parseFilterOpts(opts, existing?.modelFilter);
317
346
  process.stderr.write(pc.dim(`${t("add.discovering", { url: baseUrl })} [${protocol}]\n`));
318
347
  const discovered = await discoverProviderModels({ baseUrl, apiKey, protocol });
319
348
  console.log(t("add.providerLists", { count: discovered.length }));
320
- let modelIds = discovered;
321
- const pinned = [...(opts.defaultModel ? [opts.defaultModel] : [])];
349
+ const retained = [opts.defaultModel === undefined ? existing?.defaultModel : undefined, opts.smallModel === undefined ? existing?.smallModel : undefined].filter((id) => !!id);
350
+ let modelIds = [...new Set([...discovered, ...retained])];
351
+ const pinned = [...retained, ...(opts.defaultModel ? [opts.defaultModel] : []), ...(opts.smallModel ? [opts.smallModel] : [])];
322
352
  const outcome = applyModelFilter(modelIds, modelFilter, pinned);
323
353
  reportDropped(outcome.dropped);
324
354
  modelIds = outcome.kept;
@@ -338,6 +368,13 @@ export async function cmdQuickAdd(opts) {
338
368
  smallModel: opts.smallModel,
339
369
  reasoningEffort: opts.reasoningEffort,
340
370
  modelFilter,
371
+ existing,
372
+ openaiApi: opts.openaiApi,
373
+ modelsDev: opts.modelsDev,
374
+ gatewayMetadata: opts.gatewayMetadata,
375
+ gatewayModels: opts.gatewayModels,
376
+ metadataMode: opts.metadataMode,
377
+ gatewayLoader,
341
378
  });
342
379
  createdIds.push(provider.id);
343
380
  console.log(pc.dim(`\n${t("add.next", { id: provider.id })}`));
@@ -384,7 +421,7 @@ function reportResults(results) {
384
421
  console.log(` ${" ".repeat(9)} ${pc.dim(note)}`);
385
422
  }
386
423
  }
387
- async function runTargets(op, provider, appsFilter) {
424
+ async function runTargets(op, provider, appsFilter, redactErrors = false) {
388
425
  const selected = resolveTargets(appsFilter);
389
426
  const explicit = appsFilter !== undefined && appsFilter !== "all";
390
427
  const results = [];
@@ -398,48 +435,131 @@ async function runTargets(op, provider, appsFilter) {
398
435
  });
399
436
  continue;
400
437
  }
401
- if (!target.detect() && !explicit) {
402
- results.push({ app: target.id, changed: [], notes: [], skipped: `${target.name} not detected (pass --apps ${target.id} to force)` });
403
- continue;
404
- }
405
438
  try {
439
+ if (!explicit && !target.detect()) {
440
+ results.push({ app: target.id, changed: [], notes: [], skipped: `${target.name} not detected (pass --apps ${target.id} to force)` });
441
+ continue;
442
+ }
406
443
  results.push(await target[op](provider));
407
444
  }
408
445
  catch (err) {
409
- results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(`failed: ${err.message}`) });
446
+ results.push({ app: target.id, changed: [], notes: [], skipped: pc.red(redactErrors ? "failed: configuration could not be previewed safely" : `failed: ${err.message}`) });
410
447
  process.exitCode = 1;
411
448
  }
412
449
  }
413
450
  return results;
414
451
  }
415
- /** naive line diff for config previews: order-preserving added/removed lines */
416
- function printFileDiff(file, next) {
452
+ /** Parse first: an unknown format or malformed document must never fall back to raw secrets. */
453
+ function previewDocument(file, text) {
454
+ if (!text.trim())
455
+ return undefined;
456
+ if (path.basename(file) === ".env" || file.endsWith(".env")) {
457
+ return Object.fromEntries(envAssignments(file, text).map(({ name }) => [name, "[REDACTED]"]));
458
+ }
459
+ switch (path.extname(file).toLowerCase()) {
460
+ case ".json":
461
+ case ".jsonc": {
462
+ const errors = [];
463
+ const value = parseJsonc(text, errors, { allowTrailingComma: true });
464
+ if (errors.length)
465
+ throw new Error("invalid JSON configuration");
466
+ return value;
467
+ }
468
+ case ".toml": return parseToml(text);
469
+ case ".yaml":
470
+ case ".yml": {
471
+ const document = parseDocument(text);
472
+ if (document.errors.length)
473
+ throw new Error("invalid YAML configuration");
474
+ return document.toJS();
475
+ }
476
+ default: throw new Error("unsupported configuration format");
477
+ }
478
+ }
479
+ /** Normalized semantic diff: comments and unparseable raw input never enter output. */
480
+ function printFileDiff(file, next, apiKey) {
417
481
  const before = readTextIfExists(file) ?? "";
418
482
  if (before === next) {
419
483
  console.log(`${pc.dim("unchanged")} ${file}`);
420
484
  return;
421
485
  }
422
- console.log(pc.bold(`--- ${file}`));
423
- const oldLines = before.split("\n");
424
- const newLines = next.split("\n");
425
- const oldSeen = {};
426
- for (const l of oldLines)
427
- oldSeen[l] = (oldSeen[l] ?? 0) + 1;
428
- const newSeen = {};
429
- for (const l of newLines)
430
- newSeen[l] = (newSeen[l] ?? 0) + 1;
431
- for (const l of oldLines) {
432
- if ((newSeen[l] ?? 0) > 0)
433
- newSeen[l]--;
486
+ console.log(pc.bold(`--- ${file} (redacted configuration)`));
487
+ let oldText;
488
+ let newText;
489
+ try {
490
+ const oldDocument = previewDocument(file, before);
491
+ const newDocument = previewDocument(file, next);
492
+ const sensitive = /key|token|secret|password|auth|credential|cookie|headers/i;
493
+ const credentialFile = path.basename(file) === ".credentials.yaml";
494
+ const secrets = new Set(apiKey ? [apiKey] : []);
495
+ const visited = new WeakSet();
496
+ const collect = (value, hidden = false) => {
497
+ if (typeof value === "string" && hidden && value)
498
+ secrets.add(value);
499
+ if (!value || typeof value !== "object" || visited.has(value))
500
+ return;
501
+ visited.add(value);
502
+ for (const [key, child] of Object.entries(value))
503
+ collect(child, hidden || sensitive.test(key));
504
+ };
505
+ collect(oldDocument, credentialFile);
506
+ collect(newDocument, credentialFile);
507
+ const orderedSecrets = [...secrets].sort((a, b) => b.length - a.length);
508
+ const redact = (key, value) => {
509
+ if (sensitive.test(key))
510
+ return "[REDACTED]";
511
+ if ((key === "" || credentialFile) && typeof value === "string")
512
+ return "[REDACTED]";
513
+ if (typeof value !== "string")
514
+ return value;
515
+ let safe = value;
516
+ if (/^https?:\/\//i.test(safe)) {
517
+ const url = new URL(safe);
518
+ if (url.username)
519
+ url.username = "[REDACTED]";
520
+ if (url.password)
521
+ url.password = "[REDACTED]";
522
+ for (const name of new Set(url.searchParams.keys()))
523
+ if (sensitive.test(name))
524
+ url.searchParams.set(name, "[REDACTED]");
525
+ url.hash = "";
526
+ safe = url.toString();
527
+ }
528
+ for (const secret of orderedSecrets)
529
+ safe = safe.split(secret).join("[REDACTED]");
530
+ return safe.replace(/\b(?:Bearer|Basic)\s+[^\s"',;]+/gi, "[REDACTED]");
531
+ };
532
+ oldText = JSON.stringify(oldDocument, redact, 2) ?? "";
533
+ newText = JSON.stringify(newDocument, redact, 2) ?? "";
534
+ }
535
+ catch {
536
+ console.log(pc.dim("[content withheld: unsupported or malformed configuration]"));
537
+ return;
538
+ }
539
+ const oldLines = oldText.split("\n");
540
+ const newLines = newText.split("\n");
541
+ const oldSeen = new Map();
542
+ for (const line of oldLines)
543
+ oldSeen.set(line, (oldSeen.get(line) ?? 0) + 1);
544
+ const newSeen = new Map();
545
+ for (const line of newLines)
546
+ newSeen.set(line, (newSeen.get(line) ?? 0) + 1);
547
+ for (const line of oldLines) {
548
+ const count = newSeen.get(line) ?? 0;
549
+ if (count > 0)
550
+ newSeen.set(line, count - 1);
434
551
  else
435
- console.log(pc.red(`- ${l}`));
552
+ console.log(pc.red(`- ${line}`));
436
553
  }
437
- for (const l of newLines) {
438
- if ((oldSeen[l] ?? 0) > 0)
439
- oldSeen[l]--;
554
+ for (const line of newLines) {
555
+ const count = oldSeen.get(line) ?? 0;
556
+ if (count > 0)
557
+ oldSeen.set(line, count - 1);
440
558
  else
441
- console.log(pc.green(`+ ${l}`));
559
+ console.log(pc.green(`+ ${line}`));
442
560
  }
561
+ if (oldText === newText)
562
+ console.log(pc.dim("[only redacted values or formatting changed]"));
443
563
  }
444
564
  async function runWithOptionalDryRun(op, provider, apps, dryRun) {
445
565
  if (!dryRun) {
@@ -448,7 +568,7 @@ async function runWithOptionalDryRun(op, provider, apps, dryRun) {
448
568
  }
449
569
  setDryRun(true);
450
570
  try {
451
- const results = await runTargets(op, provider, apps);
571
+ const results = await runTargets(op, provider, apps, true);
452
572
  const writes = drainPendingWrites();
453
573
  for (const r of results) {
454
574
  if (r.skipped)
@@ -456,13 +576,14 @@ async function runWithOptionalDryRun(op, provider, apps, dryRun) {
456
576
  }
457
577
  console.log(pc.bold(`\ndry run — ${writes.length} file(s) would be written:\n`));
458
578
  for (const w of writes)
459
- printFileDiff(w.file, w.content);
579
+ printFileDiff(w.file, w.content, provider.apiKey);
460
580
  }
461
581
  finally {
462
582
  setDryRun(false);
463
583
  }
464
584
  }
465
585
  export async function cmdUse(id, opts) {
586
+ resolveTargets(opts.apps);
466
587
  const store = loadStore();
467
588
  const provider = getProvider(store, id);
468
589
  if (opts.model) {
@@ -479,6 +600,7 @@ export async function cmdUse(id, opts) {
479
600
  await runWithOptionalDryRun("apply", provider, opts.apps, opts.dryRun);
480
601
  }
481
602
  export async function cmdSync(opts) {
603
+ resolveTargets(opts.apps);
482
604
  const store = loadStore();
483
605
  const id = opts.provider ?? store.active;
484
606
  if (!id)
@@ -501,9 +623,21 @@ export function cmdStatus() {
501
623
  console.log(table(rows, ["APP", t("table.found"), t("table.protocols"), t("table.current"), t("table.config")]));
502
624
  }
503
625
  export async function cmdModels(query, opts) {
626
+ if (opts.metadata && !opts.provider)
627
+ fail("--metadata requires --provider");
504
628
  if (opts.provider) {
505
629
  const store = loadStore();
506
630
  const provider = getProvider(store, opts.provider);
631
+ if (opts.metadata) {
632
+ console.log(JSON.stringify({
633
+ provider: provider.id,
634
+ gatewayMetadata: provider.gatewayMetadata ?? "auto",
635
+ metadataMode: getMetadataMode(provider),
636
+ gatewayModelAliases: provider.gatewayModelAliases ?? {},
637
+ models: provider.models.map(({ id, metadata }) => ({ id, metadata })),
638
+ }, null, 2));
639
+ return;
640
+ }
507
641
  console.log(`${pc.bold(provider.id)} (${provider.protocol}) · ${provider.baseUrl}`);
508
642
  console.log(table(modelRows(provider.models), MODEL_HEADER));
509
643
  printUncatalogedHint(provider.models);
@@ -526,45 +660,54 @@ export async function cmdModels(query, opts) {
526
660
  });
527
661
  console.log(table(rows, ["PROVIDER", ...MODEL_HEADER]));
528
662
  }
529
- export async function cmdRefreshMeta() {
663
+ export async function cmdRefreshMeta(opts = {}) {
530
664
  const store = loadStore();
665
+ const providers = opts.provider ? [getProvider(store, opts.provider)] : Object.values(store.providers);
666
+ resolveMetadataOptions(opts);
667
+ for (const provider of providers)
668
+ Object.assign(provider, resolveMetadataOptions(opts, provider));
531
669
  const catalog = await loadCatalog({ refresh: true });
532
- if (!catalog)
533
- fail("models.dev catalog unavailable");
670
+ const gatewayLoader = sharedGatewayLoader(true);
534
671
  let updated = 0;
535
- for (const provider of Object.values(store.providers)) {
672
+ for (const provider of providers) {
536
673
  const before = JSON.stringify(provider.models);
537
- provider.models = enrichModels(catalog, provider.models.map((m) => m.id), provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl));
674
+ provider.models = await enrichProviderModels(catalog, provider.models.map((m) => m.id), {
675
+ ...provider,
676
+ modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl),
677
+ }, { gatewayLoader });
538
678
  if (JSON.stringify(provider.models) !== before)
539
679
  updated++;
540
680
  }
541
681
  saveStore(store);
542
- console.log(pc.green(`refreshed models.dev metadata (${updated} provider(s) changed)`));
682
+ console.log(pc.green(`checked model metadata (${updated} provider(s) changed)`));
543
683
  if (updated > 0)
544
684
  console.log(pc.dim("run `agentsw sync` to push updated metadata into app configs"));
545
685
  }
546
686
  export async function cmdDiscover(id, opts) {
687
+ resolveTargets(opts.apps);
547
688
  const store = loadStore();
548
689
  const provider = getProvider(store, id);
690
+ Object.assign(provider, resolveMetadataOptions(opts, provider));
549
691
  // flags override and re-persist the filter; --no-filter clears it
550
- const flagFilter = parseFilterOpts(opts);
692
+ const flagFilter = parseFilterOpts(opts, provider.modelFilter);
551
693
  if (opts.filter === false)
552
694
  provider.modelFilter = undefined;
553
695
  else if (flagFilter)
554
696
  provider.modelFilter = flagFilter;
555
697
  process.stderr.write(pc.dim(`discovering models from ${provider.baseUrl} ...\n`));
556
698
  const listed = await discoverProviderModels(provider);
557
- const outcome = applyModelFilter(listed, provider.modelFilter, [provider.defaultModel]);
699
+ const pinned = [provider.defaultModel, provider.smallModel].filter((model) => !!model);
700
+ const outcome = applyModelFilter(listed, provider.modelFilter, pinned);
558
701
  reportDropped(outcome.dropped);
559
702
  const ids = outcome.kept;
560
703
  const known = provider.models.map((m) => m.id);
561
704
  const added = ids.filter((m) => !known.includes(m));
562
705
  const gone = known.filter((m) => !ids.includes(m));
563
706
  const catalog = await loadCatalog();
564
- provider.models = enrichModels(catalog, ids, provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl));
707
+ const retainedIds = [...new Set([...ids, ...pinned])];
708
+ provider.models = await enrichProviderModels(catalog, retainedIds, { ...provider, modelsDevId: provider.modelsDevId ?? guessProviderHint(catalog, provider.baseUrl) });
565
709
  if (!ids.includes(provider.defaultModel)) {
566
710
  console.log(pc.yellow(`default model ${provider.defaultModel} no longer listed; keeping it anyway`));
567
- provider.models.push({ id: provider.defaultModel });
568
711
  }
569
712
  saveStore(store);
570
713
  console.log(`${pc.bold(id)}: ${ids.length} models (${pc.green(`+${added.length}`)} / ${pc.red(`-${gone.length}`)})` +
@@ -582,18 +725,29 @@ export async function cmdDiscover(id, opts) {
582
725
  }
583
726
  async function collectAppRows() {
584
727
  return Promise.all(appPackages.map(async (app) => {
585
- const [installed, latest] = await Promise.all([
586
- Promise.resolve(installedVersion(app)),
728
+ const [installedResult, latestResult] = await Promise.allSettled([
729
+ Promise.resolve().then(() => installedVersion(app)),
587
730
  latestVersion(app),
588
731
  ]);
589
- const knownInstalled = !!installed && installed !== "?";
732
+ const installed = installedResult.status === "fulfilled" ? installedResult.value : "?";
733
+ const latest = latestResult.status === "fulfilled" ? latestResult.value : undefined;
734
+ const knownInstalled = normalizeAppVersion(installed);
735
+ const knownLatest = normalizeAppVersion(latest);
736
+ const checkFailed = installedResult.status === "rejected"
737
+ ? "installed version check failed"
738
+ : installed && !knownInstalled
739
+ ? "installed version unknown"
740
+ : installed && !knownLatest
741
+ ? "latest version unavailable"
742
+ : undefined;
590
743
  return {
591
744
  id: app.id,
592
745
  name: app.name,
593
746
  installed,
594
747
  latest,
595
- upgradable: knownInstalled && !!latest && isNewer(installed, latest),
748
+ upgradable: !!knownInstalled && !!knownLatest && isNewer(knownInstalled, knownLatest),
596
749
  installable: !installed && !!appCommand(app, "install"),
750
+ checkFailed,
597
751
  };
598
752
  }));
599
753
  }
@@ -610,7 +764,7 @@ export async function cmdApps() {
610
764
  ? r.installable
611
765
  ? pc.dim("installable")
612
766
  : pc.dim("-")
613
- : r.installed === "?" || !r.latest
767
+ : r.checkFailed || !normalizeAppVersion(r.latest)
614
768
  ? pc.dim("unknown")
615
769
  : pc.green("up to date"),
616
770
  ]), ["APP", "INSTALLED", "LATEST", "STATUS"]));
@@ -632,38 +786,69 @@ export async function cmdInstall(id) {
632
786
  }
633
787
  console.log(`installing ${app.name}: ${pc.dim(installCmd)}`);
634
788
  runShell(installCmd);
635
- console.log(pc.green(`${app.name} installed: ${installedVersion(app) ?? "version unknown"}`));
789
+ const detected = installedVersion(app);
790
+ if (!detected)
791
+ throw new Error(`${app.name}: installer completed but the app is still not detected; check the installation and PATH`);
792
+ const version = normalizeAppVersion(detected);
793
+ if (!version) {
794
+ console.log(pc.yellow(`${app.name}: installer completed; app detected but version unknown`));
795
+ process.exitCode = 1;
796
+ return;
797
+ }
798
+ console.log(pc.green(`${app.name} installed: ${version}`));
636
799
  }
637
800
  export async function cmdUpgrade(ids) {
638
801
  let selected = appPackages.filter((a) => ids.length === 0 || ids.includes(a.id));
639
802
  const unknown = ids.filter((id) => !appPackages.some((a) => a.id === id));
640
803
  if (unknown.length)
641
804
  fail(`unknown app(s): ${unknown.join(", ")}`);
805
+ const expectedVersions = new Map();
642
806
  if (ids.length === 0) {
643
807
  // no args: upgrade everything that is installed and outdated
644
808
  process.stderr.write(pc.dim("checking versions ...\n"));
645
809
  const rows = await collectAppRows();
646
- const upgradable = rows.filter((r) => r.upgradable).map((r) => r.id);
810
+ const managed = rows.filter((row) => !!appCommand(appPackages.find((app) => app.id === row.id), "upgrade"));
811
+ const failed = managed.filter((row) => row.checkFailed);
812
+ for (const row of failed)
813
+ console.log(pc.yellow(`${row.id}: ${row.checkFailed}; update status unknown`));
814
+ if (failed.length)
815
+ process.exitCode = 1;
816
+ const upgradable = managed.filter((row) => row.upgradable);
647
817
  if (upgradable.length === 0) {
648
- console.log("everything is up to date");
818
+ if (failed.length)
819
+ console.log("could not determine update status for every installed app");
820
+ else if (managed.some((row) => row.installed))
821
+ console.log("all checked apps are up to date");
822
+ else
823
+ console.log("no installed CLI-managed apps to upgrade");
649
824
  return;
650
825
  }
651
- selected = appPackages.filter((a) => upgradable.includes(a.id));
826
+ for (const row of upgradable)
827
+ expectedVersions.set(row.id, row.latest);
828
+ selected = appPackages.filter((app) => expectedVersions.has(app.id));
652
829
  }
653
830
  for (const app of selected) {
654
831
  const cmd = appCommand(app, "upgrade");
655
832
  if (!cmd) {
656
833
  console.log(`${pc.yellow("skip")} ${app.id}: not CLI-upgradable`);
834
+ process.exitCode = 1;
657
835
  continue;
658
836
  }
659
- if (!installedVersion(app)) {
660
- console.log(`${pc.yellow("skip")} ${app.id}: not installed (use \`agentsw install ${app.id}\`)`);
661
- continue;
662
- }
663
- console.log(`upgrading ${app.name}: ${pc.dim(cmd)}`);
664
837
  try {
838
+ if (!installedVersion(app)) {
839
+ console.log(`${pc.yellow("skip")} ${app.id}: not installed (use \`agentsw install ${app.id}\`)`);
840
+ process.exitCode = 1;
841
+ continue;
842
+ }
843
+ console.log(`upgrading ${app.name}: ${pc.dim(cmd)}`);
665
844
  runShell(cmd);
666
- console.log(pc.green(`${app.id} -> ${installedVersion(app) ?? "?"}`));
845
+ const version = normalizeAppVersion(installedVersion(app));
846
+ if (!version)
847
+ throw new Error("upgrade command completed but installed version is unknown or app is not detected");
848
+ const expected = expectedVersions.get(app.id);
849
+ if (expected && isNewer(version, expected))
850
+ throw new Error(`upgrade command completed but ${version} is older than available ${expected}`);
851
+ console.log(pc.green(`${app.id} -> ${version}`));
667
852
  }
668
853
  catch (err) {
669
854
  console.log(pc.red(`${app.id} upgrade failed: ${err.message}`));
@@ -673,6 +858,7 @@ export async function cmdUpgrade(ids) {
673
858
  }
674
859
  /** Collect custom providers, dedupe by endpoint and credentials, and import what is new. */
675
860
  export async function cmdImport(opts) {
861
+ const metadataOptions = resolveMetadataOptions(opts);
676
862
  const rows = scanCandidates();
677
863
  const fresh = rows.filter((r) => !r.configured);
678
864
  for (const r of rows) {
@@ -717,6 +903,7 @@ export async function cmdImport(opts) {
717
903
  }
718
904
  const store = loadStore();
719
905
  const catalog = await loadCatalog();
906
+ const gatewayLoader = sharedGatewayLoader();
720
907
  const imported = [];
721
908
  for (const c of chosen) {
722
909
  let apiKey = c.apiKey;
@@ -754,7 +941,7 @@ export async function cmdImport(opts) {
754
941
  fail(t("import.noModelsImport", { id: c.id }));
755
942
  const id = availableProviderId(c.id, store.providers);
756
943
  const hint = guessProviderHint(catalog, c.baseUrl);
757
- const models = enrichModels(catalog, ids, hint);
944
+ const models = await enrichProviderModels(catalog, ids, { ...metadataOptions, modelsDevId: hint }, { gatewayLoader });
758
945
  const defaultModel = c.defaultModel && ids.includes(c.defaultModel) ? c.defaultModel : ids[0];
759
946
  const existed = store.providers[id] !== undefined;
760
947
  store.providers[id] = {
@@ -767,6 +954,7 @@ export async function cmdImport(opts) {
767
954
  models,
768
955
  defaultModel,
769
956
  modelsDevId: hint,
957
+ ...metadataOptions,
770
958
  };
771
959
  if (!store.active)
772
960
  store.active = id;