@modelstatus/cli 0.1.83 → 0.1.85

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.85",
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",
package/src/api.js CHANGED
@@ -90,7 +90,7 @@ export function createClient({ apiBase, apiKey }) {
90
90
  listNotifications: (params) => req("GET", `/notifications${qs(params)}`),
91
91
  readNotification: (id) => req("POST", `/notifications/${id}/read`),
92
92
 
93
- // billing
94
- checkout: () => req("POST", "/billing/checkout"),
93
+ // billing — plan: undefined → Pro ($5/yr subscription); "lifetime" → $29 one-time
94
+ checkout: (plan) => req("POST", `/billing/checkout${plan ? `?plan=${encodeURIComponent(plan)}` : ""}`),
95
95
  };
96
96
  }
@@ -1,6 +1,28 @@
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.85",
6
+ "date": "2026-06-14",
7
+ "title": "Command polish + safer billing",
8
+ "items": [
9
+ "`mm upgrade --lifetime` starts the one-time Lifetime checkout from the CLI (plain `mm upgrade` is still the $5/yr Pro plan).",
10
+ "`mm <command> --help` now prints per-command usage and flags; an unknown command exits non-zero instead of silently printing the help.",
11
+ "`mm clear` requires an explicit `--yes`/`--force` — it will no longer wipe your cloud inventory on `--ci` alone. `mm logout` no longer claims it removed a key when you weren't signed in, and warns if an env-var key still authenticates you.",
12
+ "Billing safety: checkout won't start a duplicate charge for an account that's already on Pro or Lifetime."
13
+ ]
14
+ },
15
+ {
16
+ "version": "0.1.84",
17
+ "date": "2026-06-14",
18
+ "title": "Clearer mm status, faster scans, safer CI",
19
+ "items": [
20
+ "`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\".",
21
+ "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.",
22
+ "`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.",
23
+ "`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."
24
+ ]
25
+ },
4
26
  {
5
27
  "version": "0.1.83",
6
28
  "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
@@ -72,7 +72,7 @@ if (process.argv[2] === "__bench_frames") {
72
72
  const p = runGame({
73
73
  width: 80, height: 24, scanStore: scan,
74
74
  _inject: {
75
- out, inp: input, proc: process, now,
75
+ out, inp: input, proc: process, now, noPersist: true, // bench: never write dkHighScore
76
76
  schedule: (fn, ms) => setTimeout(() => { const t = now(); if (prev != null) emit({ t: "frame", dtMs: t - prev }); prev = t; fn(); }, ms),
77
77
  cancel: (h) => clearTimeout(h),
78
78
  },
@@ -181,8 +181,14 @@ async function cmdSignup(_positional, flags) {
181
181
  }
182
182
 
183
183
  function cmdLogout() {
184
+ const had = !!loadConfig().apiKey;
184
185
  clearAuth();
185
- console.log("✓ Signed out (API key removed). Run `mm login` to sign back in.");
186
+ if (had) console.log("✓ Signed out (saved API key removed). Run `mm login` to sign back in.");
187
+ else console.log("Not signed in — no saved API key to remove.");
188
+ // clearAuth only touches the config file; an env key still authenticates.
189
+ if (process.env.LLMSTATUS_API_KEY || process.env.MM_API_KEY) {
190
+ console.log("Note: an API key is still set via LLMSTATUS_API_KEY/MM_API_KEY — unset it to fully sign out.");
191
+ }
186
192
  }
187
193
 
188
194
  async function cmdUpgrade(_positional, flags) {
@@ -193,7 +199,9 @@ async function cmdUpgrade(_positional, flags) {
193
199
  }
194
200
  const client = createClient({ apiBase, apiKey });
195
201
  const { upgradeViaBrowser } = await import("./upgrade.js");
196
- const plan = await upgradeViaBrowser({ client });
202
+ // --lifetime opens the one-time $29 checkout; default is the $5/yr Pro subscription.
203
+ const checkoutPlan = flags.lifetime ? "lifetime" : undefined;
204
+ const plan = await upgradeViaBrowser({ client, plan: checkoutPlan });
197
205
  if (!plan) {
198
206
  console.error("Upgrade not detected (timed out). Run `mm upgrade` again if you completed checkout.");
199
207
  process.exit(1);
@@ -244,10 +252,18 @@ async function cmdPlay(positional, flags) {
244
252
  }
245
253
 
246
254
  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.
255
+ // The Ink TUI needs raw-mode stdin. Without a TTY (piped, redirected, CI logs)
256
+ // Ink throws an unhandled "Raw mode is not supported" deep in a passive effect
257
+ // it escapes main()'s try/catch and dumps a React/runtime stack. Guard like
258
+ // `mm play` does and point at the non-interactive command instead.
259
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
260
+ console.error("The mm dashboard needs an interactive terminal (a TTY).");
261
+ console.error("For a non-interactive check, run: mm status [dir] (add --json for scripts)");
262
+ process.exit(1);
263
+ }
264
+ // Pass apiKey straight through (may be null). The TUI lands on the public
265
+ // "Here" tab (no account needed); sign-in is lazy — the Account tab (press 7)
266
+ // or any auth-gated tab prompts when you get there.
251
267
  const { apiBase, apiKey } = resolveAuth(flags);
252
268
  const dir = path.resolve(flags.dir || ".");
253
269
  const { runApp } = await import("./tui/app.js");
@@ -257,13 +273,15 @@ async function launchTui(initialView, flags) {
257
273
  }
258
274
 
259
275
  async function cmdScan(positional, flags) {
260
- const dir = path.resolve(positional[1] || ".");
276
+ const dir = path.resolve(positional[1] || flags.dir || ".");
261
277
  const { apiBase, apiKey } = resolveAuth(flags);
262
278
  if (!apiKey) {
263
279
  console.error("No API key. Run `mm login` or pass --key.");
264
280
  process.exit(1);
265
281
  }
266
- const interactive = !flags.yes && !flags.json && process.stdout.isTTY;
282
+ // --dry-run must NOT launch the interactive TUI (which can upload) it's a
283
+ // no-upload preview, so force the non-interactive path even on a TTY.
284
+ const interactive = !flags.yes && !flags.json && !flags["dry-run"] && process.stdout.isTTY;
267
285
  if (interactive) {
268
286
  const { runApp } = await import("./tui/app.js");
269
287
  await runApp({ apiBase, apiKey, dir, initialView: "scan" });
@@ -290,7 +308,19 @@ async function cmdScan(positional, flags) {
290
308
  const patterns = await client.detectionPatterns();
291
309
  const candidates = await collectFrom(sources, opts, patterns, explicit);
292
310
  if (candidates.length === 0) {
293
- console.log("No model usage found.");
311
+ // Respect --json/--ci even on the empty path, so `mm scan --ci | jq` never
312
+ // chokes on a bare text line. Mirror the shape of the matching non-empty path.
313
+ if (flags.json) {
314
+ const srcRows = avail.map((a) => ({ id: a.id, available: a.available }));
315
+ console.log(JSON.stringify(
316
+ flags["dry-run"]
317
+ ? { scanned: 0, would_upload: [], sources: avail }
318
+ : { scanned: 0, uploaded: 0, sources: srcRows, created: 0, updated: 0, failed: 0 },
319
+ null, 2,
320
+ ));
321
+ } else {
322
+ console.log("No model usage found.");
323
+ }
294
324
  return;
295
325
  }
296
326
 
@@ -436,8 +466,18 @@ async function postCiRun(dir, flags, res, failOn) {
436
466
  * annotations + a step summary under GITHUB_ACTIONS; exits non-zero per --fail-on
437
467
  * so it gates merges. Offline (public registry) by default; --report syncs (Pro). */
438
468
  async function cmdCi(positional, flags) {
439
- const dir = path.resolve(positional[1] || flags.dir || ".");
469
+ // realpathSync so `dir` matches git's `--show-toplevel` (which resolves
470
+ // symlinks). Without this, on a symlinked checkout (macOS /tmp, symlinked CI
471
+ // workspaces) the --diff path math mismatches and ALL findings are dropped →
472
+ // a silent GREEN check while retired models in changed files slip through.
473
+ let dir = path.resolve(positional[1] || flags.dir || ".");
474
+ try { dir = fs.realpathSync(dir); } catch { /* keep resolved path if it doesn't exist */ }
475
+ const VALID_FAIL_ON = new Set(["none", "deprecating", "retiring", "retired"]);
440
476
  const failOn = String(flags["fail-on"] || "retired").toLowerCase();
477
+ if (flags["fail-on"] !== undefined && !VALID_FAIL_ON.has(failOn)) {
478
+ console.error(`Invalid --fail-on "${flags["fail-on"]}". One of: ${[...VALID_FAIL_ON].join(", ")}.`);
479
+ process.exit(1);
480
+ }
441
481
  const { evaluateCi, annotationLines, summaryMarkdown, filterToChangedFiles, getChangedFiles, HEALTH_RANK } = await import("./ci.js");
442
482
  const res = await evaluateCi({
443
483
  dir,
@@ -522,9 +562,13 @@ async function cmdClear(_positional, flags) {
522
562
  }
523
563
  const all = !!flags.all;
524
564
  const scope = all ? "ALL usages, projects, alert rules + the in-app feed" : "ALL usages";
525
- if (!flags.yes && !flags.force) {
565
+ // A destructive delete requires an EXPLICIT --yes/--force. `--ci` implies --yes
566
+ // for scan/fix non-interactivity, but it must NOT silently skip the safety
567
+ // prompt here — so check the raw argv, not the --ci-derived flags.yes.
568
+ const explicitConfirm = process.argv.includes("--yes") || process.argv.includes("--force");
569
+ if (!explicitConfirm) {
526
570
  if (!process.stdin.isTTY) {
527
- console.error(`Refusing to delete ${scope} without confirmation. Re-run with --yes.`);
571
+ console.error(`Refusing to delete ${scope} without confirmation. Re-run with --yes (or --force).`);
528
572
  process.exit(1);
529
573
  }
530
574
  const ok = await confirm(`Delete ${scope} from your account? This cannot be undone. [y/N] `);
@@ -551,7 +595,8 @@ async function cmdFix(positional, flags) {
551
595
  const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => prog.log(m) });
552
596
 
553
597
  prog.update("scanning for model references…");
554
- const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection);
598
+ const onProgress = ({ filesScanned, candidates: c }) => prog.update(`scanning… ${filesScanned} files, ${c} reference(s)`);
599
+ const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection, new Set(), onProgress);
555
600
  prog.stop();
556
601
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
557
602
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
@@ -697,11 +742,15 @@ async function cmdStatus(positional, flags) {
697
742
  // A cold `mm status` downloads the ~225 kB registry snapshot then walks the
698
743
  // repo with zero output — it reads as frozen. Show live stderr progress
699
744
  // (auto-off for --json / pipes so machine output stays byte-identical).
745
+ // A cold `mm status` downloads the registry snapshot then walks the repo with
746
+ // zero output — it reads as frozen. Show live stderr progress with a MOVING
747
+ // file counter (auto-off for --json / pipes so machine output is byte-identical).
700
748
  const prog = startProgress(!flags.json, "fetching the model registry…");
701
749
  const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => prog.log(m) });
702
750
 
703
751
  prog.update("scanning for model references…");
704
- let candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags));
752
+ const onProgress = ({ filesScanned, candidates: c }) => prog.update(`scanning… ${filesScanned} files, ${c} reference(s)`);
753
+ let candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags), onProgress);
705
754
  prog.stop();
706
755
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
707
756
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
@@ -719,22 +768,36 @@ async function cmdStatus(positional, flags) {
719
768
  known.set(r.model_slug, e);
720
769
  } else custom.set(c.model_string, (custom.get(c.model_string) || 0) + 1);
721
770
  }
771
+ const fileCount = new Set(candidates.map((c) => c.source_path || c.location_label)).size;
722
772
 
723
773
  const today = new Date();
724
774
  const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔", custom: "⚪" };
725
- const rank = { withdrawn: 0, retired: 0, retiring: 1, deprecating: 2, ok: 3 };
775
+ // Order by WHAT DIES FIRST: already-dead (retired/withdrawn) first, then aging
776
+ // by soonest retirement date regardless of bucket (a deprecating model that
777
+ // retires sooner outranks a retiring one that retires later), then ok last.
778
+ const tier = (h) => (h === "retired" || h === "withdrawn") ? 0 : h === "ok" ? 2 : 1;
726
779
  const rows = [...known.values()]
727
780
  .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")));
781
+ .sort((a, b) => tier(a.health) - tier(b.health) || String(a.model.retires_date || "9999").localeCompare(String(b.model.retires_date || "9999")));
729
782
  const attention = rows.filter((r) => r.health !== "ok");
783
+ const nBy = (h) => rows.filter((r) => r.health === h).length;
784
+ const nDead = nBy("retired") + nBy("withdrawn");
785
+ const nRetiring = nBy("retiring");
786
+ const nDeprecating = nBy("deprecating");
787
+
788
+ // Custom strings: surface the resolver's near-miss suggestion + sort by frequency.
789
+ const customList = [...custom.entries()]
790
+ .map(([name, places]) => ({ name, places, suggestion: byStr.get(name.toLowerCase())?.suggestion || null }))
791
+ .sort((a, b) => b.places - a.places || a.name.localeCompare(b.name));
730
792
 
731
793
  if (flags.json) {
732
794
  console.log(JSON.stringify({
733
795
  registry: { version: snapshot.version, generated_at: snapshot.generated_at, models: snapshot.models.length },
734
796
  scanned: dir,
735
797
  references: candidates.length,
798
+ files: fileCount,
736
799
  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 })),
800
+ custom: customList,
738
801
  needs_attention: attention.length,
739
802
  }, null, 2));
740
803
  return;
@@ -742,9 +805,21 @@ async function cmdStatus(positional, flags) {
742
805
 
743
806
  const ageDays = Math.round((today - new Date(snapshot.generated_at)) / 86_400_000);
744
807
  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`);
808
+ console.log(`Scanned ${dir}: ${candidates.length} reference(s) in ${fileCount} file(s) → ${known.size} model(s), ${custom.size} custom`);
809
+ // Lead with the verdict so the result reads at a glance.
810
+ if (attention.length) {
811
+ const parts = [];
812
+ if (nDead) parts.push(`${nDead} retired`);
813
+ if (nRetiring) parts.push(`${nRetiring} retiring soon`);
814
+ if (nDeprecating) parts.push(`${nDeprecating} deprecating`);
815
+ console.log(`${attention.length} of ${known.size} model(s) need attention: ${parts.join(" · ")}`);
816
+ } else if (rows.length) {
817
+ console.log(`All ${known.size} model(s) you use are current.`);
818
+ }
819
+ console.log("");
820
+
746
821
  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.");
822
+ console.log("No model usage found here. Point `mm status` at a repo, or run `mm scan` to map a whole project.");
748
823
  return;
749
824
  }
750
825
  if (rows.length) {
@@ -755,15 +830,23 @@ async function cmdStatus(positional, flags) {
755
830
  console.log(` ${ICON[r.health]} ${r.health.padEnd(11)} ${r.model.slug.padEnd(32)} ${tail.padEnd(20)}${repl} (${r.count})`);
756
831
  }
757
832
  }
758
- if (custom.size) {
833
+ if (customList.length) {
759
834
  console.log("\nCustom / unrecognized:");
760
- for (const [name, places] of custom) console.log(` ⚪ ${name} (${places})`);
835
+ const CAP = 12;
836
+ for (const c of customList.slice(0, CAP)) {
837
+ const hint = c.suggestion ? ` ≈ ${c.suggestion}?` : "";
838
+ console.log(` ⚪ ${c.name} (${c.places})${hint}`);
839
+ }
840
+ if (customList.length > CAP) console.log(` … +${customList.length - CAP} more (mm status --json for the full list)`);
761
841
  }
842
+ // Functional next steps only (no marketing voice): `mm fix` when something's
843
+ // auto-rewritable; the alerts pointer only when not signed in.
762
844
  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.");
845
+ const fixable = attention.filter((r) => r.model.replacement_slug).length;
846
+ const tips = [];
847
+ if (fixable) tips.push(`\`mm fix\` rewrites the ${fixable} with a known replacement`);
848
+ if (!loadConfig().apiKey) tips.push("`mm login` to get alerted before these dates (Pro)");
849
+ if (tips.length) console.log("\n" + tips.map((t) => "→ " + t).join("\n"));
767
850
  }
768
851
  }
769
852
 
@@ -779,14 +862,15 @@ Usage:
779
862
  mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
780
863
  mm fix [dir] Rewrite dying model ids to their replacement, in place (--dry-run previews; --model <slug> limits; --yes skips the confirm)
781
864
  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)
865
+ (--fail-on <none|deprecating|retiring|retired> sets the threshold, default retired;
866
+ --json-out <file> writes findings JSON; --diff <base> limits to changed files, auto on PRs)
783
867
  mm sources List detection sources and whether each can run here
784
868
  mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
785
869
  mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
786
870
  mm update Update to the latest version now and relaunch (or add --update to any command)
787
- mm upgrade Open Stripe checkout and poll until Pro is active (the paid plan, not the binary)
871
+ mm upgrade Open Stripe checkout and poll until active (--lifetime for the one-time plan; this is the paid plan, not the binary)
788
872
  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)
873
+ mm tui Force-launch the TUI (needs an interactive terminal)
790
874
 
791
875
  Scan sources (--sources; default = filesystem + enabled integrations; "all" for everything):
792
876
  filesystem repo files aws-secrets AWS Secrets Manager + SSM
@@ -798,11 +882,87 @@ Scan sources (--sources; default = filesystem + enabled integrations; "all" for
798
882
  Secret sources shell out to your already-authenticated CLIs, run read-only,
799
883
  and only ever upload model ids — secret VALUES never leave your machine.
800
884
 
801
- Flags: --update · --api <url> · --key <key> · --project <id|name> · --yes · --json · --ci · --dry-run
885
+ Flags: --update · --api <url> · --key <key> · --project <id|name> · --dir <path> · --yes · --json · --ci · --dry-run
886
+ --offline (use the cached registry) · --model <slug> (fix) · --fail-on <t> · --json-out <file> (ci) · --all · --force (clear)
802
887
  --sources <list> · --region <r> · --namespace <ns> · --kube-context <c> · --db <dsn> · --sql-table <t>
803
888
  --vercel-project <p> · --vercel-team <t> · --gh-repo <owner/name> · --supabase-ref <ref>
804
889
 
805
- Get started: \`mm login\` (opens your browser).`;
890
+ Get started: \`mm login\` (opens your browser).
891
+
892
+ Per-command help: \`mm <command> --help\` (e.g. \`mm ci --help\`).`;
893
+
894
+ /** Per-command usage. `mm <cmd> --help` / `mm help <cmd>` prints these; anything
895
+ * not listed falls back to the global HELP. Keep flags here in sync with parseArgs. */
896
+ const COMMAND_HELP = {
897
+ status: `mm status [dir] Offline, account-less model-health check (free).
898
+
899
+ Pulls the signed registry, scans the dir locally, prints each model in use with
900
+ its health + replacement, then the custom/unrecognized ids. Always exits 0 (it
901
+ informs; use \`mm ci\` to gate a build).
902
+
903
+ --json machine-readable {registry, scanned, references, files, models[], custom[], needs_attention}
904
+ --offline use the cached registry only (no network)
905
+ --sources <list> detection sources (default: filesystem + enabled integrations; "all" for everything)
906
+ --dir <path> directory to scan (alternative to the positional arg)`,
907
+ fix: `mm fix [dir] Rewrite dying model ids to their registry replacement, in place.
908
+
909
+ Boundary-safe, style-preserving, chain-resolved. Only filesystem refs with a known
910
+ replacement are touched. Asks before writing (a non-TTY needs --yes).
911
+
912
+ --dry-run preview the rewrites, write nothing
913
+ --json machine output ({planned, dryRun} for --dry-run; {applied, stale, failed} on apply)
914
+ --yes skip the confirmation prompt
915
+ --model <slug> only fix this one model (full provider/slug)
916
+ --offline use the cached registry only`,
917
+ ci: `mm ci [dir] CI gate: fail the build on deprecated/retiring models.
918
+
919
+ Exits non-zero when a finding is at/above --fail-on. Emits GitHub annotations +
920
+ a step summary under GITHUB_ACTIONS. Offline-capable, no account.
921
+
922
+ --fail-on <none|deprecating|retiring|retired> threshold (default: retired)
923
+ --json print the full report to stdout
924
+ --json-out <file> write clean findings JSON to a file (stdout stays annotations-only)
925
+ --diff <base> limit findings to files changed vs base (auto on PRs via GITHUB_BASE_REF)
926
+ --report (Pro) sync this run's usages + a CI-run row to your account
927
+ --offline use the cached registry only`,
928
+ scan: `mm scan [dir] Scan for model usage and upload to your account's inventory (needs login).
929
+
930
+ On a TTY with no flags it opens the interactive Scan tab. --ci/--json/--yes run
931
+ non-interactively.
932
+
933
+ --dry-run show exactly what WOULD upload, upload nothing
934
+ --json / --ci machine output (--ci implies --yes + --json)
935
+ --yes upload without the interactive TUI
936
+ --project <id|name> route everything to one project (created if the name is new)
937
+ --sources <list> detection sources ("all" for everything)
938
+ --dir <path> directory to scan`,
939
+ clear: `mm clear Delete tracked usages from your cloud inventory (DESTRUCTIVE, needs login).
940
+
941
+ Requires an explicit --yes or --force (it will NOT proceed on --ci alone).
942
+
943
+ --all also wipe projects, alert rules + the in-app feed (a full reset)
944
+ --yes / --force confirm the delete (required on a non-TTY)
945
+ --json print the result counts`,
946
+ upgrade: `mm upgrade Open Stripe checkout and poll until your plan is active (needs login).
947
+
948
+ --lifetime buy the one-time Lifetime plan ($29) instead of Pro ($5/yr)`,
949
+ integrations: `mm integrations [list | enable <id> | disable <id> | env <id> <tag>]
950
+
951
+ Manage the local on/off state of the live integrations (the gate for what
952
+ \`mm scan\`/\`mm status\` run by default). Ids: aws-lambda, vercel, supabase-edge,
953
+ github-actions. \`env <id> <prod|staging|dev|unknown>\` sets a declared env.
954
+
955
+ --json (list only) machine-readable integration state`,
956
+ config: `mm config [analytics on|off] View or change local settings.
957
+
958
+ Bare \`mm config\` lists settings (analytics, update channel, config path).
959
+ \`mm config analytics on|off\` toggles anonymous usage analytics (also honored:
960
+ MM_NO_ANALYTICS=1, DO_NOT_TRACK=1, CI=1).`,
961
+ login: `mm login [api_key] Sign in. With no key, opens the browser and polls; or paste a key.
962
+
963
+ --key <key> paste an API key directly
964
+ --api <url> override the API base`,
965
+ };
806
966
 
807
967
  /** Awaits the updater promise; prints a one-liner if an update completed. Never throws. */
808
968
  async function maybePrintUpdate(promise) {
@@ -859,8 +1019,10 @@ async function main() {
859
1019
 
860
1020
  // --help / -h / help: print usage + exit. MUST come before the no-arg → TUI
861
1021
  // fallthrough below (a bare `mm` launches the TUI, but `mm --help` must not).
1022
+ // `mm <cmd> --help` and `mm help <cmd>` print per-command usage when we have it.
862
1023
  if (cmd === "help" || flags.help || flags.h) {
863
- console.log(HELP);
1024
+ const topic = cmd === "help" ? positional[1] : cmd;
1025
+ console.log((topic && COMMAND_HELP[topic]) || HELP);
864
1026
  return;
865
1027
  }
866
1028
 
@@ -934,8 +1096,13 @@ async function main() {
934
1096
  flags.dir = cmd;
935
1097
  await launchTui(positional[1], flags);
936
1098
  }
937
- else if (cmd === "help" || flags.help) console.log(HELP);
938
- else console.log(HELP);
1099
+ else {
1100
+ // Unknown command / non-existent path: name it on stderr + exit non-zero so
1101
+ // a typo in a script fails loudly instead of silently printing help.
1102
+ console.error(`Unknown command or path: ${cmd}\n`);
1103
+ console.log(HELP);
1104
+ process.exit(1);
1105
+ }
939
1106
  } catch (e) {
940
1107
  console.error(`Error: ${e?.message ?? e}`);
941
1108
  await maybePrintUpdate(updatePromise);
@@ -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;
@@ -139,7 +139,9 @@ export function runGame({ width, height, level = 1, scanStore = null, onExit, in
139
139
  if (highSaved) return;
140
140
  highSaved = true;
141
141
  const score = (game && Math.max(game.best || 0, game.score || 0)) || 0;
142
- if (score > best) {
142
+ // _inject.noPersist (the __bench_frames seam) keeps a measurement run from
143
+ // writing dkHighScore to the user's config.
144
+ if (score > best && !_inject.noPersist) {
143
145
  try { setConfigValue("dkHighScore", score); best = score; } catch { /* best effort */ }
144
146
  }
145
147
  }
package/src/upgrade.js CHANGED
@@ -4,8 +4,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4
4
 
5
5
  /** Open Stripe checkout and poll /me until the plan flips off "free". Mirrors
6
6
  * the browser-login poll UX. Returns the new plan, or null on timeout. */
7
- export async function upgradeViaBrowser({ client, log = console.error, onTick }) {
8
- const { url } = await client.checkout();
7
+ export async function upgradeViaBrowser({ client, plan, log = console.error, onTick }) {
8
+ const { url } = await client.checkout(plan);
9
9
  if (!url) throw new Error("Could not start checkout.");
10
10
  log(`\n Opening checkout in your browser…`);
11
11
  log(` If it doesn't open, visit:\n ${url}\n`);