@modelstatus/cli 0.1.76 → 0.1.78

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
@@ -8,7 +8,7 @@ The free CLI + TUI for [LLM Status](https://llmstatus.ai) — scans your repo fo
8
8
  npx @modelstatus/cli status
9
9
  ```
10
10
 
11
- That's it. No sign-in, no account, and the scan runs entirely on your machine — you get a snapshot of every model in your repo plus health badges and replacement suggestions. (The CLI sends anonymous usage analytics — event names + counts only, never code, model names, or paths; a one-time notice says so and `MM_NO_ANALYTICS=1` turns it off.)
11
+ That's it. No sign-in, no account, and the scan runs entirely on your machine — you get a snapshot of every model in your repo plus health badges and replacement suggestions. (Anonymous usage analytics — event names + counts only, never code, model names, or paths can be turned off anytime: `mm analytics off`, or `MM_NO_ANALYTICS=1`.)
12
12
 
13
13
  ## Install
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
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/index.js CHANGED
@@ -14,7 +14,7 @@ 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, maybeAnalyticsNotice } from "./telemetry.js";
17
+ import { track, analyticsState } from "./telemetry.js";
18
18
  import { BUILD_VERSION } from "./version.js";
19
19
 
20
20
  // TRUE BACKGROUND SCAN — hidden worker dispatch. MUST be the first executable
@@ -763,6 +763,7 @@ Usage:
763
763
  mm login [api_key] Browser sign-in with polling (or paste a key)
764
764
  mm signup Create an account in the browser, then poll
765
765
  mm logout Forget the saved API key
766
+ mm analytics [on|off] Anonymous usage stats (event counts only — never code or paths)
766
767
  mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
767
768
  mm fix [dir] Rewrite dying model ids to their replacement, in place (--dry-run previews; --model <slug> limits; --yes skips the confirm)
768
769
  mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
@@ -853,7 +854,6 @@ async function main() {
853
854
 
854
855
  // Anonymous, opt-out usage analytics (one-time disclosure, then a single
855
856
  // event per invocation). No-op without a baked key / when opted out.
856
- maybeAnalyticsNotice();
857
857
  track("cli_command", { command: cmd || "tui" });
858
858
 
859
859
  // Explicit self-update: `mm update` (command) or `--update` (flag on any
@@ -878,6 +878,19 @@ async function main() {
878
878
  if (cmd === "login") await cmdLogin(positional, flags);
879
879
  else if (cmd === "signup") await cmdSignup(positional, flags);
880
880
  else if (cmd === "logout") cmdLogout();
881
+ else if (cmd === "analytics") {
882
+ const arg = (positional[1] || "").toLowerCase();
883
+ if (arg === "on" || arg === "off") {
884
+ const { setConfigValue } = await import("./config.js");
885
+ setConfigValue("analyticsOptOut", arg === "off");
886
+ console.log(`Anonymous usage analytics ${arg}.`);
887
+ } else {
888
+ const st = analyticsState();
889
+ console.log(`Anonymous usage analytics: ${st.on ? "on" : `off${st.reason ? ` — ${st.reason}` : ""}`}`);
890
+ console.log(" When on, mm sends event names + counts only — never code, model names, or paths.");
891
+ console.log(" Toggle: mm analytics on|off · also honored: MM_NO_ANALYTICS=1, DO_NOT_TRACK=1, CI=1");
892
+ }
893
+ }
881
894
  else if (cmd === "scan") await cmdScan(positional, flags);
882
895
  else if (cmd === "fix") await cmdFix(positional, flags);
883
896
  else if (cmd === "ci") await cmdCi(positional, flags);
package/src/telemetry.js CHANGED
@@ -14,7 +14,10 @@ import { BUILD_VERSION, UPDATE_CHANNEL } from "./version.js";
14
14
  const POSTHOG_KEY = typeof __POSTHOG_KEY__ !== "undefined" ? __POSTHOG_KEY__ : (process.env.MM_POSTHOG_KEY || "");
15
15
  const POSTHOG_HOST = (process.env.MM_POSTHOG_HOST || "https://us.i.posthog.com").replace(/\/$/, "");
16
16
 
17
- const optedOut = () => !!(process.env.MM_NO_ANALYTICS || process.env.DO_NOT_TRACK || process.env.CI);
17
+ const optedOut = () => {
18
+ if (process.env.MM_NO_ANALYTICS || process.env.DO_NOT_TRACK || process.env.CI) return true;
19
+ try { return !!loadConfig().analyticsOptOut; } catch { return false; }
20
+ };
18
21
  const enabled = () => !!POSTHOG_KEY && !optedOut();
19
22
 
20
23
  let cachedId = null;
@@ -27,13 +30,15 @@ function distinctId() {
27
30
  }
28
31
 
29
32
  /** One-time, pre-TUI stderr disclosure. Honors opt-out + only shows once. */
30
- export function maybeAnalyticsNotice() {
31
- if (!enabled()) return;
32
- if (loadConfig().analyticsNoticeShown) return;
33
- try { setConfigValue("analyticsNoticeShown", true); } catch { /* best effort */ }
34
- process.stderr.write(
35
- "ℹ llmstatus sends anonymous usage analytics (event names + counts only — never code, model names, or paths). Opt out: MM_NO_ANALYTICS=1\n",
36
- );
33
+ /** Current analytics state + why — powers `mm analytics` and the Account tab.
34
+ * Disclosure lives there + README/docs/--help (not as a banner interrupting
35
+ * scan output). */
36
+ export function analyticsState() {
37
+ if (process.env.MM_NO_ANALYTICS || process.env.DO_NOT_TRACK || process.env.CI) {
38
+ return { on: false, reason: "env (MM_NO_ANALYTICS / DO_NOT_TRACK / CI)" };
39
+ }
40
+ try { if (loadConfig().analyticsOptOut) return { on: false, reason: "mm analytics off" }; } catch { /* ignore */ }
41
+ return { on: !!POSTHOG_KEY, reason: POSTHOG_KEY ? "" : "no key baked in" };
37
42
  }
38
43
 
39
44
  /** Fire-and-forget capture. Never throws, never blocks the CLI meaningfully. */
package/src/tui/app.js CHANGED
@@ -4,6 +4,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from "ink";
4
4
  import { createClient } from "../api.js";
5
5
  import { track } from "../telemetry.js";
6
6
  import { BUILD_VERSION } from "../version.js";
7
+ import { feedbackEligible, recordFeedbackShown, recordFeedbackSubmitted, submitFeedback, FeedbackStrip, FeedbackCard, ACTION_THRESHOLD } from "./feedback.js";
7
8
  import {
8
9
  h, C, GLYPH, useAsync, Window, TrafficLights, TabStrip, StatusBar, legendSegments,
9
10
  KeyBar, EmptyCard,
@@ -146,6 +147,12 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
146
147
  // changes (reset below). Keeps the keybar honest for tabbed views (What's New,
147
148
  // Alerts) where the active keys depend on which sub-tab you're on.
148
149
  const [dynKeys, setDynKeys] = React.useState(null);
150
+ // Feedback: null | {stage:"strip"} | {stage:"card", score, comment, sending}.
151
+ // The strip auto-shows once ACTION_THRESHOLD real actions happen (30-day
152
+ // cooldown, never after a submit); `!` opens the card from anywhere, always.
153
+ const [feedback, setFeedback] = React.useState(null);
154
+ const actionsRef = React.useRef(0);
155
+ const fbEligibleRef = React.useRef(feedbackEligible());
149
156
  const me = useAsync(async () => (apiKey ? client.me() : null), [apiKey]);
150
157
 
151
158
  const { outer, termRows } = useTermDims();
@@ -187,6 +194,21 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
187
194
  useInput(
188
195
  (input, key) => {
189
196
  if (key.ctrl && input === "c") return exit();
197
+ if (input === "!") {
198
+ setFeedback({ stage: "card", score: null, comment: "", sending: false });
199
+ return;
200
+ }
201
+ // Count "real" actions (letter/symbol keys that do work — not tab digits,
202
+ // not bare navigation, not q). Arrows/enter arrive with empty input, so
203
+ // pure browsing never trips the ask.
204
+ if (fbEligibleRef.current && !feedback && input && !key.ctrl && !key.meta && !/^[0-9]$/.test(input) && input !== "q") {
205
+ actionsRef.current += 1;
206
+ if (actionsRef.current >= ACTION_THRESHOLD) {
207
+ fbEligibleRef.current = false;
208
+ recordFeedbackShown();
209
+ setFeedback({ stage: "strip" });
210
+ }
211
+ }
190
212
  // Ctrl-L: hard redraw. Clears screen + scrollback, then bumps `redraw` so
191
213
  // the frame string changes (ink skips writing identical output) and ink
192
214
  // repaints from a clean top. Fixes a display knocked askew by a resize /
@@ -219,7 +241,37 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
219
241
  }
220
242
  if (input === "q") return exit();
221
243
  },
222
- { isActive: !prompt && !capturing },
244
+ { isActive: !prompt && !capturing && feedback?.stage !== "card" },
245
+ );
246
+ // Feedback card is modal: digits score it (until a comment starts), printable
247
+ // chars build the comment, ←→ nudges the score, ↵ sends, esc closes.
248
+ useInput(
249
+ (input, key) => {
250
+ const fb = feedback;
251
+ if (!fb || fb.sending) return;
252
+ if (key.escape) return setFeedback(null);
253
+ if (key.return) {
254
+ if (!fb.score) return showToast("pick a score first (1-5)", "#d97706");
255
+ setFeedback({ ...fb, sending: true });
256
+ submitFeedback({ apiBase, score: fb.score, comment: fb.comment }).then((ok) => {
257
+ if (ok) {
258
+ recordFeedbackSubmitted();
259
+ setFeedback(null);
260
+ showToast("🙏 thanks — feedback sent to the humans");
261
+ } else {
262
+ setFeedback((f) => (f ? { ...f, sending: false } : f));
263
+ showToast("couldn't send feedback — check your connection, try again", "#dc2626");
264
+ }
265
+ });
266
+ return;
267
+ }
268
+ if (key.leftArrow) return setFeedback({ ...fb, score: Math.max(1, (fb.score || 4) - 1) });
269
+ if (key.rightArrow) return setFeedback({ ...fb, score: Math.min(5, (fb.score || 2) + 1) });
270
+ if (key.backspace || key.delete) return setFeedback({ ...fb, comment: fb.comment.slice(0, -1) });
271
+ if (/^[1-5]$/.test(input) && !fb.comment) return setFeedback({ ...fb, score: Number(input) });
272
+ if (input && !key.ctrl && !key.meta) return setFeedback({ ...fb, comment: (fb.comment + input).slice(0, 500) });
273
+ },
274
+ { isActive: feedback?.stage === "card" },
223
275
  );
224
276
  useInput(
225
277
  (input, key) => {
@@ -257,9 +309,13 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
257
309
  });
258
310
  keys = GATE_KEYS;
259
311
  } else {
260
- body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt, fresh });
312
+ body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt && feedback?.stage !== "card", fresh });
261
313
  keys = dynKeys || (current2.meta && current2.meta.keys) || [];
262
314
  }
315
+ if (feedback?.stage === "card") {
316
+ body = h(FeedbackCard, { score: feedback.score, comment: feedback.comment, sending: feedback.sending, width: W });
317
+ keys = [{ k: "1-5", label: "score" }, { k: "↵", label: "send" }, { k: "esc", label: "close" }];
318
+ }
263
319
 
264
320
  // Status bar segments.
265
321
  const live = status && status.forKey === current2.key ? status : null;
@@ -282,7 +338,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
282
338
  h(Text, {}, ""),
283
339
  h(Box, { flexDirection: "column", width: W, minHeight: bodyRows }, body),
284
340
  h(PromptRow, { prompt }),
285
- h(Text, {}, ""),
341
+ feedback?.stage === "strip" ? h(FeedbackStrip, null) : h(Text, {}, ""),
286
342
  toast ? h(Text, { color: toast.color }, ` ${toast.msg}`) : h(Text, {}, ""),
287
343
  h(StatusBar, { segsLeft, segsRight, width: W }),
288
344
  h(KeyBar, { keys: keys.some((k) => k.k === "P") ? keys : [...keys, { k: "P", label: "play 🦍" }], width: W }),
@@ -0,0 +1,121 @@
1
+ /* In-TUI feedback: a quiet "♥ enjoying mm? press ! to rate it" strip appears
2
+ * once the user has taken a handful of real actions, and `!` (always available
3
+ * on the Account tab too) opens a modal card — score 1–5 (1 = hot garbage,
4
+ * 5 = best thing since sliced bread) + optional comment. Submission goes to
5
+ * POST /api/v1/feedback on our API (works signed-out); the SERVER forwards to
6
+ * PostHog + the ops Slack channel — the public binary never holds a secret.
7
+ *
8
+ * Nag policy (user-chosen): auto-show at most once per 30 days; submitting
9
+ * means never auto-show again. The `!` key keeps working regardless.
10
+ */
11
+ import React from "react";
12
+ import { Box, Text } from "ink";
13
+ import { h, C } from "./ui.js";
14
+ import { loadConfig, setConfigValue } from "../config.js";
15
+ import { BUILD_VERSION } from "../version.js";
16
+
17
+ const COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
18
+ export const ACTION_THRESHOLD = 5; // strip appears after this many real actions
19
+
20
+ /** Should the auto-strip show this session? (The `!` key is always allowed.) */
21
+ export function feedbackEligible(cfg = loadConfig(), now = Date.now()) {
22
+ const f = cfg.feedback || {};
23
+ if (f.submittedAt) return false; // they already told us — never auto-nag again
24
+ if (f.shownAt && now - f.shownAt < COOLDOWN_MS) return false;
25
+ return true;
26
+ }
27
+
28
+ /** Record that the strip was shown (ignoring it counts as dismissal). */
29
+ export function recordFeedbackShown(now = Date.now()) {
30
+ try {
31
+ setConfigValue("feedback", { ...(loadConfig().feedback || {}), shownAt: now });
32
+ } catch { /* best effort */ }
33
+ }
34
+
35
+ export function recordFeedbackSubmitted(now = Date.now()) {
36
+ try {
37
+ setConfigValue("feedback", { ...(loadConfig().feedback || {}), submittedAt: now });
38
+ } catch { /* best effort */ }
39
+ }
40
+
41
+ /** POST to our API. Returns true on success; never throws. */
42
+ export async function submitFeedback({ apiBase, score, comment }) {
43
+ try {
44
+ const cfg = loadConfig();
45
+ const res = await fetch(`${(apiBase || "https://llmstatus.ai").replace(/\/$/, "")}/api/v1/feedback`, {
46
+ method: "POST",
47
+ headers: { "content-type": "application/json" },
48
+ body: JSON.stringify({
49
+ score,
50
+ thumbs: score >= 4 ? "up" : score <= 2 ? "down" : undefined,
51
+ comment: comment || undefined,
52
+ version: typeof BUILD_VERSION === "string" ? BUILD_VERSION : "",
53
+ platform: `${process.platform}-${process.arch}`,
54
+ distinct_id: cfg.anonId || undefined,
55
+ }),
56
+ signal: AbortSignal.timeout(5_000),
57
+ });
58
+ return res.ok;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ const SCORE_LABELS = {
65
+ 1: "hot garbage",
66
+ 2: "rough",
67
+ 3: "it's fine",
68
+ 4: "really good",
69
+ 5: "best thing since sliced bread",
70
+ };
71
+
72
+ /** The one-line invite, rendered by app.js above the keybar. */
73
+ export function FeedbackStrip() {
74
+ return h(
75
+ Text,
76
+ {},
77
+ h(Text, { color: "#fb7185" }, " ♥ "),
78
+ h(Text, { color: C.FG }, "enjoying mm? "),
79
+ h(Text, { color: C.ACCENT, bold: true }, "press ! to rate it"),
80
+ );
81
+ }
82
+
83
+ /** The modal card. Pure render — app.js owns the state + key handling. */
84
+ export function FeedbackCard({ score, comment, sending, width = 78 }) {
85
+ const cells = [];
86
+ for (let n = 1; n <= 5; n++) {
87
+ const active = n === score;
88
+ cells.push(
89
+ h(
90
+ Text,
91
+ { key: `s${n}` },
92
+ h(Text, { color: active ? C.ACCENT : C.FG_DIM, bold: active }, active ? ` ▸${n}◂ ` : ` ${n} `),
93
+ ),
94
+ );
95
+ }
96
+ const label = score ? SCORE_LABELS[score] : "pick a number";
97
+ return h(
98
+ Box,
99
+ { flexDirection: "column", paddingX: 1 },
100
+ h(Text, {}, h(Text, { color: C.ACCENT, bold: true }, " ✦ feedback "), h(Text, { color: C.FG_DIM }, "— 30 seconds, straight to the humans")),
101
+ h(Text, {}, ""),
102
+ h(Text, { color: C.FG }, " How do you like mm? (1 = hot garbage · 5 = best thing since sliced bread)"),
103
+ h(Text, {}, ""),
104
+ h(Box, {}, h(Text, {}, " "), ...cells, h(Text, { color: C.FG_DIM }, ` ${label}`)),
105
+ h(Text, {}, ""),
106
+ h(Text, { color: C.FG }, " anything we should know? (optional)"),
107
+ h(
108
+ Text,
109
+ {},
110
+ h(Text, { color: C.FG_DIM }, " │ "),
111
+ h(Text, { color: C.FG }, comment || ""),
112
+ h(Text, { color: C.ACCENT }, "▌"),
113
+ ),
114
+ h(Text, {}, ""),
115
+ h(
116
+ Text,
117
+ { color: C.FG_DIM },
118
+ sending ? " sending…" : " ←→ or 1-5 score · type to comment · ↵ send · esc close",
119
+ ),
120
+ );
121
+ }
@@ -2,20 +2,21 @@ import React from "react";
2
2
  import { Box, Text, useInput } from "ink";
3
3
  import { h, C, GLYPH } from "../ui.js";
4
4
  import { loadConfig, configFilePath } from "../../config.js";
5
+ import { analyticsState } from "../../telemetry.js";
5
6
  import { openUrl } from "../../openUrl.js";
6
7
 
7
8
  const FREE_LIMITS = { projects: 1, usages: 15, channels: 1 };
8
9
  // Background paint only when colors are on (mirrors ui.js BG_ON gate; ui handles the rest).
9
10
  const BG_ON = process.env.MM_ASCII !== "1" && process.env.TERM !== "dumb" && process.env.NO_COLOR == null;
10
11
 
11
- export const meta = { keys: [{ k: "u", label: "upgrade" }, { k: "g", label: "refresh" }] };
12
+ export const meta = { keys: [{ k: "u", label: "upgrade" }, { k: "g", label: "refresh" }, { k: "!", label: "feedback" }] };
12
13
 
13
14
  /** label/value row: padded accent label + value. */
14
15
  function Row({ label, value, color = C.FG, bold = false }) {
15
16
  return h(
16
17
  Text,
17
18
  {},
18
- h(Text, { color: C.ACCENT }, label.padEnd(9)),
19
+ h(Text, { color: C.ACCENT }, label.padEnd(10)),
19
20
  h(Text, { color, bold }, value),
20
21
  );
21
22
  }
@@ -98,6 +99,7 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
98
99
  h(Row, { label: "retiring", value: `${me?.retiring_window_days ?? 90} day window` }),
99
100
  h(Row, { label: "endpoint", value: endpoint }),
100
101
  h(Row, { label: "key", value: keyPrefix }),
102
+ h(Row, { label: "analytics", value: analyticsState().on ? "on · anonymous event counts only · mm analytics off" : "off", color: C.FG_FAINT }),
101
103
  h(Row, { label: "config", value: configFilePath, color: C.FG_FAINT }),
102
104
  ),
103
105
  h(Text, {}, ""),