@modelstatus/cli 0.1.87 → 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 CHANGED
@@ -75,6 +75,20 @@ place. Boundary-safe (`gpt-4` never rewrites inside `gpt-4o`), style-preserving
75
75
  it follows the chain to the first live model. In the TUI, press `f` for a
76
76
  red/green diff preview; nothing is written until you confirm.
77
77
 
78
+ ### Free: or hand the fixes to your AI coding agent
79
+
80
+ ```bash
81
+ mm prompt [dir] # print a fix-it prompt for Claude Code, Cursor, …
82
+ mm prompt [dir] | pbcopy # straight to your clipboard
83
+ ```
84
+
85
+ Prints one self-contained prompt: every retired/retiring reference (file:line +
86
+ the exact string in code), the chain-resolved replacement, and the rewrite
87
+ rules. Paste it into your agent and let it do the work — unlike `mm fix`, an
88
+ agent can also re-pin dated ids (`-20250514`), follow config indirection, and
89
+ adjust parameters the new model needs. In the TUI, press `p` on the Here or
90
+ Inventory tab to copy the same prompt to your clipboard.
91
+
78
92
  ### Sign in for cloud features
79
93
 
80
94
  ```bash
@@ -92,6 +106,7 @@ You get two binaries — `mm` (short) and `llmstatus` (descriptive). Same binary
92
106
  |---|---|
93
107
  | `mm status [dir]` | Free offline model-health check — no account |
94
108
  | `mm fix [dir]` | Rewrite dying model ids to their replacement (`--dry-run` to preview) |
109
+ | `mm prompt [dir]` | Print a fix-it prompt for your AI coding agent (`mm prompt \| pbcopy`) |
95
110
  | `mm [dir]` | Launch the TUI on a folder (defaults to the current one) — runs locally |
96
111
  | `mm update` | Update the binary in place (Homebrew installs: `brew upgrade`) |
97
112
  | `mm login [api_key]` | Browser sign-in with polling (or paste a key) |
@@ -125,6 +140,7 @@ Secret sources shell out to your already-authenticated CLIs, run **read-only**,
125
140
  | Resolve + health locally, on-device | ✓ | ✓ |
126
141
  | Secret-source aware (`env`, `aws-secrets`, `k8s`, `helm`, `sql`) | ✓ | ✓ |
127
142
  | `mm fix` — rewrite dying ids to replacements | ✓ | ✓ |
143
+ | `mm prompt` — fix-it prompt for your AI agent | ✓ | ✓ |
128
144
  | Cloud inventory across projects/teams | — | ✓ |
129
145
  | GitHub App: PR checks + one-click fix PRs | — | ✓ |
130
146
  | Alerts on deprecations/retirements (email/Slack/SMS) | — | ✓ |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.87",
3
+ "version": "0.1.88",
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",
@@ -1,6 +1,17 @@
1
1
  /* GENERATED by scripts/gen-changelog.mjs from apps/web/lib/changelog.json — do not edit.
2
2
  * Release notes baked into the binary (in-TUI + the on-load what's-new card). */
3
3
  export const CHANGELOG = [
4
+ {
5
+ "version": "0.1.88",
6
+ "date": "2026-08-22",
7
+ "title": "Hand the fixes to your AI coding agent",
8
+ "items": [
9
+ "New `mm prompt [dir]`: prints one self-contained prompt describing every retired/retiring model reference in your repo (file:line, the exact string in code, the chain-resolved replacement) plus rewrite rules. Paste it into Claude Code, Cursor, or any coding agent — or pipe it straight to your clipboard: `mm prompt | pbcopy`.",
10
+ "Unlike `mm fix` (a mechanical in-place rewrite), an agent can also re-pin dated ids like `-20250514`, follow config indirection, and adjust parameters the new model needs — the prompt spells out those rules for it.",
11
+ "TUI: press `p` on the Here tab or the Inventory tab to copy the same prompt to your clipboard (with a file fallback when no clipboard tool exists). On the Inventory tab the `/` filter scopes it — search `prod`, press `p`, get a prod-only prompt.",
12
+ "`mm status` and `mm fix` now point at `mm prompt` when there's something an auto-rewrite can't safely touch (like version-pinned ids)."
13
+ ]
14
+ },
4
15
  {
5
16
  "version": "0.1.87",
6
17
  "date": "2026-07-07",
@@ -0,0 +1,42 @@
1
+ /* Deliver generated text from inside the TUI, where stdout belongs to ink:
2
+ * copy to the system clipboard via the platform's native tool, or — when no
3
+ * clipboard tool exists (headless, bare containers) — write a file and hand
4
+ * back its path. Best-effort and synchronous (the payloads are a few KB).
5
+ * MM_NO_CLIPBOARD=1 skips the copy (tests + users who hate surprise copies);
6
+ * MM_PROMPT_DIR redirects the fallback file (tests). */
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ const CANDIDATES = process.platform === "darwin"
13
+ ? [["pbcopy", []]]
14
+ : process.platform === "win32"
15
+ ? [["clip", []]]
16
+ : [["wl-copy", []], ["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]];
17
+
18
+ /** True when the text landed on the system clipboard. */
19
+ export function copyToClipboard(text) {
20
+ if (process.env.MM_NO_CLIPBOARD === "1") return false;
21
+ for (const [cmd, args] of CANDIDATES) {
22
+ try {
23
+ const r = spawnSync(cmd, args, { input: text, stdio: ["pipe", "ignore", "ignore"], timeout: 3000 });
24
+ if (r.status === 0) return true;
25
+ } catch { /* tool missing — try the next */ }
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /** Clipboard first, file fallback. Returns { method: "clipboard" } or
31
+ * { method: "file", path } — never throws (a failed file write returns
32
+ * { method: "none" } so the caller can say so instead of crashing the TUI). */
33
+ export function deliverText(text, { filename = "mm-fix-prompt.md" } = {}) {
34
+ if (copyToClipboard(text)) return { method: "clipboard" };
35
+ try {
36
+ const p = path.join(process.env.MM_PROMPT_DIR || os.tmpdir(), filename);
37
+ fs.writeFileSync(p, text);
38
+ return { method: "file", path: p };
39
+ } catch {
40
+ return { method: "none" };
41
+ }
42
+ }
@@ -0,0 +1,106 @@
1
+ /* Build a copy-paste prompt for an AI coding agent (Claude Code, Cursor, …) to
2
+ * fix dying model references. `mm fix` is the mechanical path — boundary-safe
3
+ * string swaps at known file:lines — but it deliberately skips what a dumb
4
+ * rewrite can't do safely: version-pinned ids, config indirection, parameter
5
+ * changes the new model needs. An agent CAN do those, so the prompt hands it
6
+ * the full picture: every dying reference (file:line + the exact string), the
7
+ * chain-resolved replacement, and the rewrite rules fix.js enforces.
8
+ *
9
+ * Used by `mm prompt` (stdout), the Here tab (`p`) and the Inv tab (`p`).
10
+ * Pure text-building — no I/O, no registry access — so every surface renders
11
+ * the identical prompt and tests stay hermetic.
12
+ */
13
+
14
+ // Same severity order the TUI/status sort by: already-dead first, then aging.
15
+ const TIER = { retired: 0, withdrawn: 0, retiring: 1, deprecating: 2 };
16
+
17
+ const REFS_CAP = 100; // per model — an agent doesn't need ref #101 to get it
18
+
19
+ /** One reference line: "- path:line — currently `gpt-4o`" (repo-prefixed when
20
+ * the ref came from another checkout, so a cross-repo inventory stays honest). */
21
+ function refLine(r) {
22
+ const loc = `${r.repo ? r.repo + " · " : ""}${r.file}${r.line ? ":" + r.line : ""}`;
23
+ return `- ${loc}${r.matched ? ` — currently \`${r.matched}\`` : ""}`;
24
+ }
25
+
26
+ /** Dedupe a model's refs (same file:line:string appears once; at one file:line
27
+ * a matched string that's a substring of a sibling's is dropped — the detector
28
+ * can emit both "gpt-4o" and "openai/gpt-4o" for the same spot). */
29
+ function dedupeRefs(refs) {
30
+ const seen = new Map();
31
+ for (const r of refs || []) {
32
+ if (!r?.file) continue;
33
+ const key = `${r.repo || ""}|${r.file}:${r.line ?? ""}:${(r.matched || "").toLowerCase()}`;
34
+ if (!seen.has(key)) seen.set(key, r);
35
+ }
36
+ const list = [...seen.values()];
37
+ return list.filter(
38
+ (r) => !list.some(
39
+ (q) => q !== r && q.file === r.file && q.line === r.line && (q.repo || "") === (r.repo || "")
40
+ && q.matched && r.matched && q.matched.toLowerCase() !== r.matched.toLowerCase()
41
+ && q.matched.toLowerCase().includes(r.matched.toLowerCase()),
42
+ ),
43
+ );
44
+ }
45
+
46
+ /** Human health phrase for a section header. */
47
+ function healthPhrase(f) {
48
+ if (f.health === "retired") return f.retires_date ? `retired ${f.retires_date}` : "retired";
49
+ if (f.health === "withdrawn") return "withdrawn by the provider";
50
+ return `${f.health}${f.retires_date ? `, retires ${f.retires_date}` : ""}`;
51
+ }
52
+
53
+ /**
54
+ * Render the prompt. `findings` = [{ slug, display, health, retires_date,
55
+ * replacement, refs: [{ file, line, matched, repo? }] }] where `replacement`
56
+ * is the write-form target ("provider/canonical_id", terminalReplacement's
57
+ * output) or null when the registry has no successor yet. Findings with zero
58
+ * usable refs are dropped; returns null when nothing remains.
59
+ */
60
+ export function buildFixPrompt(findings, { date = new Date() } = {}) {
61
+ const rows = (findings || [])
62
+ .map((f) => ({ ...f, refs: dedupeRefs(f.refs) }))
63
+ .filter((f) => f.refs.length)
64
+ .sort((a, b) => (TIER[a.health] ?? 3) - (TIER[b.health] ?? 3)
65
+ || String(a.retires_date || "9999").localeCompare(String(b.retires_date || "9999"))
66
+ || String(a.slug || a.display).localeCompare(String(b.slug || b.display)));
67
+ if (!rows.length) return null;
68
+
69
+ const day = date.toISOString().slice(0, 10);
70
+ const nRefs = rows.reduce((n, f) => n + f.refs.length, 0);
71
+ const out = [];
72
+ out.push("# Task: update retired / retiring AI model references");
73
+ out.push("");
74
+ out.push(`This codebase references ${rows.length === 1 ? "an AI model" : `${rows.length} AI models`} that ${rows.length === 1 ? "is" : "are"} retired or scheduled for retirement (checked against the LLM Status registry on ${day}). Update every reference listed below — ${nRefs} in total.`);
75
+ out.push("");
76
+ out.push("## Models to replace");
77
+
78
+ for (const f of rows) {
79
+ const name = f.display && f.display !== f.slug ? `${f.display} (\`${f.slug}\`)` : `\`${f.slug || f.display}\``;
80
+ out.push("");
81
+ out.push(`### ${name} — ${healthPhrase(f)}`);
82
+ if (f.replacement) {
83
+ const i = f.replacement.indexOf("/");
84
+ const bare = i >= 0 ? f.replacement.slice(i + 1) : f.replacement;
85
+ out.push(bare !== f.replacement
86
+ ? `Replace with: \`${bare}\` (provider-prefixed form: \`${f.replacement}\`).`
87
+ : `Replace with: \`${f.replacement}\`.`);
88
+ } else {
89
+ out.push("No registry replacement is listed yet — pick the provider's closest current successor, apply it consistently, and note the choice in your summary.");
90
+ }
91
+ out.push("References:");
92
+ for (const r of f.refs.slice(0, REFS_CAP)) out.push(refLine(r));
93
+ if (f.refs.length > REFS_CAP) out.push(`- …and ${f.refs.length - REFS_CAP} more (rewrite every occurrence of the old id, not just the lines listed)`);
94
+ }
95
+
96
+ out.push("");
97
+ out.push("## Rules");
98
+ out.push("");
99
+ out.push("1. Preserve each call site's existing style: keep a bare id bare (`claude-sonnet-4-5`) and a provider-prefixed id prefixed (`anthropic/claude-sonnet-4-5`); don't change quoting or surrounding formatting.");
100
+ out.push("2. Version-pinned ids (dated like `-20250514`, `@`-pins, Bedrock `-v1:0`, fine-tune `:suffix`): switch to the replacement model's own current id — pinned only if the platform requires a pin. Never graft the old model's version pin onto the new id.");
101
+ out.push("3. If a model id comes from a config file, env var, constant, or database row, update it at the source — not just the call sites listed above.");
102
+ out.push("4. Review adjacent parameters that may need adjusting for the new model (max output tokens, temperature constraints, flags the old model needed).");
103
+ out.push("5. When you're done, run the project's tests, then run `mm status` (the LLM Status CLI, https://llmstatus.ai) to confirm no retired or retiring references remain.");
104
+ out.push("");
105
+ return out.join("\n");
106
+ }
package/src/index.js CHANGED
@@ -774,9 +774,79 @@ async function cmdFix(positional, flags) {
774
774
  console.log(`\n✓ rewrote ${res.applied.length} reference(s) in ${new Set(res.applied.map((p) => p.file)).size} file(s).`);
775
775
  for (const s of res.stale) console.log(` ! skipped ${s.file}:${s.line} — ${s.error}`);
776
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
+ }
777
780
  if (res.applied.length) console.log("\nRe-run your tests, then `mm status` to confirm everything reads current.");
778
781
  }
779
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
+
780
850
  /** List detection sources and whether each can run right now. Live integrations
781
851
  * also show their on/off toggle (the `int` column) so toggled state is visible
782
852
  * here too. */
@@ -973,6 +1043,7 @@ async function cmdStatus(positional, flags) {
973
1043
  const fixable = attention.filter((r) => r.model.replacement_slug).length;
974
1044
  const tips = [];
975
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, …)");
976
1047
  if (!loadConfig().apiKey) tips.push("`mm login` to get alerted before these dates (Pro)");
977
1048
  if (tips.length) console.log("\n" + tips.map((t) => "→ " + t).join("\n"));
978
1049
  }
@@ -989,6 +1060,7 @@ Usage:
989
1060
  mm config View or change settings (analytics, …)
990
1061
  mm scan [dir] Scan for model usage; interactive TUI, or --ci/--json for pipelines
991
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)
992
1064
  mm ci [dir] CI gate: fail the build on deprecated/retiring models (GitHub annotations)
993
1065
  (--fail-on <none|deprecating|retiring|retired> sets the threshold, default retired;
994
1066
  --json-out <file> writes findings JSON; --diff <base> limits to changed files, auto on PRs)
@@ -1042,6 +1114,19 @@ const COMMAND_HELP = {
1042
1114
  --yes skip the confirmation prompt
1043
1115
  --model <slug> only fix this one model (full provider/slug)
1044
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)`,
1045
1130
  ci: `mm ci [dir] CI gate: fail the build on deprecated/retiring models.
1046
1131
 
1047
1132
  Exits non-zero when a finding is at/above --fail-on. Emits GitHub annotations +
@@ -1217,6 +1302,7 @@ async function main() {
1217
1302
  }
1218
1303
  else if (cmd === "scan") await cmdScan(positional, flags);
1219
1304
  else if (cmd === "fix") await cmdFix(positional, flags);
1305
+ else if (cmd === "prompt") await cmdPrompt(positional, flags);
1220
1306
  else if (cmd === "ci") await cmdCi(positional, flags);
1221
1307
  else if (cmd === "status") await cmdStatus(positional, flags);
1222
1308
  else if (cmd === "sources") await cmdSources(positional, flags);
package/src/telemetry.js CHANGED
@@ -75,8 +75,8 @@ export function analyticsState() {
75
75
  * paths") includes the path the user points mm at. Keep in sync with the
76
76
  * dispatch in index.js main(). */
77
77
  const KNOWN_COMMANDS = new Set([
78
- "login", "signup", "logout", "config", "analytics", "scan", "fix", "ci",
79
- "status", "sources", "integrations", "clear", "play", "upgrade", "tui",
78
+ "login", "signup", "logout", "config", "analytics", "scan", "fix", "prompt",
79
+ "ci", "status", "sources", "integrations", "clear", "play", "upgrade", "tui",
80
80
  "update", "version", "help",
81
81
  ]);
82
82
 
@@ -24,6 +24,7 @@ export const meta = {
24
24
  { k: "g", label: "refresh" },
25
25
  { k: "r", label: "rescan" },
26
26
  { k: "n", label: "new" },
27
+ { k: "p", label: "llm prompt" },
27
28
  { k: "e", label: "env" },
28
29
  { k: "t", label: "tag" },
29
30
  { k: "c", label: "critical" },
@@ -100,6 +101,61 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
100
101
  ui?.reportStatus?.({ counts, context: query ? `${filtered.length} of ${usages.length}` : `${usages.length} tracked` });
101
102
  }, [usages, ui, query, filtered.length, q.error, q.loading]);
102
103
 
104
+ /** p: build the LLM fix prompt for every dying usage in the CURRENT view
105
+ * (the / filter scopes it — search "prod", press p, get a prod-only prompt)
106
+ * and put it on the clipboard (file fallback). Inventory rows can point at
107
+ * code on other machines, which is exactly why the prompt exists: paste it
108
+ * into the agent that lives WITH that code. Registry lookup is best-effort
109
+ * (cached snapshot) to turn replacement slugs into real API ids; without a
110
+ * cache the slug is still an unambiguous instruction for an agent. */
111
+ async function makeLlmPrompt() {
112
+ const dying = filtered.filter((u) => u.health && u.health !== "ok" && u.health !== "custom");
113
+ if (!dying.length) return ui.showToast(query ? "no matching usages need fixing" : "all current — nothing to fix", "#16a34a");
114
+ try {
115
+ const [{ terminalReplacement }, { buildFixPrompt }, { deliverText }, { computeHealth }] = await Promise.all([
116
+ import("../../fix.js"), import("../../fix-prompt.js"), import("../../clipboard.js"), import("../../registry/local.js"),
117
+ ]);
118
+ const today = new Date();
119
+ let bySlug = new Map();
120
+ try {
121
+ const { getRegistry } = await import("../../registry/fetch.js");
122
+ const snap = await getRegistry({ offline: true, cacheFile: process.env.LLMSTATUS_REGISTRY_CACHE || undefined });
123
+ bySlug = new Map((snap.models || []).map((m) => [m.slug, m]));
124
+ } catch { /* no cached registry — replacement slugs still name the target */ }
125
+ const isCurrent = (m) => computeHealth(m, 90, today) === "ok";
126
+ const byModel = new Map(); // one section per model, refs merged across projects
127
+ for (const u of dying) {
128
+ const key = u.model_display || u.custom_model_name || u.canonical_id || "?";
129
+ const e = byModel.get(key) || {
130
+ slug: u.canonical_id || key,
131
+ display: u.model_display || key,
132
+ health: u.health,
133
+ retires_date: u.retires_date ? String(u.retires_date).slice(0, 10) : null,
134
+ replacement: u.replacement_slug
135
+ ? terminalReplacement(u.replacement_slug, (slug) => bySlug.get(slug) ?? null, isCurrent)
136
+ : null,
137
+ refs: [],
138
+ };
139
+ e.refs.push({
140
+ file: u.source_path || u.location_label || "(added manually — find its usages)",
141
+ line: u.source_line,
142
+ matched: u.custom_model_name || u.canonical_id,
143
+ repo: u.source_repo || undefined,
144
+ });
145
+ byModel.set(key, e);
146
+ }
147
+ const text = buildFixPrompt([...byModel.values()], { date: today });
148
+ if (!text) return ui.showToast("no locatable references to hand off", "#d97706");
149
+ const nRefs = [...byModel.values()].reduce((n, f) => n + f.refs.length, 0);
150
+ const res = deliverText(text);
151
+ if (res.method === "clipboard") ui.showToast(`${GLYPH.check} LLM fix prompt copied (${byModel.size} model${byModel.size === 1 ? "" : "s"} · ${nRefs} refs) — paste into your AI agent`);
152
+ else if (res.method === "file") ui.showToast(`${GLYPH.check} no clipboard tool — prompt saved to ${res.path}`);
153
+ else ui.showToast("couldn't copy or save the prompt", "#dc2626");
154
+ } catch (e) {
155
+ ui.showToast(e.message, "red");
156
+ }
157
+ }
158
+
103
159
  async function patch(u, body, label) {
104
160
  try {
105
161
  await client.patchUsage(u.id, body);
@@ -128,6 +184,7 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
128
184
  if (input === "g") return q.reload();
129
185
  if (input === "r") return ui.switchTo("scan");
130
186
  if (input === "n") return ui.switchTo("add");
187
+ if (input === "p") return makeLlmPrompt();
131
188
  if (input === "t") {
132
189
  const untagged = usages.filter(isUntagged);
133
190
  if (!untagged.length) return ui.showToast("nothing untagged");
@@ -29,6 +29,7 @@ export const meta = {
29
29
  { k: "↑↓", label: "nav" },
30
30
  { k: "↵", label: "refs" },
31
31
  { k: "f", label: "fix all" },
32
+ { k: "p", label: "llm prompt" },
32
33
  { k: "u", label: "push → Inv" },
33
34
  { k: "g", label: "rescan" },
34
35
  { k: "/", label: "search" },
@@ -134,6 +135,39 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
134
135
  });
135
136
  }
136
137
 
138
+ /** p (while idle): build the LLM fix prompt for EVERY dying model found here
139
+ * and put it on the clipboard (file fallback) — for the refs `mm fix` can't
140
+ * safely touch (pinned ids, config indirection) hand the whole job to the
141
+ * user's AI coding agent instead. Same builder as `mm prompt`. */
142
+ function makeLlmPrompt() {
143
+ const dying = items.filter((it) => it.model && it.health !== "ok" && it.health !== "custom");
144
+ if (!dying.length) return ui?.showToast?.("all current — nothing to fix", "#16a34a");
145
+ Promise.all([import("../../fix.js"), import("../../fix-prompt.js"), import("../../clipboard.js")]).then(
146
+ ([{ terminalReplacement }, { buildFixPrompt }, { deliverText }]) => {
147
+ const bySlug = new Map((scan.snapshot?.models || []).map((m) => [m.slug, m]));
148
+ const today = new Date();
149
+ const isCurrent = (m) => computeHealth(m, 90, today) === "ok";
150
+ const findings = dying.map((it) => ({
151
+ slug: it.model.slug,
152
+ display: it.model.display,
153
+ health: it.health,
154
+ retires_date: it.model.retires_date,
155
+ replacement: it.model.replacement_slug
156
+ ? terminalReplacement(it.model.replacement_slug, (slug) => bySlug.get(slug) ?? null, isCurrent)
157
+ : null,
158
+ refs: distinctRefs(it.refs).map((r) => ({ file: r.source_path || r.location_label, line: r.source_line, matched: r.model_string })),
159
+ }));
160
+ const text = buildFixPrompt(findings, { date: today });
161
+ if (!text) return ui?.showToast?.("no file references to hand off", "#d97706");
162
+ const nRefs = findings.reduce((n, f) => n + f.refs.length, 0);
163
+ const res = deliverText(text);
164
+ if (res.method === "clipboard") ui?.showToast?.(`${GLYPH.check} LLM fix prompt copied (${dying.length} model${dying.length === 1 ? "" : "s"} · ${nRefs} refs) — paste into your AI agent`);
165
+ else if (res.method === "file") ui?.showToast?.(`${GLYPH.check} no clipboard tool — prompt saved to ${res.path}`);
166
+ else ui?.showToast?.("couldn't copy or save the prompt", "#dc2626");
167
+ },
168
+ );
169
+ }
170
+
137
171
  const tick = useTick(80, running || pushing);
138
172
  const spin = SPINNER[tick % SPINNER.length];
139
173
  const search = useSearch();
@@ -281,7 +315,9 @@ export function LocalView({ client, me, dir, ui, width = 78, height = 14, active
281
315
  if (key.upArrow || input === "k") return nav.up();
282
316
  if (input === "e") return excludeRef(drefs[0]); // exclude the highlighted model's location (editable)
283
317
  if (input === "f") return fixRefs(cur?.refs || [], `all ${cur?.count ?? 0} references`);
284
- if (input === "p" && running) return scan.togglePause();
318
+ // p is overloaded by state: pausing only means anything mid-scan, and the
319
+ // prompt is only trustworthy once the scan is done — no key collision.
320
+ if (input === "p") return running ? scan.togglePause() : makeLlmPrompt();
285
321
  if (input === "g") { justReloadedRef.current = true; return scan.reload(); }
286
322
  if (input === "u" && !pushing) return pushToInventory();
287
323
  },