@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 +1 -1
- package/src/fix.js +127 -0
- package/src/index.js +65 -1
- package/src/tui/views/local.js +31 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
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);
|
package/src/tui/views/local.js
CHANGED
|
@@ -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();
|