@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/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
- const name = a.slice(2);
101
- if (valueFlags.has(name)) flags[name] = argv[++i];
102
- else flags[name] = true;
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
- console.error("Upgrade not detected (timed out). Run `mm upgrade` again if you completed checkout.");
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
- if (!apiKey) {
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 && !flags["dry-run"] && process.stdout.isTTY;
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, no TUI.
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
- const patterns = await client.detectionPatterns();
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
- flags["dry-run"]
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
- const resolved = uniq.length ? (await client.resolve(uniq)).data : [];
329
- const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
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
- return { ...c, model_id: r?.model_id ?? null, display: r?.model_id ? r.display : c.model_string };
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
- // Build the upload payload via the SHARED helper so the command, the TUI Scan
344
- // tab, and the Here push produce byte-identical usages (model resolution +
345
- // redaction + the location_label scheme prefix that carries provenance). Parity-
346
- // only: buildUsages reads the same fields the inline map did and falls back to
347
- // GITHUB_REPOSITORY for source_repo. No new data is uploaded.
348
- const usages = await buildUsages(client, rows);
349
-
350
- // --dry-run: show exactly what WOULD upload (secret-source safety check).
351
- if (flags["dry-run"]) {
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 if it doesn't exist */ }
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: parseSources(flags),
485
- explicit: explicitSources(flags),
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 retired AI models found.");
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(parseSources(flags), scanOpts(flags, dir), snapshot.detection, explicitSources(flags), onProgress);
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", { command: cmd || "tui" });
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);
@@ -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/corrupt file → {}, like loadConfig). */
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
- return JSON.parse(fs.readFileSync(integrationsFilePath(), "utf8"));
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
@@ -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 res = await fetch(u, { redirect: "follow" });
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) continue;
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);
@@ -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