@modelstatus/cli 0.1.43 → 0.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +16 -1
- package/src/tui/app.js +10 -2
- package/src/tui/views/alerts.js +13 -0
- package/src/tui/views/inventory.js +49 -15
- package/src/tui/views/whatsnew.js +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.45",
|
|
4
4
|
"description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"llm",
|
package/src/index.js
CHANGED
|
@@ -98,7 +98,9 @@ function parseArgs(argv) {
|
|
|
98
98
|
const name = a.slice(2);
|
|
99
99
|
if (valueFlags.has(name)) flags[name] = argv[++i];
|
|
100
100
|
else flags[name] = true;
|
|
101
|
-
} else
|
|
101
|
+
} else if (a === "-h") flags.help = true;
|
|
102
|
+
else if (a === "-v") flags.version = true;
|
|
103
|
+
else positional.push(a);
|
|
102
104
|
}
|
|
103
105
|
if (flags.ci) {
|
|
104
106
|
flags.yes = true;
|
|
@@ -556,6 +558,12 @@ function cmdIntegrations(positional, flags) {
|
|
|
556
558
|
const id = positional[2];
|
|
557
559
|
const known = (x) => INTEGRATION_IDS.includes(x);
|
|
558
560
|
|
|
561
|
+
// Missing id → a clear usage line (not `Unknown integration "undefined"`).
|
|
562
|
+
if ((sub === "enable" || sub === "disable" || sub === "env") && !id) {
|
|
563
|
+
console.error(`Usage: mm integrations ${sub} <id>${sub === "env" ? " <prod|staging|dev|unknown>" : ""} (id: ${INTEGRATION_IDS.join(", ")})`);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
|
|
559
567
|
if (sub === "enable" || sub === "disable") {
|
|
560
568
|
if (!known(id)) {
|
|
561
569
|
console.error(`Unknown integration "${id}". One of: ${INTEGRATION_IDS.join(", ")}`);
|
|
@@ -730,6 +738,13 @@ async function main() {
|
|
|
730
738
|
return;
|
|
731
739
|
}
|
|
732
740
|
|
|
741
|
+
// --help / -h / help: print usage + exit. MUST come before the no-arg → TUI
|
|
742
|
+
// fallthrough below (a bare `mm` launches the TUI, but `mm --help` must not).
|
|
743
|
+
if (cmd === "help" || flags.help || flags.h) {
|
|
744
|
+
console.log(HELP);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
733
748
|
// Anonymous, opt-out usage analytics (one-time disclosure, then a single
|
|
734
749
|
// event per invocation). No-op without a baked key / when opted out.
|
|
735
750
|
maybeAnalyticsNotice();
|
package/src/tui/app.js
CHANGED
|
@@ -86,6 +86,11 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
86
86
|
const [prompt, setPrompt] = React.useState(null);
|
|
87
87
|
const [capturing, setCapturing] = React.useState(false); // a view is capturing text (e.g. / search)
|
|
88
88
|
const [status, setStatus] = React.useState(null); // { forKey, counts, context }
|
|
89
|
+
// A view can publish a CONTEXTUAL keybar for its current sub-state (a tab, a
|
|
90
|
+
// drill-in) via ui.setKeys(); it overrides the static meta.keys until the view
|
|
91
|
+
// changes (reset below). Keeps the keybar honest for tabbed views (What's New,
|
|
92
|
+
// Alerts) where the active keys depend on which sub-tab you're on.
|
|
93
|
+
const [dynKeys, setDynKeys] = React.useState(null);
|
|
89
94
|
const me = useAsync(async () => (apiKey ? client.me() : null), [apiKey]);
|
|
90
95
|
|
|
91
96
|
const { outer, termRows } = useTermDims();
|
|
@@ -97,6 +102,9 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
97
102
|
curKeyRef.current = current.key;
|
|
98
103
|
// Anonymous: which tab is being viewed (no content, just the view name).
|
|
99
104
|
React.useEffect(() => { track("tui_view", { view: current.key }); }, [current.key]);
|
|
105
|
+
// Drop any contextual keybar when the top-level view changes (the new view
|
|
106
|
+
// re-publishes its own if it wants one).
|
|
107
|
+
React.useEffect(() => { setDynKeys(null); }, [idx]);
|
|
100
108
|
|
|
101
109
|
const showToast = React.useCallback((msg, color = "#16a34a") => {
|
|
102
110
|
setToast({ msg, color });
|
|
@@ -168,7 +176,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
168
176
|
{ isActive: !!prompt },
|
|
169
177
|
);
|
|
170
178
|
|
|
171
|
-
const ui = { showToast, askPrompt, reportStatus, setCapturing, setHandlesBack, switchTo: (k) => setIdx(VIEWS.findIndex((v) => v.key === k)) };
|
|
179
|
+
const ui = { showToast, askPrompt, reportStatus, setCapturing, setHandlesBack, setKeys: setDynKeys, switchTo: (k) => setIdx(VIEWS.findIndex((v) => v.key === k)) };
|
|
172
180
|
const current2 = current;
|
|
173
181
|
const View = current2.Comp;
|
|
174
182
|
const account = me.data?.account ?? null;
|
|
@@ -195,7 +203,7 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
|
195
203
|
keys = GATE_KEYS;
|
|
196
204
|
} else {
|
|
197
205
|
body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt, fresh });
|
|
198
|
-
keys = (current2.meta && current2.meta.keys) || [];
|
|
206
|
+
keys = dynKeys || (current2.meta && current2.meta.keys) || [];
|
|
199
207
|
}
|
|
200
208
|
|
|
201
209
|
// Status bar segments.
|
package/src/tui/views/alerts.js
CHANGED
|
@@ -23,10 +23,19 @@ const KIND_COLOR = {
|
|
|
23
23
|
webhook: C.FG_DIM,
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
// Per-section keybars — Rules and Channels have different actions, so the keybar
|
|
27
|
+
// follows the active sub-tab (published via ui.setKeys) rather than advertising
|
|
28
|
+
// Rules keys while you're on Channels.
|
|
29
|
+
const ALERTS_TAB_KEYS = [
|
|
30
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "n", label: "new rule" }, { k: "space", label: "enable" }, { k: "c", label: "cadence" }, { k: "d", label: "delete" }, { k: "g", label: "refresh" }],
|
|
31
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "nav" }, { k: "n", label: "add channel" }, { k: "t", label: "test" }, { k: "g", label: "refresh" }],
|
|
32
|
+
];
|
|
33
|
+
|
|
26
34
|
export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
27
35
|
const ROWS = Math.max(3, height - 4); // subtabs + blank + footer overhead
|
|
28
36
|
const [tab, setTab] = React.useState(0); // 0 rules, 1 channels
|
|
29
37
|
const [cursor, setCursor] = React.useState(0);
|
|
38
|
+
React.useEffect(() => { ui?.setKeys?.(ALERTS_TAB_KEYS[tab]); }, [tab, ui]);
|
|
30
39
|
const rules = useAsync(async () => (await client.listRules()).data || [], []);
|
|
31
40
|
const channels = useAsync(async () => (await client.listChannels()).data || [], []);
|
|
32
41
|
|
|
@@ -36,6 +45,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
36
45
|
const cur = list[clampCursor(cursor, list.length)] || null;
|
|
37
46
|
|
|
38
47
|
const loading = tab === 0 ? rules.loading : channels.loading;
|
|
48
|
+
const err = tab === 0 ? rules.error : channels.error;
|
|
39
49
|
const tick = useTick(80, loading);
|
|
40
50
|
|
|
41
51
|
React.useEffect(
|
|
@@ -133,6 +143,9 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
133
143
|
let body;
|
|
134
144
|
if (loading) {
|
|
135
145
|
body = h(StateLine, { kind: "loading", spin: SPINNER[tick % SPINNER.length], text: tab === 0 ? "loading rules…" : "loading channels…" });
|
|
146
|
+
} else if (err) {
|
|
147
|
+
// Don't let a network/API error masquerade as "no rules" (a false empty).
|
|
148
|
+
body = h(StateLine, { kind: "error", text: `couldn't load ${tab === 0 ? "rules" : "channels"} — ${err}. Press g to retry.` });
|
|
136
149
|
} else if (tab === 0) {
|
|
137
150
|
if (!ruleList.length) {
|
|
138
151
|
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 });
|
|
@@ -6,8 +6,8 @@ import React from "react";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { Box, Text, useInput } from "ink";
|
|
8
8
|
import {
|
|
9
|
-
h, C, GLYPH, ListRow, StateLine, EmptyCard, healthColor, healthGlyph,
|
|
10
|
-
relativeTime, envTag, cell, cellE, snippetLines, SPINNER, useTick, useAsync, clampCursor,
|
|
9
|
+
h, C, GLYPH, ListRow, StateLine, EmptyCard, SearchBar, healthColor, healthGlyph,
|
|
10
|
+
relativeTime, envTag, cell, cellE, snippetLines, SPINNER, useTick, useAsync, useSearch, clampCursor,
|
|
11
11
|
} from "../ui.js";
|
|
12
12
|
import { readSnippet, findSourceFile } from "../snippet.js";
|
|
13
13
|
import { sourceOf, SOURCE_META, sourceGlyph } from "../source-meta.js";
|
|
@@ -17,6 +17,7 @@ const ENV_ORDER = ["prod", "staging", "dev", "unknown"];
|
|
|
17
17
|
export const meta = {
|
|
18
18
|
keys: [
|
|
19
19
|
{ k: "↑↓", label: "nav" },
|
|
20
|
+
{ k: "/", label: "search" },
|
|
20
21
|
{ k: "e", label: "env" },
|
|
21
22
|
{ k: "t", label: "tag untagged" },
|
|
22
23
|
{ k: "c", label: "critical" },
|
|
@@ -44,7 +45,27 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
44
45
|
const usages = q.data?.usages || [];
|
|
45
46
|
const projects = q.data?.projects || [];
|
|
46
47
|
const projName = (id) => projects.find((p) => p.id === id)?.name || "—";
|
|
47
|
-
|
|
48
|
+
|
|
49
|
+
// Filter (press / ) — matches the model, the SOURCE (badge + label + scheme),
|
|
50
|
+
// the project + env. So "vercel"/"supabase"/"aws"/"github"/"repo"/"manual"
|
|
51
|
+
// search BY SOURCE, while "gpt" / a project name still work. Reuses the Scan
|
|
52
|
+
// tab's search hook so the / + esc + type flow is identical everywhere.
|
|
53
|
+
const search = useSearch();
|
|
54
|
+
const query = search.query.trim().toLowerCase();
|
|
55
|
+
const haystack = (u) => {
|
|
56
|
+
const st = sourceOf(u.location_label, u.discovered_by);
|
|
57
|
+
const m = SOURCE_META[st] || {};
|
|
58
|
+
return [u.model_display, u.custom_model_name, u.canonical_id, m.badge, m.label, st, projName(u.project_id), u.environment]
|
|
59
|
+
.map((x) => String(x || "").toLowerCase())
|
|
60
|
+
.join(" ");
|
|
61
|
+
};
|
|
62
|
+
const filtered = query ? usages.filter((u) => haystack(u).includes(query)) : usages;
|
|
63
|
+
const cur = filtered[clampCursor(cursor, filtered.length)] || null;
|
|
64
|
+
|
|
65
|
+
// Keep the cursor in range as the filter narrows; let the shell route backspace
|
|
66
|
+
// to "clear filter" (not "previous tab") while a query is active.
|
|
67
|
+
React.useEffect(() => { setCursor(0); }, [query]);
|
|
68
|
+
React.useEffect(() => { ui?.setHandlesBack?.(!!search.query); }, [search.query, ui]);
|
|
48
69
|
|
|
49
70
|
// Drill-down code preview for the highlighted usage's source location — same
|
|
50
71
|
// syntax-highlighted snippet as the Here tab, shown below the list (the side
|
|
@@ -67,8 +88,8 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
67
88
|
React.useEffect(() => {
|
|
68
89
|
const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, custom: 0 };
|
|
69
90
|
for (const u of usages) if (counts[u.health] != null) counts[u.health] += 1;
|
|
70
|
-
ui?.reportStatus?.({ counts, context: `${usages.length} tracked` });
|
|
71
|
-
}, [usages, ui]);
|
|
91
|
+
ui?.reportStatus?.({ counts, context: query ? `${filtered.length} of ${usages.length}` : `${usages.length} tracked` });
|
|
92
|
+
}, [usages, ui, query, filtered.length]);
|
|
72
93
|
|
|
73
94
|
async function patch(u, body, label) {
|
|
74
95
|
try {
|
|
@@ -83,8 +104,18 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
83
104
|
useInput(
|
|
84
105
|
(input, key) => {
|
|
85
106
|
if (!active) return;
|
|
86
|
-
|
|
87
|
-
if (
|
|
107
|
+
// --- filter mode: type to search (matches model + SOURCE + project + env) ---
|
|
108
|
+
if (search.isSearchingNow()) {
|
|
109
|
+
if (key.escape) { search.clear(); setCursor(0); ui?.setCapturing?.(false); return; }
|
|
110
|
+
if (key.return) { search.confirm(); ui?.setCapturing?.(false); return; }
|
|
111
|
+
if (key.backspace || key.delete) return search.backspace();
|
|
112
|
+
if (input && !key.ctrl && !key.meta) return search.type(input);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (input === "/") { search.open(); ui?.setCapturing?.(true); return; }
|
|
116
|
+
if ((key.escape || key.backspace || key.delete) && search.query) { search.clear(); setCursor(0); return; }
|
|
117
|
+
if (key.downArrow || input === "j") return setCursor((c) => clampCursor(c + 1, filtered.length));
|
|
118
|
+
if (key.upArrow || input === "k") return setCursor((c) => clampCursor(c - 1, filtered.length));
|
|
88
119
|
if (input === "g") return q.reload();
|
|
89
120
|
if (input === "r") return ui.switchTo("scan");
|
|
90
121
|
if (input === "n") return ui.switchTo("add");
|
|
@@ -135,7 +166,7 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
135
166
|
.deleteUsage(cur.id)
|
|
136
167
|
.then(() => {
|
|
137
168
|
ui.showToast("deleted");
|
|
138
|
-
setCursor((c) => clampCursor(c,
|
|
169
|
+
setCursor((c) => clampCursor(c, filtered.length - 1));
|
|
139
170
|
q.reload();
|
|
140
171
|
})
|
|
141
172
|
.catch((e) => ui.showToast(e.message, "red"));
|
|
@@ -184,10 +215,11 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
184
215
|
const showNote = !showSnip && !!srcRef && height >= 14; // has a path but no local file
|
|
185
216
|
const reserve = showSnip ? SNIP : showNote ? 2 : 0;
|
|
186
217
|
const topH = height - reserve;
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
const
|
|
218
|
+
const searchRow = search.active ? 1 : 0; // the / search bar costs a row when shown
|
|
219
|
+
const VISIBLE = Math.max(3, topH - 2 - searchRow); // header + "…and N more" overhead
|
|
220
|
+
const c = clampCursor(cursor, filtered.length);
|
|
221
|
+
const start = Math.max(0, Math.min(c - Math.floor(VISIBLE / 2), Math.max(0, filtered.length - VISIBLE)));
|
|
222
|
+
const view = filtered.slice(start, start + VISIBLE);
|
|
191
223
|
|
|
192
224
|
const header = h(
|
|
193
225
|
Text,
|
|
@@ -215,13 +247,15 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
|
|
|
215
247
|
return h(ListRow, { key: u.id, active: isCur, cells, width: leftWidth });
|
|
216
248
|
});
|
|
217
249
|
|
|
250
|
+
const noMatches = query && filtered.length === 0;
|
|
218
251
|
const list = h(
|
|
219
252
|
Box,
|
|
220
253
|
{ flexDirection: "column", width: leftWidth },
|
|
254
|
+
search.active ? h(SearchBar, { searching: search.searching, query: search.query, count: filtered.length }) : null,
|
|
221
255
|
header,
|
|
222
|
-
...rowNodes,
|
|
223
|
-
|
|
224
|
-
? h(Text, { color: C.FG_FAINT }, ` ${start + 1}-${Math.min(start + VISIBLE,
|
|
256
|
+
...(noMatches ? [h(Text, { color: C.FG_DIM }, ` no matches for "${search.query}"`)] : rowNodes),
|
|
257
|
+
filtered.length > VISIBLE
|
|
258
|
+
? h(Text, { color: C.FG_FAINT }, ` ${start + 1}-${Math.min(start + VISIBLE, filtered.length)} of ${filtered.length}`)
|
|
225
259
|
: null,
|
|
226
260
|
);
|
|
227
261
|
|
|
@@ -22,10 +22,20 @@ export const meta = {
|
|
|
22
22
|
],
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
// Per-section keybars — the active actions differ by tab (Registry: mark seen ·
|
|
26
|
+
// Notifications: mark read · Drift: rescan + archive). Published via ui.setKeys()
|
|
27
|
+
// so the keybar never advertises a key that's dead on the current section.
|
|
28
|
+
const TAB_KEYS = [
|
|
29
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "scroll" }, { k: "m", label: "mark all seen" }],
|
|
30
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "scroll" }, { k: "o", label: "mark read" }],
|
|
31
|
+
[{ k: "←→", label: "section" }, { k: "↑↓", label: "scroll" }, { k: "r", label: "rescan" }, { k: "a", label: "archive" }],
|
|
32
|
+
];
|
|
33
|
+
|
|
25
34
|
export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14 }) {
|
|
26
35
|
const [tab, setTab] = React.useState(0);
|
|
27
36
|
const [cursor, setCursor] = React.useState(0);
|
|
28
37
|
const lastSeen = loadConfig().lastEventsSeenAt || null;
|
|
38
|
+
React.useEffect(() => { ui?.setKeys?.(TAB_KEYS[tab]); }, [tab, ui]);
|
|
29
39
|
|
|
30
40
|
const reg = useAsync(async () => {
|
|
31
41
|
const [ev, m, p] = await Promise.all([
|