@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/tui/app.js
CHANGED
|
@@ -371,9 +371,20 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
371
371
|
}
|
|
372
372
|
|
|
373
373
|
/** Top-level TUI entry — always renders App; auth unlocks tabs in-place. */
|
|
374
|
-
function Bootstrap(props) {
|
|
374
|
+
export function Bootstrap(props) {
|
|
375
375
|
const [apiKey, setApiKey] = React.useState(props.apiKey);
|
|
376
|
-
return h(App, {
|
|
376
|
+
return h(App, {
|
|
377
|
+
...props,
|
|
378
|
+
apiKey,
|
|
379
|
+
onSignedIn: (k) => {
|
|
380
|
+
track("signed_in");
|
|
381
|
+
// Keep the module-level controller in sync: a game round-trip remounts
|
|
382
|
+
// from _opts (captured at runApp time), and a stale null apiKey there
|
|
383
|
+
// would silently sign the session back out after the game.
|
|
384
|
+
if (appController._opts) appController._opts = { ...appController._opts, apiKey: k };
|
|
385
|
+
setApiKey(k);
|
|
386
|
+
},
|
|
387
|
+
});
|
|
377
388
|
}
|
|
378
389
|
|
|
379
390
|
// Module-level controller so a view (the Scan tab) can UNMOUNT the whole Ink
|
|
@@ -383,6 +394,22 @@ function Bootstrap(props) {
|
|
|
383
394
|
export const appController = {
|
|
384
395
|
_instance: null,
|
|
385
396
|
_opts: null,
|
|
397
|
+
// A game handoff is in flight: the coming unmount is NOT a real quit. Set by
|
|
398
|
+
// launch.js BEFORE unmount(); cleared by remount() (or endHandoff() when the
|
|
399
|
+
// game fails to come back). runApp's exit waiter checks it to decide whether
|
|
400
|
+
// to keep the alt screen + promise alive or treat the unmount as a quit.
|
|
401
|
+
_handoff: false,
|
|
402
|
+
_handoffWaiter: null, // runApp's deferred quit-or-rearm check (see runApp)
|
|
403
|
+
/** Mark the next unmount as a game handoff (a remount will follow). */
|
|
404
|
+
beginHandoff() { this._handoff = true; },
|
|
405
|
+
/** End a handoff — normally via remount(); called directly (without a
|
|
406
|
+
* remount) when the game errored, so runApp treats the unmount as a quit. */
|
|
407
|
+
endHandoff() {
|
|
408
|
+
this._handoff = false;
|
|
409
|
+
const w = this._handoffWaiter;
|
|
410
|
+
this._handoffWaiter = null;
|
|
411
|
+
if (w) w();
|
|
412
|
+
},
|
|
386
413
|
/** Tear down the current Ink tree (releases raw mode + stdin listeners). */
|
|
387
414
|
unmount() {
|
|
388
415
|
try { this._instance && this._instance.unmount(); } catch { /* already gone */ }
|
|
@@ -398,6 +425,10 @@ export const appController = {
|
|
|
398
425
|
// right before this, so by now the terminal reports its stable full height.
|
|
399
426
|
try { process.stdout.write("\x1b[2J\x1b[H"); } catch { /* ignore */ }
|
|
400
427
|
this._instance = render(h(Bootstrap, opts));
|
|
428
|
+
// launch.js re-entered the alt screen right before remounting — record it so
|
|
429
|
+
// the final quit's leaveAlt() actually restores the host screen + scrollback.
|
|
430
|
+
this._inAlt = true;
|
|
431
|
+
this.endHandoff(); // flush runApp's waiter → it re-arms on the new instance
|
|
401
432
|
return this._instance;
|
|
402
433
|
},
|
|
403
434
|
};
|
|
@@ -427,20 +458,31 @@ export function runApp(opts) {
|
|
|
427
458
|
appController._opts = opts;
|
|
428
459
|
const app = render(h(Bootstrap, opts));
|
|
429
460
|
appController._instance = app;
|
|
430
|
-
// waitUntilExit resolves when the CURRENT instance unmounts
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
461
|
+
// waitUntilExit resolves when the CURRENT instance unmounts — including the
|
|
462
|
+
// deliberate unmount at the start of a game handoff (launch.js), which nulls
|
|
463
|
+
// _instance and resolves this promise BEFORE the remount happens. So a game
|
|
464
|
+
// launch must not read as a quit: launch.js sets appController._handoff before
|
|
465
|
+
// unmounting, and while it's set we park the decision in _handoffWaiter.
|
|
466
|
+
// remount()/endHandoff() flush the waiter — a remount re-arms on the new
|
|
467
|
+
// instance; a failed handoff (no remount) falls through to the real-quit path.
|
|
434
468
|
return new Promise((resolve) => {
|
|
435
469
|
const arm = (inst) => {
|
|
436
470
|
inst.waitUntilExit().then(() => {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
471
|
+
const settle = () => {
|
|
472
|
+
if (appController._handoff) {
|
|
473
|
+
// Game round-trip in flight — decide after remount()/endHandoff().
|
|
474
|
+
appController._handoffWaiter = settle;
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// If a remount happened (a new instance is live and differs), keep waiting.
|
|
478
|
+
if (appController._instance && appController._instance !== inst) {
|
|
479
|
+
arm(appController._instance);
|
|
480
|
+
} else {
|
|
481
|
+
leaveAlt(); // real quit → restore the host screen + scrollback
|
|
482
|
+
resolve();
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
settle();
|
|
444
486
|
});
|
|
445
487
|
};
|
|
446
488
|
arm(app);
|
package/src/tui/game/launch.js
CHANGED
|
@@ -26,13 +26,15 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
|
|
|
26
26
|
if (launching) return;
|
|
27
27
|
launching = true;
|
|
28
28
|
track("game_opened", { game: "donkey_kong", scan_phase: scanPhase });
|
|
29
|
+
let appController = null; // hoisted so the catch below can end a dangling handoff
|
|
29
30
|
try {
|
|
30
|
-
const [{ runGame }, { startScanProcess },
|
|
31
|
+
const [{ runGame }, { startScanProcess }, app, { writeDiskScan, loadRegistry }] = await Promise.all([
|
|
31
32
|
import("./loop.js"),
|
|
32
33
|
import("../../sources/scan-process.js"),
|
|
33
34
|
import("../app.js"),
|
|
34
35
|
import("../scan-stream.js"),
|
|
35
36
|
]);
|
|
37
|
+
appController = app.appController;
|
|
36
38
|
|
|
37
39
|
// (1) Start a TRUE background scan SUBPROCESS over `dir`. It survives the Ink
|
|
38
40
|
// unmount (separate OS process). Pre-fetch + cache the registry so the worker
|
|
@@ -56,6 +58,11 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
|
|
|
56
58
|
} catch { handle = null; }
|
|
57
59
|
|
|
58
60
|
// (2) Unmount the Ink tree (releases raw mode + stdin). Let teardown settle.
|
|
61
|
+
// beginHandoff FIRST: unmount() resolves ink's exit promise synchronously,
|
|
62
|
+
// and without the flag runApp's exit waiter would read this as a real quit —
|
|
63
|
+
// dropping the alt screen mid-game and resolving runApp early (main() then
|
|
64
|
+
// races the game for stdout). See appController/runApp in ../app.js.
|
|
65
|
+
appController.beginHandoff();
|
|
59
66
|
appController.unmount();
|
|
60
67
|
await new Promise((r) => setImmediate(r));
|
|
61
68
|
|
|
@@ -90,8 +97,13 @@ export async function playGameInTui({ dir, width, height, initialView = "scan",
|
|
|
90
97
|
// useTermDims — we no longer poll for it).
|
|
91
98
|
try { process.stdout.write("\x1b[?1049l\x1b[?1049h\x1b[2J\x1b[H"); } catch { /* ignore */ }
|
|
92
99
|
await new Promise((r) => setImmediate(r));
|
|
93
|
-
appController.remount({ initialView, fresh: false });
|
|
100
|
+
appController.remount({ initialView, fresh: false }); // remount ends the handoff
|
|
94
101
|
} catch (e) {
|
|
102
|
+
// The handoff died without a remount (e.g. the game threw). End it so
|
|
103
|
+
// runApp's parked waiter treats the earlier unmount as a real quit and
|
|
104
|
+
// restores the host screen instead of waiting forever. No-op when the
|
|
105
|
+
// error happened before beginHandoff (tree still mounted → toast shows).
|
|
106
|
+
if (appController) appController.endHandoff();
|
|
95
107
|
if (onError) onError(e); else throw e;
|
|
96
108
|
} finally {
|
|
97
109
|
launching = false;
|
package/src/tui/signin.js
CHANGED
|
@@ -12,6 +12,19 @@ import { h } from "./ui.js";
|
|
|
12
12
|
|
|
13
13
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
14
14
|
|
|
15
|
+
// One in-flight device-auth session per apiBase, held OUTSIDE the component.
|
|
16
|
+
// SignIn is remounted on every signed-out Account-tab visit (and after a game
|
|
17
|
+
// round-trip); without this, each mount minted a NEW device code and popped
|
|
18
|
+
// ANOTHER browser tab — and an approval made in the browser after the user
|
|
19
|
+
// switched tabs landed on a code nobody was polling. A remount now resumes the
|
|
20
|
+
// pending session (same code, same URL, no extra tab) and picks the approval up.
|
|
21
|
+
// Sessions clear on approval/denial/expiry/start-failure (and g re-mints one).
|
|
22
|
+
// apiBase -> { device_code, user_code, verification_url, interval, deadline, opened }
|
|
23
|
+
const authSessions = new Map();
|
|
24
|
+
|
|
25
|
+
/** Test hook: forget all pending device-auth sessions. */
|
|
26
|
+
export function _resetAuthSessions() { authSessions.clear(); }
|
|
27
|
+
|
|
15
28
|
export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
16
29
|
const { exit } = useApp();
|
|
17
30
|
const [phase, setPhase] = React.useState("starting"); // starting | polling | error
|
|
@@ -35,19 +48,38 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
35
48
|
|
|
36
49
|
const start = async () => {
|
|
37
50
|
try {
|
|
38
|
-
|
|
51
|
+
// Resume the session a previous mount left in flight (tab switch, game
|
|
52
|
+
// round-trip) — same code, same browser tab, and an approval made while
|
|
53
|
+
// we weren't looking gets picked up on the first poll. Mint a fresh one
|
|
54
|
+
// only when none is pending or the old one passed its deadline.
|
|
55
|
+
let s = authSessions.get(apiBase);
|
|
56
|
+
if (!s || Date.now() > s.deadline) {
|
|
57
|
+
const fresh = await client.authStart({ client_name: "mm CLI" });
|
|
58
|
+
s = {
|
|
59
|
+
device_code: fresh.device_code,
|
|
60
|
+
user_code: fresh.user_code,
|
|
61
|
+
verification_url: fresh.verification_url,
|
|
62
|
+
interval: Math.max(1, fresh.interval || 3) * 1000,
|
|
63
|
+
deadline: Date.now() + (fresh.expires_in || 600) * 1000,
|
|
64
|
+
opened: false,
|
|
65
|
+
};
|
|
66
|
+
// Cache even if this mount was cancelled mid-start — the code is
|
|
67
|
+
// already minted, so let the next visit resume it instead of orphaning it.
|
|
68
|
+
authSessions.set(apiBase, s);
|
|
69
|
+
}
|
|
39
70
|
if (cancelled) return;
|
|
40
71
|
setCode(s.user_code);
|
|
41
72
|
setUrl(s.verification_url);
|
|
42
73
|
setPhase("polling");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
74
|
+
if (!s.opened) {
|
|
75
|
+
s.opened = true;
|
|
76
|
+
openUrl(s.verification_url); // best-effort, ONCE per session (o re-opens)
|
|
77
|
+
}
|
|
47
78
|
|
|
48
79
|
const poll = async () => {
|
|
49
80
|
if (cancelled) return;
|
|
50
|
-
if (Date.now() > deadline) {
|
|
81
|
+
if (Date.now() > s.deadline) {
|
|
82
|
+
authSessions.delete(apiBase);
|
|
51
83
|
setError("The login request expired.");
|
|
52
84
|
setPhase("error");
|
|
53
85
|
return;
|
|
@@ -60,6 +92,7 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
60
92
|
}
|
|
61
93
|
if (cancelled) return;
|
|
62
94
|
if (res?.status === "approved") {
|
|
95
|
+
authSessions.delete(apiBase);
|
|
63
96
|
const cfg = loadConfig();
|
|
64
97
|
cfg.apiKey = res.api_key;
|
|
65
98
|
cfg.apiBase = apiBase;
|
|
@@ -68,20 +101,23 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
68
101
|
return;
|
|
69
102
|
}
|
|
70
103
|
if (res?.status === "denied") {
|
|
104
|
+
authSessions.delete(apiBase);
|
|
71
105
|
setError("Authorization was denied.");
|
|
72
106
|
setPhase("error");
|
|
73
107
|
return;
|
|
74
108
|
}
|
|
75
109
|
if (res?.status === "expired") {
|
|
110
|
+
authSessions.delete(apiBase);
|
|
76
111
|
setError("The login request expired. Press q and run mm login again.");
|
|
77
112
|
setPhase("error");
|
|
78
113
|
return;
|
|
79
114
|
}
|
|
80
|
-
pollTimer = setTimeout(poll, interval);
|
|
115
|
+
pollTimer = setTimeout(poll, s.interval);
|
|
81
116
|
};
|
|
82
117
|
poll();
|
|
83
118
|
} catch (e) {
|
|
84
119
|
if (cancelled) return;
|
|
120
|
+
authSessions.delete(apiBase); // a failed start isn't resumable
|
|
85
121
|
setError(e?.message || String(e));
|
|
86
122
|
setPhase("error");
|
|
87
123
|
}
|
package/src/tui/views/account.js
CHANGED
|
@@ -28,6 +28,18 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
28
28
|
|
|
29
29
|
React.useEffect(() => ui?.reportStatus?.({ context: `plan: ${me?.plan ?? "…"}` }), [me, ui]);
|
|
30
30
|
|
|
31
|
+
// The upgrade poll below is a self-rescheduling setTimeout chain — without
|
|
32
|
+
// cleanup it outlives the view (keeps hitting /me every 4s for up to 10min,
|
|
33
|
+
// sets state on an unmounted component, and holds the event loop open AFTER
|
|
34
|
+
// quit, hanging the user's shell). Cancel it the moment the view unmounts.
|
|
35
|
+
const pollTimerRef = React.useRef(null);
|
|
36
|
+
const mountedRef = React.useRef(true);
|
|
37
|
+
React.useEffect(() => () => {
|
|
38
|
+
mountedRef.current = false;
|
|
39
|
+
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
|
40
|
+
pollTimerRef.current = null;
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
31
43
|
async function upgrade() {
|
|
32
44
|
// Without a loaded account a checkout would just fire at an unreachable
|
|
33
45
|
// endpoint and die with a raw fetch error — say what to do instead.
|
|
@@ -40,9 +52,11 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
40
52
|
setStatus("Complete checkout in your browser… (polling /me)");
|
|
41
53
|
const deadline = Date.now() + 10 * 60 * 1000;
|
|
42
54
|
const tick = async () => {
|
|
55
|
+
if (!mountedRef.current) return;
|
|
43
56
|
if (Date.now() > deadline) return setStatus("Timed out — press u to retry.");
|
|
44
57
|
try {
|
|
45
58
|
const m = await client.me();
|
|
59
|
+
if (!mountedRef.current) return;
|
|
46
60
|
if (m?.account?.plan && m.account.plan !== "free") {
|
|
47
61
|
setStatus(null);
|
|
48
62
|
ui.showToast(`upgraded to ${m.account.plan}!`);
|
|
@@ -52,9 +66,12 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
52
66
|
} catch {
|
|
53
67
|
/* keep polling */
|
|
54
68
|
}
|
|
55
|
-
|
|
69
|
+
if (!mountedRef.current) return;
|
|
70
|
+
pollTimerRef.current = setTimeout(tick, 4000);
|
|
56
71
|
};
|
|
57
|
-
|
|
72
|
+
// A second u press replaces (not stacks) any poll chain already running.
|
|
73
|
+
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
|
74
|
+
pollTimerRef.current = setTimeout(tick, 4000);
|
|
58
75
|
} catch (e) {
|
|
59
76
|
setStatus(e.status === 503 ? "Billing isn't configured on this server." : `checkout failed: ${e.message} — press u to retry.`);
|
|
60
77
|
}
|
package/src/tui/views/alerts.js
CHANGED
|
@@ -125,11 +125,20 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
125
125
|
.patchRule(cur.id, { delivery: DELIVERY[(DELIVERY.indexOf(cur.delivery) + 1) % 3] })
|
|
126
126
|
.then(() => rules.reload())
|
|
127
127
|
.catch((e) => ui.showToast(e.message, "red"));
|
|
128
|
-
if (input === "d")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
if (input === "d") {
|
|
129
|
+
// One stray keypress shouldn't silently delete a rule — confirm with a
|
|
130
|
+
// single y, same pattern as InventoryView's d (inventory.js).
|
|
131
|
+
return ui.askPrompt(`Delete ${ruleLabel(cur)}? type y`, {
|
|
132
|
+
onSubmit: (v) => {
|
|
133
|
+
if (String(v || "").trim().toLowerCase() !== "y") return ui.showToast("delete cancelled");
|
|
134
|
+
client.deleteRule(cur.id).then(() => {
|
|
135
|
+
ui.showToast("rule deleted");
|
|
136
|
+
setCursor((c) => clampCursor(c, ruleList.length - 1));
|
|
137
|
+
rules.reload();
|
|
138
|
+
}).catch((e) => ui.showToast(e.message, "red"));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
133
142
|
} else {
|
|
134
143
|
if (input === "n") return addChannel();
|
|
135
144
|
if (input === "t" && cur) return testChannel(cur);
|
|
@@ -150,14 +159,18 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
150
159
|
if (!ruleList.length) {
|
|
151
160
|
body = h(EmptyCard, { title: "No alert rules yet", lines: ["Stay ahead of your model timeline — a heads-up 90, 30, 7, and 1 day before anything you use is deprecated or retired.", "Press n to set the sensible default (your models · in-app + email · those lead times)."], width });
|
|
152
161
|
} else {
|
|
162
|
+
// Window the list around the cursor (same as whatsnew.js) — a fixed
|
|
163
|
+
// slice(0, ROWS) lets the selection walk below the visible page and
|
|
164
|
+
// space/c/d act on a row the user can't see.
|
|
153
165
|
const curIdx = clampCursor(cursor, ruleList.length);
|
|
166
|
+
const start = Math.max(0, Math.min(curIdx - ROWS + 1, ruleList.length - ROWS));
|
|
154
167
|
const fixed = 2 + 26 + 1 + 10 + 1; // glyph + name + gap + delivery + gap
|
|
155
168
|
const rest = Math.max(8, width - 1 - fixed);
|
|
156
169
|
body = h(
|
|
157
170
|
Box,
|
|
158
171
|
{ flexDirection: "column" },
|
|
159
|
-
...ruleList.slice(
|
|
160
|
-
const isCur = i === curIdx;
|
|
172
|
+
...ruleList.slice(start, start + ROWS).map((r, i) => {
|
|
173
|
+
const isCur = start + i === curIdx;
|
|
161
174
|
const enabled = !!r.enabled;
|
|
162
175
|
const leads = r.leadTimes || r.lead_times || [];
|
|
163
176
|
const chanText = [(r.channels || []).join(","), leads.length ? leads.join("/") + "d" : ""].filter(Boolean).join(" · ");
|
|
@@ -182,13 +195,15 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
182
195
|
h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro — press 7 → u to upgrade."),
|
|
183
196
|
);
|
|
184
197
|
} else {
|
|
198
|
+
// Windowed around the cursor like the rules list above.
|
|
185
199
|
const curIdx = clampCursor(cursor, chanList.length);
|
|
200
|
+
const start = Math.max(0, Math.min(curIdx - ROWS + 1, chanList.length - ROWS));
|
|
186
201
|
const rest = Math.max(8, width - 1 - 10); // after the 10-wide kind pill
|
|
187
202
|
body = h(
|
|
188
203
|
Box,
|
|
189
204
|
{ flexDirection: "column" },
|
|
190
|
-
...chanList.slice(
|
|
191
|
-
const isCur = i === curIdx;
|
|
205
|
+
...chanList.slice(start, start + ROWS).map((c, i) => {
|
|
206
|
+
const isCur = start + i === curIdx;
|
|
192
207
|
const cells = [
|
|
193
208
|
{ text: cell(c.kind, 10), color: KIND_COLOR[c.kind] || C.FG_DIM, bold: true },
|
|
194
209
|
{ text: cellE(c.label || c.value, rest), color: C.FG },
|
|
@@ -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");
|
package/src/tui/views/local.js
CHANGED
|
@@ -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
|
-
|
|
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
|
},
|