@modelstatus/cli 0.1.62 → 0.1.63

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.62",
3
+ "version": "0.1.63",
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/fix.js CHANGED
@@ -16,8 +16,62 @@
16
16
  * keeps the provider prefix; if it says "gpt-4" it stays bare.
17
17
  */
18
18
  import fs from "node:fs";
19
+ import os from "node:os";
19
20
  import path from "node:path";
20
21
 
22
+ /**
23
+ * Follow a replacement chain to the first CURRENT model. Replacements can
24
+ * themselves be dying (davinci → gpt-3-5-turbo [deprecating] → gpt-4o-mini), and
25
+ * rewriting to a dying model just means fixing twice — land on the live one.
26
+ * `getModel(slug)` → model|null; `isCurrent(model)` → bool. Cycle-guarded;
27
+ * unknown slugs end the walk (best known answer wins).
28
+ */
29
+ export function terminalReplacement(startSlug, getModel, isCurrent) {
30
+ let cur = startSlug;
31
+ const seen = new Set();
32
+ while (cur && !seen.has(cur)) {
33
+ seen.add(cur);
34
+ const m = getModel(cur);
35
+ if (!m || isCurrent(m) || !m.replacement_slug) break;
36
+ cur = m.replacement_slug;
37
+ }
38
+ return cur || startSlug;
39
+ }
40
+
41
+ /* ------------------------------------------------------------ fix history */
42
+
43
+ // MM_FIXES_FILE override keeps tests off the real user history.
44
+ const FIXES_FILE = process.env.MM_FIXES_FILE || path.join(os.homedir(), ".config", "llmstatus", "fixes.json");
45
+ const FIXES_CAP = 300;
46
+
47
+ /** Append applied fixes to the local history (newest first, capped). Read by
48
+ * the TUI's What's New → Fixes section. Best-effort — never throws. */
49
+ export function recordFixes(dir, applied, { source = "tui" } = {}) {
50
+ if (!applied?.length) return;
51
+ try {
52
+ let log = [];
53
+ try {
54
+ const j = JSON.parse(fs.readFileSync(FIXES_FILE, "utf8"));
55
+ if (Array.isArray(j)) log = j;
56
+ } catch { /* first write */ }
57
+ const ts = Date.now();
58
+ const entries = applied.map((p) => ({ ts, dir: path.resolve(dir), file: p.file, line: p.line, from: p.from, to: p.to, source }));
59
+ log = [...entries, ...log].slice(0, FIXES_CAP);
60
+ fs.mkdirSync(path.dirname(FIXES_FILE), { recursive: true, mode: 0o700 });
61
+ fs.writeFileSync(FIXES_FILE, JSON.stringify(log));
62
+ } catch { /* best effort */ }
63
+ }
64
+
65
+ /** The recorded fix history, newest first. Never throws. */
66
+ export function readFixes() {
67
+ try {
68
+ const j = JSON.parse(fs.readFileSync(FIXES_FILE, "utf8"));
69
+ return Array.isArray(j) ? j : [];
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
21
75
  /** The string to write into the file: the replacement slug, styled to match how
22
76
  * the old id was written (provider-prefixed vs bare). */
23
77
  export function styleReplacement(oldStr, replacementSlug) {
package/src/index.js CHANGED
@@ -541,7 +541,7 @@ async function cmdFix(positional, flags) {
541
541
  const dir = path.resolve(positional[1] || flags.dir || ".");
542
542
  const { getRegistry } = await import("./registry/fetch.js");
543
543
  const { resolveLocal, computeHealth } = await import("./registry/local.js");
544
- const { planFixes, applyFixes } = await import("./fix.js");
544
+ const { planFixes, applyFixes, terminalReplacement, recordFixes } = await import("./fix.js");
545
545
  const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
546
546
 
547
547
  const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection);
@@ -562,18 +562,28 @@ async function cmdFix(positional, flags) {
562
562
  byModel.set(r.model_slug, e);
563
563
  }
564
564
 
565
+ const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
566
+ const isCurrent = (m) => computeHealth(m, 90, today) === "ok";
565
567
  const all = [];
566
568
  for (const { model, health, refs } of byModel.values()) {
567
- for (const p of planFixes(refs, model.replacement_slug)) all.push({ ...p, slug: model.slug, health });
569
+ // Chase the replacement chain rewriting to a model that's ITSELF dying
570
+ // (davinci → gpt-3-5-turbo → gpt-4o-mini) just means fixing twice.
571
+ const target = terminalReplacement(model.replacement_slug, (slug) => bySlug.get(slug) ?? null, isCurrent);
572
+ for (const p of planFixes(refs, target)) all.push({ ...p, slug: model.slug, health });
568
573
  }
569
574
  if (!all.length) {
575
+ if (flags.json) return console.log(JSON.stringify({ applied: [], stale: [], failed: [], reason: "nothing to fix" }, null, 2));
570
576
  console.log("Nothing to fix — no deprecated/retiring model with a registry replacement found in scannable files.");
571
577
  return;
572
578
  }
573
579
 
574
- console.log(`mm fix ${all.length} reference(s) across ${new Set(all.map((p) => p.file)).size} file(s):\n`);
575
- for (const p of all) console.log(` ${p.file}:${p.line} ${p.from} → ${p.to}`);
580
+ // --json = machine output: NOTHING but JSON on stdout.
581
+ if (!flags.json) {
582
+ console.log(`mm fix — ${all.length} reference(s) across ${new Set(all.map((p) => p.file)).size} file(s):\n`);
583
+ for (const p of all) console.log(` ${p.file}:${p.line} ${p.from} → ${p.to}`);
584
+ }
576
585
  if (flags["dry-run"]) {
586
+ if (flags.json) return console.log(JSON.stringify({ planned: all, dryRun: true }, null, 2));
577
587
  console.log("\n(dry run — nothing written. Re-run without --dry-run to apply.)");
578
588
  return;
579
589
  }
@@ -587,6 +597,7 @@ async function cmdFix(positional, flags) {
587
597
  }
588
598
 
589
599
  const res = applyFixes(dir, all);
600
+ recordFixes(dir, res.applied, { source: "cli" });
590
601
  if (flags.json) return console.log(JSON.stringify(res, null, 2));
591
602
  console.log(`\n✓ rewrote ${res.applied.length} reference(s) in ${new Set(res.applied.map((p) => p.file)).size} file(s).`);
592
603
  for (const s of res.stale) console.log(` ! skipped ${s.file}:${s.line} — ${s.error}`);
@@ -18,6 +18,7 @@ import { buildUsages, assignProjects } from "../../upload.js";
18
18
  import { readSnippet } from "../snippet.js";
19
19
  import { openLocation } from "../../openUrl.js";
20
20
  import { addGlobalIgnore } from "../../sources/filesystem.js";
21
+ import { computeHealth } from "../../registry/local.js";
21
22
 
22
23
  // Ordered by survival priority — KeyBar drops trailing entries on narrow
23
24
  // terminals, so the primary verb (u push) and recovery (g rescan) must come
@@ -83,17 +84,27 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
83
84
  /** Rewrite refs of the focused model to its registry replacement (in place,
84
85
  * boundary-safe, y-confirmed). One ref from the drill-in, or all of them. */
85
86
  function fixRefs(refs, what) {
86
- const repl = cur?.model?.replacement_slug;
87
87
  if (!cur?.model) return ui?.showToast?.("custom model — no registry replacement known", "#d97706");
88
88
  if (cur.health === "ok") return ui?.showToast?.(`${cur.slug} is current — nothing to fix`, "#d97706");
89
- if (!repl) return ui?.showToast?.("no replacement in the registry yet", "#d97706");
90
- import("../../fix.js").then(({ planFixes, applyFixes, styleReplacement }) => {
89
+ if (!cur.model.replacement_slug) return ui?.showToast?.("no replacement in the registry yet", "#d97706");
90
+ import("../../fix.js").then(({ planFixes, applyFixes, styleReplacement, terminalReplacement, recordFixes }) => {
91
+ // Chase the replacement chain to the first CURRENT model — rewriting to a
92
+ // model that's itself dying just means pressing f twice.
93
+ const models = scan.snapshot?.models || [];
94
+ const bySlug = new Map(models.map((m) => [m.slug, m]));
95
+ const today = new Date();
96
+ const repl = terminalReplacement(
97
+ cur.model.replacement_slug,
98
+ (slug) => bySlug.get(slug) ?? null,
99
+ (m) => computeHealth(m, 90, today) === "ok",
100
+ );
91
101
  const plans = planFixes(refs, repl);
92
102
  if (!plans.length) return ui?.showToast?.("no rewritable file refs (integration-sourced?)", "#d97706");
93
103
  ui?.askPrompt?.(`Rewrite ${what} → ${repl}? type y`, {
94
104
  onSubmit: (v) => {
95
105
  if (String(v || "").trim().toLowerCase() !== "y") return ui?.showToast?.("fix cancelled");
96
106
  const res = applyFixes(dir, plans);
107
+ recordFixes(dir, res.applied, { source: "tui" });
97
108
  const files = new Set(res.applied.map((p) => p.file)).size;
98
109
  if (res.applied.length) {
99
110
  // Update the in-memory scan IN PLACE (no filesystem re-walk — on a big
@@ -10,8 +10,11 @@ import {
10
10
  } from "../ui.js";
11
11
  import { collectFrom } from "../../sources/index.js";
12
12
  import { loadConfig, setConfigValue } from "../../config.js";
13
+ import { readFixes } from "../../fix.js";
14
+ import { openLocation } from "../../openUrl.js";
15
+ import path from "node:path";
13
16
 
14
- const TABS = ["Registry", "Alerts", "Drift"];
17
+ const TABS = ["Registry", "Alerts", "Drift", "Fixes"];
15
18
 
16
19
  // Per-section keybars — the active actions differ by tab (Registry: mark seen ·
17
20
  // Notifications: mark read · Drift: rescan + archive). Published via ui.setKeys()
@@ -20,6 +23,7 @@ const TAB_KEYS = [
20
23
  [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "m", label: "mark all seen" }, { k: "g", label: "refresh" }],
21
24
  [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "mark read" }, { k: "g", label: "refresh" }],
22
25
  [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
26
+ [{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "o", label: "open in editor" }, { k: "g", label: "refresh" }],
23
27
  ];
24
28
 
25
29
  // The static fallback must match the default (Registry) section exactly —
@@ -43,6 +47,8 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
43
47
  return { events: ev.data || [], models, provs };
44
48
  }, []);
45
49
  const notif = useAsync(async () => (await client.listNotifications({})).data || [], []);
50
+ const [fixes, setFixes] = React.useState(() => readFixes());
51
+ React.useEffect(() => { if (tab === 3) setFixes(readFixes()); }, [tab]);
46
52
  const [drift, setDrift] = React.useState(null);
47
53
  // Gate the spinner tick on actual loading — not tab focus — so an idle
48
54
  // What's New tab doesn't re-render ~12×/sec forever.
@@ -93,6 +99,7 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
93
99
  if (input === "g") {
94
100
  if (tab === 0) return reg.reload();
95
101
  if (tab === 1) return notif.reload();
102
+ if (tab === 3) return setFixes(readFixes());
96
103
  return runDrift();
97
104
  }
98
105
 
@@ -109,6 +116,12 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
109
116
  ui.showToast("marked read");
110
117
  notif.reload();
111
118
  }).catch((e) => ui.showToast(e.message, "red"));
119
+ } else if (tab === 3) {
120
+ const cur = fixes[clampCursor(cursor, fixes.length)];
121
+ if (input === "o" && cur) {
122
+ openLocation(path.resolve(cur.dir, cur.file), cur.line);
123
+ ui.showToast(`opened ${cur.file}:${cur.line}`);
124
+ }
112
125
  } else if (tab === 2) {
113
126
  if (input === "r") return runDrift();
114
127
  const gone = drift?.gone || [];
@@ -131,9 +144,10 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
131
144
  let context;
132
145
  if (tab === 0) context = reg.error ? "offline" : `${reg.data?.events.length || 0} changes`;
133
146
  else if (tab === 1) context = notif.error ? "offline" : `${(notif.data || []).length} alerts`;
147
+ else if (tab === 3) context = `${fixes.length} fix${fixes.length === 1 ? "" : "es"}`;
134
148
  else context = drift && !drift.loading && !drift.error ? `+${drift.added.length} / -${drift.gone.length}` : "drift";
135
149
  ui?.reportStatus?.({ context });
136
- }, [tab, reg.data, reg.error, notif.data, notif.error, drift, ui]);
150
+ }, [tab, reg.data, reg.error, notif.data, notif.error, drift, fixes, ui]);
137
151
 
138
152
  let body;
139
153
  if (tab === 0) {
@@ -188,6 +202,36 @@ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14
188
202
  }),
189
203
  );
190
204
  }
205
+ } else if (tab === 3) {
206
+ if (!fixes.length) {
207
+ body = h(Text, { color: C.FG_DIM }, " No fixes applied yet. Press f on the Here tab (or run mm fix) to rewrite dying model ids.");
208
+ } else {
209
+ // Windowed like the Alerts section so ↑↓ reaches every row.
210
+ const cur = clampCursor(cursor, fixes.length);
211
+ const start = Math.max(0, Math.min(cur - ROWS + 1, fixes.length - ROWS));
212
+ const ago = (ts) => {
213
+ const m = Math.max(0, Math.round((Date.now() - ts) / 60000));
214
+ if (m < 1) return "now";
215
+ if (m < 60) return `${m}m ago`;
216
+ const hr = Math.round(m / 60);
217
+ if (hr < 24) return `${hr}h ago`;
218
+ return `${Math.round(hr / 24)}d ago`;
219
+ };
220
+ body = h(
221
+ Box,
222
+ { flexDirection: "column" },
223
+ ...fixes.slice(start, start + ROWS).map((f, i) => {
224
+ const cells = [
225
+ { text: `${GLYPH.check} `, color: "#16a34a" },
226
+ { text: cellE(`${path.basename(f.dir)} · ${f.file}:${f.line}`, 38), color: C.FG },
227
+ { text: " ", color: C.FG },
228
+ { text: cellE(`${f.from} → ${f.to}`, Math.max(16, width - 58)), color: C.FG_DIM },
229
+ { text: cellE(ago(f.ts), 8), color: C.FG_FAINT },
230
+ ];
231
+ return h(ListRow, { key: `f${start + i}`, active: start + i === cur, cells, width });
232
+ }),
233
+ );
234
+ }
191
235
  } else {
192
236
  if (!drift) body = h(Text, { color: C.FG_DIM }, ` Press r to scan ${dir} and compare against tracked usages.`);
193
237
  else if (drift.loading) body = h(StateLine, { kind: "loading", spin, text: "scanning for drift…" });