@modelstatus/cli 0.1.83 → 0.1.84

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
@@ -148,6 +148,25 @@ Apple-notarized, every release manifest is Ed25519-signed and verified — again
148
148
  a public key embedded in the installer and the self-updater, not fetched from the
149
149
  CDN — and both refuse to proceed if any check fails.
150
150
 
151
+ ### Verify a download yourself
152
+
153
+ The installer already checks everything, but you can verify any binary
154
+ independently with [minisign](https://jedisct1.github.io/minisign/) (Ed25519).
155
+ Each binary ships a detached `.minisig` next to it on the CDN:
156
+
157
+ ```sh
158
+ # e.g. Linux x64 (use latest/ or a pinned cli/v0.1.83/)
159
+ curl -fLO https://cdn.llmstatus.ai/cli/latest/modelstatus-cli-linux-x64
160
+ curl -fLO https://cdn.llmstatus.ai/cli/latest/modelstatus-cli-linux-x64.minisig
161
+ minisign -Vm modelstatus-cli-linux-x64 \
162
+ -P RWQlKGcm5lXiIiTvGv/cdc0joBn1sm9vOFr9WqR6CI15PXzoHNHVBY6S
163
+ # → "Signature and comment signature verified"
164
+ ```
165
+
166
+ The public key is also committed at `packages/cli/release-minisign.pub`. (On
167
+ macOS you additionally get the Developer ID signature + Apple notarization; on
168
+ Linux/Windows the minisign signature is the independent check.)
169
+
151
170
  ## Links
152
171
 
153
172
  - Website: [llmstatus.ai](https://llmstatus.ai)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.83",
3
+ "version": "0.1.84",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
@@ -1,6 +1,17 @@
1
1
  /* GENERATED by scripts/gen-changelog.mjs from apps/web/lib/changelog.json — do not edit.
2
2
  * Release notes baked into the binary (in-TUI + the on-load what's-new card). */
3
3
  export const CHANGELOG = [
4
+ {
5
+ "version": "0.1.84",
6
+ "date": "2026-06-14",
7
+ "title": "Clearer mm status, faster scans, safer CI",
8
+ "items": [
9
+ "`mm status` now leads with a one-line verdict, orders models by what dies first, and shows a `≈ suggestion?` for ids written without their provider prefix — so a model the registry knows isn't just labelled \"unrecognized\".",
10
+ "Scans are ~9x faster on large trees, with a live file counter while they run; random config keys and generated tokens no longer get mistaken for model ids.",
11
+ "`mm ci --diff` no longer silently passes on a symlinked checkout — it was dropping every finding, hiding retiring models in changed files behind a green check.",
12
+ "`mm scan --dry-run` always previews (it never launches the uploader), `mm scan --json` always emits JSON, and running the dashboard without an interactive terminal prints a clear message instead of crashing."
13
+ ]
14
+ },
4
15
  {
5
16
  "version": "0.1.83",
6
17
  "date": "2026-06-14",
@@ -50,11 +50,31 @@ function matchesAtBoundary(haystack, term) {
50
50
  }
51
51
  }
52
52
 
53
+ /** Reject a generic match that carries a long, high-entropy alnum run — a config
54
+ * key / generated id / random token (e.g. "claude-config-8f4k2m9x7p3q",
55
+ * "qwen1ah0aqumtapuychbwd2maghkvcltn") rather than a real model id. A model id —
56
+ * even a brand-new one not yet in the registry — has short, separated, mostly
57
+ * pronounceable segments; a generated key has one long mixed-case/digit blob.
58
+ * Only applied to GENERIC matches (exact registry strings bypass looksLikeModel),
59
+ * so it can never drop a known model. Pure-alpha segments (davinci) and pure-digit
60
+ * snapshot dates (20250514) are never flagged — only MIXED long segments are. */
61
+ function hasRandomSegment(s) {
62
+ for (const seg of s.split(/[-._/:]+/)) {
63
+ if (seg.length < 12) continue;
64
+ if (!/[a-z]/i.test(seg) || !/[0-9]/.test(seg)) continue; // need mixed letters+digits
65
+ const distinct = new Set(seg).size;
66
+ const vowels = (seg.match(/[aeiou]/gi) || []).length;
67
+ if (distinct / seg.length > 0.6 || vowels / seg.length < 0.18) return true;
68
+ }
69
+ return false;
70
+ }
71
+
53
72
  /** Family globs catch brand-NEW versioned models before they're in the
54
73
  * registry, so a real hit virtually always carries a version digit. Requiring
55
- * one (and rejecting filename/domain tails) kills the bulk of false positives. */
74
+ * one (and rejecting filename/domain tails + random-key blobs) kills the bulk of
75
+ * false positives. */
56
76
  function looksLikeModel(s) {
57
- return s.length >= 5 && /[0-9]/.test(s) && !BANNED_TAIL.test(s);
77
+ return s.length >= 5 && /[0-9]/.test(s) && !BANNED_TAIL.test(s) && !hasRandomSegment(s);
58
78
  }
59
79
 
60
80
  // Detection strings that are ALSO everyday code/English words. Cohere's bare
@@ -78,8 +98,20 @@ export function compilePatterns(patterns) {
78
98
  // (The old >=4 floor silently dropped the entire OpenAI o-series.)
79
99
  if (ms.match && ms.match.length >= 2 && !STOPWORDS.has(ms.match.toLowerCase())) exact.push(ms.match.toLowerCase());
80
100
  }
101
+ // Bucket the exact strings by their first 2 chars. detectInLine then runs the
102
+ // boundary matcher ONLY on buckets whose 2-char head appears in the line (a
103
+ // cheap indexOf prefilter). With 1500+ registry strings this is ~9x faster on a
104
+ // large tree and byte-identical: a string always contains its own first 2 chars,
105
+ // so a present string's bucket is never skipped. (~47 distinct heads today.)
106
+ const exactByHead = new Map();
107
+ for (const s of exact) {
108
+ const head = s.slice(0, 2);
109
+ let bucket = exactByHead.get(head);
110
+ if (!bucket) exactByHead.set(head, (bucket = []));
111
+ bucket.push(s);
112
+ }
81
113
  const generic = (patterns.generic_model_regexes || []).map((r) => new RegExp(r, "gi"));
82
- return { exact, generic };
114
+ return { exact, exactByHead, generic };
83
115
  }
84
116
 
85
117
  /** Find model strings on a single line. Returns a Set of lowercased strings. */
@@ -89,7 +121,16 @@ export function detectInLine(line, compiled) {
89
121
  // Exact registry strings must match at a token boundary, not as a raw substring
90
122
  // — otherwise a short alias ("gpt-4") resolves inside a longer id ("gpt-4o-mini")
91
123
  // to the wrong model. Matches the server PR scanner's boundary semantics.
92
- for (const s of compiled.exact) if (matchesAtBoundary(lower, s)) found.add(s);
124
+ // Use the 2-char-head bucket index when available (only scan buckets whose head
125
+ // appears in the line); fall back to the flat list for any older compiled shape.
126
+ if (compiled.exactByHead) {
127
+ for (const [head, bucket] of compiled.exactByHead) {
128
+ if (lower.indexOf(head) < 0) continue;
129
+ for (const s of bucket) if (matchesAtBoundary(lower, s)) found.add(s);
130
+ }
131
+ } else {
132
+ for (const s of compiled.exact) if (matchesAtBoundary(lower, s)) found.add(s);
133
+ }
93
134
  for (const re of compiled.generic) {
94
135
  re.lastIndex = 0;
95
136
  let m;
package/src/index.js CHANGED
@@ -244,10 +244,18 @@ async function cmdPlay(positional, flags) {
244
244
  }
245
245
 
246
246
  async function launchTui(initialView, flags) {
247
- // Pass apiKey straight through (may be null). The TUI's Bootstrap wrapper
248
- // renders an interactive SignIn screen when there's no key, then swaps to
249
- // the main App on success no separate browser-login polling phase that the
250
- // user has to wait through without seeing anything interactive.
247
+ // The Ink TUI needs raw-mode stdin. Without a TTY (piped, redirected, CI logs)
248
+ // Ink throws an unhandled "Raw mode is not supported" deep in a passive effect
249
+ // it escapes main()'s try/catch and dumps a React/runtime stack. Guard like
250
+ // `mm play` does and point at the non-interactive command instead.
251
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
252
+ console.error("The mm dashboard needs an interactive terminal (a TTY).");
253
+ console.error("For a non-interactive check, run: mm status [dir] (add --json for scripts)");
254
+ process.exit(1);
255
+ }
256
+ // Pass apiKey straight through (may be null). The TUI lands on the public
257
+ // "Here" tab (no account needed); sign-in is lazy — the Account tab (press 7)
258
+ // or any auth-gated tab prompts when you get there.
251
259
  const { apiBase, apiKey } = resolveAuth(flags);
252
260
  const dir = path.resolve(flags.dir || ".");
253
261
  const { runApp } = await import("./tui/app.js");
@@ -257,13 +265,15 @@ async function launchTui(initialView, flags) {
257
265
  }
258
266
 
259
267
  async function cmdScan(positional, flags) {
260
- const dir = path.resolve(positional[1] || ".");
268
+ const dir = path.resolve(positional[1] || flags.dir || ".");
261
269
  const { apiBase, apiKey } = resolveAuth(flags);
262
270
  if (!apiKey) {
263
271
  console.error("No API key. Run `mm login` or pass --key.");
264
272
  process.exit(1);
265
273
  }
266
- const interactive = !flags.yes && !flags.json && process.stdout.isTTY;
274
+ // --dry-run must NOT launch the interactive TUI (which can upload) it's a
275
+ // no-upload preview, so force the non-interactive path even on a TTY.
276
+ const interactive = !flags.yes && !flags.json && !flags["dry-run"] && process.stdout.isTTY;
267
277
  if (interactive) {
268
278
  const { runApp } = await import("./tui/app.js");
269
279
  await runApp({ apiBase, apiKey, dir, initialView: "scan" });
@@ -290,7 +300,19 @@ async function cmdScan(positional, flags) {
290
300
  const patterns = await client.detectionPatterns();
291
301
  const candidates = await collectFrom(sources, opts, patterns, explicit);
292
302
  if (candidates.length === 0) {
293
- console.log("No model usage found.");
303
+ // Respect --json/--ci even on the empty path, so `mm scan --ci | jq` never
304
+ // chokes on a bare text line. Mirror the shape of the matching non-empty path.
305
+ if (flags.json) {
306
+ const srcRows = avail.map((a) => ({ id: a.id, available: a.available }));
307
+ console.log(JSON.stringify(
308
+ flags["dry-run"]
309
+ ? { scanned: 0, would_upload: [], sources: avail }
310
+ : { scanned: 0, uploaded: 0, sources: srcRows, created: 0, updated: 0, failed: 0 },
311
+ null, 2,
312
+ ));
313
+ } else {
314
+ console.log("No model usage found.");
315
+ }
294
316
  return;
295
317
  }
296
318
 
@@ -436,8 +458,18 @@ async function postCiRun(dir, flags, res, failOn) {
436
458
  * annotations + a step summary under GITHUB_ACTIONS; exits non-zero per --fail-on
437
459
  * so it gates merges. Offline (public registry) by default; --report syncs (Pro). */
438
460
  async function cmdCi(positional, flags) {
439
- const dir = path.resolve(positional[1] || flags.dir || ".");
461
+ // realpathSync so `dir` matches git's `--show-toplevel` (which resolves
462
+ // symlinks). Without this, on a symlinked checkout (macOS /tmp, symlinked CI
463
+ // workspaces) the --diff path math mismatches and ALL findings are dropped →
464
+ // a silent GREEN check while retired models in changed files slip through.
465
+ let dir = path.resolve(positional[1] || flags.dir || ".");
466
+ try { dir = fs.realpathSync(dir); } catch { /* keep resolved path if it doesn't exist */ }
467
+ const VALID_FAIL_ON = new Set(["none", "deprecating", "retiring", "retired"]);
440
468
  const failOn = String(flags["fail-on"] || "retired").toLowerCase();
469
+ if (flags["fail-on"] !== undefined && !VALID_FAIL_ON.has(failOn)) {
470
+ console.error(`Invalid --fail-on "${flags["fail-on"]}". One of: ${[...VALID_FAIL_ON].join(", ")}.`);
471
+ process.exit(1);
472
+ }
441
473
  const { evaluateCi, annotationLines, summaryMarkdown, filterToChangedFiles, getChangedFiles, HEALTH_RANK } = await import("./ci.js");
442
474
  const res = await evaluateCi({
443
475
  dir,
@@ -551,7 +583,8 @@ async function cmdFix(positional, flags) {
551
583
  const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => prog.log(m) });
552
584
 
553
585
  prog.update("scanning for model references…");
554
- const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection);
586
+ const onProgress = ({ filesScanned, candidates: c }) => prog.update(`scanning… ${filesScanned} files, ${c} reference(s)`);
587
+ const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection, new Set(), onProgress);
555
588
  prog.stop();
556
589
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
557
590
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
@@ -697,11 +730,15 @@ async function cmdStatus(positional, flags) {
697
730
  // A cold `mm status` downloads the ~225 kB registry snapshot then walks the
698
731
  // repo with zero output — it reads as frozen. Show live stderr progress
699
732
  // (auto-off for --json / pipes so machine output stays byte-identical).
733
+ // A cold `mm status` downloads the registry snapshot then walks the repo with
734
+ // zero output — it reads as frozen. Show live stderr progress with a MOVING
735
+ // file counter (auto-off for --json / pipes so machine output is byte-identical).
700
736
  const prog = startProgress(!flags.json, "fetching the model registry…");
701
737
  const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => prog.log(m) });
702
738
 
703
739
  prog.update("scanning for model references…");
704
- let candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags));
740
+ const onProgress = ({ filesScanned, candidates: c }) => prog.update(`scanning… ${filesScanned} files, ${c} reference(s)`);
741
+ let candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags), onProgress);
705
742
  prog.stop();
706
743
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
707
744
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
@@ -719,22 +756,36 @@ async function cmdStatus(positional, flags) {
719
756
  known.set(r.model_slug, e);
720
757
  } else custom.set(c.model_string, (custom.get(c.model_string) || 0) + 1);
721
758
  }
759
+ const fileCount = new Set(candidates.map((c) => c.source_path || c.location_label)).size;
722
760
 
723
761
  const today = new Date();
724
762
  const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔", custom: "⚪" };
725
- const rank = { withdrawn: 0, retired: 0, retiring: 1, deprecating: 2, ok: 3 };
763
+ // Order by WHAT DIES FIRST: already-dead (retired/withdrawn) first, then aging
764
+ // by soonest retirement date regardless of bucket (a deprecating model that
765
+ // retires sooner outranks a retiring one that retires later), then ok last.
766
+ const tier = (h) => (h === "retired" || h === "withdrawn") ? 0 : h === "ok" ? 2 : 1;
726
767
  const rows = [...known.values()]
727
768
  .map(({ model, count }) => ({ model, count, health: computeHealth(model, 90, today) }))
728
- .sort((a, b) => rank[a.health] - rank[b.health] || String(a.model.retires_date || "9999").localeCompare(String(b.model.retires_date || "9999")));
769
+ .sort((a, b) => tier(a.health) - tier(b.health) || String(a.model.retires_date || "9999").localeCompare(String(b.model.retires_date || "9999")));
729
770
  const attention = rows.filter((r) => r.health !== "ok");
771
+ const nBy = (h) => rows.filter((r) => r.health === h).length;
772
+ const nDead = nBy("retired") + nBy("withdrawn");
773
+ const nRetiring = nBy("retiring");
774
+ const nDeprecating = nBy("deprecating");
775
+
776
+ // Custom strings: surface the resolver's near-miss suggestion + sort by frequency.
777
+ const customList = [...custom.entries()]
778
+ .map(([name, places]) => ({ name, places, suggestion: byStr.get(name.toLowerCase())?.suggestion || null }))
779
+ .sort((a, b) => b.places - a.places || a.name.localeCompare(b.name));
730
780
 
731
781
  if (flags.json) {
732
782
  console.log(JSON.stringify({
733
783
  registry: { version: snapshot.version, generated_at: snapshot.generated_at, models: snapshot.models.length },
734
784
  scanned: dir,
735
785
  references: candidates.length,
786
+ files: fileCount,
736
787
  models: rows.map((r) => ({ slug: r.model.slug, display: r.model.display, health: r.health, retires_date: r.model.retires_date, replacement_slug: r.model.replacement_slug, places: r.count })),
737
- custom: [...custom.entries()].map(([name, places]) => ({ name, places })),
788
+ custom: customList,
738
789
  needs_attention: attention.length,
739
790
  }, null, 2));
740
791
  return;
@@ -742,9 +793,21 @@ async function cmdStatus(positional, flags) {
742
793
 
743
794
  const ageDays = Math.round((today - new Date(snapshot.generated_at)) / 86_400_000);
744
795
  console.log(`LLM Status — registry ${snapshot.version} (${ageDays <= 0 ? "today" : ageDays + "d old"}), ${snapshot.models.length} models`);
745
- console.log(`Scanned ${dir}: ${candidates.length} reference(s) → ${known.size} model(s), ${custom.size} custom\n`);
796
+ console.log(`Scanned ${dir}: ${candidates.length} reference(s) in ${fileCount} file(s) → ${known.size} model(s), ${custom.size} custom`);
797
+ // Lead with the verdict so the result reads at a glance.
798
+ if (attention.length) {
799
+ const parts = [];
800
+ if (nDead) parts.push(`${nDead} retired`);
801
+ if (nRetiring) parts.push(`${nRetiring} retiring soon`);
802
+ if (nDeprecating) parts.push(`${nDeprecating} deprecating`);
803
+ console.log(`${attention.length} of ${known.size} model(s) need attention: ${parts.join(" · ")}`);
804
+ } else if (rows.length) {
805
+ console.log(`All ${known.size} model(s) you use are current.`);
806
+ }
807
+ console.log("");
808
+
746
809
  if (!rows.length && !custom.size) {
747
- console.log("No model usage found here. Try `mm status <dir>`, or `mm scan` to map a whole project.");
810
+ console.log("No model usage found here. Point `mm status` at a repo, or run `mm scan` to map a whole project.");
748
811
  return;
749
812
  }
750
813
  if (rows.length) {
@@ -755,15 +818,23 @@ async function cmdStatus(positional, flags) {
755
818
  console.log(` ${ICON[r.health]} ${r.health.padEnd(11)} ${r.model.slug.padEnd(32)} ${tail.padEnd(20)}${repl} (${r.count})`);
756
819
  }
757
820
  }
758
- if (custom.size) {
821
+ if (customList.length) {
759
822
  console.log("\nCustom / unrecognized:");
760
- for (const [name, places] of custom) console.log(` ⚪ ${name} (${places})`);
823
+ const CAP = 12;
824
+ for (const c of customList.slice(0, CAP)) {
825
+ const hint = c.suggestion ? ` ≈ ${c.suggestion}?` : "";
826
+ console.log(` ⚪ ${c.name} (${c.places})${hint}`);
827
+ }
828
+ if (customList.length > CAP) console.log(` … +${customList.length - CAP} more (mm status --json for the full list)`);
761
829
  }
830
+ // Functional next steps only (no marketing voice): `mm fix` when something's
831
+ // auto-rewritable; the alerts pointer only when not signed in.
762
832
  if (attention.length) {
763
- console.log(`\n⚠ ${attention.length} model(s) need attention before they retire.`);
764
- console.log(" Get alerted automatically (email/Slack/SMS): `mm login` then `mm upgrade` — scanning is free, alerting is the paid feature.");
765
- } else if (rows.length) {
766
- console.log("\n✓ Everything you use is current.");
833
+ const fixable = attention.filter((r) => r.model.replacement_slug).length;
834
+ const tips = [];
835
+ if (fixable) tips.push(`\`mm fix\` rewrites the ${fixable} with a known replacement`);
836
+ if (!loadConfig().apiKey) tips.push("`mm login` to get alerted before these dates (Pro)");
837
+ if (tips.length) console.log("\n" + tips.map((t) => "→ " + t).join("\n"));
767
838
  }
768
839
  }
769
840
 
@@ -779,14 +850,15 @@ Usage:
779
850
  mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
780
851
  mm fix [dir] Rewrite dying model ids to their replacement, in place (--dry-run previews; --model <slug> limits; --yes skips the confirm)
781
852
  mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
782
- (--diff <base> limits findings to files changed vs base; auto on PRs via GITHUB_BASE_REF)
853
+ (--fail-on <none|deprecating|retiring|retired> sets the threshold, default retired;
854
+ --json-out <file> writes findings JSON; --diff <base> limits to changed files, auto on PRs)
783
855
  mm sources List detection sources and whether each can run here
784
856
  mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
785
857
  mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
786
858
  mm update Update to the latest version now and relaunch (or add --update to any command)
787
859
  mm upgrade Open Stripe checkout and poll until Pro is active (the paid plan, not the binary)
788
860
  mm play [dir] Play Donkey Kong while a background scan walks the dir (just for fun)
789
- mm tui Force-launch the TUI (logs you in first if needed)
861
+ mm tui Force-launch the TUI (needs an interactive terminal)
790
862
 
791
863
  Scan sources (--sources; default = filesystem + enabled integrations; "all" for everything):
792
864
  filesystem repo files aws-secrets AWS Secrets Manager + SSM
@@ -798,7 +870,8 @@ Scan sources (--sources; default = filesystem + enabled integrations; "all" for
798
870
  Secret sources shell out to your already-authenticated CLIs, run read-only,
799
871
  and only ever upload model ids — secret VALUES never leave your machine.
800
872
 
801
- Flags: --update · --api <url> · --key <key> · --project <id|name> · --yes · --json · --ci · --dry-run
873
+ Flags: --update · --api <url> · --key <key> · --project <id|name> · --dir <path> · --yes · --json · --ci · --dry-run
874
+ --offline (use the cached registry) · --model <slug> (fix) · --fail-on <t> · --json-out <file> (ci) · --all · --force (clear)
802
875
  --sources <list> · --region <r> · --namespace <ns> · --kube-context <c> · --db <dsn> · --sql-table <t>
803
876
  --vercel-project <p> · --vercel-team <t> · --gh-repo <owner/name> · --supabase-ref <ref>
804
877
 
@@ -21,10 +21,22 @@ export function resolveLocal(snapshot, strings) {
21
21
  const model = bySlug.get(slug) || null;
22
22
  return { input: s, model_slug: slug, display: model?.display ?? s, model, confidence: 1, suggestion: null };
23
23
  }
24
- // No exact hit — find the nearest known id (longest two-way substring overlap).
25
- let near = null;
24
+ // No exact hit — find the nearest known id. Prefer (1) a key the input is a
25
+ // PREFIX of (input = base id, key = a versioned form, e.g. "gpt-3" →
26
+ // "gpt-3.5-turbo"), taking the SHORTEST such (closest minor version); else
27
+ // (2) the longest known id the input CONTAINS (input is a longer variant);
28
+ // else (3) the longest known id that contains the input. Tiering avoids
29
+ // latching onto a long dated/ARN variant (the old longest-overlap rule
30
+ // suggested e.g. an Amazon Bedrock ARN slug for a bare "claude-sonnet-4").
31
+ let near = null, bestScore = -1, bestTie = -Infinity;
26
32
  for (const k of keys) {
27
- if (k.length >= 4 && (k.includes(n) || n.includes(k)) && (!near || k.length > near.length)) near = k;
33
+ if (k.length < 4) continue;
34
+ let score, tie;
35
+ if (k.startsWith(n)) { score = 3; tie = -k.length; } // versioned form of the base — shortest wins
36
+ else if (n.includes(k)) { score = 2; tie = k.length; } // input is a longer variant of a known id
37
+ else if (k.includes(n)) { score = 1; tie = -k.length; } // input embedded in a longer id — weakest
38
+ else continue;
39
+ if (score > bestScore || (score === bestScore && tie > bestTie)) { near = k; bestScore = score; bestTie = tie; }
28
40
  }
29
41
  const suggestion = near ? exact.get(near) || null : null;
30
42
  return { input: s, model_slug: null, display: s, model: null, confidence: suggestion ? 0.6 : 0, suggestion };
Binary file
@@ -94,7 +94,7 @@ export async function availability(sourceIds, opts = {}, explicit = new Set()) {
94
94
  * the cheap path: uses available() (PATH check), never authState() (spawn).
95
95
  * `explicit` is the set of ids the caller named verbatim — naming a live
96
96
  * integration there overrides the enabled-gate. */
97
- export async function collectFrom(sourceIds, opts, patterns, explicit = new Set()) {
97
+ export async function collectFrom(sourceIds, opts, patterns, explicit = new Set(), onProgress = null) {
98
98
  const compiled = compilePatterns(patterns);
99
99
  const ids = sourceIds && sourceIds.length ? sourceIds : ["filesystem"];
100
100
  const seen = new Set();
@@ -108,7 +108,9 @@ export async function collectFrom(sourceIds, opts, patterns, explicit = new Set(
108
108
  // Fold the per-source declared envTag into opts.env so the integration's env
109
109
  // overrides guessEnvFrom — but an explicit --env flag (opts.env) still wins,
110
110
  // and Vercel's authoritative deploy target still wins inside its own collect.
111
- const srcOpts = src.integration ? { ...opts, env: opts.env || getEnvTag(id) } : opts;
111
+ // onProgress (optional) lets a caller render a live file counter only the
112
+ // filesystem source emits progress; the rest ignore the extra opt.
113
+ const srcOpts = src.integration ? { ...opts, env: opts.env || getEnvTag(id), onProgress } : { ...opts, onProgress };
112
114
  for (const c of await src.collect(srcOpts, compiled)) {
113
115
  const key = `${c.model_string}|${c.location_label}`;
114
116
  if (seen.has(key)) continue;