@modelstatus/cli 0.1.82 → 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
@@ -29,10 +29,20 @@ Or skip install entirely and one-shot it: `npx @modelstatus/cli status`.
29
29
 
30
30
  ## Quick start
31
31
 
32
- ### Free: offline health check
32
+ ### Free: open the dashboard
33
33
 
34
34
  ```bash
35
- mm status [dir] # if installed
35
+ mm [dir] # the full TUI — inventory · scan · what's new · alerts
36
+ ```
37
+
38
+ Just run `mm` (optionally on a folder) for the interactive dashboard: it scans
39
+ locally, shows every model in use with its health, and lets you fix the dying
40
+ ones — no account needed.
41
+
42
+ ### Free: a one-shot health check (great for CI / pipes)
43
+
44
+ ```bash
45
+ mm status [dir] # quick offline check, prints + exits
36
46
  npx @modelstatus/cli status [dir] # zero install (needs Node)
37
47
  ```
38
48
 
@@ -138,6 +148,25 @@ Apple-notarized, every release manifest is Ed25519-signed and verified — again
138
148
  a public key embedded in the installer and the self-updater, not fetched from the
139
149
  CDN — and both refuse to proceed if any check fails.
140
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
+
141
170
  ## Links
142
171
 
143
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.82",
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,26 @@
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
+ },
15
+ {
16
+ "version": "0.1.83",
17
+ "date": "2026-06-14",
18
+ "title": "Live progress on mm status",
19
+ "items": [
20
+ "`mm status` and `mm fix` now show live progress while they pull the registry and scan your repo — a cold run no longer sits silent and looks frozen.",
21
+ "Docs and the post-install quick start now lead with `mm` (the full dashboard); `mm status` is the quick, scriptable, no-account check that drops into CI."
22
+ ]
23
+ },
4
24
  {
5
25
  "version": "0.1.82",
6
26
  "date": "2026-06-13",
@@ -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
@@ -14,7 +14,8 @@ import { redactValue } from "./redact.js";
14
14
  import { assignProjects, buildUsages } from "./upload.js";
15
15
  import { loginViaBrowser } from "./auth.js";
16
16
  import { maybeCheckForUpdate, forceUpdate } from "./updater.js";
17
- import { track, analyticsState } from "./telemetry.js";
17
+ import { track, analyticsState, maybeFirstRun } from "./telemetry.js";
18
+ import { startProgress } from "./spinner.js";
18
19
  import { BUILD_VERSION } from "./version.js";
19
20
 
20
21
  // TRUE BACKGROUND SCAN — hidden worker dispatch. MUST be the first executable
@@ -243,10 +244,18 @@ async function cmdPlay(positional, flags) {
243
244
  }
244
245
 
245
246
  async function launchTui(initialView, flags) {
246
- // Pass apiKey straight through (may be null). The TUI's Bootstrap wrapper
247
- // renders an interactive SignIn screen when there's no key, then swaps to
248
- // the main App on success no separate browser-login polling phase that the
249
- // 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.
250
259
  const { apiBase, apiKey } = resolveAuth(flags);
251
260
  const dir = path.resolve(flags.dir || ".");
252
261
  const { runApp } = await import("./tui/app.js");
@@ -256,13 +265,15 @@ async function launchTui(initialView, flags) {
256
265
  }
257
266
 
258
267
  async function cmdScan(positional, flags) {
259
- const dir = path.resolve(positional[1] || ".");
268
+ const dir = path.resolve(positional[1] || flags.dir || ".");
260
269
  const { apiBase, apiKey } = resolveAuth(flags);
261
270
  if (!apiKey) {
262
271
  console.error("No API key. Run `mm login` or pass --key.");
263
272
  process.exit(1);
264
273
  }
265
- 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;
266
277
  if (interactive) {
267
278
  const { runApp } = await import("./tui/app.js");
268
279
  await runApp({ apiBase, apiKey, dir, initialView: "scan" });
@@ -289,7 +300,19 @@ async function cmdScan(positional, flags) {
289
300
  const patterns = await client.detectionPatterns();
290
301
  const candidates = await collectFrom(sources, opts, patterns, explicit);
291
302
  if (candidates.length === 0) {
292
- 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
+ }
293
316
  return;
294
317
  }
295
318
 
@@ -435,8 +458,18 @@ async function postCiRun(dir, flags, res, failOn) {
435
458
  * annotations + a step summary under GITHUB_ACTIONS; exits non-zero per --fail-on
436
459
  * so it gates merges. Offline (public registry) by default; --report syncs (Pro). */
437
460
  async function cmdCi(positional, flags) {
438
- 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"]);
439
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
+ }
440
473
  const { evaluateCi, annotationLines, summaryMarkdown, filterToChangedFiles, getChangedFiles, HEALTH_RANK } = await import("./ci.js");
441
474
  const res = await evaluateCi({
442
475
  dir,
@@ -544,9 +577,15 @@ async function cmdFix(positional, flags) {
544
577
  const { getRegistry } = await import("./registry/fetch.js");
545
578
  const { resolveLocal, computeHealth } = await import("./registry/local.js");
546
579
  const { planFixes, applyFixes, terminalReplacement, recordFixes } = await import("./fix.js");
547
- const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
548
-
549
- const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection);
580
+ // Same cold-start silence as `mm status`: registry download + repo walk with
581
+ // no feedback. Spinner is stderr-only and auto-off under --json.
582
+ const prog = startProgress(!flags.json, "fetching the model registry…");
583
+ const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => prog.log(m) });
584
+
585
+ prog.update("scanning for model references…");
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);
588
+ prog.stop();
550
589
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
551
590
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
552
591
  const today = new Date();
@@ -688,9 +727,19 @@ async function cmdStatus(positional, flags) {
688
727
  const dir = path.resolve(positional[1] || flags.dir || ".");
689
728
  const { getRegistry } = await import("./registry/fetch.js");
690
729
  const { resolveLocal, computeHealth, dropResolvedFragments } = await import("./registry/local.js");
691
- const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
692
-
693
- let candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags));
730
+ // A cold `mm status` downloads the ~225 kB registry snapshot then walks the
731
+ // repo with zero output — it reads as frozen. Show live stderr progress
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).
736
+ const prog = startProgress(!flags.json, "fetching the model registry…");
737
+ const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => prog.log(m) });
738
+
739
+ prog.update("scanning for model references…");
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);
742
+ prog.stop();
694
743
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
695
744
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
696
745
  // Suppress detector fragments of resolved aliases (see dropResolvedFragments).
@@ -707,22 +756,36 @@ async function cmdStatus(positional, flags) {
707
756
  known.set(r.model_slug, e);
708
757
  } else custom.set(c.model_string, (custom.get(c.model_string) || 0) + 1);
709
758
  }
759
+ const fileCount = new Set(candidates.map((c) => c.source_path || c.location_label)).size;
710
760
 
711
761
  const today = new Date();
712
762
  const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔", custom: "⚪" };
713
- 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;
714
767
  const rows = [...known.values()]
715
768
  .map(({ model, count }) => ({ model, count, health: computeHealth(model, 90, today) }))
716
- .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")));
717
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));
718
780
 
719
781
  if (flags.json) {
720
782
  console.log(JSON.stringify({
721
783
  registry: { version: snapshot.version, generated_at: snapshot.generated_at, models: snapshot.models.length },
722
784
  scanned: dir,
723
785
  references: candidates.length,
786
+ files: fileCount,
724
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 })),
725
- custom: [...custom.entries()].map(([name, places]) => ({ name, places })),
788
+ custom: customList,
726
789
  needs_attention: attention.length,
727
790
  }, null, 2));
728
791
  return;
@@ -730,9 +793,21 @@ async function cmdStatus(positional, flags) {
730
793
 
731
794
  const ageDays = Math.round((today - new Date(snapshot.generated_at)) / 86_400_000);
732
795
  console.log(`LLM Status — registry ${snapshot.version} (${ageDays <= 0 ? "today" : ageDays + "d old"}), ${snapshot.models.length} models`);
733
- 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
+
734
809
  if (!rows.length && !custom.size) {
735
- 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.");
736
811
  return;
737
812
  }
738
813
  if (rows.length) {
@@ -743,15 +818,23 @@ async function cmdStatus(positional, flags) {
743
818
  console.log(` ${ICON[r.health]} ${r.health.padEnd(11)} ${r.model.slug.padEnd(32)} ${tail.padEnd(20)}${repl} (${r.count})`);
744
819
  }
745
820
  }
746
- if (custom.size) {
821
+ if (customList.length) {
747
822
  console.log("\nCustom / unrecognized:");
748
- 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)`);
749
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.
750
832
  if (attention.length) {
751
- console.log(`\n⚠ ${attention.length} model(s) need attention before they retire.`);
752
- console.log(" Get alerted automatically (email/Slack/SMS): `mm login` then `mm upgrade` — scanning is free, alerting is the paid feature.");
753
- } else if (rows.length) {
754
- 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"));
755
838
  }
756
839
  }
757
840
 
@@ -767,14 +850,15 @@ Usage:
767
850
  mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
768
851
  mm fix [dir] Rewrite dying model ids to their replacement, in place (--dry-run previews; --model <slug> limits; --yes skips the confirm)
769
852
  mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
770
- (--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)
771
855
  mm sources List detection sources and whether each can run here
772
856
  mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
773
857
  mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
774
858
  mm update Update to the latest version now and relaunch (or add --update to any command)
775
859
  mm upgrade Open Stripe checkout and poll until Pro is active (the paid plan, not the binary)
776
860
  mm play [dir] Play Donkey Kong while a background scan walks the dir (just for fun)
777
- mm tui Force-launch the TUI (logs you in first if needed)
861
+ mm tui Force-launch the TUI (needs an interactive terminal)
778
862
 
779
863
  Scan sources (--sources; default = filesystem + enabled integrations; "all" for everything):
780
864
  filesystem repo files aws-secrets AWS Secrets Manager + SSM
@@ -786,7 +870,8 @@ Scan sources (--sources; default = filesystem + enabled integrations; "all" for
786
870
  Secret sources shell out to your already-authenticated CLIs, run read-only,
787
871
  and only ever upload model ids — secret VALUES never leave your machine.
788
872
 
789
- 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)
790
875
  --sources <list> · --region <r> · --namespace <ns> · --kube-context <c> · --db <dsn> · --sql-table <t>
791
876
  --vercel-project <p> · --vercel-team <t> · --gh-repo <owner/name> · --supabase-ref <ref>
792
877
 
@@ -854,6 +939,7 @@ async function main() {
854
939
 
855
940
  // Anonymous, opt-out usage analytics (one-time disclosure, then a single
856
941
  // event per invocation). No-op without a baked key / when opted out.
942
+ maybeFirstRun(); // one-time cli_first_run per install (same opt-out as below)
857
943
  track("cli_command", { command: cmd || "tui" });
858
944
 
859
945
  // Explicit self-update: `mm update` (command) or `--update` (flag on any
@@ -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;
package/src/spinner.js ADDED
@@ -0,0 +1,45 @@
1
+ /* Minimal dependency-free progress spinner for stderr. A silent command that
2
+ * spends a few seconds downloading the registry / scanning a repo reads as
3
+ * frozen — this gives live feedback. No-op when stderr isn't a TTY (piped, CI,
4
+ * --json) so machine output is never polluted. */
5
+
6
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const CLEAR = "\r\x1b[2K"; // carriage return + clear the whole line
8
+
9
+ /** Start a spinner. `active` lets callers force it off (e.g. --json). Returns
10
+ * { update(text), log(line), stop() }. `log` prints a line ABOVE the spinner
11
+ * (clears, writes, resumes) so warnings from the work aren't clobbered. */
12
+ export function startProgress(active, text) {
13
+ const isTTY = !!process.stderr.isTTY;
14
+ if (!active || !isTTY) {
15
+ return { update() {}, log: (m) => process.stderr.write(`! ${m}\n`), stop() {} };
16
+ }
17
+ let i = 0;
18
+ let msg = text;
19
+ let timer = null;
20
+ const frame = () => process.stderr.write(`${CLEAR}${FRAMES[i = (i + 1) % FRAMES.length]} ${msg}`);
21
+ const start = () => {
22
+ frame();
23
+ timer = setInterval(frame, 80);
24
+ if (timer.unref) timer.unref(); // don't keep the process alive
25
+ };
26
+ const clear = () => {
27
+ if (timer) clearInterval(timer);
28
+ timer = null;
29
+ process.stderr.write(CLEAR);
30
+ };
31
+ start();
32
+ return {
33
+ update(m) {
34
+ msg = m;
35
+ },
36
+ log(m) {
37
+ clear();
38
+ process.stderr.write(`! ${m}\n`);
39
+ start();
40
+ },
41
+ stop() {
42
+ clear();
43
+ },
44
+ };
45
+ }
package/src/telemetry.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * --define __POSTHOG_KEY__; with no key, every function here is a silent no-op. */
9
9
  import crypto from "node:crypto";
10
10
  import { loadConfig, setConfigValue } from "./config.js";
11
- import { BUILD_VERSION, UPDATE_CHANNEL } from "./version.js";
11
+ import { BUILD_VERSION, UPDATE_CHANNEL, IS_SHELL_INSTALL } from "./version.js";
12
+ import { isBrewManaged } from "./updater.js";
12
13
 
13
14
  // eslint-disable-next-line no-undef
14
15
  const POSTHOG_KEY = typeof __POSTHOG_KEY__ !== "undefined" ? __POSTHOG_KEY__ : (process.env.MM_POSTHOG_KEY || "");
@@ -20,6 +21,33 @@ const optedOut = () => {
20
21
  };
21
22
  const enabled = () => !!POSTHOG_KEY && !optedOut();
22
23
 
24
+ /** How this CLI got onto the machine, for real-user-metrics segmentation.
25
+ * - "homebrew": the binary lives under a brew prefix (shared with updater.js).
26
+ * - "npm": running through Node from a node_modules tree (npm -g / npx).
27
+ * - "binary": the curl|bash CDN binary (Bun-compiled, IS_SHELL_INSTALL=true) or
28
+ * anything else we can't otherwise place.
29
+ * Small + pure: takes its inputs so it's trivially testable. */
30
+ export function installMethod(execPath = process.execPath, scriptPath = process.argv[1] || "") {
31
+ if (isBrewManaged(execPath)) return "homebrew";
32
+ // npm/npx run Node directly with our script under a node_modules tree; the
33
+ // compiled CDN binary is its own execPath and never lives under node_modules.
34
+ if (!IS_SHELL_INSTALL && (/node_modules/.test(scriptPath) || /node_modules/.test(execPath))) return "npm";
35
+ return "binary";
36
+ }
37
+
38
+ /** Properties stamped onto EVERY captured event — version/os/install method, plus
39
+ * internal:true for our own dev machines (set MM_INTERNAL=1) so they can be
40
+ * excluded from real-user metrics. Kept tiny + side-effect free. */
41
+ function baseProps() {
42
+ const p = {
43
+ cli_version: BUILD_VERSION,
44
+ os: process.platform,
45
+ install_method: installMethod(),
46
+ };
47
+ if (process.env.MM_INTERNAL) p.internal = true;
48
+ return p;
49
+ }
50
+
23
51
  let cachedId = null;
24
52
  function distinctId() {
25
53
  if (cachedId) return cachedId;
@@ -56,10 +84,9 @@ export function track(event, properties = {}) {
56
84
  distinct_id: distinctId(),
57
85
  properties: {
58
86
  $process_person_profile: false, // anonymous events, no person profiles
59
- cli_version: BUILD_VERSION,
60
87
  channel: UPDATE_CHANNEL,
61
- os: process.platform,
62
88
  arch: process.arch,
89
+ ...baseProps(), // cli_version, os, install_method, internal (every event)
63
90
  ...properties,
64
91
  },
65
92
  timestamp: new Date().toISOString(),
@@ -69,3 +96,24 @@ export function track(event, properties = {}) {
69
96
  }).catch(() => {}).finally(() => clearTimeout(timer));
70
97
  } catch { /* analytics must never break the CLI */ }
71
98
  }
99
+
100
+ /** Emit a one-time `cli_first_run` event the first time the CLI runs per install.
101
+ * "First run" is marked by a `firstRunAt` ISO timestamp persisted to the config
102
+ * file — written on the first invocation that gets here and never re-emitted.
103
+ * Honors the SAME opt-out as every other event (track() gates on enabled(): no
104
+ * key, MM_NO_ANALYTICS / DO_NOT_TRACK / CI, or a persisted analyticsOptOut). The
105
+ * marker is still written when opted-out so flipping analytics on later doesn't
106
+ * retroactively fire a "first run". Never throws. */
107
+ export function maybeFirstRun() {
108
+ try {
109
+ let cfg;
110
+ try { cfg = loadConfig(); } catch { cfg = {}; }
111
+ if (cfg.firstRunAt) return; // already seen this install
112
+ try { setConfigValue("firstRunAt", new Date().toISOString()); } catch { /* best effort */ }
113
+ track("cli_first_run", {
114
+ install_method: installMethod(),
115
+ cli_version: BUILD_VERSION,
116
+ os: process.platform,
117
+ });
118
+ } catch { /* analytics must never break the CLI */ }
119
+ }
package/src/updater.js CHANGED
@@ -127,9 +127,10 @@ function dirWritable(dir) {
127
127
 
128
128
  /** True when the running binary lives under a Homebrew prefix. Brew owns the
129
129
  * binary's lifecycle (`brew upgrade`), and silently swapping it out from under
130
- * brew would break its integrity tracking — so we never self-update there. */
131
- function isBrewManaged() {
132
- const p = process.execPath;
130
+ * brew would break its integrity tracking — so we never self-update there.
131
+ * Exported so telemetry's install-method detection shares one definition. */
132
+ export function isBrewManaged(execPath = process.execPath) {
133
+ const p = execPath;
133
134
  return p.includes("/Cellar/") || p.includes("/homebrew/") || p.includes("/linuxbrew/");
134
135
  }
135
136