@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.29",
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 { deriveProject } from "./detect/project.js";
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
- const projects = (await client.listProjects()).data;
199
- const byName = new Map(projects.map((p) => [p.name.toLowerCase(), p.id]));
200
- const scanRoot = path.resolve(dir);
201
- // Reuse a project by name, create on demand. If creation is blocked (the free
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
- // Explicit --project: one project for the whole scan (unchanged behavior).
228
- projectId = uuidish(flags.project)
229
- ? flags.project
230
- : projects.find((p) => p.name === flags.project || p.slug === flags.project)?.id;
231
- if (!projectId) projectId = await ensureProject(flags.project);
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" });
@@ -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 tick = useTick(80, running);
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 (phase === "registry") strip = h(StateLine, { kind: "loading", spin, text: "fetching the signed registry…" });
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")
@@ -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
- const project = projects[clampCursor(projectIdx, projects.length)] || null;
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
- let projectId = project?.id;
170
- if (!projectId) projectId = (await client.createProject(path.basename(path.resolve(dir)))).id;
171
- const uniq = [...new Set(selected.filter((r) => r.slug).map((r) => r.model_string))];
172
- const ids = uniq.length ? (await client.resolve(uniq)).data || [] : [];
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
- ui.showToast(`uploaded ${usages.length} ${res.created} new, ${res.updated} updated`);
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) => (projects.length ? (i + 1) % projects.length : 0));
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
+ }