@modelstatus/cli 0.1.29 → 0.1.30
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 +11 -45
- package/src/tui/views/local.js +37 -3
- package/src/tui/views/scan.js +13 -12
- package/src/upload.js +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
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
|
@@ -5,7 +5,7 @@ import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "
|
|
|
5
5
|
import { createClient } from "./api.js";
|
|
6
6
|
import { collectFrom, availability, ALL_SOURCE_IDS } from "./sources/index.js";
|
|
7
7
|
import { redactValue } from "./redact.js";
|
|
8
|
-
import {
|
|
8
|
+
import { assignProjects } from "./upload.js";
|
|
9
9
|
import { loginViaBrowser } from "./auth.js";
|
|
10
10
|
import { maybeCheckForUpdate } from "./updater.js";
|
|
11
11
|
import { track, maybeAnalyticsNotice } from "./telemetry.js";
|
|
@@ -195,53 +195,19 @@ async function cmdScan(positional, flags) {
|
|
|
195
195
|
return;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// plan's project cap), STOP creating and route everything to one project — the
|
|
203
|
-
// per-file source_repo still preserves which repo each usage came from, so the
|
|
204
|
-
// multi-repo scan degrades gracefully instead of crashing. Pro = real per-repo.
|
|
205
|
-
let capHit = false;
|
|
206
|
-
let defaultProjectId = projects[0]?.id ?? null;
|
|
207
|
-
const ensureProject = async (name) => {
|
|
208
|
-
const key = name.toLowerCase();
|
|
209
|
-
if (byName.has(key)) return byName.get(key);
|
|
210
|
-
if (capHit) return defaultProjectId;
|
|
211
|
-
try {
|
|
212
|
-
const id = (await client.createProject(name)).id;
|
|
213
|
-
byName.set(key, id);
|
|
214
|
-
if (!defaultProjectId) defaultProjectId = id;
|
|
215
|
-
return id;
|
|
216
|
-
} catch {
|
|
217
|
-
capHit = true; // plan cap or any create failure → don't make more projects
|
|
218
|
-
if (!defaultProjectId) {
|
|
219
|
-
try { defaultProjectId = (await client.createProject(path.basename(scanRoot))).id; } catch { defaultProjectId = null; }
|
|
220
|
-
}
|
|
221
|
-
return defaultProjectId;
|
|
222
|
-
}
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
let projectId = null;
|
|
198
|
+
// --project routes everything to one project; otherwise assignProjects derives a
|
|
199
|
+
// real per-file project (git-root README › repo › path chunk). Shared with the
|
|
200
|
+
// TUI so the command + tabs assign identically.
|
|
201
|
+
let forceProjectId = null;
|
|
226
202
|
if (flags.project) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
} else {
|
|
233
|
-
// Derive a real project PER FILE (git-root README › repo name › path chunk) so
|
|
234
|
-
// a multi-repo workspace uploads as its parts, not one generic folder name.
|
|
235
|
-
// The bulk endpoint honors per-item project_id (falling back to the batch one).
|
|
236
|
-
for (let i = 0; i < usages.length; i++) {
|
|
237
|
-
const sp = rows[i].source_path;
|
|
238
|
-
const ctx = sp ? deriveProject(path.resolve(scanRoot, sp), scanRoot) : { project: path.basename(scanRoot), repo: path.basename(scanRoot) };
|
|
239
|
-
const pid = await ensureProject(ctx.project);
|
|
240
|
-
if (pid) usages[i].project_id = pid;
|
|
241
|
-
if (!usages[i].source_repo) usages[i].source_repo = ctx.repo;
|
|
203
|
+
if (uuidish(flags.project)) forceProjectId = flags.project;
|
|
204
|
+
else {
|
|
205
|
+
const projects = (await client.listProjects()).data;
|
|
206
|
+
forceProjectId = projects.find((p) => p.name === flags.project || p.slug === flags.project)?.id
|
|
207
|
+
?? (await client.createProject(flags.project)).id;
|
|
242
208
|
}
|
|
243
|
-
projectId = usages.find((u) => u.project_id)?.project_id ?? defaultProjectId ?? (await ensureProject(path.basename(scanRoot)));
|
|
244
209
|
}
|
|
210
|
+
const projectId = await assignProjects(client, usages, path.resolve(dir), { forceProjectId });
|
|
245
211
|
|
|
246
212
|
const res = await client.bulkUpload(projectId, usages);
|
|
247
213
|
track("usages_uploaded", { count: usages.length, created: res.created, updated: res.updated, source: "cli" });
|
package/src/tui/views/local.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
useTick, useCursorList, useSearch,
|
|
15
15
|
} from "../ui.js";
|
|
16
16
|
import { useStreamingScan, countHealth } from "../scan-stream.js";
|
|
17
|
+
import { buildUsages, assignProjects } from "../../upload.js";
|
|
17
18
|
import { readSnippet } from "../snippet.js";
|
|
18
19
|
import { openLocation } from "../../openUrl.js";
|
|
19
20
|
import { addGlobalIgnore } from "../../sources/filesystem.js";
|
|
@@ -26,6 +27,7 @@ export const meta = {
|
|
|
26
27
|
{ k: "/", label: "search" },
|
|
27
28
|
{ k: "p", label: "pause" },
|
|
28
29
|
{ k: "g", label: "rescan" },
|
|
30
|
+
{ k: "u", label: "push to inventory" },
|
|
29
31
|
],
|
|
30
32
|
};
|
|
31
33
|
|
|
@@ -41,10 +43,40 @@ const agoText = (ms) => {
|
|
|
41
43
|
return `${Math.round(hr / 24)}d ago`;
|
|
42
44
|
};
|
|
43
45
|
|
|
44
|
-
export function LocalView({ dir, ui, width = 78, height = 14, active = true, fresh = false }) {
|
|
46
|
+
export function LocalView({ client, me, dir, ui, width = 78, height = 14, active = true, fresh = false }) {
|
|
45
47
|
const scan = useStreamingScan(dir, { fresh });
|
|
46
48
|
const running = scan.phase === "registry" || scan.phase === "scanning";
|
|
47
|
-
const
|
|
49
|
+
const [pushing, setPushing] = React.useState(false);
|
|
50
|
+
|
|
51
|
+
// u: push this local scan to your cloud Inventory (the Here tab is local-only
|
|
52
|
+
// otherwise). Each usage lands in a project derived from its repo (README › repo
|
|
53
|
+
// › path), so a multi-repo workspace syncs as its real parts. Needs sign-in.
|
|
54
|
+
async function pushToInventory() {
|
|
55
|
+
if (!me) return ui?.showToast?.("sign in (7 Account) to push these to your inventory", "#d97706");
|
|
56
|
+
const cands = scan.candidates || [];
|
|
57
|
+
if (!cands.length) return ui?.showToast?.("nothing to push yet — let the scan finish", "#d97706");
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
const rows = [];
|
|
60
|
+
for (const c of cands) {
|
|
61
|
+
const k = `${c.model_string}|${c.location_label}`;
|
|
62
|
+
if (seen.has(k)) continue;
|
|
63
|
+
seen.add(k);
|
|
64
|
+
rows.push({ model_string: c.model_string, environment: c.environment, location_label: c.location_label, source_path: c.source_path, source_line: c.source_line });
|
|
65
|
+
}
|
|
66
|
+
setPushing(true);
|
|
67
|
+
try {
|
|
68
|
+
const usages = await buildUsages(client, rows);
|
|
69
|
+
const projectId = await assignProjects(client, usages, path.resolve(dir), {});
|
|
70
|
+
const res = await client.bulkUpload(projectId, usages);
|
|
71
|
+
const nproj = new Set(usages.map((u) => u.project_id)).size;
|
|
72
|
+
ui?.showToast?.(`${GLYPH.check} pushed ${usages.length} → ${res.created} new, ${res.updated} updated · ${nproj} project${nproj === 1 ? "" : "s"} (open 3 Inv)`);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
ui?.showToast?.(e?.message || "push failed", "#dc2626");
|
|
75
|
+
} finally {
|
|
76
|
+
setPushing(false);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const tick = useTick(80, running || pushing);
|
|
48
80
|
const spin = SPINNER[tick % SPINNER.length];
|
|
49
81
|
const search = useSearch();
|
|
50
82
|
const [focus, setFocus] = React.useState("list"); // list | refs
|
|
@@ -174,6 +206,7 @@ export function LocalView({ dir, ui, width = 78, height = 14, active = true, fre
|
|
|
174
206
|
if (input === "e") return excludeRef(drefs[0]); // exclude the highlighted model's location (editable)
|
|
175
207
|
if (input === "p" && running) return scan.togglePause();
|
|
176
208
|
if (input === "g") { justReloadedRef.current = true; return scan.reload(); }
|
|
209
|
+
if (input === "u" && !pushing) return pushToInventory();
|
|
177
210
|
},
|
|
178
211
|
{ isActive: active },
|
|
179
212
|
);
|
|
@@ -184,7 +217,8 @@ export function LocalView({ dir, ui, width = 78, height = 14, active = true, fre
|
|
|
184
217
|
const counters = `${fmtNum(filesScanned)} files · ${fmtNum(dirsSeen)} dirs · ${fmtNum(candidateCount)} refs${catalogsSkipped ? ` · ${catalogsSkipped} catalog${catalogsSkipped === 1 ? "" : "s"} skipped` : ""}`;
|
|
185
218
|
const attention = rows.filter((r) => r.health !== "ok").length;
|
|
186
219
|
let strip;
|
|
187
|
-
if (
|
|
220
|
+
if (pushing) strip = h(StateLine, { kind: "loading", spin, text: "pushing to inventory…" });
|
|
221
|
+
else if (phase === "registry") strip = h(StateLine, { kind: "loading", spin, text: "fetching the signed registry…" });
|
|
188
222
|
else if (phase === "scanning" && scan.paused)
|
|
189
223
|
strip = h(Box, {}, h(Text, { color: "#d97706" }, ` ${GLYPH.pause} paused `), h(Text, { color: C.FG_DIM }, `${counters} · p resume`));
|
|
190
224
|
else if (phase === "scanning")
|
package/src/tui/views/scan.js
CHANGED
|
@@ -17,6 +17,7 @@ import { HEALTH_RANK, countHealth, useStreamingScan } from "../scan-stream.js";
|
|
|
17
17
|
import { resolveLocal, computeHealth } from "../../registry/local.js";
|
|
18
18
|
import { addGlobalIgnore } from "../../sources/filesystem.js";
|
|
19
19
|
import { readSnippet } from "../snippet.js";
|
|
20
|
+
import { buildUsages, assignProjects } from "../../upload.js";
|
|
20
21
|
import { track } from "../../telemetry.js";
|
|
21
22
|
import { openUrl, openLocation } from "../../openUrl.js";
|
|
22
23
|
|
|
@@ -53,7 +54,11 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
53
54
|
// Upload-target projects (cheap; independent of the scan).
|
|
54
55
|
const projQ = useAsync(() => client.listProjects().then((r) => r.data || []).catch(() => []), []);
|
|
55
56
|
const projects = projQ.data || [];
|
|
56
|
-
|
|
57
|
+
// "auto · per repo" (id:null) is the DEFAULT upload target — each usage lands in
|
|
58
|
+
// a project derived from its git-root README / repo / path. Cycle with p to force
|
|
59
|
+
// everything into one specific project instead.
|
|
60
|
+
const projOptions = [{ id: null, name: "auto · per repo" }, ...projects];
|
|
61
|
+
const project = projOptions[clampCursor(projectIdx, projOptions.length)];
|
|
57
62
|
|
|
58
63
|
// Selectable per-(model, location) rows derived from the cached scan's
|
|
59
64
|
// candidates — resolved locally for display/health; server model_ids are
|
|
@@ -166,18 +171,14 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
166
171
|
if (!selected.length) return ui.showToast("nothing selected", "#d97706");
|
|
167
172
|
setBusy(true);
|
|
168
173
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const idByStr = new Map(ids.map((r) => [r.input.toLowerCase(), r.model_id]));
|
|
174
|
-
const usages = selected.map((r) => {
|
|
175
|
-
const mid = idByStr.get(r.model_string.toLowerCase());
|
|
176
|
-
return { model_id: mid ?? undefined, custom_model_name: mid ? undefined : r.model_string, environment: r.environment, location_label: r.location_label, source_repo: (process.env.GITHUB_REPOSITORY || "").trim() || undefined, source_path: r.source_path, source_line: r.source_line };
|
|
177
|
-
});
|
|
174
|
+
const usages = await buildUsages(client, selected);
|
|
175
|
+
// id:null = "auto · per repo" → derive a project per file; else force the pick.
|
|
176
|
+
const forceProjectId = project && project.id ? project.id : null;
|
|
177
|
+
const projectId = await assignProjects(client, usages, path.resolve(dir), { forceProjectId });
|
|
178
178
|
const res = await client.bulkUpload(projectId, usages);
|
|
179
179
|
track("usages_uploaded", { count: usages.length, created: res.created, updated: res.updated, source: "tui" });
|
|
180
|
-
|
|
180
|
+
const nproj = new Set(usages.map((u) => u.project_id)).size;
|
|
181
|
+
ui.showToast(`${GLYPH.check} uploaded ${usages.length} → ${res.created} new, ${res.updated} updated${forceProjectId ? "" : ` · ${nproj} project${nproj === 1 ? "" : "s"}`}`);
|
|
181
182
|
} catch (e) {
|
|
182
183
|
ui.showToast(e.message, "#dc2626");
|
|
183
184
|
} finally {
|
|
@@ -225,7 +226,7 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
|
|
|
225
226
|
if (input === "e") return excludeRef(drefs[0]); // exclude the highlighted model's location (editable)
|
|
226
227
|
if (input === "a") return setItems((its) => its.map((it) => ({ ...it, selected: true })));
|
|
227
228
|
if (input === "x") return setItems((its) => its.map((it) => ({ ...it, selected: false })));
|
|
228
|
-
if (input === "p") return setProjectIdx((i) => (
|
|
229
|
+
if (input === "p") return setProjectIdx((i) => (i + 1) % projOptions.length);
|
|
229
230
|
if (input === "P") return ui.askPrompt("New project", { onSubmit: createProject });
|
|
230
231
|
if (input === "g") return scan.reload();
|
|
231
232
|
if (input === "u") return upload();
|
package/src/upload.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Shared upload helpers used by the `mm scan` command, the TUI Scan tab, and the
|
|
2
|
+
* TUI Here tab — so all three produce identical usage payloads + per-repo project
|
|
3
|
+
* assignment. Keeping one implementation is why the CLI and TUI stay in sync. */
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { redactValue } from "./redact.js";
|
|
6
|
+
import { deriveProject } from "./detect/project.js";
|
|
7
|
+
|
|
8
|
+
/** Resolve deduped detection rows → usage payloads. Each row needs at least
|
|
9
|
+
* { model_string, environment, location_label, source_path?, source_line? }.
|
|
10
|
+
* Model ids are resolved server-side; unresolved strings become a redacted
|
|
11
|
+
* custom_model_name (an .env glob hit can over-capture a secret fragment). */
|
|
12
|
+
export async function buildUsages(client, rows) {
|
|
13
|
+
const uniq = [...new Set(rows.map((r) => r.model_string).filter(Boolean))];
|
|
14
|
+
const resolved = uniq.length ? (await client.resolve(uniq)).data || [] : [];
|
|
15
|
+
const idByStr = new Map(resolved.map((r) => [String(r.input).toLowerCase(), r.model_id]));
|
|
16
|
+
const ghRepo = (process.env.GITHUB_REPOSITORY || "").trim();
|
|
17
|
+
return rows.map((r) => {
|
|
18
|
+
const mid = idByStr.get(String(r.model_string).toLowerCase());
|
|
19
|
+
return {
|
|
20
|
+
model_id: mid ?? undefined,
|
|
21
|
+
custom_model_name: mid ? undefined : redactValue(r.model_string).slice(0, 120),
|
|
22
|
+
environment: r.environment,
|
|
23
|
+
location_label: r.location_label,
|
|
24
|
+
source_repo: r.source_repo || ghRepo || undefined,
|
|
25
|
+
source_path: r.source_path,
|
|
26
|
+
source_line: r.source_line ?? undefined,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create/reuse projects + assign a per-file project_id to each usage
|
|
32
|
+
* (git-root README › repo folder › path chunk › scan root). Mutates each usage
|
|
33
|
+
* with project_id + source_repo and returns a batch-fallback projectId.
|
|
34
|
+
*
|
|
35
|
+
* Resilient to the free plan's project cap: if a create is blocked, it stops
|
|
36
|
+
* creating and routes everything to one project (source_repo still preserves the
|
|
37
|
+
* repo) rather than aborting. `forceProjectId` routes everything to one project
|
|
38
|
+
* (an explicit --project, or a TUI project pick). */
|
|
39
|
+
export async function assignProjects(client, usages, scanRoot, { forceProjectId = null } = {}) {
|
|
40
|
+
if (forceProjectId) {
|
|
41
|
+
for (const u of usages) u.project_id = forceProjectId;
|
|
42
|
+
return forceProjectId;
|
|
43
|
+
}
|
|
44
|
+
const projects = (await client.listProjects()).data || [];
|
|
45
|
+
const byName = new Map(projects.map((p) => [String(p.name).toLowerCase(), p.id]));
|
|
46
|
+
let capHit = false;
|
|
47
|
+
let fallback = projects[0]?.id ?? null;
|
|
48
|
+
const ensure = async (name) => {
|
|
49
|
+
const key = String(name).toLowerCase();
|
|
50
|
+
if (byName.has(key)) return byName.get(key);
|
|
51
|
+
if (capHit) return fallback;
|
|
52
|
+
try {
|
|
53
|
+
const id = (await client.createProject(name)).id;
|
|
54
|
+
byName.set(key, id);
|
|
55
|
+
if (!fallback) fallback = id;
|
|
56
|
+
return id;
|
|
57
|
+
} catch {
|
|
58
|
+
capHit = true; // plan cap or any failure → don't create more
|
|
59
|
+
if (!fallback) {
|
|
60
|
+
try { fallback = (await client.createProject(path.basename(scanRoot))).id; } catch { fallback = null; }
|
|
61
|
+
}
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
for (const u of usages) {
|
|
66
|
+
const ctx = u.source_path
|
|
67
|
+
? deriveProject(path.resolve(scanRoot, u.source_path), scanRoot)
|
|
68
|
+
: { project: path.basename(scanRoot), repo: path.basename(scanRoot) };
|
|
69
|
+
const pid = await ensure(ctx.project);
|
|
70
|
+
if (pid) u.project_id = pid;
|
|
71
|
+
if (!u.source_repo) u.source_repo = ctx.repo;
|
|
72
|
+
}
|
|
73
|
+
return usages.find((u) => u.project_id)?.project_id ?? fallback ?? (await ensure(path.basename(scanRoot)));
|
|
74
|
+
}
|