@modelstatus/cli 0.1.60 → 0.1.61

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.60",
3
+ "version": "0.1.61",
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 ADDED
@@ -0,0 +1,127 @@
1
+ /* Auto-fix: rewrite model ids in source files to their registry replacement.
2
+ *
3
+ * The scan already knows every reference's file:line + the exact matched string,
4
+ * and the registry knows each dying model's replacement_slug — so a fix is a
5
+ * boundary-safe string substitution at a known location. Used by the TUI Here
6
+ * tab (`f` fix one ref / all refs of a model) and `mm fix` (whole repo).
7
+ *
8
+ * Safety rules:
9
+ * - Replace ONLY on the recorded line. If the string is no longer there (file
10
+ * changed since the scan), the ref is reported "stale" and skipped — never
11
+ * guess. A rescan refreshes the locations.
12
+ * - Boundary-aware matching: "gpt-4" must never rewrite inside "gpt-4o" /
13
+ * "gpt-4-turbo". A model-id character ([A-Za-z0-9._-]) on either side of the
14
+ * match blocks it.
15
+ * - Style-preserving replacement: if the code says "openai/gpt-4" the new id
16
+ * keeps the provider prefix; if it says "gpt-4" it stays bare.
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ /** The string to write into the file: the replacement slug, styled to match how
22
+ * the old id was written (provider-prefixed vs bare). */
23
+ export function styleReplacement(oldStr, replacementSlug) {
24
+ if (!replacementSlug) return null;
25
+ const i = replacementSlug.indexOf("/");
26
+ const bare = i >= 0 ? replacementSlug.slice(i + 1) : replacementSlug;
27
+ return oldStr.includes("/") ? replacementSlug : bare;
28
+ }
29
+
30
+ const BOUND = "[A-Za-z0-9._-]";
31
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32
+
33
+ /** Boundary-safe replace of every occurrence of `from` in one line of text.
34
+ * Returns { out, n } — the rewritten line and how many occurrences changed. */
35
+ export function replaceOnLine(lineText, from, to) {
36
+ const re = new RegExp(`(?<!${BOUND})${escRe(from)}(?!${BOUND})`, "g");
37
+ let n = 0;
38
+ const out = lineText.replace(re, () => {
39
+ n += 1;
40
+ return to;
41
+ });
42
+ return { out, n };
43
+ }
44
+
45
+ /**
46
+ * Build fix plans from scan refs. `refs` = [{ model_string, source_path,
47
+ * source_line }] (the shape the scanner emits); `replacementSlug` is the
48
+ * registry replacement for the model those refs belong to. Refs without a real
49
+ * file path (integration scheme labels like vercel://) are skipped — only
50
+ * filesystem refs are rewritable.
51
+ */
52
+ export function planFixes(refs, replacementSlug) {
53
+ const plans = [];
54
+ const seen = new Set();
55
+ for (const r of refs || []) {
56
+ if (!r?.source_path || !r.source_line || !r.model_string) continue;
57
+ const to = styleReplacement(r.model_string, replacementSlug);
58
+ if (!to || to === r.model_string) continue;
59
+ const key = `${r.source_path}:${r.source_line}:${r.model_string}`;
60
+ if (seen.has(key)) continue;
61
+ seen.add(key);
62
+ plans.push({ file: r.source_path, line: r.source_line, from: r.model_string, to });
63
+ }
64
+ // The detector can emit BOTH "gpt-4" and "openai/gpt-4" for the same spot. At
65
+ // one file:line, keep only the most specific plan (drop a `from` that is a
66
+ // substring of a sibling's) — the longer rewrite covers the shorter one, and
67
+ // the duplicate would otherwise report a confusing "stale" skip.
68
+ return plans.filter(
69
+ (p) => !plans.some((q) => q !== p && q.file === p.file && q.line === p.line && q.from.includes(p.from) && q.from !== p.from),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Apply plans to files under `dir`. Groups by file so multi-ref files are one
75
+ * read+write. Returns { applied, stale, failed } — applied entries carry the
76
+ * occurrence count actually rewritten on that line.
77
+ */
78
+ export function applyFixes(dir, plans) {
79
+ const byFile = new Map();
80
+ for (const p of plans) {
81
+ const arr = byFile.get(p.file) || [];
82
+ arr.push(p);
83
+ byFile.set(p.file, arr);
84
+ }
85
+ const applied = [];
86
+ const stale = [];
87
+ const failed = [];
88
+ for (const [file, filePlans] of byFile) {
89
+ const abs = path.resolve(dir, file);
90
+ let text;
91
+ try {
92
+ text = fs.readFileSync(abs, "utf8");
93
+ } catch (e) {
94
+ for (const p of filePlans) failed.push({ ...p, error: e.code === "ENOENT" ? "file not found" : e.message });
95
+ continue;
96
+ }
97
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
98
+ const lines = text.split(/\r?\n/);
99
+ let dirty = false;
100
+ for (const p of filePlans) {
101
+ const idx = p.line - 1;
102
+ if (idx < 0 || idx >= lines.length) {
103
+ stale.push({ ...p, error: "line out of range — rescan" });
104
+ continue;
105
+ }
106
+ const { out, n } = replaceOnLine(lines[idx], p.from, p.to);
107
+ if (n === 0) {
108
+ stale.push({ ...p, error: "string not on that line anymore — rescan" });
109
+ continue;
110
+ }
111
+ lines[idx] = out;
112
+ dirty = true;
113
+ applied.push({ ...p, count: n });
114
+ }
115
+ if (dirty) {
116
+ try {
117
+ fs.writeFileSync(abs, lines.join(eol));
118
+ } catch (e) {
119
+ // Roll the bookkeeping back: everything in this file actually failed.
120
+ for (let i = applied.length - 1; i >= 0; i--) {
121
+ if (applied[i].file === file) failed.push({ ...applied.splice(i, 1)[0], error: e.message });
122
+ }
123
+ }
124
+ }
125
+ }
126
+ return { applied, stale, failed };
127
+ }
package/src/index.js CHANGED
@@ -88,7 +88,7 @@ function parseArgs(argv) {
88
88
  const flags = {};
89
89
  const positional = [];
90
90
  const valueFlags = new Set([
91
- "api", "key", "project", "dir", "fail-on", "diff", "json-out",
91
+ "api", "key", "project", "dir", "fail-on", "diff", "json-out", "model",
92
92
  "sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
93
93
  // Per-integration scope flags (non-secret): consumed by the 4 live sources.
94
94
  "vercel-project", "vercel-team", "gh-repo", "supabase-ref",
@@ -532,6 +532,68 @@ async function cmdClear(_positional, flags) {
532
532
  console.log(`✓ Cleared ${res.usages ?? 0} usage(s)${res.projects ? ` + ${res.projects} project(s)` : ""}. Your inventory is clean — rescan to repopulate.`);
533
533
  }
534
534
 
535
+ /** `mm fix [dir]` — rewrite dying model ids to their registry replacement,
536
+ * in-place, and print the change list. The scan knows file:line + the exact
537
+ * string; the registry knows the replacement. --dry-run previews; --yes skips
538
+ * the confirm; --model <slug> limits to one model. Only filesystem refs are
539
+ * rewritable (vercel:// etc. are skipped). */
540
+ async function cmdFix(positional, flags) {
541
+ const dir = path.resolve(positional[1] || flags.dir || ".");
542
+ const { getRegistry } = await import("./registry/fetch.js");
543
+ const { resolveLocal, computeHealth } = await import("./registry/local.js");
544
+ const { planFixes, applyFixes } = await import("./fix.js");
545
+ const snapshot = await getRegistry({ offline: !!flags.offline, log: (m) => process.stderr.write(`! ${m}\n`) });
546
+
547
+ const candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection);
548
+ const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
549
+ const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
550
+ const today = new Date();
551
+
552
+ // Group rewritable refs by model; keep only dying models WITH a replacement.
553
+ const byModel = new Map(); // slug -> { model, health, refs }
554
+ for (const c of candidates) {
555
+ const r = byStr.get(c.model_string.toLowerCase());
556
+ if (!r?.model_slug || !r.model?.replacement_slug) continue;
557
+ const health = computeHealth(r.model, 90, today);
558
+ if (health === "ok") continue;
559
+ if (flags.model && r.model_slug !== flags.model) continue;
560
+ const e = byModel.get(r.model_slug) || { model: r.model, health, refs: [] };
561
+ e.refs.push(c);
562
+ byModel.set(r.model_slug, e);
563
+ }
564
+
565
+ const all = [];
566
+ 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 });
568
+ }
569
+ if (!all.length) {
570
+ console.log("Nothing to fix — no deprecated/retiring model with a registry replacement found in scannable files.");
571
+ return;
572
+ }
573
+
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}`);
576
+ if (flags["dry-run"]) {
577
+ console.log("\n(dry run — nothing written. Re-run without --dry-run to apply.)");
578
+ return;
579
+ }
580
+ if (!flags.yes) {
581
+ if (!process.stdin.isTTY) {
582
+ console.error("\nRefusing to rewrite files without confirmation. Re-run with --yes (or --dry-run to preview).");
583
+ process.exit(1);
584
+ }
585
+ const ok = await confirm(`\nRewrite these ${all.length} reference(s) in place? [y/N] `);
586
+ if (!ok) return console.log("Aborted — nothing changed.");
587
+ }
588
+
589
+ const res = applyFixes(dir, all);
590
+ if (flags.json) return console.log(JSON.stringify(res, null, 2));
591
+ console.log(`\n✓ rewrote ${res.applied.length} reference(s) in ${new Set(res.applied.map((p) => p.file)).size} file(s).`);
592
+ for (const s of res.stale) console.log(` ! skipped ${s.file}:${s.line} — ${s.error}`);
593
+ for (const f of res.failed) console.log(` × failed ${f.file}:${f.line} — ${f.error}`);
594
+ if (res.applied.length) console.log("\nRe-run your tests, then `mm status` to confirm everything reads current.");
595
+ }
596
+
535
597
  /** List detection sources and whether each can run right now. Live integrations
536
598
  * also show their on/off toggle (the `int` column) so toggled state is visible
537
599
  * here too. */
@@ -687,6 +749,7 @@ Usage:
687
749
  mm signup Create an account in the browser, then poll
688
750
  mm logout Forget the saved API key
689
751
  mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
752
+ mm fix [dir] Rewrite dying model ids to their replacement, in place (--dry-run previews; --model <slug> limits; --yes skips the confirm)
690
753
  mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
691
754
  (--diff <base> limits findings to files changed vs base; auto on PRs via GITHUB_BASE_REF)
692
755
  mm sources List detection sources and whether each can run here
@@ -800,6 +863,7 @@ async function main() {
800
863
  else if (cmd === "signup") await cmdSignup(positional, flags);
801
864
  else if (cmd === "logout") cmdLogout();
802
865
  else if (cmd === "scan") await cmdScan(positional, flags);
866
+ else if (cmd === "fix") await cmdFix(positional, flags);
803
867
  else if (cmd === "ci") await cmdCi(positional, flags);
804
868
  else if (cmd === "status") await cmdStatus(positional, flags);
805
869
  else if (cmd === "sources") await cmdSources(positional, flags);
@@ -27,6 +27,7 @@ export const meta = {
27
27
  keys: [
28
28
  { k: "↑↓", label: "nav" },
29
29
  { k: "↵", label: "refs" },
30
+ { k: "f", label: "fix → repl" },
30
31
  { k: "u", label: "push → Inv" },
31
32
  { k: "g", label: "rescan" },
32
33
  { k: "/", label: "search" },
@@ -79,6 +80,33 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
79
80
  setPushing(false);
80
81
  }
81
82
  }
83
+ /** Rewrite refs of the focused model to its registry replacement (in place,
84
+ * boundary-safe, y-confirmed). One ref from the drill-in, or all of them. */
85
+ function fixRefs(refs, what) {
86
+ const repl = cur?.model?.replacement_slug;
87
+ if (!cur?.model) return ui?.showToast?.("custom model — no registry replacement known", "#d97706");
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 }) => {
91
+ const plans = planFixes(refs, repl);
92
+ if (!plans.length) return ui?.showToast?.("no rewritable file refs (integration-sourced?)", "#d97706");
93
+ ui?.askPrompt?.(`Rewrite ${what} → ${repl}? type y`, {
94
+ onSubmit: (v) => {
95
+ if (String(v || "").trim().toLowerCase() !== "y") return ui?.showToast?.("fix cancelled");
96
+ const res = applyFixes(dir, plans);
97
+ const files = new Set(res.applied.map((p) => p.file)).size;
98
+ if (res.applied.length) {
99
+ ui?.showToast?.(`${GLYPH.check} rewrote ${res.applied.length} ref${res.applied.length === 1 ? "" : "s"} in ${files} file${files === 1 ? "" : "s"} → ${repl}${res.stale.length ? ` · ${res.stale.length} stale skipped` : ""}`);
100
+ justReloadedRef.current = true;
101
+ scan.reload(); // re-scan so the list reflects the rewritten files
102
+ } else {
103
+ ui?.showToast?.(res.stale.length ? "files changed since the scan — press g to rescan first" : "nothing rewritten", "#dc2626");
104
+ }
105
+ },
106
+ });
107
+ });
108
+ }
109
+
82
110
  const tick = useTick(80, running || pushing);
83
111
  const spin = SPINNER[tick % SPINNER.length];
84
112
  const search = useSearch();
@@ -177,7 +205,7 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
177
205
  const setKeys = ui?.setKeys;
178
206
  React.useEffect(() => {
179
207
  setKeys?.(focus === "refs"
180
- ? [{ k: "↑↓", label: "nav" }, { k: "↵", label: "open in editor" }, { k: "e", label: "exclude path" }, { k: "esc", label: "back" }]
208
+ ? [{ k: "↑↓", label: "nav" }, { k: "↵", label: "open in editor" }, { k: "f", label: "fix ref" }, { k: "e", label: "exclude path" }, { k: "esc", label: "back" }]
181
209
  : null);
182
210
  return () => setKeys?.(null);
183
211
  }, [setKeys, focus]);
@@ -200,6 +228,7 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
200
228
  if (key.upArrow || input === "k") { refIdxRef.current = clampCursor(refIdxRef.current - 1, drefs.length); return setRefIdx(refIdxRef.current); }
201
229
  if (key.return || input === "o") return openRef(drefs[clampCursor(refIdxRef.current, drefs.length)]);
202
230
  if (input === "e") return excludeRef(drefs[clampCursor(refIdxRef.current, drefs.length)]);
231
+ if (input === "f") return fixRefs([drefs[clampCursor(refIdxRef.current, drefs.length)]], "this reference");
203
232
  return;
204
233
  }
205
234
  if (typeof input === "string" && input.startsWith("/")) {
@@ -217,6 +246,7 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
217
246
  if (key.downArrow || input === "j") return nav.down();
218
247
  if (key.upArrow || input === "k") return nav.up();
219
248
  if (input === "e") return excludeRef(drefs[0]); // exclude the highlighted model's location (editable)
249
+ if (input === "f") return fixRefs(cur?.refs || [], `all ${cur?.count ?? 0} references`);
220
250
  if (input === "p" && running) return scan.togglePause();
221
251
  if (input === "g") { justReloadedRef.current = true; return scan.reload(); }
222
252
  if (input === "u" && !pushing) return pushToInventory();