@modelstatus/cli 0.1.80 → 0.1.81

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.80",
3
+ "version": "0.1.81",
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",
@@ -0,0 +1,214 @@
1
+ /* GENERATED by scripts/gen-changelog.mjs from apps/web/lib/changelog.json — do not edit.
2
+ * Release notes baked into the binary (in-TUI + the on-load what's-new card). */
3
+ export const CHANGELOG = [
4
+ {
5
+ "version": "0.1.81",
6
+ "date": "2026-06-12",
7
+ "title": "Release notes, in the app",
8
+ "items": [
9
+ "After an update, the first launch shows that version's release notes — once, dismissed with any key. Fresh installs aren't interrupted.",
10
+ "What's New gains a Releases section: every release with its highlights, right in the TUI. Full history at llmstatus.ai/changelog."
11
+ ]
12
+ },
13
+ {
14
+ "version": "0.1.80",
15
+ "date": "2026-06-12",
16
+ "title": "Feedback is one keypress away",
17
+ "items": [
18
+ "`! feedback` now shows on every tab's keybar — score mm 1–5 (1 = hot garbage, 5 = best thing since sliced bread) and leave a comment; it lands directly with the humans who build this."
19
+ ]
20
+ },
21
+ {
22
+ "version": "0.1.79",
23
+ "date": "2026-06-11",
24
+ "title": "mm config",
25
+ "items": [
26
+ "New `mm config` — view and change settings in one place (analytics, channel, config path). `mm config analytics on|off` controls anonymous usage stats."
27
+ ]
28
+ },
29
+ {
30
+ "version": "0.1.78",
31
+ "date": "2026-06-11",
32
+ "title": "Quieter scans",
33
+ "items": [
34
+ "The first-run analytics notice no longer interrupts `mm status` output. The setting (and exactly what's sent: event counts only, never code or paths) lives in `mm config` and on the Account tab.",
35
+ "Analytics opt-out is now persistent — no env var needed."
36
+ ]
37
+ },
38
+ {
39
+ "version": "0.1.77",
40
+ "date": "2026-06-11",
41
+ "title": "In-TUI feedback",
42
+ "items": [
43
+ "Rate mm without leaving the terminal: a quiet prompt appears after you've used it a bit, and the card takes a 1–5 score plus an optional comment. Asked at most once per 30 days; never again after you answer."
44
+ ]
45
+ },
46
+ {
47
+ "version": "0.1.76",
48
+ "date": "2026-06-10",
49
+ "title": "Scan precision",
50
+ "items": [
51
+ "Fixed a double-count where one reference (e.g. a Bedrock ARN like `anthropic.claude-3-sonnet-20240229-v1:0`) showed up both as its resolved model and as a phantom “custom” id. One line, one finding.",
52
+ "Fine-tune-style variants (`ft:gpt-4:…`) now report under their base model — the base carries the lifecycle that matters."
53
+ ]
54
+ },
55
+ {
56
+ "version": "0.1.75",
57
+ "date": "2026-06-10",
58
+ "title": "Docs catch-up",
59
+ "items": [
60
+ "README and docs now cover `mm fix`, Homebrew install, the GitHub App, and the supply-chain posture — and state the analytics policy plainly."
61
+ ]
62
+ },
63
+ {
64
+ "version": "0.1.74",
65
+ "date": "2026-06-10",
66
+ "title": "Signed releases on every platform",
67
+ "items": [
68
+ "Every release manifest is now Ed25519-signed and verified — at install and on every self-update — against a public key embedded in the installer itself, not fetched from the CDN. Linux and Windows downloads get a real authenticity check, not just a same-origin checksum."
69
+ ]
70
+ },
71
+ {
72
+ "version": "0.1.73",
73
+ "date": "2026-06-10",
74
+ "title": "Fail-closed installs",
75
+ "items": [
76
+ "The installer now refuses to proceed if the checksum manifest can't be fetched or verification can't run — no more silent 'installing unverified' fallback.",
77
+ "The self-updater verifies the new binary's Developer ID signature before swapping it in.",
78
+ "Releases can't ship un-notarized: the pipeline blocks any macOS binary Apple hasn't accepted."
79
+ ]
80
+ },
81
+ {
82
+ "version": "0.1.71",
83
+ "date": "2026-06-10",
84
+ "title": "Homebrew + tighter sandbox",
85
+ "items": [
86
+ "`brew install randomartifact/tap/modelstatus-cli` — mm is on Homebrew (macOS + Linux). Brew owns updates there; the self-updater steps aside.",
87
+ "macOS hardened-runtime entitlements trimmed to the single flag Bun's JS engine needs (allow-jit) — nothing else.",
88
+ "The installer cryptographically verifies the binary is signed by Random Artifact LLC before installing."
89
+ ]
90
+ },
91
+ {
92
+ "version": "0.1.70",
93
+ "date": "2026-06-10",
94
+ "title": "Notarized by Apple",
95
+ "items": [
96
+ "macOS binaries are Developer ID signed and Apple-notarized. The installer no longer touches com.apple.quarantine — Gatekeeper passes it legitimately."
97
+ ]
98
+ },
99
+ {
100
+ "version": "0.1.69",
101
+ "date": "2026-06-09",
102
+ "title": "macOS signature fix",
103
+ "items": [
104
+ "Fixed invalid code signatures on macOS binaries (stricter Macs killed them on launch — credit: an external security review). The installer also sha256-verifies every download before installing."
105
+ ]
106
+ },
107
+ {
108
+ "version": "0.1.68",
109
+ "date": "2026-06-09",
110
+ "title": "mm <dir>",
111
+ "items": [
112
+ "`mm ~/path/to/project` opens the TUI scoped to that folder — the thing everyone tried first now just works. Everything runs locally."
113
+ ]
114
+ },
115
+ {
116
+ "version": "0.1.67",
117
+ "date": "2026-06-09",
118
+ "title": "First-run polish",
119
+ "items": [
120
+ "A failed sign-in is no longer a dead end — `g` retries, and tabs 1–2 work with no account.",
121
+ "Running mm in a folder with no AI calls now says what to do next."
122
+ ]
123
+ },
124
+ {
125
+ "version": "0.1.66",
126
+ "date": "2026-06-09",
127
+ "title": "Here first",
128
+ "items": [
129
+ "Everyone lands on the Here tab (the live local scan) — signed in or not. Inventory is one keypress away (3)."
130
+ ]
131
+ },
132
+ {
133
+ "version": "0.1.65",
134
+ "date": "2026-06-09",
135
+ "title": "Fix history with diffs",
136
+ "items": [
137
+ "What's New → Fixes now shows the exact red/green diff of every fix you've applied, with `o` to open the file at that line."
138
+ ]
139
+ },
140
+ {
141
+ "version": "0.1.64",
142
+ "date": "2026-06-09",
143
+ "title": "See the diff before it writes",
144
+ "items": [
145
+ "Pressing `f` now opens a diff preview — old line red, new line green, per file:line — and nothing is written until you press y."
146
+ ]
147
+ },
148
+ {
149
+ "version": "0.1.63",
150
+ "date": "2026-06-09",
151
+ "title": "Smarter fixes",
152
+ "items": [
153
+ "Replacement chains: if a model's replacement is itself dying (davinci → gpt-3.5 → gpt-4o-mini), `mm fix` follows the chain to the first live model — in the CLI, the TUI, and the GitHub App's fix PRs.",
154
+ "Every applied fix is recorded; a new Fixes section on What's New lists them.",
155
+ "`mm fix --json` emits pure JSON for tooling."
156
+ ]
157
+ },
158
+ {
159
+ "version": "0.1.62",
160
+ "date": "2026-06-09",
161
+ "title": "In-place fixes",
162
+ "items": [
163
+ "Applying a fix updates the list instantly instead of kicking off a full rescan of the workspace."
164
+ ]
165
+ },
166
+ {
167
+ "version": "0.1.61",
168
+ "date": "2026-06-09",
169
+ "title": "mm fix",
170
+ "items": [
171
+ "New `mm fix` (and `f` in the TUI): rewrites deprecated/retiring model ids to their registry replacement, in place. Boundary-safe — `gpt-4` never rewrites inside `gpt-4o` — and style-preserving. `--dry-run` previews."
172
+ ]
173
+ },
174
+ {
175
+ "version": "0.1.60",
176
+ "date": "2026-06-09",
177
+ "title": "Ergonomics pass",
178
+ "items": [
179
+ "30+ fixes across all 8 tabs: every failed fetch now says how to recover (g retries), keybars never hide their most important keys, offline no longer renders as Pro, and the Here header leads with the verdict."
180
+ ]
181
+ },
182
+ {
183
+ "version": "0.1.58",
184
+ "date": "2026-06-08",
185
+ "title": "Warp, finally",
186
+ "items": [
187
+ "The long tale of the clipped top row in Warp ends: the TUI renders full-screen correctly in Warp, before and after the game."
188
+ ]
189
+ },
190
+ {
191
+ "version": "0.1.53",
192
+ "date": "2026-06-08",
193
+ "title": "mm update",
194
+ "items": [
195
+ "`mm update` updates the binary in place and relaunches — one run lands on the latest version. (The background self-updater still applies updates on next launch.)"
196
+ ]
197
+ },
198
+ {
199
+ "version": "0.1.48",
200
+ "date": "2026-06-07",
201
+ "title": "Alternate screen",
202
+ "items": [
203
+ "The TUI runs in the terminal's alternate screen buffer — quitting restores your scrollback cleanly."
204
+ ]
205
+ },
206
+ {
207
+ "version": "0.1.0 – 0.1.47",
208
+ "date": "2026-06-03",
209
+ "title": "Early days",
210
+ "items": [
211
+ "Everything that made mm, mm: the scanner and lifecycle registry (signed snapshots, offline cache), the TUI with its 8 tabs, cloud inventory and alerts, the CI gate, secret-aware scan sources (env, AWS, k8s, helm, SQL), inventory search, npm + curl installers, the self-updater — and Donkey Kong (press P)."
212
+ ]
213
+ }
214
+ ];
package/src/tui/app.js CHANGED
@@ -5,6 +5,7 @@ import { createClient } from "../api.js";
5
5
  import { track } from "../telemetry.js";
6
6
  import { BUILD_VERSION } from "../version.js";
7
7
  import { feedbackEligible, recordFeedbackShown, recordFeedbackSubmitted, submitFeedback, FeedbackStrip, FeedbackCard, ACTION_THRESHOLD } from "./feedback.js";
8
+ import { releaseNotesToShow, ReleaseNotesCard } from "./release-notes.js";
8
9
  import {
9
10
  h, C, GLYPH, useAsync, Window, TrafficLights, TabStrip, StatusBar, legendSegments,
10
11
  KeyBar, EmptyCard,
@@ -163,6 +164,8 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
163
164
  const [feedback, setFeedback] = React.useState(null);
164
165
  const actionsRef = React.useRef(0);
165
166
  const fbEligibleRef = React.useRef(feedbackEligible());
167
+ // First launch on a new version → that version's release notes, once.
168
+ const [releaseNotes, setReleaseNotes] = React.useState(() => releaseNotesToShow());
166
169
  const me = useAsync(async () => (apiKey ? client.me() : null), [apiKey]);
167
170
 
168
171
  const { outer, termRows } = useTermDims();
@@ -201,6 +204,14 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
201
204
  const handlesBackRef = React.useRef(false);
202
205
  const setHandlesBack = React.useCallback((v) => { handlesBackRef.current = !!v; }, []);
203
206
 
207
+ useInput(
208
+ (input, key) => {
209
+ if (key.ctrl && input === "c") return exit();
210
+ setReleaseNotes(null); // any key clears the what's-new card
211
+ return undefined;
212
+ },
213
+ { isActive: !!releaseNotes },
214
+ );
204
215
  useInput(
205
216
  (input, key) => {
206
217
  if (key.ctrl && input === "c") return exit();
@@ -251,7 +262,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
251
262
  }
252
263
  if (input === "q") return exit();
253
264
  },
254
- { isActive: !prompt && !capturing && feedback?.stage !== "card" },
265
+ { isActive: !prompt && !capturing && feedback?.stage !== "card" && !releaseNotes },
255
266
  );
256
267
  // Feedback card is modal: digits score it (until a comment starts), printable
257
268
  // chars build the comment, ←→ nudges the score, ↵ sends, esc closes.
@@ -319,13 +330,17 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
319
330
  });
320
331
  keys = GATE_KEYS;
321
332
  } else {
322
- body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt && feedback?.stage !== "card", fresh });
333
+ body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt && feedback?.stage !== "card" && !releaseNotes, fresh });
323
334
  keys = dynKeys || (current2.meta && current2.meta.keys) || [];
324
335
  }
325
336
  if (feedback?.stage === "card") {
326
337
  body = h(FeedbackCard, { score: feedback.score, comment: feedback.comment, sending: feedback.sending, width: W });
327
338
  keys = [{ k: "1-5", label: "score" }, { k: "↵", label: "send" }, { k: "esc", label: "close" }];
328
339
  }
340
+ if (releaseNotes) {
341
+ body = h(ReleaseNotesCard, { entry: releaseNotes, width: W });
342
+ keys = [{ k: "any key", label: "continue" }];
343
+ }
329
344
 
330
345
  // Status bar segments.
331
346
  const live = status && status.forKey === current2.key ? status : null;
@@ -351,7 +366,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
351
366
  feedback?.stage === "strip" ? h(FeedbackStrip, null) : h(Text, {}, ""),
352
367
  toast ? h(Text, { color: toast.color }, ` ${toast.msg}`) : h(Text, {}, ""),
353
368
  h(StatusBar, { segsLeft, segsRight, width: W }),
354
- h(KeyBar, { keys: feedback?.stage === "card" ? keys : withGlobalKeys(keys), width: W }),
369
+ h(KeyBar, { keys: feedback?.stage === "card" || releaseNotes ? keys : withGlobalKeys(keys), width: W }),
355
370
  );
356
371
  }
357
372
 
@@ -0,0 +1,59 @@
1
+ /* On-load "what's new" — Claude Code style: the first launch on a NEW version
2
+ * shows that version's release notes once, then never again. A brand-new
3
+ * install shows nothing (don't interrupt the first run); we just record the
4
+ * version. Data is baked into the binary (changelog-data.js), so the card the
5
+ * user sees always matches the version they just landed on. */
6
+ import React from "react";
7
+ import { Box, Text } from "ink";
8
+ import { h, C } from "./ui.js";
9
+ import { loadConfig, setConfigValue } from "../config.js";
10
+ import { BUILD_VERSION } from "../version.js";
11
+ import { CHANGELOG } from "../changelog-data.js";
12
+
13
+ const ver = () => (typeof BUILD_VERSION === "string" ? BUILD_VERSION.replace(/^v/, "") : "");
14
+
15
+ /** The entry to show on this launch, or null. Records lastSeenVersion either
16
+ * way so the card shows at most once per version. */
17
+ export function releaseNotesToShow(cfg = loadConfig()) {
18
+ const v = ver();
19
+ if (!v) return null;
20
+ const last = cfg.lastSeenVersion;
21
+ if (last === v) return null;
22
+ try { setConfigValue("lastSeenVersion", v); } catch { /* best effort */ }
23
+ if (!last) return null; // fresh install — set silently, don't interrupt the first run
24
+ return CHANGELOG.find((e) => e.version === v) ?? null;
25
+ }
26
+
27
+ /** Modal card body. Dismissed by app.js on any key. */
28
+ export function ReleaseNotesCard({ entry, width = 78 }) {
29
+ const lineW = Math.max(24, width - 6);
30
+ const wrap = (text) => {
31
+ const words = String(text).split(" ");
32
+ const lines = [];
33
+ let cur = "";
34
+ for (const w of words) {
35
+ if ((cur + " " + w).trim().length > lineW) { lines.push(cur.trim()); cur = w; }
36
+ else cur += " " + w;
37
+ }
38
+ if (cur.trim()) lines.push(cur.trim());
39
+ return lines;
40
+ };
41
+ return h(
42
+ Box,
43
+ { flexDirection: "column", paddingX: 1 },
44
+ h(Text, {},
45
+ h(Text, { color: C.ACCENT, bold: true }, ` ✦ what's new in v${entry.version} `),
46
+ h(Text, { color: C.FG_DIM }, `— ${entry.date}`),
47
+ ),
48
+ h(Text, {}, ""),
49
+ h(Text, { color: C.FG, bold: true }, ` ${entry.title}`),
50
+ h(Text, {}, ""),
51
+ ...entry.items.flatMap((it, i) =>
52
+ wrap(it).map((line, j) =>
53
+ h(Text, { key: `i${i}.${j}`, color: C.FG }, j === 0 ? ` • ${line}` : ` ${line}`),
54
+ ),
55
+ ),
56
+ h(Text, {}, ""),
57
+ h(Text, { color: C.FG_DIM }, " full history: llmstatus.ai/changelog · press any key to continue"),
58
+ );
59
+ }
@@ -11,10 +11,11 @@ import {
11
11
  import { collectFrom } from "../../sources/index.js";
12
12
  import { loadConfig, setConfigValue } from "../../config.js";
13
13
  import { readFixes } from "../../fix.js";
14
+ import { CHANGELOG } from "../../changelog-data.js";
14
15
  import { openLocation } from "../../openUrl.js";
15
16
  import path from "node:path";
16
17
 
17
- const TABS = ["Registry", "Alerts", "Drift", "Fixes"];
18
+ const TABS = ["Registry", "Alerts", "Drift", "Fixes", "Releases"];
18
19
 
19
20
  // Per-section keybars — the active actions differ by tab (Registry: mark seen ·
20
21
  // Notifications: mark read · Drift: rescan + archive). Published via ui.setKeys()
@@ -24,6 +25,7 @@ const TAB_KEYS = [
24
25
  [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
25
26
  [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
26
27
  [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "open in editor" }, { k: "g", label: "refresh" }],
28
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }],
27
29
  ];
28
30
 
29
31
  // The static fallback must match the default (Registry) section exactly —
@@ -145,6 +147,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
145
147
  if (tab === 0) context = reg.error ? "offline" : `${reg.data?.events.length || 0} changes`;
146
148
  else if (tab === 1) context = notif.error ? "offline" : `${(notif.data || []).length} alerts`;
147
149
  else if (tab === 3) context = `${fixes.length} fix${fixes.length === 1 ? "" : "es"}`;
150
+ else if (tab === 4) context = `${CHANGELOG.length} releases · llmstatus.ai/changelog`;
148
151
  else context = drift && !drift.loading && !drift.error ? `+${drift.added.length} / -${drift.gone.length}` : "drift";
149
152
  ui?.reportStatus?.({ context });
150
153
  }, [tab, reg.data, reg.error, notif.data, notif.error, drift, fixes, ui]);
@@ -247,6 +250,28 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
247
250
  })(),
248
251
  );
249
252
  }
253
+ } else if (tab === 4) {
254
+ const RROWS = Math.max(3, ROWS - 5); // reserve rows for the selected entry's bullets
255
+ const cur = clampCursor(cursor, CHANGELOG.length);
256
+ const start = Math.max(0, Math.min(cur - RROWS + 1, CHANGELOG.length - RROWS));
257
+ const sel = CHANGELOG[cur];
258
+ body = h(
259
+ Box,
260
+ { flexDirection: "column" },
261
+ ...CHANGELOG.slice(start, start + RROWS).map((e, i) => {
262
+ const cells = [
263
+ { text: cellE(`v${e.version}`, 16), color: C.ACCENT },
264
+ { text: cellE(e.date, 12), color: C.FG_FAINT },
265
+ { text: cellE(e.title, Math.max(16, width - 34)), color: C.FG },
266
+ ];
267
+ return h(ListRow, { key: `r${start + i}`, active: start + i === cur, cells, width });
268
+ }),
269
+ h(Text, { key: "rsp" }, ""),
270
+ ...(sel
271
+ ? sel.items.slice(0, 4).map((it, i) =>
272
+ h(Text, { key: `rb${i}`, color: C.FG_DIM }, cellE(` • ${it.replace(/\`/g, "")}`, Math.max(24, width - 2))))
273
+ : []),
274
+ );
250
275
  } else {
251
276
  if (!drift) body = h(Text, { color: C.FG_DIM }, ` Press r to scan ${dir} and compare against tracked usages.`);
252
277
  else if (drift.loading) body = h(StateLine, { kind: "loading", spin, text: "scanning for drift…" });