@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 +31 -2
- package/package.json +1 -1
- package/src/changelog-data.js +20 -0
- package/src/detect/core.js +45 -4
- package/src/index.js +115 -29
- package/src/registry/local.js +15 -3
- package/src/sources/filesystem.js +0 -0
- package/src/sources/index.js +4 -2
- package/src/spinner.js +45 -0
- package/src/telemetry.js +51 -3
- package/src/updater.js +4 -3
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:
|
|
32
|
+
### Free: open the dashboard
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
mm
|
|
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.
|
|
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",
|
package/src/changelog-data.js
CHANGED
|
@@ -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",
|
package/src/detect/core.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
const
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
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) =>
|
|
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:
|
|
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
|
|
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.
|
|
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 (
|
|
821
|
+
if (customList.length) {
|
|
747
822
|
console.log("\nCustom / unrecognized:");
|
|
748
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
(--
|
|
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 (
|
|
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
|
package/src/registry/local.js
CHANGED
|
@@ -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 (
|
|
25
|
-
|
|
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
|
|
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
|
package/src/sources/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|