@modelstatus/cli 0.1.81 → 0.1.83

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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
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,24 @@
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.83",
6
+ "date": "2026-06-14",
7
+ "title": "Live progress on mm status",
8
+ "items": [
9
+ "`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.",
10
+ "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."
11
+ ]
12
+ },
13
+ {
14
+ "version": "0.1.82",
15
+ "date": "2026-06-13",
16
+ "title": "Withdrawn models",
17
+ "items": [
18
+ "When a provider pulls a model it released (an unplanned withdrawal, not a planned retirement), it now shows a distinct ⚠ withdrawn badge — in `mm status`, the TUI, and on its model page — instead of silently becoming an unrecognized id.",
19
+ "If you reference a withdrawn model, you get alerted (the same as the people who saw its announcement)."
20
+ ]
21
+ },
4
22
  {
5
23
  "version": "0.1.81",
6
24
  "date": "2026-06-12",
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
@@ -486,7 +487,7 @@ async function cmdCi(positional, flags) {
486
487
  if (flags.json) {
487
488
  console.log(JSON.stringify(report, null, 2));
488
489
  } else {
489
- const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴" };
490
+ const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔" };
490
491
  console.log(`LLM Status CI — scanned ${dir} (fail-on: ${failOn})`);
491
492
  if (!findings.length) {
492
493
  console.log("✓ No deprecated, retiring, or retired AI models found.");
@@ -544,9 +545,14 @@ async function cmdFix(positional, flags) {
544
545
  const { getRegistry } = await import("./registry/fetch.js");
545
546
  const { resolveLocal, computeHealth } = await import("./registry/local.js");
546
547
  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
+ // Same cold-start silence as `mm status`: registry download + repo walk with
549
+ // no feedback. Spinner is stderr-only and auto-off under --json.
550
+ const prog = startProgress(!flags.json, "fetching the model registry…");
551
+ const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => prog.log(m) });
548
552
 
553
+ prog.update("scanning for model references…");
549
554
  const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection);
555
+ prog.stop();
550
556
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
551
557
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
552
558
  const today = new Date();
@@ -688,9 +694,15 @@ async function cmdStatus(positional, flags) {
688
694
  const dir = path.resolve(positional[1] || flags.dir || ".");
689
695
  const { getRegistry } = await import("./registry/fetch.js");
690
696
  const { resolveLocal, computeHealth, dropResolvedFragments } = await import("./registry/local.js");
691
- const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
697
+ // A cold `mm status` downloads the ~225 kB registry snapshot then walks the
698
+ // repo with zero output — it reads as frozen. Show live stderr progress
699
+ // (auto-off for --json / pipes so machine output stays byte-identical).
700
+ const prog = startProgress(!flags.json, "fetching the model registry…");
701
+ const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => prog.log(m) });
692
702
 
703
+ prog.update("scanning for model references…");
693
704
  let candidates = await collectFrom(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags));
705
+ prog.stop();
694
706
  const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
695
707
  const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
696
708
  // Suppress detector fragments of resolved aliases (see dropResolvedFragments).
@@ -709,8 +721,8 @@ async function cmdStatus(positional, flags) {
709
721
  }
710
722
 
711
723
  const today = new Date();
712
- const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", custom: "⚪" };
713
- const rank = { retired: 0, retiring: 1, deprecating: 2, ok: 3 };
724
+ const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔", custom: "⚪" };
725
+ const rank = { withdrawn: 0, retired: 0, retiring: 1, deprecating: 2, ok: 3 };
714
726
  const rows = [...known.values()]
715
727
  .map(({ model, count }) => ({ model, count, health: computeHealth(model, 90, today) }))
716
728
  .sort((a, b) => rank[a.health] - rank[b.health] || String(a.model.retires_date || "9999").localeCompare(String(b.model.retires_date || "9999")));
@@ -854,6 +866,7 @@ async function main() {
854
866
 
855
867
  // Anonymous, opt-out usage analytics (one-time disclosure, then a single
856
868
  // event per invocation). No-op without a baked key / when opted out.
869
+ maybeFirstRun(); // one-time cli_first_run per install (same opt-out as below)
857
870
  track("cli_command", { command: cmd || "tui" });
858
871
 
859
872
  // Explicit self-update: `mm update` (command) or `--update` (flag on any
@@ -34,6 +34,10 @@ export function resolveLocal(snapshot, strings) {
34
34
  /** Mirror of the server's computeHealth for a snapshot (slug-space) model. */
35
35
  export function computeHealth(model, retiringWindowDays = 90, today = new Date()) {
36
36
  if (!model) return "custom";
37
+ // Withdrawn (provider pulled it) is distinct from a planned retirement and is
38
+ // checked first. Snapshots also stamp retires_date on withdrawn models so an
39
+ // OLDER binary (which doesn't know this status) falls to "retired" below.
40
+ if (model.status === "withdrawn") return "withdrawn";
37
41
  const ret = model.retires_date ? new Date(model.retires_date) : null;
38
42
  if (model.status === "retired" || (ret && ret <= today)) return "retired";
39
43
  if (ret) {
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
+ }
@@ -15,7 +15,7 @@ import { resolveLocal, computeHealth, dropResolvedFragments } from "../registry/
15
15
  import { scanFilesystemStreaming } from "../sources/filesystem.js";
16
16
  import { compilePatterns } from "../detect/core.js";
17
17
 
18
- export const HEALTH_RANK = { retired: 0, retiring: 1, deprecating: 2, ok: 3 };
18
+ export const HEALTH_RANK = { withdrawn: 0, retired: 0, retiring: 1, deprecating: 2, ok: 3 };
19
19
 
20
20
  // dir → { snapshot, candidates, ... } for the last completed scan THIS session.
21
21
  const SCAN_CACHE = new Map();
@@ -120,7 +120,7 @@ export function summarize(snapshot, candidates) {
120
120
 
121
121
  /** Health distribution for the status-bar legend. */
122
122
  export function countHealth(rows) {
123
- const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, custom: 0 };
123
+ const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, withdrawn: 0, custom: 0 };
124
124
  for (const r of rows) counts[r.health] = (counts[r.health] || 0) + 1;
125
125
  return counts;
126
126
  }
package/src/tui/ui.js CHANGED
@@ -57,6 +57,7 @@ export const HEALTH_COLOR = {
57
57
  deprecating: "#d97706",
58
58
  retiring: "#ea580c",
59
59
  retired: "#dc2626",
60
+ withdrawn: "#dc2626", // pulled by provider — as severe as retired
60
61
  custom: "#9ca3af",
61
62
  };
62
63
  export const HEALTH_GLYPH = {
@@ -64,6 +65,7 @@ export const HEALTH_GLYPH = {
64
65
  deprecating: GLYPH.deprecating,
65
66
  retiring: GLYPH.retiring,
66
67
  retired: GLYPH.retired,
68
+ withdrawn: GLYPH.warn, // ⚠ distinguishes a pull from a planned × retirement
67
69
  custom: GLYPH.custom,
68
70
  };
69
71
  export const healthColor = (hh) => HEALTH_COLOR[hh] || C.FG;
@@ -292,7 +294,7 @@ export function ListRow({ active, selected, cells, width }) {
292
294
 
293
295
  /** counts → [{text,color}] legend segments, all states in fixed order, zeros dimmed. */
294
296
  export function legendSegments(counts = {}) {
295
- const order = ["ok", "deprecating", "retiring", "retired"];
297
+ const order = ["ok", "deprecating", "retiring", "retired", "withdrawn"];
296
298
  const segs = [];
297
299
  order.forEach((k, i) => {
298
300
  const n = counts[k] || 0;
@@ -6,7 +6,7 @@ import {
6
6
  } from "../ui.js";
7
7
 
8
8
  const DELIVERY = ["immediate", "daily", "weekly"];
9
- const DEFAULT_EVENTS = ["model_deprecated", "model_retired", "replacement_set", "price_changed", "new_model"];
9
+ const DEFAULT_EVENTS = ["model_deprecated", "model_retired", "model_withdrawn", "replacement_set", "price_changed", "new_model"];
10
10
  const CHANNEL_KINDS = ["slack", "discord", "teams", "webhook", "sms"];
11
11
 
12
12
  // Readable label for a rule even when it has no name (seed/sample rules do):
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