@modelstatus/cli 0.1.82 → 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.82",
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,15 @@
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
+ },
4
13
  {
5
14
  "version": "0.1.82",
6
15
  "date": "2026-06-13",
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
@@ -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).
@@ -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
package/src/spinner.js ADDED
@@ -0,0 +1,45 @@
1
+ /* Minimal dependency-free progress spinner for stderr. A silent command that
2
+ * spends a few seconds downloading the registry / scanning a repo reads as
3
+ * frozen — this gives live feedback. No-op when stderr isn't a TTY (piped, CI,
4
+ * --json) so machine output is never polluted. */
5
+
6
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const CLEAR = "\r\x1b[2K"; // carriage return + clear the whole line
8
+
9
+ /** Start a spinner. `active` lets callers force it off (e.g. --json). Returns
10
+ * { update(text), log(line), stop() }. `log` prints a line ABOVE the spinner
11
+ * (clears, writes, resumes) so warnings from the work aren't clobbered. */
12
+ export function startProgress(active, text) {
13
+ const isTTY = !!process.stderr.isTTY;
14
+ if (!active || !isTTY) {
15
+ return { update() {}, log: (m) => process.stderr.write(`! ${m}\n`), stop() {} };
16
+ }
17
+ let i = 0;
18
+ let msg = text;
19
+ let timer = null;
20
+ const frame = () => process.stderr.write(`${CLEAR}${FRAMES[i = (i + 1) % FRAMES.length]} ${msg}`);
21
+ const start = () => {
22
+ frame();
23
+ timer = setInterval(frame, 80);
24
+ if (timer.unref) timer.unref(); // don't keep the process alive
25
+ };
26
+ const clear = () => {
27
+ if (timer) clearInterval(timer);
28
+ timer = null;
29
+ process.stderr.write(CLEAR);
30
+ };
31
+ start();
32
+ return {
33
+ update(m) {
34
+ msg = m;
35
+ },
36
+ log(m) {
37
+ clear();
38
+ process.stderr.write(`! ${m}\n`);
39
+ start();
40
+ },
41
+ stop() {
42
+ clear();
43
+ },
44
+ };
45
+ }
package/src/telemetry.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * --define __POSTHOG_KEY__; with no key, every function here is a silent no-op. */
9
9
  import crypto from "node:crypto";
10
10
  import { loadConfig, setConfigValue } from "./config.js";
11
- import { BUILD_VERSION, UPDATE_CHANNEL } from "./version.js";
11
+ import { BUILD_VERSION, UPDATE_CHANNEL, IS_SHELL_INSTALL } from "./version.js";
12
+ import { isBrewManaged } from "./updater.js";
12
13
 
13
14
  // eslint-disable-next-line no-undef
14
15
  const POSTHOG_KEY = typeof __POSTHOG_KEY__ !== "undefined" ? __POSTHOG_KEY__ : (process.env.MM_POSTHOG_KEY || "");
@@ -20,6 +21,33 @@ const optedOut = () => {
20
21
  };
21
22
  const enabled = () => !!POSTHOG_KEY && !optedOut();
22
23
 
24
+ /** How this CLI got onto the machine, for real-user-metrics segmentation.
25
+ * - "homebrew": the binary lives under a brew prefix (shared with updater.js).
26
+ * - "npm": running through Node from a node_modules tree (npm -g / npx).
27
+ * - "binary": the curl|bash CDN binary (Bun-compiled, IS_SHELL_INSTALL=true) or
28
+ * anything else we can't otherwise place.
29
+ * Small + pure: takes its inputs so it's trivially testable. */
30
+ export function installMethod(execPath = process.execPath, scriptPath = process.argv[1] || "") {
31
+ if (isBrewManaged(execPath)) return "homebrew";
32
+ // npm/npx run Node directly with our script under a node_modules tree; the
33
+ // compiled CDN binary is its own execPath and never lives under node_modules.
34
+ if (!IS_SHELL_INSTALL && (/node_modules/.test(scriptPath) || /node_modules/.test(execPath))) return "npm";
35
+ return "binary";
36
+ }
37
+
38
+ /** Properties stamped onto EVERY captured event — version/os/install method, plus
39
+ * internal:true for our own dev machines (set MM_INTERNAL=1) so they can be
40
+ * excluded from real-user metrics. Kept tiny + side-effect free. */
41
+ function baseProps() {
42
+ const p = {
43
+ cli_version: BUILD_VERSION,
44
+ os: process.platform,
45
+ install_method: installMethod(),
46
+ };
47
+ if (process.env.MM_INTERNAL) p.internal = true;
48
+ return p;
49
+ }
50
+
23
51
  let cachedId = null;
24
52
  function distinctId() {
25
53
  if (cachedId) return cachedId;
@@ -56,10 +84,9 @@ export function track(event, properties = {}) {
56
84
  distinct_id: distinctId(),
57
85
  properties: {
58
86
  $process_person_profile: false, // anonymous events, no person profiles
59
- cli_version: BUILD_VERSION,
60
87
  channel: UPDATE_CHANNEL,
61
- os: process.platform,
62
88
  arch: process.arch,
89
+ ...baseProps(), // cli_version, os, install_method, internal (every event)
63
90
  ...properties,
64
91
  },
65
92
  timestamp: new Date().toISOString(),
@@ -69,3 +96,24 @@ export function track(event, properties = {}) {
69
96
  }).catch(() => {}).finally(() => clearTimeout(timer));
70
97
  } catch { /* analytics must never break the CLI */ }
71
98
  }
99
+
100
+ /** Emit a one-time `cli_first_run` event the first time the CLI runs per install.
101
+ * "First run" is marked by a `firstRunAt` ISO timestamp persisted to the config
102
+ * file — written on the first invocation that gets here and never re-emitted.
103
+ * Honors the SAME opt-out as every other event (track() gates on enabled(): no
104
+ * key, MM_NO_ANALYTICS / DO_NOT_TRACK / CI, or a persisted analyticsOptOut). The
105
+ * marker is still written when opted-out so flipping analytics on later doesn't
106
+ * retroactively fire a "first run". Never throws. */
107
+ export function maybeFirstRun() {
108
+ try {
109
+ let cfg;
110
+ try { cfg = loadConfig(); } catch { cfg = {}; }
111
+ if (cfg.firstRunAt) return; // already seen this install
112
+ try { setConfigValue("firstRunAt", new Date().toISOString()); } catch { /* best effort */ }
113
+ track("cli_first_run", {
114
+ install_method: installMethod(),
115
+ cli_version: BUILD_VERSION,
116
+ os: process.platform,
117
+ });
118
+ } catch { /* analytics must never break the CLI */ }
119
+ }
package/src/updater.js CHANGED
@@ -127,9 +127,10 @@ function dirWritable(dir) {
127
127
 
128
128
  /** True when the running binary lives under a Homebrew prefix. Brew owns the
129
129
  * binary's lifecycle (`brew upgrade`), and silently swapping it out from under
130
- * brew would break its integrity tracking — so we never self-update there. */
131
- function isBrewManaged() {
132
- const p = process.execPath;
130
+ * brew would break its integrity tracking — so we never self-update there.
131
+ * Exported so telemetry's install-method detection shares one definition. */
132
+ export function isBrewManaged(execPath = process.execPath) {
133
+ const p = execPath;
133
134
  return p.includes("/Cellar/") || p.includes("/homebrew/") || p.includes("/linuxbrew/");
134
135
  }
135
136