@modelstatus/cli 0.1.1 → 0.1.26
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/api.js +3 -0
- package/src/ci.js +143 -0
- package/src/detect/core.js +48 -4
- package/src/index.js +204 -11
- package/src/openUrl.js +43 -1
- package/src/registry/local.js +23 -4
- package/src/sources/filesystem.js +0 -0
- package/src/telemetry.js +66 -0
- package/src/tui/app.js +173 -91
- package/src/tui/scan-stream.js +234 -0
- package/src/tui/signin.js +142 -0
- package/src/tui/snippet.js +127 -0
- package/src/tui/ui.js +661 -16
- package/src/tui/views/account.js +43 -13
- package/src/tui/views/add.js +33 -11
- package/src/tui/views/alerts.js +91 -39
- package/src/tui/views/inventory.js +149 -47
- package/src/tui/views/local.js +229 -0
- package/src/tui/views/scan.js +231 -72
- package/src/tui/views/whatsnew.js +92 -50
- package/src/updater.js +170 -0
- package/src/version.js +32 -0
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* Anonymous, opt-out usage analytics for the CLI/TUI (PostHog).
|
|
2
|
+
*
|
|
3
|
+
* Privacy: we send event NAMES + counts + version/OS only — never code, model
|
|
4
|
+
* strings, file paths, repo names, or your API key. The distinct id is a random
|
|
5
|
+
* per-machine UUID stored in the config (not tied to your account). Disable with
|
|
6
|
+
* any of MM_NO_ANALYTICS=1, DO_NOT_TRACK=1, or CI=1. The PostHog project token is
|
|
7
|
+
* a public ingest key (the same kind shipped in web bundles), baked at build via
|
|
8
|
+
* --define __POSTHOG_KEY__; with no key, every function here is a silent no-op. */
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import { loadConfig, setConfigValue } from "./config.js";
|
|
11
|
+
import { BUILD_VERSION, UPDATE_CHANNEL } from "./version.js";
|
|
12
|
+
|
|
13
|
+
// eslint-disable-next-line no-undef
|
|
14
|
+
const POSTHOG_KEY = typeof __POSTHOG_KEY__ !== "undefined" ? __POSTHOG_KEY__ : (process.env.MM_POSTHOG_KEY || "");
|
|
15
|
+
const POSTHOG_HOST = (process.env.MM_POSTHOG_HOST || "https://us.i.posthog.com").replace(/\/$/, "");
|
|
16
|
+
|
|
17
|
+
const optedOut = () => !!(process.env.MM_NO_ANALYTICS || process.env.DO_NOT_TRACK || process.env.CI);
|
|
18
|
+
const enabled = () => !!POSTHOG_KEY && !optedOut();
|
|
19
|
+
|
|
20
|
+
let cachedId = null;
|
|
21
|
+
function distinctId() {
|
|
22
|
+
if (cachedId) return cachedId;
|
|
23
|
+
const cfg = loadConfig();
|
|
24
|
+
cachedId = cfg.anonId || "cli_" + crypto.randomUUID();
|
|
25
|
+
if (!cfg.anonId) { try { setConfigValue("anonId", cachedId); } catch { /* best effort */ } }
|
|
26
|
+
return cachedId;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One-time, pre-TUI stderr disclosure. Honors opt-out + only shows once. */
|
|
30
|
+
export function maybeAnalyticsNotice() {
|
|
31
|
+
if (!enabled()) return;
|
|
32
|
+
if (loadConfig().analyticsNoticeShown) return;
|
|
33
|
+
try { setConfigValue("analyticsNoticeShown", true); } catch { /* best effort */ }
|
|
34
|
+
process.stderr.write(
|
|
35
|
+
"ℹ llmstatus sends anonymous usage analytics (event names + counts only — never code, model names, or paths). Opt out: MM_NO_ANALYTICS=1\n",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Fire-and-forget capture. Never throws, never blocks the CLI meaningfully. */
|
|
40
|
+
export function track(event, properties = {}) {
|
|
41
|
+
if (!enabled()) return;
|
|
42
|
+
try {
|
|
43
|
+
const ctrl = new AbortController();
|
|
44
|
+
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
45
|
+
fetch(`${POSTHOG_HOST}/i/v0/e/`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
api_key: POSTHOG_KEY,
|
|
50
|
+
event,
|
|
51
|
+
distinct_id: distinctId(),
|
|
52
|
+
properties: {
|
|
53
|
+
$process_person_profile: false, // anonymous events, no person profiles
|
|
54
|
+
cli_version: BUILD_VERSION,
|
|
55
|
+
channel: UPDATE_CHANNEL,
|
|
56
|
+
os: process.platform,
|
|
57
|
+
arch: process.arch,
|
|
58
|
+
...properties,
|
|
59
|
+
},
|
|
60
|
+
timestamp: new Date().toISOString(),
|
|
61
|
+
}),
|
|
62
|
+
signal: ctrl.signal,
|
|
63
|
+
keepalive: true,
|
|
64
|
+
}).catch(() => {}).finally(() => clearTimeout(timer));
|
|
65
|
+
} catch { /* analytics must never break the CLI */ }
|
|
66
|
+
}
|
package/src/tui/app.js
CHANGED
|
@@ -1,139 +1,221 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { render, Box, Text, useInput, useApp, useStdout } from "ink";
|
|
3
4
|
import { createClient } from "../api.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
5
|
+
import { track } from "../telemetry.js";
|
|
6
|
+
import { BUILD_VERSION } from "../version.js";
|
|
7
|
+
import {
|
|
8
|
+
h, C, GLYPH, useAsync, Window, TrafficLights, TabStrip, StatusBar, legendSegments,
|
|
9
|
+
KeyBar, EmptyCard,
|
|
10
|
+
} from "./ui.js";
|
|
11
|
+
import { LocalView, meta as localMeta } from "./views/local.js";
|
|
12
|
+
import { InventoryView, meta as inventoryMeta } from "./views/inventory.js";
|
|
13
|
+
import { ScanView, meta as scanMeta } from "./views/scan.js";
|
|
14
|
+
import { WhatsNewView, meta as whatsnewMeta } from "./views/whatsnew.js";
|
|
15
|
+
import { AddView, meta as addMeta } from "./views/add.js";
|
|
16
|
+
import { AlertsView, meta as alertsMeta } from "./views/alerts.js";
|
|
17
|
+
import { AccountView, meta as accountMeta } from "./views/account.js";
|
|
18
|
+
import { SignIn } from "./signin.js";
|
|
11
19
|
|
|
20
|
+
// `needsAuth: true` views show a sign-in card when there's no apiKey; Local +
|
|
21
|
+
// What's New are public (signed registry over HTTP, no account needed).
|
|
12
22
|
const VIEWS = [
|
|
13
|
-
{ key: "
|
|
14
|
-
{ key: "
|
|
15
|
-
{ key: "
|
|
16
|
-
{ key: "
|
|
17
|
-
{ key: "
|
|
18
|
-
{ key: "
|
|
23
|
+
{ key: "local", label: "Here", title: "here", Comp: LocalView, meta: localMeta, needsAuth: false },
|
|
24
|
+
{ key: "whatsnew", label: "What's New", title: "new", Comp: WhatsNewView, meta: whatsnewMeta, needsAuth: false },
|
|
25
|
+
{ key: "inventory", label: "Inventory", title: "inventory", Comp: InventoryView, meta: inventoryMeta, needsAuth: true },
|
|
26
|
+
{ key: "scan", label: "Scan", title: "scan", Comp: ScanView, meta: scanMeta, needsAuth: true },
|
|
27
|
+
{ key: "add", label: "Add", title: "add", Comp: AddView, meta: addMeta, needsAuth: true },
|
|
28
|
+
{ key: "alerts", label: "Alerts", title: "alerts", Comp: AlertsView, meta: alertsMeta, needsAuth: true },
|
|
29
|
+
{ key: "account", label: "Account", title: "account", Comp: AccountView, meta: accountMeta, needsAuth: false },
|
|
19
30
|
];
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
),
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
32
|
+
const GATE_KEYS = [{ k: "1-7", label: "switch" }, { k: "7", label: "sign in" }];
|
|
33
|
+
|
|
34
|
+
// Lines of chrome around the body (topRule, lights, tabs, blank, prompt, blank,
|
|
35
|
+
// toast, status, keybar, bottomRule) — the body fills whatever rows remain.
|
|
36
|
+
const CHROME_ROWS = 10;
|
|
37
|
+
|
|
38
|
+
/** Full terminal dims, resize-aware. Fills the screen edge-to-edge.
|
|
39
|
+
* MM_TUI_WIDTH / MM_TUI_HEIGHT force exact dims (reproducible screenshots). */
|
|
40
|
+
function useTermDims() {
|
|
41
|
+
const { stdout } = useStdout();
|
|
42
|
+
const [dims, setDims] = React.useState({ cols: (stdout && stdout.columns) || 80, rows: (stdout && stdout.rows) || 24 });
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
if (!stdout) return undefined;
|
|
45
|
+
const on = () => setDims({ cols: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
46
|
+
stdout.on("resize", on);
|
|
47
|
+
return () => stdout.off("resize", on);
|
|
48
|
+
}, [stdout]);
|
|
49
|
+
const fw = Number(process.env.MM_TUI_WIDTH);
|
|
50
|
+
const fh = Number(process.env.MM_TUI_HEIGHT);
|
|
51
|
+
const cols = Number.isFinite(fw) && fw > 0 ? fw : dims.cols;
|
|
52
|
+
const rows = Number.isFinite(fh) && fh > 0 ? fh : dims.rows;
|
|
53
|
+
// Never exceed the real terminal width (a wider frame soft-wraps every chrome
|
|
54
|
+
// line and shatters the window). Fill the FULL terminal height: when the frame
|
|
55
|
+
// height ≥ the terminal rows, ink redraws with a full clear each frame; render
|
|
56
|
+
// shorter than the screen (the old 80-row cap did this on tall external
|
|
57
|
+
// monitors) and ink falls back to cursor-up diffing, which drifts/scrolls.
|
|
58
|
+
return { outer: Math.min(cols || 80, 220), termRows: Math.max(8, Math.min(rows || 24, 200)) };
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
function
|
|
50
|
-
if (!prompt) return
|
|
61
|
+
function PromptRow({ prompt }) {
|
|
62
|
+
if (!prompt) return h(Text, {}, "");
|
|
51
63
|
return h(
|
|
52
|
-
|
|
53
|
-
{
|
|
54
|
-
h(Text, { color:
|
|
55
|
-
h(Text, {}, prompt.value),
|
|
56
|
-
h(Text, { color:
|
|
64
|
+
Text,
|
|
65
|
+
{},
|
|
66
|
+
h(Text, { color: C.ACCENT, bold: true }, ` ${prompt.label} `),
|
|
67
|
+
h(Text, { color: C.FG }, prompt.value || ""),
|
|
68
|
+
h(Text, { color: C.ACCENT }, "▏"),
|
|
57
69
|
);
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
export function App({ apiBase, apiKey, dir, initialView }) {
|
|
72
|
+
export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
|
|
61
73
|
const { exit } = useApp();
|
|
74
|
+
const { stdout } = useStdout();
|
|
62
75
|
const client = React.useMemo(() => createClient({ apiBase, apiKey }), [apiBase, apiKey]);
|
|
63
|
-
const
|
|
76
|
+
const fallbackKey = apiKey ? "inventory" : "local";
|
|
77
|
+
const startIdx = Math.max(0, VIEWS.findIndex((v) => v.key === (initialView || fallbackKey)));
|
|
64
78
|
const [idx, setIdx] = React.useState(startIdx);
|
|
79
|
+
const [redraw, setRedraw] = React.useState(0); // bumped by ctrl-L to force a clean repaint
|
|
65
80
|
const [toast, setToast] = React.useState(null);
|
|
66
81
|
const [prompt, setPrompt] = React.useState(null);
|
|
67
|
-
const
|
|
82
|
+
const [capturing, setCapturing] = React.useState(false); // a view is capturing text (e.g. / search)
|
|
83
|
+
const [status, setStatus] = React.useState(null); // { forKey, counts, context }
|
|
84
|
+
const me = useAsync(async () => (apiKey ? client.me() : null), [apiKey]);
|
|
85
|
+
|
|
86
|
+
const { outer, termRows } = useTermDims();
|
|
87
|
+
const W = outer - 4; // content width: 2 border rails + 2 paddingX
|
|
88
|
+
const bodyRows = Math.max(3, termRows - CHROME_ROWS);
|
|
68
89
|
|
|
69
|
-
const
|
|
90
|
+
const current = VIEWS[idx];
|
|
91
|
+
const curKeyRef = React.useRef(current.key);
|
|
92
|
+
curKeyRef.current = current.key;
|
|
93
|
+
// Anonymous: which tab is being viewed (no content, just the view name).
|
|
94
|
+
React.useEffect(() => { track("tui_view", { view: current.key }); }, [current.key]);
|
|
95
|
+
|
|
96
|
+
const showToast = React.useCallback((msg, color = "#16a34a") => {
|
|
70
97
|
setToast({ msg, color });
|
|
71
98
|
setTimeout(() => setToast(null), 2500);
|
|
72
99
|
}, []);
|
|
73
|
-
|
|
74
100
|
const askPrompt = React.useCallback(
|
|
75
|
-
(label, { initial = "", onSubmit, onCancel } = {}) =>
|
|
76
|
-
setPrompt({ label, value: initial, onSubmit, onCancel }),
|
|
101
|
+
(label, { initial = "", onSubmit, onCancel } = {}) => setPrompt({ label, value: initial, onSubmit, onCancel }),
|
|
77
102
|
[],
|
|
78
103
|
);
|
|
104
|
+
// Views push their live legend counts + context up here (data-keyed, not tick).
|
|
105
|
+
const reportStatus = React.useCallback((next) => {
|
|
106
|
+
setStatus((prev) => {
|
|
107
|
+
const stamped = { ...next, forKey: curKeyRef.current };
|
|
108
|
+
if (prev && JSON.stringify(prev) === JSON.stringify(stamped)) return prev;
|
|
109
|
+
return stamped;
|
|
110
|
+
});
|
|
111
|
+
}, []);
|
|
112
|
+
// A view sets this true when it handles "back" itself (drilled into a detail,
|
|
113
|
+
// an active filter, …). Backspace then defers to the view; otherwise backspace
|
|
114
|
+
// walks back a tab (mirrors Shift-Tab). Keeps backspace a single coherent
|
|
115
|
+
// "back" without the app + view both firing on the same key.
|
|
116
|
+
const handlesBackRef = React.useRef(false);
|
|
117
|
+
const setHandlesBack = React.useCallback((v) => { handlesBackRef.current = !!v; }, []);
|
|
79
118
|
|
|
80
|
-
// Global keys — paused while a prompt is capturing text.
|
|
81
119
|
useInput(
|
|
82
120
|
(input, key) => {
|
|
83
121
|
if (key.ctrl && input === "c") return exit();
|
|
84
|
-
|
|
122
|
+
// Ctrl-L: hard redraw. Clears screen + scrollback, then bumps `redraw` so
|
|
123
|
+
// the frame string changes (ink skips writing identical output) and ink
|
|
124
|
+
// repaints from a clean top. Fixes a display knocked askew by a resize /
|
|
125
|
+
// monitor change without restarting.
|
|
126
|
+
if (key.ctrl && input === "l") {
|
|
127
|
+
if (stdout) stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
128
|
+
return setRedraw((r) => r + 1);
|
|
129
|
+
}
|
|
130
|
+
const n = Number(input);
|
|
131
|
+
if (n >= 1 && n <= VIEWS.length) return setIdx(n - 1);
|
|
132
|
+
if (key.tab && key.shift) return setIdx((i) => (i - 1 + VIEWS.length) % VIEWS.length);
|
|
85
133
|
if (key.tab) return setIdx((i) => (i + 1) % VIEWS.length);
|
|
134
|
+
// Backspace = go back: let the active view back out of a drill-in/filter
|
|
135
|
+
// first; with nothing to back out of, step to the previous tab.
|
|
136
|
+
if (key.backspace || key.delete) {
|
|
137
|
+
if (handlesBackRef.current) return undefined;
|
|
138
|
+
return setIdx((i) => (i - 1 + VIEWS.length) % VIEWS.length);
|
|
139
|
+
}
|
|
86
140
|
if (input === "q") return exit();
|
|
87
141
|
},
|
|
88
|
-
{ isActive: !prompt },
|
|
142
|
+
{ isActive: !prompt && !capturing },
|
|
89
143
|
);
|
|
90
|
-
|
|
91
|
-
// Prompt capture.
|
|
92
144
|
useInput(
|
|
93
145
|
(input, key) => {
|
|
94
|
-
if (key.escape) {
|
|
95
|
-
|
|
96
|
-
setPrompt(null);
|
|
97
|
-
cb?.();
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
if (key.return) {
|
|
101
|
-
const { onSubmit, value } = prompt;
|
|
102
|
-
setPrompt(null);
|
|
103
|
-
onSubmit?.(value);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
146
|
+
if (key.escape) { const cb = prompt.onCancel; setPrompt(null); cb && cb(); return; }
|
|
147
|
+
if (key.return) { const { onSubmit, value } = prompt; setPrompt(null); onSubmit && onSubmit(value); return; }
|
|
106
148
|
if (key.backspace || key.delete) return setPrompt((p) => ({ ...p, value: p.value.slice(0, -1) }));
|
|
107
|
-
if (input && !key.ctrl && !key.meta) setPrompt((p) => ({ ...p, value: p.value + input }));
|
|
149
|
+
if (input && !key.ctrl && !key.meta) return setPrompt((p) => ({ ...p, value: p.value + input }));
|
|
108
150
|
},
|
|
109
151
|
{ isActive: !!prompt },
|
|
110
152
|
);
|
|
111
153
|
|
|
112
|
-
const ui = { showToast, askPrompt, switchTo: (
|
|
113
|
-
const
|
|
154
|
+
const ui = { showToast, askPrompt, reportStatus, setCapturing, setHandlesBack, switchTo: (k) => setIdx(VIEWS.findIndex((v) => v.key === k)) };
|
|
155
|
+
const current2 = current;
|
|
156
|
+
const View = current2.Comp;
|
|
114
157
|
const account = me.data?.account ?? null;
|
|
158
|
+
const plan = account?.plan ?? (apiKey ? null : undefined);
|
|
159
|
+
const workspace = account?.name || path.basename(path.resolve(dir));
|
|
160
|
+
// Trailing toggle so a ctrl-L redraw changes the frame string (ink won't
|
|
161
|
+
// re-emit byte-identical output); invisible (just rebalances a title dash).
|
|
162
|
+
const title = `llmstatus ${current2.title} — ${workspace}${redraw % 2 ? " " : ""}`;
|
|
163
|
+
|
|
164
|
+
const showSignInOnAccount = current2.key === "account" && !apiKey;
|
|
165
|
+
const showSignInGate = current2.needsAuth && !apiKey;
|
|
166
|
+
|
|
167
|
+
let body, keys;
|
|
168
|
+
if (showSignInOnAccount) {
|
|
169
|
+
body = h(SignIn, { apiBase, onSuccess: onSignedIn || (() => {}) });
|
|
170
|
+
keys = GATE_KEYS;
|
|
171
|
+
} else if (showSignInGate) {
|
|
172
|
+
body = h(EmptyCard, {
|
|
173
|
+
icon: GLYPH.spark,
|
|
174
|
+
title: `${current2.label} needs a sign-in`,
|
|
175
|
+
lines: ["Press 7 for Account to sign in with your browser.", "Tabs 1-2 (Here, What's New) work without an account."],
|
|
176
|
+
width: W,
|
|
177
|
+
});
|
|
178
|
+
keys = GATE_KEYS;
|
|
179
|
+
} else {
|
|
180
|
+
body = h(View, { client, me: account, refreshMe: me.reload, dir, apiBase, ui, width: W, height: bodyRows, active: !prompt, fresh });
|
|
181
|
+
keys = (current2.meta && current2.meta.keys) || [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Status bar segments.
|
|
185
|
+
const live = status && status.forKey === current2.key ? status : null;
|
|
186
|
+
const segsLeft = [
|
|
187
|
+
{ text: "workspace ", color: C.FG_FAINT },
|
|
188
|
+
{ text: workspace, color: C.ACCENT, bold: true },
|
|
189
|
+
];
|
|
190
|
+
const segsRight = [];
|
|
191
|
+
if (live && live.counts) segsRight.push(...legendSegments(live.counts));
|
|
192
|
+
if (live && live.context) {
|
|
193
|
+
if (segsRight.length) segsRight.push({ text: " ", color: C.FG_FAINT });
|
|
194
|
+
segsRight.push({ text: live.context, color: C.FG_DIM });
|
|
195
|
+
}
|
|
115
196
|
|
|
116
197
|
return h(
|
|
117
|
-
|
|
118
|
-
{
|
|
119
|
-
h(
|
|
120
|
-
h(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
),
|
|
125
|
-
h(
|
|
126
|
-
h(
|
|
127
|
-
|
|
128
|
-
{ marginTop: 0 },
|
|
129
|
-
toast
|
|
130
|
-
? h(Text, { color: toast.color }, ` ${toast.msg}`)
|
|
131
|
-
: h(Text, { color: "gray" }, " 1-6/Tab switch · q quit · keys per view shown above"),
|
|
132
|
-
),
|
|
198
|
+
Window,
|
|
199
|
+
{ title, width: outer },
|
|
200
|
+
h(TrafficLights, null),
|
|
201
|
+
h(TabStrip, { idx, tabs: VIEWS, account: apiKey ? null : "(local)", plan, version: BUILD_VERSION }),
|
|
202
|
+
h(Text, {}, ""),
|
|
203
|
+
h(Box, { flexDirection: "column", width: W, minHeight: bodyRows }, body),
|
|
204
|
+
h(PromptRow, { prompt }),
|
|
205
|
+
h(Text, {}, ""),
|
|
206
|
+
toast ? h(Text, { color: toast.color }, ` ${toast.msg}`) : h(Text, {}, ""),
|
|
207
|
+
h(StatusBar, { segsLeft, segsRight, width: W }),
|
|
208
|
+
h(KeyBar, { keys, width: W }),
|
|
133
209
|
);
|
|
134
210
|
}
|
|
135
211
|
|
|
212
|
+
/** Top-level TUI entry — always renders App; auth unlocks tabs in-place. */
|
|
213
|
+
function Bootstrap(props) {
|
|
214
|
+
const [apiKey, setApiKey] = React.useState(props.apiKey);
|
|
215
|
+
return h(App, { ...props, apiKey, onSignedIn: (k) => { track("signed_in"); setApiKey(k); } });
|
|
216
|
+
}
|
|
217
|
+
|
|
136
218
|
export function runApp(opts) {
|
|
137
|
-
const app = render(h(
|
|
219
|
+
const app = render(h(Bootstrap, opts));
|
|
138
220
|
return app.waitUntilExit();
|
|
139
221
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* Streaming filesystem scan, factored out of local.js so both the free Local
|
|
2
|
+
* view and (potentially) other views share one cooperative-scan engine. Fetches
|
|
3
|
+
* the signed registry, then streams the filesystem scan, batching progress into
|
|
4
|
+
* React state on a ~120ms cadence (per-file updates would thrash the renderer).
|
|
5
|
+
* Yields live { phase, counts, rows, custom } as models are discovered.
|
|
6
|
+
*
|
|
7
|
+
* A module-level dir-keyed cache means returning to the Here tab reuses the
|
|
8
|
+
* completed scan instead of restarting it; `reload()` busts the cache + rescans. */
|
|
9
|
+
import React from "react";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { getRegistry } from "../registry/fetch.js";
|
|
14
|
+
import { resolveLocal, computeHealth } from "../registry/local.js";
|
|
15
|
+
import { scanFilesystemStreaming } from "../sources/filesystem.js";
|
|
16
|
+
import { compilePatterns } from "../detect/core.js";
|
|
17
|
+
|
|
18
|
+
export const HEALTH_RANK = { retired: 0, retiring: 1, deprecating: 2, ok: 3 };
|
|
19
|
+
|
|
20
|
+
// dir → { snapshot, candidates, ... } for the last completed scan THIS session.
|
|
21
|
+
const SCAN_CACHE = new Map();
|
|
22
|
+
|
|
23
|
+
// Cross-run persistence: the last scan's candidates per dir, so opening the TUI
|
|
24
|
+
// loads instantly instead of re-walking the tree every launch. Override for tests.
|
|
25
|
+
const scanDiskPath = () => process.env.LLMSTATUS_SCAN_CACHE || path.join(os.homedir(), ".config", "llmstatus", "scans.json");
|
|
26
|
+
const trimCandidate = (c) => ({ model_string: c.model_string, source_path: c.source_path, source_line: c.source_line, location_label: c.location_label, environment: c.environment });
|
|
27
|
+
|
|
28
|
+
/** The persisted scan for `dir`, or null. Version-gated so a schema bump (or a
|
|
29
|
+
* malformed/hand-edited file) invalidates old entries instead of feeding stale
|
|
30
|
+
* shapes into summarize(). */
|
|
31
|
+
export function readDiskScan(dir) {
|
|
32
|
+
try {
|
|
33
|
+
const j = JSON.parse(fs.readFileSync(scanDiskPath(), "utf8"));
|
|
34
|
+
if (!j || j.version !== 1 || !j.scans || typeof j.scans !== "object" || Array.isArray(j.scans)) return null;
|
|
35
|
+
return j.scans[dir] || null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Persist `dir`'s candidates (atomic, pruned to the 20 most-recent dirs).
|
|
42
|
+
* Concurrent mm instances scanning different dirs may drop each other's
|
|
43
|
+
* just-added entry (read-modify-write, no lock) — benign for a best-effort cache:
|
|
44
|
+
* the lost dir simply re-walks (and re-persists) on its next launch. */
|
|
45
|
+
export function writeDiskScan(dir, candidates) {
|
|
46
|
+
try {
|
|
47
|
+
const file = scanDiskPath();
|
|
48
|
+
let j = { version: 1, scans: {} };
|
|
49
|
+
// Reuse only a well-formed v1 file; anything else (wrong version, scans not a
|
|
50
|
+
// plain object) is discarded and overwritten fresh, self-healing corruption
|
|
51
|
+
// instead of throwing on `j.scans[dir] = …` and wedging persistence forever.
|
|
52
|
+
try { const p = JSON.parse(fs.readFileSync(file, "utf8")); if (p && p.version === 1 && p.scans && typeof p.scans === "object" && !Array.isArray(p.scans)) j = p; } catch { /* fresh */ }
|
|
53
|
+
j.scans[dir] = { scannedAt: Date.now(), candidates: candidates.map(trimCandidate) };
|
|
54
|
+
j.scans = Object.fromEntries(Object.entries(j.scans).sort((a, b) => (b[1].scannedAt || 0) - (a[1].scannedAt || 0)).slice(0, 20));
|
|
55
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
56
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
57
|
+
fs.writeFileSync(tmp, JSON.stringify(j));
|
|
58
|
+
fs.renameSync(tmp, file);
|
|
59
|
+
} catch { /* best effort */ }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fetch the signed registry. Honors LLMSTATUS_REGISTRY_OFFLINE=1 +
|
|
63
|
+
* LLMSTATUS_REGISTRY_CACHE=<file> so tests (and offline power-users) can pin a
|
|
64
|
+
* deterministic snapshot without network. */
|
|
65
|
+
export function loadRegistry(log = () => {}) {
|
|
66
|
+
return getRegistry({
|
|
67
|
+
log,
|
|
68
|
+
offline: process.env.LLMSTATUS_REGISTRY_OFFLINE === "1",
|
|
69
|
+
cacheFile: process.env.LLMSTATUS_REGISTRY_CACHE || undefined,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Roll candidates up into health-sorted known rows + a custom map, against the
|
|
74
|
+
* snapshot. Also returns the per-model + per-custom reference lists (for the
|
|
75
|
+
* detail pane) so a highlighted row can show exactly where it's used. */
|
|
76
|
+
export function summarize(snapshot, candidates) {
|
|
77
|
+
const resolved = resolveLocal(snapshot, [...new Set(candidates.map((c) => c.model_string))]);
|
|
78
|
+
const byStr = new Map(resolved.map((r) => [r.input.toLowerCase(), r]));
|
|
79
|
+
const known = new Map(); // slug → { model, count }
|
|
80
|
+
const custom = new Map(); // string → count
|
|
81
|
+
const refsBySlug = new Map(); // slug → [candidate]
|
|
82
|
+
const customRefs = new Map(); // string → [candidate]
|
|
83
|
+
// Dedupe by location per target so the list "(count)" matches the detail
|
|
84
|
+
// panel's "used in N places" (two aliases of one slug on the same line = 1 use).
|
|
85
|
+
const seenLoc = new Set();
|
|
86
|
+
for (const c of candidates) {
|
|
87
|
+
const r = byStr.get(c.model_string.toLowerCase());
|
|
88
|
+
const loc = (c.source_path || c.location_label || "") + (c.source_line ? ":" + c.source_line : "");
|
|
89
|
+
if (r?.model_slug && r.model) {
|
|
90
|
+
if (seenLoc.has(r.model_slug + "|" + loc)) continue;
|
|
91
|
+
seenLoc.add(r.model_slug + "|" + loc);
|
|
92
|
+
const e = known.get(r.model_slug) || { model: r.model, count: 0 };
|
|
93
|
+
e.count++;
|
|
94
|
+
known.set(r.model_slug, e);
|
|
95
|
+
if (!refsBySlug.has(r.model_slug)) refsBySlug.set(r.model_slug, []);
|
|
96
|
+
refsBySlug.get(r.model_slug).push(c);
|
|
97
|
+
} else {
|
|
98
|
+
if (seenLoc.has("custom:" + c.model_string + "|" + loc)) continue;
|
|
99
|
+
seenLoc.add("custom:" + c.model_string + "|" + loc);
|
|
100
|
+
custom.set(c.model_string, (custom.get(c.model_string) || 0) + 1);
|
|
101
|
+
if (!customRefs.has(c.model_string)) customRefs.set(c.model_string, []);
|
|
102
|
+
customRefs.get(c.model_string).push(c);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const today = new Date();
|
|
106
|
+
const rows = [...known.values()]
|
|
107
|
+
.map(({ model, count }) => ({ model, count, health: computeHealth(model, 90, today) }))
|
|
108
|
+
.sort(
|
|
109
|
+
(a, b) =>
|
|
110
|
+
HEALTH_RANK[a.health] - HEALTH_RANK[b.health] ||
|
|
111
|
+
String(a.model.retires_date || "9999-99-99").localeCompare(
|
|
112
|
+
String(b.model.retires_date || "9999-99-99"),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
return { rows, custom, refsBySlug, customRefs };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Health distribution for the status-bar legend. */
|
|
119
|
+
export function countHealth(rows) {
|
|
120
|
+
const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, custom: 0 };
|
|
121
|
+
for (const r of rows) counts[r.health] = (counts[r.health] || 0) + 1;
|
|
122
|
+
return counts;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const baseState = (extra = {}) => ({
|
|
126
|
+
phase: "registry", // registry | scanning | done | error
|
|
127
|
+
error: null,
|
|
128
|
+
snapshot: null,
|
|
129
|
+
candidates: [],
|
|
130
|
+
filesScanned: 0,
|
|
131
|
+
dirsSeen: 0,
|
|
132
|
+
catalogsSkipped: 0,
|
|
133
|
+
currentDir: "",
|
|
134
|
+
rows: [],
|
|
135
|
+
custom: new Map(),
|
|
136
|
+
refsBySlug: new Map(),
|
|
137
|
+
customRefs: new Map(),
|
|
138
|
+
candidateCount: 0,
|
|
139
|
+
scannedAt: null,
|
|
140
|
+
fromCache: false,
|
|
141
|
+
...extra,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const doneFromCache = (dir) => {
|
|
145
|
+
const e = SCAN_CACHE.get(dir);
|
|
146
|
+
return baseState({ phase: "done", snapshot: e.snapshot, candidates: e.candidates, filesScanned: e.filesScanned, dirsSeen: e.dirsSeen, catalogsSkipped: e.catalogsSkipped, scannedAt: e.scannedAt, fromCache: e.fromCache, candidateCount: e.candidates.length, ...summarize(e.snapshot, e.candidates) });
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Streaming scan controller. On open, reuses this session's cache, else loads
|
|
150
|
+
* the last persisted scan from disk (skipping the fs walk) unless `opts.fresh`;
|
|
151
|
+
* `reload()` always re-walks. Aborts on unmount. */
|
|
152
|
+
export function useStreamingScan(dir, opts = {}) {
|
|
153
|
+
const [nonce, setNonce] = React.useState(0);
|
|
154
|
+
const [state, setState] = React.useState(() => (SCAN_CACHE.has(dir) ? doneFromCache(dir) : baseState()));
|
|
155
|
+
const [paused, setPaused] = React.useState(false);
|
|
156
|
+
const pausedRef = React.useRef(false);
|
|
157
|
+
const togglePause = React.useCallback(() => {
|
|
158
|
+
pausedRef.current = !pausedRef.current;
|
|
159
|
+
setPaused(pausedRef.current);
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
React.useEffect(() => {
|
|
163
|
+
// Initial visit (nonce 0) with a same-session cache → the useState
|
|
164
|
+
// initializer already seeded it; nothing to do.
|
|
165
|
+
if (nonce === 0 && SCAN_CACHE.has(dir)) return undefined;
|
|
166
|
+
pausedRef.current = false;
|
|
167
|
+
setPaused(false);
|
|
168
|
+
const ac = new AbortController();
|
|
169
|
+
let cancelled = false;
|
|
170
|
+
const acc = { snapshot: null, candidates: [], filesScanned: 0, dirsSeen: 0, catalogsSkipped: 0, currentDir: "", dirty: false };
|
|
171
|
+
setState(baseState());
|
|
172
|
+
|
|
173
|
+
const flush = () => {
|
|
174
|
+
if (cancelled || !acc.dirty) return;
|
|
175
|
+
acc.dirty = false;
|
|
176
|
+
const sum = acc.snapshot ? summarize(acc.snapshot, acc.candidates) : { rows: [], custom: new Map(), refsBySlug: new Map(), customRefs: new Map() };
|
|
177
|
+
setState((s) => ({ ...s, filesScanned: acc.filesScanned, dirsSeen: acc.dirsSeen, catalogsSkipped: acc.catalogsSkipped, currentDir: acc.currentDir, candidateCount: acc.candidates.length, candidates: acc.candidates, ...sum }));
|
|
178
|
+
};
|
|
179
|
+
let flushTimer = null;
|
|
180
|
+
|
|
181
|
+
(async () => {
|
|
182
|
+
try {
|
|
183
|
+
const snapshot = await loadRegistry();
|
|
184
|
+
if (cancelled) return;
|
|
185
|
+
acc.snapshot = snapshot;
|
|
186
|
+
|
|
187
|
+
// Fast path: load the last persisted scan (skip the fs walk) on a fresh
|
|
188
|
+
// open, unless the caller forced --scan or this is a g-triggered reload.
|
|
189
|
+
const forced = opts.fresh || nonce > 0;
|
|
190
|
+
const disk = forced ? null : readDiskScan(dir);
|
|
191
|
+
// Drop malformed candidates (null / non-string model_string) so a corrupt
|
|
192
|
+
// entry can't throw in summarize() and surface phase:"error" on every
|
|
193
|
+
// launch; if nothing usable survives, fall through to a fresh walk.
|
|
194
|
+
const clean = disk && Array.isArray(disk.candidates)
|
|
195
|
+
? disk.candidates.filter((c) => c && typeof c.model_string === "string")
|
|
196
|
+
: null;
|
|
197
|
+
if (clean && clean.length) {
|
|
198
|
+
try {
|
|
199
|
+
const sum = summarize(snapshot, clean);
|
|
200
|
+
SCAN_CACHE.set(dir, { snapshot, candidates: clean, filesScanned: 0, dirsSeen: 0, catalogsSkipped: 0, scannedAt: disk.scannedAt || null, fromCache: true });
|
|
201
|
+
setState(baseState({ phase: "done", snapshot, candidates: clean, scannedAt: disk.scannedAt || null, fromCache: true, candidateCount: clean.length, ...sum }));
|
|
202
|
+
return;
|
|
203
|
+
} catch { /* corrupt cache → fall through to a fresh walk (which re-persists) */ }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setState((s) => ({ ...s, phase: "scanning", snapshot }));
|
|
207
|
+
flushTimer = setInterval(flush, 120);
|
|
208
|
+
const compiled = compilePatterns(snapshot.detection);
|
|
209
|
+
await scanFilesystemStreaming({ root: dir, signal: ac.signal, isPaused: () => pausedRef.current }, compiled, (ev) => {
|
|
210
|
+
if (ev.type === "dir") { acc.dirsSeen = ev.dirsSeen; acc.currentDir = ev.path; }
|
|
211
|
+
else if (ev.type === "candidate") acc.candidates.push(ev.candidate);
|
|
212
|
+
else if (ev.type === "skip") acc.catalogsSkipped = ev.catalogsSkipped;
|
|
213
|
+
else if (ev.type === "progress") { acc.filesScanned = ev.filesScanned; acc.dirsSeen = ev.dirsSeen; acc.catalogsSkipped = ev.catalogsSkipped; }
|
|
214
|
+
acc.dirty = true;
|
|
215
|
+
});
|
|
216
|
+
if (cancelled) return;
|
|
217
|
+
clearInterval(flushTimer);
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
SCAN_CACHE.set(dir, { snapshot, candidates: acc.candidates, filesScanned: acc.filesScanned, dirsSeen: acc.dirsSeen, catalogsSkipped: acc.catalogsSkipped, scannedAt: now, fromCache: false });
|
|
220
|
+
writeDiskScan(dir, acc.candidates);
|
|
221
|
+
setState((s) => ({ ...s, phase: "done", filesScanned: acc.filesScanned, dirsSeen: acc.dirsSeen, catalogsSkipped: acc.catalogsSkipped, currentDir: "", scannedAt: now, fromCache: false, candidateCount: acc.candidates.length, candidates: acc.candidates, ...summarize(snapshot, acc.candidates) }));
|
|
222
|
+
} catch (e) {
|
|
223
|
+
if (cancelled) return;
|
|
224
|
+
if (flushTimer) clearInterval(flushTimer);
|
|
225
|
+
setState((s) => ({ ...s, phase: "error", error: e?.message || String(e) }));
|
|
226
|
+
}
|
|
227
|
+
})();
|
|
228
|
+
|
|
229
|
+
return () => { cancelled = true; ac.abort(); clearInterval(flushTimer); };
|
|
230
|
+
}, [dir, nonce]);
|
|
231
|
+
|
|
232
|
+
const reload = React.useCallback(() => { SCAN_CACHE.delete(dir); setNonce((n) => n + 1); }, [dir]);
|
|
233
|
+
return { ...state, paused, reload, togglePause };
|
|
234
|
+
}
|