@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 +19 -0
- package/package.json +1 -1
- package/src/api.js +2 -2
- package/src/changelog-data.js +22 -0
- package/src/detect/core.js +45 -4
- package/src/index.js +201 -34
- package/src/registry/local.js +15 -3
- package/src/sources/filesystem.js +0 -0
- package/src/sources/index.js +4 -2
- package/src/tui/game/loop.js +3 -1
- package/src/upgrade.js +2 -2
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.
|
|
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",
|
|
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
|
}
|
package/src/changelog-data.js
CHANGED
|
@@ -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",
|
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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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:
|
|
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
|
|
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.
|
|
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 (
|
|
833
|
+
if (customList.length) {
|
|
759
834
|
console.log("\nCustom / unrecognized:");
|
|
760
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
(--
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
938
|
-
|
|
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);
|
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/tui/game/loop.js
CHANGED
|
@@ -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
|
-
|
|
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`);
|