@modelstatus/cli 0.1.86 → 0.1.88
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 +16 -0
- package/package.json +7 -2
- package/src/api.js +41 -11
- package/src/changelog-data.js +25 -0
- package/src/ci.js +8 -4
- package/src/clipboard.js +42 -0
- package/src/detect/core.js +17 -6
- package/src/fix-prompt.js +106 -0
- package/src/fix.js +132 -46
- package/src/index.js +258 -38
- package/src/integrations.js +18 -3
- package/src/registry/fetch.js +16 -1
- package/src/sources/aws-lambda.js +9 -2
- package/src/sources/aws.js +7 -1
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +9 -2
- package/src/sources/helm.js +9 -3
- package/src/sources/index.js +45 -3
- package/src/sources/k8s.js +6 -2
- package/src/sources/shell.js +126 -14
- package/src/sources/sql.js +6 -2
- package/src/sources/supabase-edge.js +19 -5
- package/src/sources/vercel.js +64 -6
- package/src/telemetry.js +21 -0
- package/src/tui/app.js +55 -13
- package/src/tui/game/launch.js +14 -2
- package/src/tui/signin.js +43 -7
- package/src/tui/views/account.js +19 -2
- package/src/tui/views/alerts.js +24 -9
- package/src/tui/views/inventory.js +57 -0
- package/src/tui/views/local.js +37 -1
- package/src/updater.js +160 -39
- package/src/upgrade.js +37 -8
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "./config.js";
|
|
7
7
|
import { createClient } from "./api.js";
|
|
8
8
|
import { collectFrom, availability, ALL_SOURCE_IDS, getSource } from "./sources/index.js";
|
|
9
|
+
import * as SourcesMod from "./sources/index.js";
|
|
9
10
|
import {
|
|
10
11
|
INTEGRATION_IDS, INTEGRATION_META, readIntegrations, enabledIds,
|
|
11
12
|
getEnvTag, setEnabled, setEnvTag,
|
|
@@ -14,7 +15,7 @@ import { redactValue } from "./redact.js";
|
|
|
14
15
|
import { assignProjects, buildUsages } from "./upload.js";
|
|
15
16
|
import { loginViaBrowser } from "./auth.js";
|
|
16
17
|
import { maybeCheckForUpdate, forceUpdate } from "./updater.js";
|
|
17
|
-
import { track, analyticsState, maybeFirstRun } from "./telemetry.js";
|
|
18
|
+
import { track, analyticsState, maybeFirstRun, telemetryCommand } from "./telemetry.js";
|
|
18
19
|
import { startProgress } from "./spinner.js";
|
|
19
20
|
import { BUILD_VERSION } from "./version.js";
|
|
20
21
|
|
|
@@ -85,21 +86,55 @@ if (process.argv[2] === "__bench_frames") {
|
|
|
85
86
|
process.exit(0);
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// The ONE table of every flag the CLI accepts (keep HELP/COMMAND_HELP in sync).
|
|
90
|
+
// VALUE_FLAGS take an argument (space or GNU `--flag=value` form); BOOL_FLAGS
|
|
91
|
+
// don't. Anything else is an invocation error — a typo'd flag must fail loudly,
|
|
92
|
+
// never silently change behavior (`--fail-on=none` used to parse as an unknown
|
|
93
|
+
// boolean and silently run the CI gate at the default threshold).
|
|
94
|
+
const VALUE_FLAGS = new Set([
|
|
95
|
+
"api", "key", "project", "dir", "fail-on", "diff", "json-out", "model",
|
|
96
|
+
"sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
|
|
97
|
+
// Per-integration scope flags (non-secret): consumed by the 4 live sources.
|
|
98
|
+
"vercel-project", "vercel-team", "gh-repo", "supabase-ref",
|
|
99
|
+
]);
|
|
100
|
+
const BOOL_FLAGS = new Set([
|
|
101
|
+
"help", "h", "version", "v", "ci", "yes", "json", "dry-run", "update",
|
|
102
|
+
"offline", "all", "force", "report", "lifetime", "scan", "rescan", "fresh",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function argError(msg) {
|
|
106
|
+
console.error(msg);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
88
110
|
function parseArgs(argv) {
|
|
89
111
|
const flags = {};
|
|
90
112
|
const positional = [];
|
|
91
|
-
const valueFlags = new Set([
|
|
92
|
-
"api", "key", "project", "dir", "fail-on", "diff", "json-out", "model",
|
|
93
|
-
"sources", "region", "namespace", "kube-context", "db", "sql-table", "env",
|
|
94
|
-
// Per-integration scope flags (non-secret): consumed by the 4 live sources.
|
|
95
|
-
"vercel-project", "vercel-team", "gh-repo", "supabase-ref",
|
|
96
|
-
]);
|
|
97
113
|
for (let i = 0; i < argv.length; i++) {
|
|
98
114
|
const a = argv[i];
|
|
99
115
|
if (a.startsWith("--")) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
116
|
+
let name = a.slice(2);
|
|
117
|
+
let inline = null; // GNU `--name=value` form: split on the FIRST '='
|
|
118
|
+
const eq = name.indexOf("=");
|
|
119
|
+
if (eq !== -1) {
|
|
120
|
+
inline = name.slice(eq + 1);
|
|
121
|
+
name = name.slice(0, eq);
|
|
122
|
+
}
|
|
123
|
+
if (VALUE_FLAGS.has(name)) {
|
|
124
|
+
const v = inline !== null ? inline : argv[++i];
|
|
125
|
+
// A missing value — or the NEXT flag where the value should be — must
|
|
126
|
+
// error, not swallow the flag (`mm status --dir --json` used to scan a
|
|
127
|
+
// dir literally named "--json" and lose --json).
|
|
128
|
+
if (v === undefined || v === "" || (inline === null && v.startsWith("--"))) {
|
|
129
|
+
argError(`--${name} requires a value`);
|
|
130
|
+
}
|
|
131
|
+
flags[name] = v;
|
|
132
|
+
} else if (BOOL_FLAGS.has(name)) {
|
|
133
|
+
if (inline !== null) argError(`--${name} does not take a value`);
|
|
134
|
+
flags[name] = true;
|
|
135
|
+
} else {
|
|
136
|
+
argError(`unknown flag: --${name} (run \`mm --help\` for the list)`);
|
|
137
|
+
}
|
|
103
138
|
} else if (a === "-h") flags.help = true;
|
|
104
139
|
else if (a === "-v") flags.version = true;
|
|
105
140
|
else positional.push(a);
|
|
@@ -156,6 +191,34 @@ function scanOpts(flags, dir) {
|
|
|
156
191
|
};
|
|
157
192
|
}
|
|
158
193
|
|
|
194
|
+
/** Loud path guard for scan-shaped commands: a missing path (or a file where a
|
|
195
|
+
* directory is expected) is an INVOCATION error, exit 1 — never a clean
|
|
196
|
+
* "0 references" scan. A typo'd dir in a CI workflow used to gate green forever. */
|
|
197
|
+
function requireDirectory(dir) {
|
|
198
|
+
let st = null;
|
|
199
|
+
try { st = fs.statSync(dir); } catch { /* missing */ }
|
|
200
|
+
if (!st) {
|
|
201
|
+
console.error(`path does not exist: ${dir}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
if (!st.isDirectory()) {
|
|
205
|
+
console.error(`expected a directory: ${dir}`);
|
|
206
|
+
process.exit(1);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Unknown --sources ids are an invocation error (exit 1), never a silent
|
|
211
|
+
* empty scan (`--sources filesysten` used to report a clean result). Prefers
|
|
212
|
+
* sources/index.js's unknownSourceIds; falls back to a getSource filter. */
|
|
213
|
+
function assertKnownSources(ids) {
|
|
214
|
+
const unknownOf = SourcesMod.unknownSourceIds ?? ((xs) => xs.filter((id) => !getSource(id)));
|
|
215
|
+
const unknown = unknownOf(ids);
|
|
216
|
+
if (unknown.length) {
|
|
217
|
+
console.error(`unknown source: ${unknown.join(", ")} (known: ${ALL_SOURCE_IDS.join(", ")})`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
159
222
|
async function cmdLogin(positional, flags) {
|
|
160
223
|
const key = positional[1] || flags.key;
|
|
161
224
|
const { apiBase } = resolveAuth(flags);
|
|
@@ -203,7 +266,8 @@ async function cmdUpgrade(_positional, flags) {
|
|
|
203
266
|
const checkoutPlan = flags.lifetime ? "lifetime" : undefined;
|
|
204
267
|
const plan = await upgradeViaBrowser({ client, plan: checkoutPlan });
|
|
205
268
|
if (!plan) {
|
|
206
|
-
|
|
269
|
+
// Never claim success here — the target plan was not confirmed.
|
|
270
|
+
console.error("Purchase not confirmed yet — check your plan at llmstatus.ai/app, or re-run `mm upgrade` after completing checkout.");
|
|
207
271
|
process.exit(1);
|
|
208
272
|
}
|
|
209
273
|
}
|
|
@@ -274,23 +338,28 @@ async function launchTui(initialView, flags) {
|
|
|
274
338
|
|
|
275
339
|
async function cmdScan(positional, flags) {
|
|
276
340
|
const dir = path.resolve(positional[1] || flags.dir || ".");
|
|
341
|
+
requireDirectory(dir);
|
|
342
|
+
const dryRun = !!flags["dry-run"];
|
|
277
343
|
const { apiBase, apiKey } = resolveAuth(flags);
|
|
278
|
-
|
|
344
|
+
// --dry-run is a fully LOCAL preview — no account, ZERO calls to the API.
|
|
345
|
+
// (The signed registry CDN fetch is its only network, same as `mm status`.)
|
|
346
|
+
if (!apiKey && !dryRun) {
|
|
279
347
|
console.error("No API key. Run `mm login` or pass --key.");
|
|
280
348
|
process.exit(1);
|
|
281
349
|
}
|
|
282
350
|
// --dry-run must NOT launch the interactive TUI (which can upload) — it's a
|
|
283
351
|
// no-upload preview, so force the non-interactive path even on a TTY.
|
|
284
|
-
const interactive = !flags.yes && !flags.json && !
|
|
352
|
+
const interactive = !flags.yes && !flags.json && !dryRun && process.stdout.isTTY;
|
|
285
353
|
if (interactive) {
|
|
286
354
|
const { runApp } = await import("./tui/app.js");
|
|
287
355
|
await runApp({ apiBase, apiKey, dir, initialView: "scan" });
|
|
288
356
|
return;
|
|
289
357
|
}
|
|
290
358
|
|
|
291
|
-
// Non-interactive (CI / --json / --yes): scan + bulk upload
|
|
292
|
-
const client = createClient({ apiBase, apiKey });
|
|
359
|
+
// Non-interactive (CI / --json / --yes): scan (+ bulk upload unless --dry-run).
|
|
360
|
+
const client = dryRun ? null : createClient({ apiBase, apiKey });
|
|
293
361
|
const sources = parseSources(flags);
|
|
362
|
+
assertKnownSources(sources);
|
|
294
363
|
const explicit = explicitSources(flags);
|
|
295
364
|
const opts = scanOpts(flags, dir);
|
|
296
365
|
|
|
@@ -305,7 +374,22 @@ async function cmdScan(positional, flags) {
|
|
|
305
374
|
const active = avail.filter((a) => a.available).map((a) => a.id);
|
|
306
375
|
process.stderr.write(`Scanning [${active.join(", ") || "none"}] …\n`);
|
|
307
376
|
|
|
308
|
-
|
|
377
|
+
// Detection patterns: the upload path asks the API (needs the key anyway); the
|
|
378
|
+
// dry-run path uses the SIGNED PUBLIC SNAPSHOT so the preview never touches
|
|
379
|
+
// the API — it works logged-out and offline (--offline / cached registry).
|
|
380
|
+
let snapshot = null;
|
|
381
|
+
let patterns;
|
|
382
|
+
if (dryRun) {
|
|
383
|
+
const { getRegistry } = await import("./registry/fetch.js");
|
|
384
|
+
snapshot = await getRegistry({
|
|
385
|
+
offline: !!flags.offline || process.env.LLMSTATUS_REGISTRY_OFFLINE === "1",
|
|
386
|
+
cacheFile: process.env.LLMSTATUS_REGISTRY_CACHE || undefined,
|
|
387
|
+
log: (m) => process.stderr.write(`! ${m}\n`),
|
|
388
|
+
});
|
|
389
|
+
patterns = snapshot.detection;
|
|
390
|
+
} else {
|
|
391
|
+
patterns = await client.detectionPatterns();
|
|
392
|
+
}
|
|
309
393
|
const candidates = await collectFrom(sources, opts, patterns, explicit);
|
|
310
394
|
if (candidates.length === 0) {
|
|
311
395
|
// Respect --json/--ci even on the empty path, so `mm scan --ci | jq` never
|
|
@@ -313,7 +397,7 @@ async function cmdScan(positional, flags) {
|
|
|
313
397
|
if (flags.json) {
|
|
314
398
|
const srcRows = avail.map((a) => ({ id: a.id, available: a.available }));
|
|
315
399
|
console.log(JSON.stringify(
|
|
316
|
-
|
|
400
|
+
dryRun
|
|
317
401
|
? { scanned: 0, would_upload: [], sources: avail }
|
|
318
402
|
: { scanned: 0, uploaded: 0, sources: srcRows, created: 0, updated: 0, failed: 0 },
|
|
319
403
|
null, 2,
|
|
@@ -324,31 +408,45 @@ async function cmdScan(positional, flags) {
|
|
|
324
408
|
return;
|
|
325
409
|
}
|
|
326
410
|
|
|
411
|
+
// Resolution: dry-run resolves against the local snapshot (no POST of the
|
|
412
|
+
// detected model inventory); the real upload path resolves server-side.
|
|
327
413
|
const uniq = [...new Set(candidates.map((c) => c.model_string))];
|
|
328
|
-
|
|
329
|
-
|
|
414
|
+
let byStr;
|
|
415
|
+
if (dryRun) {
|
|
416
|
+
const { resolveLocal } = await import("./registry/local.js");
|
|
417
|
+
byStr = new Map(resolveLocal(snapshot, uniq).map((r) => [r.input.toLowerCase(), r]));
|
|
418
|
+
} else {
|
|
419
|
+
const resolved = uniq.length ? (await client.resolve(uniq)).data : [];
|
|
420
|
+
byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
421
|
+
}
|
|
330
422
|
const seenRows = new Set();
|
|
331
423
|
const rows = candidates
|
|
332
424
|
.map((c) => {
|
|
333
425
|
const r = byStr.get(c.model_string.toLowerCase());
|
|
334
|
-
|
|
426
|
+
const hit = r?.model_id ?? r?.model_slug ?? null;
|
|
427
|
+
return { ...c, model_id: r?.model_id ?? null, model_slug: r?.model_slug ?? null, display: hit ? r.display : c.model_string };
|
|
335
428
|
})
|
|
336
429
|
.filter((r) => {
|
|
337
|
-
const k = `${r.model_id ?? "custom:" + r.model_string}|${r.location_label}`;
|
|
430
|
+
const k = `${r.model_id ?? r.model_slug ?? "custom:" + r.model_string}|${r.location_label}`;
|
|
338
431
|
if (seenRows.has(k)) return false;
|
|
339
432
|
seenRows.add(k);
|
|
340
433
|
return true;
|
|
341
434
|
});
|
|
342
435
|
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
// redaction
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
436
|
+
// --dry-run: show exactly what WOULD upload (secret-source safety check),
|
|
437
|
+
// built entirely locally — model_slug instead of the server's model_id, the
|
|
438
|
+
// same redaction as the real payload, and nothing sent anywhere.
|
|
439
|
+
if (dryRun) {
|
|
440
|
+
const ghRepo = (process.env.GITHUB_REPOSITORY || "").trim();
|
|
441
|
+
const usages = rows.map((r) => ({
|
|
442
|
+
model_slug: r.model_slug ?? undefined,
|
|
443
|
+
custom_model_name: r.model_slug ? undefined : redactValue(r.model_string).slice(0, 120),
|
|
444
|
+
environment: r.environment,
|
|
445
|
+
location_label: r.location_label,
|
|
446
|
+
source_repo: r.source_repo || ghRepo || undefined,
|
|
447
|
+
source_path: r.source_path,
|
|
448
|
+
source_line: r.source_line ?? undefined,
|
|
449
|
+
}));
|
|
352
450
|
if (flags.json) {
|
|
353
451
|
console.log(JSON.stringify({ scanned: rows.length, would_upload: usages, sources: avail }, null, 2));
|
|
354
452
|
} else {
|
|
@@ -356,10 +454,20 @@ async function cmdScan(positional, flags) {
|
|
|
356
454
|
console.log(`Dry run — ${rows.length} usage(s) found across [${types}]. Nothing uploaded:`);
|
|
357
455
|
for (const r of rows.slice(0, 60)) console.log(` ${(r.display || r.model_string).padEnd(28)} ${r.location_label} (${r.environment})`);
|
|
358
456
|
if (rows.length > 60) console.log(` …and ${rows.length - 60} more`);
|
|
457
|
+
console.log(apiKey
|
|
458
|
+
? "\n(dry run — nothing uploaded. Re-run without --dry-run to upload.)"
|
|
459
|
+
: "\n(dry run — nothing uploaded. Uploading needs an account: run `mm login` first.)");
|
|
359
460
|
}
|
|
360
461
|
return;
|
|
361
462
|
}
|
|
362
463
|
|
|
464
|
+
// Build the upload payload via the SHARED helper so the command, the TUI Scan
|
|
465
|
+
// tab, and the Here push produce byte-identical usages (model resolution +
|
|
466
|
+
// redaction + the location_label scheme prefix that carries provenance). Parity-
|
|
467
|
+
// only: buildUsages reads the same fields the inline map did and falls back to
|
|
468
|
+
// GITHUB_REPOSITORY for source_repo. No new data is uploaded.
|
|
469
|
+
const usages = await buildUsages(client, rows);
|
|
470
|
+
|
|
363
471
|
// --project routes everything to one project; otherwise assignProjects derives a
|
|
364
472
|
// real per-file project (git-root README › repo › path chunk). Shared with the
|
|
365
473
|
// TUI so the command + tabs assign identically.
|
|
@@ -471,18 +579,28 @@ async function cmdCi(positional, flags) {
|
|
|
471
579
|
// workspaces) the --diff path math mismatches and ALL findings are dropped →
|
|
472
580
|
// a silent GREEN check while retired models in changed files slip through.
|
|
473
581
|
let dir = path.resolve(positional[1] || flags.dir || ".");
|
|
474
|
-
try { dir = fs.realpathSync(dir); } catch { /* keep resolved path
|
|
582
|
+
try { dir = fs.realpathSync(dir); } catch { /* keep resolved path; requireDirectory errors next */ }
|
|
583
|
+
// A CI gate must NEVER read a bad invocation as a clean pass: a missing/renamed
|
|
584
|
+
// dir or a typo'd source id is exit 1, not a green "no models found".
|
|
585
|
+
requireDirectory(dir);
|
|
586
|
+
const sources = parseSources(flags);
|
|
587
|
+
assertKnownSources(sources);
|
|
588
|
+
const explicit = explicitSources(flags);
|
|
475
589
|
const VALID_FAIL_ON = new Set(["none", "deprecating", "retiring", "retired"]);
|
|
476
590
|
const failOn = String(flags["fail-on"] || "retired").toLowerCase();
|
|
477
591
|
if (flags["fail-on"] !== undefined && !VALID_FAIL_ON.has(failOn)) {
|
|
478
592
|
console.error(`Invalid --fail-on "${flags["fail-on"]}". One of: ${[...VALID_FAIL_ON].join(", ")}.`);
|
|
479
593
|
process.exit(1);
|
|
480
594
|
}
|
|
595
|
+
// Surface skipped-but-requested sources on stderr (annotations stay on stdout).
|
|
596
|
+
for (const a of await availability(sources, scanOpts(flags, dir), explicit)) {
|
|
597
|
+
if (!a.available) process.stderr.write(`! ${a.id} unavailable (tool, creds, or flags missing) — skipped\n`);
|
|
598
|
+
}
|
|
481
599
|
const { evaluateCi, annotationLines, summaryMarkdown, filterToChangedFiles, getChangedFiles, HEALTH_RANK } = await import("./ci.js");
|
|
482
600
|
const res = await evaluateCi({
|
|
483
601
|
dir,
|
|
484
|
-
sources
|
|
485
|
-
explicit
|
|
602
|
+
sources,
|
|
603
|
+
explicit,
|
|
486
604
|
scanOpts: scanOpts(flags, dir),
|
|
487
605
|
failOn,
|
|
488
606
|
offline: !!flags.offline,
|
|
@@ -501,7 +619,7 @@ async function cmdCi(positional, flags) {
|
|
|
501
619
|
if (changed) {
|
|
502
620
|
findings = filterToChangedFiles(findings, changed);
|
|
503
621
|
failing = findings.filter((f) => HEALTH_RANK[f.health] >= threshold);
|
|
504
|
-
counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0 };
|
|
622
|
+
counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, withdrawn: 0 };
|
|
505
623
|
for (const f of findings) counts[f.health]++;
|
|
506
624
|
process.stderr.write(`! --diff ${diffBase}: ${findings.length} finding(s) in ${changed.size} changed file(s).\n`);
|
|
507
625
|
}
|
|
@@ -530,7 +648,7 @@ async function cmdCi(positional, flags) {
|
|
|
530
648
|
const ICON = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔" };
|
|
531
649
|
console.log(`LLM Status CI — scanned ${dir} (fail-on: ${failOn})`);
|
|
532
650
|
if (!findings.length) {
|
|
533
|
-
console.log("✓ No deprecated, retiring, or
|
|
651
|
+
console.log("✓ No deprecated, retiring, retired, or withdrawn AI models found.");
|
|
534
652
|
} else {
|
|
535
653
|
for (const f of findings) {
|
|
536
654
|
console.log(` ${ICON[f.health]} ${f.health.padEnd(11)} ${f.slug.padEnd(30)} ${String(f.location || "").padEnd(28)}${f.retires ? ` retires ${f.retires}` : ""}${f.replacement ? ` → ${f.replacement}` : ""}`);
|
|
@@ -586,6 +704,7 @@ async function cmdClear(_positional, flags) {
|
|
|
586
704
|
* rewritable (vercel:// etc. are skipped). */
|
|
587
705
|
async function cmdFix(positional, flags) {
|
|
588
706
|
const dir = path.resolve(positional[1] || flags.dir || ".");
|
|
707
|
+
requireDirectory(dir);
|
|
589
708
|
const { getRegistry } = await import("./registry/fetch.js");
|
|
590
709
|
const { resolveLocal, computeHealth } = await import("./registry/local.js");
|
|
591
710
|
const { planFixes, applyFixes, terminalReplacement, recordFixes } = await import("./fix.js");
|
|
@@ -655,9 +774,79 @@ async function cmdFix(positional, flags) {
|
|
|
655
774
|
console.log(`\n✓ rewrote ${res.applied.length} reference(s) in ${new Set(res.applied.map((p) => p.file)).size} file(s).`);
|
|
656
775
|
for (const s of res.stale) console.log(` ! skipped ${s.file}:${s.line} — ${s.error}`);
|
|
657
776
|
for (const f of res.failed) console.log(` × failed ${f.file}:${f.line} — ${f.error}`);
|
|
777
|
+
if (res.stale.some((s) => /pinned variant/.test(s.error || ""))) {
|
|
778
|
+
console.log("\n→ pinned ids need a human (or an agent): `mm prompt` prints a fix-it prompt for your AI coding agent.");
|
|
779
|
+
}
|
|
658
780
|
if (res.applied.length) console.log("\nRe-run your tests, then `mm status` to confirm everything reads current.");
|
|
659
781
|
}
|
|
660
782
|
|
|
783
|
+
/** `mm prompt [dir]` — print a copy-paste prompt for an AI coding agent to fix
|
|
784
|
+
* dying model refs. Where `mm fix` mechanically rewrites what it can prove safe,
|
|
785
|
+
* the prompt hands EVERYTHING to an agent — including version-pinned ids, models
|
|
786
|
+
* with no registry replacement yet, and the config/parameter follow-ups a string
|
|
787
|
+
* swap can't do. stdout is the prompt and nothing else (pipe it: `mm prompt |
|
|
788
|
+
* pbcopy`); progress + the summary line go to stderr. */
|
|
789
|
+
async function cmdPrompt(positional, flags) {
|
|
790
|
+
const dir = path.resolve(positional[1] || flags.dir || ".");
|
|
791
|
+
requireDirectory(dir);
|
|
792
|
+
const { getRegistry } = await import("./registry/fetch.js");
|
|
793
|
+
const { resolveLocal, computeHealth, dropResolvedFragments } = await import("./registry/local.js");
|
|
794
|
+
const { terminalReplacement } = await import("./fix.js");
|
|
795
|
+
const { buildFixPrompt } = await import("./fix-prompt.js");
|
|
796
|
+
const prog = startProgress(true, "fetching the model registry…");
|
|
797
|
+
const snapshot = await getRegistry({
|
|
798
|
+
offline: !!flags.offline || process.env.LLMSTATUS_REGISTRY_OFFLINE === "1",
|
|
799
|
+
cacheFile: process.env.LLMSTATUS_REGISTRY_CACHE || undefined,
|
|
800
|
+
log: (m) => prog.log(m),
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
prog.update("scanning for model references…");
|
|
804
|
+
const onProgress = ({ filesScanned, candidates: c }) => prog.update(`scanning… ${filesScanned} files, ${c} reference(s)`);
|
|
805
|
+
let candidates = await collectFrom(["filesystem"], { root: dir }, snapshot.detection, new Set(), onProgress);
|
|
806
|
+
prog.stop();
|
|
807
|
+
const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
|
|
808
|
+
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
809
|
+
candidates = dropResolvedFragments(candidates, (c) => !!byStr.get(c.model_string.toLowerCase())?.model_slug);
|
|
810
|
+
const today = new Date();
|
|
811
|
+
|
|
812
|
+
// Group EVERY dying model's refs — unlike cmdFix, keep models with no
|
|
813
|
+
// replacement (the agent picks a successor) and pinned refs (it re-pins).
|
|
814
|
+
const byModel = new Map(); // slug -> { model, health, refs }
|
|
815
|
+
for (const c of candidates) {
|
|
816
|
+
const r = byStr.get(c.model_string.toLowerCase());
|
|
817
|
+
if (!r?.model_slug || !r.model) continue;
|
|
818
|
+
const health = computeHealth(r.model, 90, today);
|
|
819
|
+
if (health === "ok") continue;
|
|
820
|
+
if (flags.model && r.model_slug !== flags.model) continue;
|
|
821
|
+
const e = byModel.get(r.model_slug) || { model: r.model, health, refs: [] };
|
|
822
|
+
e.refs.push(c);
|
|
823
|
+
byModel.set(r.model_slug, e);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const bySlug = new Map(snapshot.models.map((m) => [m.slug, m]));
|
|
827
|
+
const isCurrent = (m) => computeHealth(m, 90, today) === "ok";
|
|
828
|
+
const findings = [...byModel.values()].map(({ model, health, refs }) => ({
|
|
829
|
+
slug: model.slug,
|
|
830
|
+
display: model.display,
|
|
831
|
+
health,
|
|
832
|
+
retires_date: model.retires_date,
|
|
833
|
+
replacement: model.replacement_slug
|
|
834
|
+
? terminalReplacement(model.replacement_slug, (slug) => bySlug.get(slug) ?? null, isCurrent)
|
|
835
|
+
: null,
|
|
836
|
+
refs: refs.map((c) => ({ file: c.source_path || c.location_label, line: c.source_line, matched: c.model_string })),
|
|
837
|
+
}));
|
|
838
|
+
|
|
839
|
+
const text = buildFixPrompt(findings, { date: today });
|
|
840
|
+
if (!text) {
|
|
841
|
+
process.stderr.write("Nothing to fix — every recognized model here is current. No prompt to generate.\n");
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
console.log(text);
|
|
845
|
+
const nRefs = findings.reduce((n, f) => n + f.refs.length, 0);
|
|
846
|
+
const copyTool = process.platform === "darwin" ? "pbcopy" : process.platform === "win32" ? "clip" : "xclip -selection clipboard";
|
|
847
|
+
process.stderr.write(`\n(${findings.length} model(s), ${nRefs} reference(s). Copy it straight to your clipboard: mm prompt | ${copyTool})\n`);
|
|
848
|
+
}
|
|
849
|
+
|
|
661
850
|
/** List detection sources and whether each can run right now. Live integrations
|
|
662
851
|
* also show their on/off toggle (the `int` column) so toggled state is visible
|
|
663
852
|
* here too. */
|
|
@@ -737,6 +926,15 @@ function cmdIntegrations(positional, flags) {
|
|
|
737
926
|
* here, resolve + score health entirely on-device. The free tier's core value. */
|
|
738
927
|
async function cmdStatus(positional, flags) {
|
|
739
928
|
const dir = path.resolve(positional[1] || flags.dir || ".");
|
|
929
|
+
// Loud invocation errors BEFORE scanning: a missing path, a file argument, or
|
|
930
|
+
// a typo'd --sources id must never read as a clean "0 references" result.
|
|
931
|
+
requireDirectory(dir);
|
|
932
|
+
const sources = parseSources(flags);
|
|
933
|
+
assertKnownSources(sources);
|
|
934
|
+
const explicit = explicitSources(flags);
|
|
935
|
+
for (const a of await availability(sources, scanOpts(flags, dir), explicit)) {
|
|
936
|
+
if (!a.available) process.stderr.write(`! ${a.id} unavailable (tool, creds, or flags missing) — skipped\n`);
|
|
937
|
+
}
|
|
740
938
|
const { getRegistry } = await import("./registry/fetch.js");
|
|
741
939
|
const { resolveLocal, computeHealth, dropResolvedFragments } = await import("./registry/local.js");
|
|
742
940
|
// A cold `mm status` downloads the ~225 kB registry snapshot then walks the
|
|
@@ -750,7 +948,7 @@ async function cmdStatus(positional, flags) {
|
|
|
750
948
|
|
|
751
949
|
prog.update("scanning for model references…");
|
|
752
950
|
const onProgress = ({ filesScanned, candidates: c }) => prog.update(`scanning… ${filesScanned} files, ${c} reference(s)`);
|
|
753
|
-
let candidates = await collectFrom(
|
|
951
|
+
let candidates = await collectFrom(sources, scanOpts(flags, dir), snapshot.detection, explicit, onProgress);
|
|
754
952
|
prog.stop();
|
|
755
953
|
const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
|
|
756
954
|
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
@@ -845,6 +1043,7 @@ async function cmdStatus(positional, flags) {
|
|
|
845
1043
|
const fixable = attention.filter((r) => r.model.replacement_slug).length;
|
|
846
1044
|
const tips = [];
|
|
847
1045
|
if (fixable) tips.push(`\`mm fix\` rewrites the ${fixable} with a known replacement`);
|
|
1046
|
+
tips.push("`mm prompt` prints a fix-it prompt for your AI coding agent (Claude Code, Cursor, …)");
|
|
848
1047
|
if (!loadConfig().apiKey) tips.push("`mm login` to get alerted before these dates (Pro)");
|
|
849
1048
|
if (tips.length) console.log("\n" + tips.map((t) => "→ " + t).join("\n"));
|
|
850
1049
|
}
|
|
@@ -861,6 +1060,7 @@ Usage:
|
|
|
861
1060
|
mm config View or change settings (analytics, …)
|
|
862
1061
|
mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
|
|
863
1062
|
mm fix [dir] Rewrite dying model ids to their replacement, in place (--dry-run previews; --model <slug> limits; --yes skips the confirm)
|
|
1063
|
+
mm prompt [dir] Print a fix-it prompt for your AI coding agent — paste it into Claude Code, Cursor, … and let it do the rewrites (mm prompt | pbcopy)
|
|
864
1064
|
mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
|
|
865
1065
|
(--fail-on <none|deprecating|retiring|retired> sets the threshold, default retired;
|
|
866
1066
|
--json-out <file> writes findings JSON; --diff <base> limits to changed files, auto on PRs)
|
|
@@ -914,6 +1114,19 @@ const COMMAND_HELP = {
|
|
|
914
1114
|
--yes skip the confirmation prompt
|
|
915
1115
|
--model <slug> only fix this one model (full provider/slug)
|
|
916
1116
|
--offline use the cached registry only`,
|
|
1117
|
+
prompt: `mm prompt [dir] Print a copy-paste prompt for an AI coding agent to fix dying model refs.
|
|
1118
|
+
|
|
1119
|
+
Scans like \`mm status\`, then prints one self-contained prompt to stdout: every
|
|
1120
|
+
retired/retiring model reference (file:line + the exact string in code), the
|
|
1121
|
+
chain-resolved replacement, and the rewrite rules. Paste it into Claude Code,
|
|
1122
|
+
Cursor, or any coding agent. Unlike \`mm fix\` (a mechanical in-place rewrite),
|
|
1123
|
+
an agent can also re-pin dated ids, follow config indirection, and adjust
|
|
1124
|
+
parameters the new model needs. stdout is ONLY the prompt — pipe it:
|
|
1125
|
+
mm prompt | pbcopy
|
|
1126
|
+
|
|
1127
|
+
--model <slug> only include this one model (full provider/slug)
|
|
1128
|
+
--offline use the cached registry only
|
|
1129
|
+
--dir <path> directory to scan (alternative to the positional arg)`,
|
|
917
1130
|
ci: `mm ci [dir] CI gate: fail the build on deprecated/retiring models.
|
|
918
1131
|
|
|
919
1132
|
Exits non-zero when a finding is at/above --fail-on. Emits GitHub annotations +
|
|
@@ -930,7 +1143,7 @@ const COMMAND_HELP = {
|
|
|
930
1143
|
On a TTY with no flags it opens the interactive Scan tab. --ci/--json/--yes run
|
|
931
1144
|
non-interactively.
|
|
932
1145
|
|
|
933
|
-
--dry-run show exactly what WOULD upload, upload nothing
|
|
1146
|
+
--dry-run show exactly what WOULD upload, upload nothing (fully local — no login needed)
|
|
934
1147
|
--json / --ci machine output (--ci implies --yes + --json)
|
|
935
1148
|
--yes upload without the interactive TUI
|
|
936
1149
|
--project <id|name> route everything to one project (created if the name is new)
|
|
@@ -1028,8 +1241,14 @@ async function main() {
|
|
|
1028
1241
|
|
|
1029
1242
|
// Anonymous, opt-out usage analytics (one-time disclosure, then a single
|
|
1030
1243
|
// event per invocation). No-op without a baked key / when opted out.
|
|
1244
|
+
// telemetryCommand sends the vetted command WORD only — `mm ~/some/repo`
|
|
1245
|
+
// reports "dir", a typo reports "unknown"; a path never leaves the machine.
|
|
1031
1246
|
maybeFirstRun(); // one-time cli_first_run per install (same opt-out as below)
|
|
1032
|
-
track("cli_command", {
|
|
1247
|
+
track("cli_command", {
|
|
1248
|
+
command: telemetryCommand(cmd, (p) => {
|
|
1249
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
1250
|
+
}),
|
|
1251
|
+
});
|
|
1033
1252
|
|
|
1034
1253
|
// Explicit self-update: `mm update` (command) or `--update` (flag on any
|
|
1035
1254
|
// command). Apply the update in place and RE-EXEC the fresh binary so this one
|
|
@@ -1083,6 +1302,7 @@ async function main() {
|
|
|
1083
1302
|
}
|
|
1084
1303
|
else if (cmd === "scan") await cmdScan(positional, flags);
|
|
1085
1304
|
else if (cmd === "fix") await cmdFix(positional, flags);
|
|
1305
|
+
else if (cmd === "prompt") await cmdPrompt(positional, flags);
|
|
1086
1306
|
else if (cmd === "ci") await cmdCi(positional, flags);
|
|
1087
1307
|
else if (cmd === "status") await cmdStatus(positional, flags);
|
|
1088
1308
|
else if (cmd === "sources") await cmdSources(positional, flags);
|
package/src/integrations.js
CHANGED
|
@@ -51,13 +51,28 @@ export const INTEGRATION_META = {
|
|
|
51
51
|
|
|
52
52
|
const ENV_TAGS = new Set(["prod", "staging", "dev", "unknown"]);
|
|
53
53
|
|
|
54
|
-
/** Parsed integrations.json, or {} (missing
|
|
54
|
+
/** Parsed integrations.json, or {} (missing file → {}, like loadConfig). A file
|
|
55
|
+
* that parses to a NON-OBJECT (e.g. the literal `null`, an array, a number) or
|
|
56
|
+
* doesn't parse at all also yields {} — every downstream reader does
|
|
57
|
+
* `cur[id]?.enabled`, so returning null once crashed EVERY command with an
|
|
58
|
+
* opaque TypeError. Invalid content warns ONCE per process, naming the file. */
|
|
59
|
+
let warnedInvalidIntegrations = false;
|
|
55
60
|
export function readIntegrations() {
|
|
61
|
+
const file = integrationsFilePath();
|
|
62
|
+
let raw;
|
|
56
63
|
try {
|
|
57
|
-
|
|
64
|
+
raw = fs.readFileSync(file, "utf8");
|
|
58
65
|
} catch {
|
|
59
|
-
return {};
|
|
66
|
+
return {}; // missing file is the normal first-run state — stay quiet
|
|
67
|
+
}
|
|
68
|
+
let parsed = null;
|
|
69
|
+
try { parsed = JSON.parse(raw); } catch { /* falls through to the warning */ }
|
|
70
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
71
|
+
if (!warnedInvalidIntegrations) {
|
|
72
|
+
warnedInvalidIntegrations = true;
|
|
73
|
+
process.stderr.write(`! ignoring invalid integrations file (${file}) — expected a JSON object. Fix or delete it; using defaults.\n`);
|
|
60
74
|
}
|
|
75
|
+
return {};
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/** Write the whole map. Owner-only (0600), mirroring config.js's saveConfig
|
package/src/registry/fetch.js
CHANGED
|
@@ -12,9 +12,24 @@ const DEFAULT_BASE = process.env.LLMSTATUS_REGISTRY_URL || "https://cdn.llmstatu
|
|
|
12
12
|
const DEFAULT_CACHE = path.join(os.homedir(), ".config", "llmstatus", "registry-cache.json");
|
|
13
13
|
const STALE_DAYS = 30;
|
|
14
14
|
|
|
15
|
+
// Cap each snapshot fetch. Without it, a CDN edge that accepts the connection
|
|
16
|
+
// but never responds holds every registry-touching command (status/fix/ci/TUI)
|
|
17
|
+
// for undici's ~5-minute default before the cache fallback can kick in — a
|
|
18
|
+
// timeout throws into getRegistry's existing catch, which falls back to the
|
|
19
|
+
// cached copy with one stderr note. Overridable for tests / hostile networks.
|
|
20
|
+
const fetchTimeoutMs = () => Number(process.env.MM_REGISTRY_TIMEOUT_MS) || 10_000;
|
|
21
|
+
const isTimeout = (e) => e?.name === "TimeoutError" || e?.name === "AbortError" || e?.code === "ABORT_ERR" || e?.cause?.name === "TimeoutError";
|
|
22
|
+
|
|
15
23
|
async function readUrl(u) {
|
|
16
24
|
if (u.startsWith("file://")) return fs.readFileSync(fileURLToPath(u));
|
|
17
|
-
const
|
|
25
|
+
const ms = fetchTimeoutMs();
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await fetch(u, { redirect: "follow", signal: AbortSignal.timeout(ms) });
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (isTimeout(e)) throw new Error(`GET ${u} timed out after ${Math.max(1, Math.round(ms / 1000))}s`);
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
18
33
|
if (!res.ok) throw new Error(`GET ${u} -> ${res.status}`);
|
|
19
34
|
return Buffer.from(await res.arrayBuffer());
|
|
20
35
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasCmd, run } from "./shell.js";
|
|
1
|
+
import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
|
|
2
2
|
import { scanConfigEntries, entriesFromKV } from "./configscan.js";
|
|
3
3
|
|
|
4
4
|
/* Pure parsers (unit-tested) — keep all JSON shape knowledge here, like aws.js.
|
|
@@ -63,14 +63,20 @@ export const awsLambdaSource = {
|
|
|
63
63
|
async collect(opts, compiled) {
|
|
64
64
|
const region = opts?.region ? ["--region", opts.region] : [];
|
|
65
65
|
const tag = opts?.region || "default";
|
|
66
|
+
// Expired SSO / dead creds must be LOUD, never a silent "no functions".
|
|
67
|
+
const fail = makeCliFailureReporter("aws-lambda", "aws", opts);
|
|
66
68
|
const out = [];
|
|
67
69
|
|
|
68
70
|
// (a) Lambda functions → per-function env vars (Bedrock model ids show up here).
|
|
69
71
|
const list = await run("aws", ["lambda", "list-functions", "--output", "json", ...region]);
|
|
72
|
+
if (!list.ok) fail(list);
|
|
70
73
|
if (list.ok) {
|
|
71
74
|
for (const fn of parseFunctionList(list.stdout)) {
|
|
72
75
|
const cfg = await run("aws", ["lambda", "get-function-configuration", "--function-name", fn, "--output", "json", ...region]);
|
|
73
|
-
if (!cfg.ok)
|
|
76
|
+
if (!cfg.ok) {
|
|
77
|
+
fail(cfg); // per-item failures collapse to one line per failure mode
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
74
80
|
for (const [k, v] of Object.entries(parseFunctionEnv(cfg.stdout))) {
|
|
75
81
|
const entries = entriesFromKV(k, v, `aws-lambda://${tag}/${fn}#${k}`, fn);
|
|
76
82
|
out.push(...scanConfigEntries(entries, compiled, { sourceType: "aws-lambda", env: opts?.env }));
|
|
@@ -83,6 +89,7 @@ export const awsLambdaSource = {
|
|
|
83
89
|
// on; opts.awsBedrock === false skips the extra call.
|
|
84
90
|
if (opts?.awsBedrock !== false) {
|
|
85
91
|
const bm = await run("aws", ["bedrock", "list-foundation-models", "--output", "json", ...region]);
|
|
92
|
+
if (!bm.ok) fail(bm);
|
|
86
93
|
if (bm.ok) {
|
|
87
94
|
for (const modelId of parseBedrockModels(bm.stdout)) {
|
|
88
95
|
const entries = entriesFromKV("bedrock-model", modelId, `aws-bedrock://${tag}/foundation-models#${modelId}`, opts?.region);
|
package/src/sources/aws.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasCmd, run } from "./shell.js";
|
|
1
|
+
import { hasCmd, run, makeCliFailureReporter } from "./shell.js";
|
|
2
2
|
import { scanConfigEntries, entriesFromKV } from "./configscan.js";
|
|
3
3
|
|
|
4
4
|
/* Pure parsers (unit-tested) — keep all JSON shape knowledge here. */
|
|
@@ -36,12 +36,17 @@ export const awsSecretsSource = {
|
|
|
36
36
|
async collect(opts, compiled) {
|
|
37
37
|
const region = opts?.region ? ["--region", opts.region] : [];
|
|
38
38
|
const tag = opts?.region || "default";
|
|
39
|
+
// Expired SSO / missing creds / a timeout kill must be LOUD — a failed aws
|
|
40
|
+
// call reading as "no secrets" is a false-clean for a deprecation scanner.
|
|
41
|
+
const fail = makeCliFailureReporter("aws-secrets", "aws", opts);
|
|
39
42
|
const out = [];
|
|
40
43
|
|
|
41
44
|
const list = await run("aws", ["secretsmanager", "list-secrets", "--output", "json", ...region]);
|
|
45
|
+
if (!list.ok) fail(list);
|
|
42
46
|
if (list.ok) {
|
|
43
47
|
for (const name of parseSecretList(list.stdout)) {
|
|
44
48
|
const v = await run("aws", ["secretsmanager", "get-secret-value", "--secret-id", name, "--output", "json", ...region]);
|
|
49
|
+
if (!v.ok) fail(v); // per-item failures collapse to one line per failure mode
|
|
45
50
|
const val = v.ok ? parseSecretValue(v.stdout) : null;
|
|
46
51
|
if (val == null) continue;
|
|
47
52
|
const entries = entriesFromKV(name.split("/").pop(), val, `aws-secrets://${tag}/${name}`, name);
|
|
@@ -52,6 +57,7 @@ export const awsSecretsSource = {
|
|
|
52
57
|
const ssm = await run("aws", [
|
|
53
58
|
"ssm", "get-parameters-by-path", "--path", "/", "--recursive", "--with-decryption", "--output", "json", ...region,
|
|
54
59
|
]);
|
|
60
|
+
if (!ssm.ok) fail(ssm);
|
|
55
61
|
if (ssm.ok) {
|
|
56
62
|
for (const p of parseSsm(ssm.stdout)) {
|
|
57
63
|
const entries = entriesFromKV(p.name.split("/").pop(), p.value, `aws-ssm://${tag}${p.name}`, p.name);
|
|
Binary file
|