@modelstatus/cli 0.1.26 → 0.1.28

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.26",
3
+ "version": "0.1.28",
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",
@@ -0,0 +1,88 @@
1
+ /** Human-readable PROJECT identity for a scanned file, so a multi-repo workspace
2
+ * reads as its real parts (listingpro, pophire, …) instead of one folder name.
3
+ *
4
+ * Priority, best → worst:
5
+ * 1. The H1 title of the README at the file's nearest git repo root.
6
+ * 2. That repo root's folder name.
7
+ * 3. The top path segment under the scan root (a "path chunk").
8
+ * 4. The scan root's folder name.
9
+ *
10
+ * Pure fs reads (no `git` shell-out); results are cached per directory so a big
11
+ * repo reads its README + walks for .git exactly once. */
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ const gitRootCache = new Map(); // dir → repoRoot | null
16
+ const nameCache = new Map(); // repoRoot → project name
17
+
18
+ /** Nearest ancestor dir containing a `.git` (dir OR file, for submodules/worktrees),
19
+ * not climbing above `stopAt` (the scan root). null if none. */
20
+ function findGitRoot(startDir, stopAt) {
21
+ let d = startDir;
22
+ const visited = [];
23
+ for (;;) {
24
+ if (gitRootCache.has(d)) {
25
+ const r = gitRootCache.get(d);
26
+ for (const v of visited) gitRootCache.set(v, r);
27
+ return r;
28
+ }
29
+ visited.push(d);
30
+ let hit = false;
31
+ try { hit = fs.existsSync(path.join(d, ".git")); } catch { /* unreadable → not a root */ }
32
+ if (hit) {
33
+ for (const v of visited) gitRootCache.set(v, d);
34
+ return d;
35
+ }
36
+ const parent = path.dirname(d);
37
+ if (parent === d || d === stopAt || (stopAt && d.length <= stopAt.length)) {
38
+ for (const v of visited) gitRootCache.set(v, null);
39
+ return null;
40
+ }
41
+ d = parent;
42
+ }
43
+ }
44
+
45
+ /** First markdown H1 of a README at `root`, cleaned + capped, or null. */
46
+ function readmeTitle(root) {
47
+ for (const name of ["README.md", "readme.md", "Readme.md", "README.markdown", "README"]) {
48
+ let txt;
49
+ try { txt = fs.readFileSync(path.join(root, name), "utf8"); } catch { continue; }
50
+ const m = txt.match(/^\s{0,3}#\s+(.+?)\s*#*\s*$/m);
51
+ if (!m) return null; // README exists but has no usable title
52
+ const title = m[1]
53
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) / ![alt](url) → text/alt
54
+ .replace(/[`*_]/g, "") // emphasis / code ticks
55
+ .trim();
56
+ return title ? title.slice(0, 48) : null;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /** { project, repo } for an absolute file path within an absolute scan root. */
62
+ export function deriveProject(absFile, scanRoot) {
63
+ const gitRoot = findGitRoot(path.dirname(absFile), scanRoot);
64
+ const root = gitRoot || scanRoot;
65
+ let name = nameCache.get(root);
66
+ if (name === undefined) {
67
+ name = readmeTitle(root) || path.basename(root) || "project";
68
+ nameCache.set(root, name);
69
+ }
70
+ // NO git root found anywhere up to the scan root → prefer the TOP folder under
71
+ // the scan root (a "path chunk") so a plain parent-of-projects dir still splits.
72
+ // (Only when there's genuinely no repo — a single-repo scan keeps the repo name.)
73
+ if (!gitRoot) {
74
+ const rel = path.relative(scanRoot, absFile);
75
+ const seg = rel.split(path.sep)[0];
76
+ if (rel.includes(path.sep) && seg && seg !== ".." && !seg.startsWith(".")) {
77
+ const sub = path.join(scanRoot, seg);
78
+ return { project: readmeTitle(sub) || seg, repo: seg };
79
+ }
80
+ }
81
+ return { project: name, repo: path.basename(root) };
82
+ }
83
+
84
+ /** Test/long-run hygiene: drop the per-process caches. */
85
+ export function _resetProjectCache() {
86
+ gitRootCache.clear();
87
+ nameCache.clear();
88
+ }
package/src/index.js CHANGED
@@ -5,6 +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
9
  import { loginViaBrowser } from "./auth.js";
9
10
  import { maybeCheckForUpdate } from "./updater.js";
10
11
  import { track, maybeAnalyticsNotice } from "./telemetry.js";
@@ -194,15 +195,52 @@ async function cmdScan(positional, flags) {
194
195
  return;
195
196
  }
196
197
 
197
- let projectId = null;
198
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;
199
226
  if (flags.project) {
227
+ // Explicit --project: one project for the whole scan (unchanged behavior).
200
228
  projectId = uuidish(flags.project)
201
229
  ? flags.project
202
230
  : projects.find((p) => p.name === flags.project || p.slug === flags.project)?.id;
203
- if (!projectId) projectId = (await client.createProject(flags.project)).id;
231
+ if (!projectId) projectId = await ensureProject(flags.project);
204
232
  } else {
205
- projectId = projects[0]?.id ?? (await client.createProject(path.basename(dir))).id;
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;
242
+ }
243
+ projectId = usages.find((u) => u.project_id)?.project_id ?? defaultProjectId ?? (await ensureProject(path.basename(scanRoot)));
206
244
  }
207
245
 
208
246
  const res = await client.bulkUpload(projectId, usages);
package/src/tui/app.js CHANGED
@@ -171,8 +171,8 @@ export function App({ apiBase, apiKey, dir, initialView, onSignedIn, fresh }) {
171
171
  } else if (showSignInGate) {
172
172
  body = h(EmptyCard, {
173
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."],
174
+ title: "Track your AI models across every project",
175
+ lines: [`${current2.label} syncs with your account — press 7 to sign in with your browser.`, "No account needed: tabs 1-2 (Here, What's New) work offline."],
176
176
  width: W,
177
177
  });
178
178
  keys = GATE_KEYS;
@@ -90,6 +90,22 @@ function highlightLine(line, ext, matchStr) {
90
90
  * the matched model string on the match line. Returns { path, line, rows } where
91
91
  * each row is { num, isMatch, segs }; null if the file can't be read.
92
92
  */
93
+ /** Resolve a scan-root-relative source_path to a real local file, searching from
94
+ * `fromDir` upward (the inventory may be viewed from a subdir of, or a sibling
95
+ * within, the originally-scanned tree). Returns an absolute path or null. */
96
+ export function findSourceFile(sourcePath, fromDir, maxUp = 8) {
97
+ if (!sourcePath) return null;
98
+ let d = path.resolve(fromDir || ".");
99
+ for (let i = 0; i <= maxUp; i++) {
100
+ const abs = path.resolve(d, sourcePath);
101
+ try { if (fs.statSync(abs).isFile()) return abs; } catch { /* keep climbing */ }
102
+ const parent = path.dirname(d);
103
+ if (parent === d) break;
104
+ d = parent;
105
+ }
106
+ return null;
107
+ }
108
+
93
109
  export function readSnippet(absPath, line, matchStr, ctx = 3) {
94
110
  let content;
95
111
  try {
package/src/tui/ui.js CHANGED
@@ -614,11 +614,17 @@ export function ModelDetailBar({ title, health, model, refs = [], width, height
614
614
  const W = Math.max(20, width);
615
615
  const focused = refCursor >= 0;
616
616
  const rt = model && model.retires_date ? relativeTime(model.retires_date) : null;
617
+ // A model retiring within 7 days earns a quiet ✦ on its detail header — a rare,
618
+ // earned "this one's hot" marker. Static (no animation); ASCII-safe (GLYPH.spark → *).
619
+ const retiresInDays = model && model.retires_date
620
+ ? Math.round((new Date(model.retires_date).getTime() - Date.now()) / 86_400_000)
621
+ : null;
622
+ const imminent = retiresInDays !== null && retiresInDays >= 0 && retiresInDays <= 7;
617
623
  const lines = [];
618
624
  // separator rule
619
625
  lines.push(h(Text, { key: "rule", color: C.BORDER }, "─".repeat(W)));
620
626
  // header: health glyph + slug (strong) + health word, padded to W
621
- const hg = `${healthGlyph(health)} `;
627
+ const hg = `${imminent ? `${GLYPH.spark} ` : ""}${healthGlyph(health)} `;
622
628
  const hw = ` ${health}`;
623
629
  const slugW = Math.max(8, W - hg.length - hw.length);
624
630
  lines.push(
@@ -135,7 +135,7 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
135
135
  body = h(StateLine, { kind: "loading", spin: SPINNER[tick % SPINNER.length], text: tab === 0 ? "loading rules…" : "loading channels…" });
136
136
  } else if (tab === 0) {
137
137
  if (!ruleList.length) {
138
- body = h(EmptyCard, { title: "No alert rules", lines: ["A rule notifies you when models you use are deprecated/retired or get a replacement.", "Press n for a sensible default (your models · in-app + email · 90/30/7/1-day lead times)."], width });
138
+ 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 });
139
139
  } else {
140
140
  const curIdx = clampCursor(cursor, ruleList.length);
141
141
  const fixed = 2 + 26 + 1 + 10 + 1; // glyph + name + gap + delivery + gap
@@ -9,7 +9,7 @@ import {
9
9
  h, C, GLYPH, ListRow, StateLine, EmptyCard, healthColor, healthGlyph,
10
10
  relativeTime, envTag, cell, cellE, snippetLines, SPINNER, useTick, useAsync, clampCursor,
11
11
  } from "../ui.js";
12
- import { readSnippet } from "../snippet.js";
12
+ import { readSnippet, findSourceFile } from "../snippet.js";
13
13
 
14
14
  const ENV_ORDER = ["prod", "staging", "dev", "other"];
15
15
 
@@ -46,7 +46,10 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
46
46
  const matchStr = cur ? (cur.custom_model_name || (cur.model_display || "").split("/").pop() || cur.model_display) : null;
47
47
  const snippet = React.useMemo(() => {
48
48
  if (!cur || !cur.source_path) return null;
49
- return readSnippet(path.resolve(dir, cur.source_path), cur.source_line, matchStr);
49
+ // Search from the launch dir UPWARD the inventory may be viewed from a
50
+ // subdir of (or a sibling within) the originally-scanned tree.
51
+ const abs = findSourceFile(cur.source_path, dir);
52
+ return abs ? readSnippet(abs, cur.source_line, matchStr) : null;
50
53
  }, [cur?.source_path, cur?.source_line, matchStr, dir]); // eslint-disable-line react-hooks/exhaustive-deps
51
54
 
52
55
  // Push health legend + tracked context up to the shell status bar.
@@ -95,8 +98,11 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
95
98
  if (!usages.length)
96
99
  return h(EmptyCard, {
97
100
  icon: GLYPH.spark,
98
- title: "No tracked usages yet",
99
- lines: ["Press 4 Scan to import a repo, or 5 Add to enter one."],
101
+ title: "Let's find your AI models",
102
+ lines: [
103
+ "Press 4 Scan to auto-detect every model used in this repo.",
104
+ "Or press 5 Add to enter one by name — takes about 30 seconds.",
105
+ ],
100
106
  width,
101
107
  });
102
108
 
@@ -115,6 +121,10 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
115
121
  // LOCAL file at source_path). Reserve rows only with room to keep the bordered
116
122
  // drawer (~11 rows) intact, so small terminals never overflow + scroll.
117
123
  const srcRef = cur && cur.source_path ? `${cur.source_path}${cur.source_line ? ":" + cur.source_line : ""}` : null;
124
+ // A GitHub deep-link when the usage carries an owner/name repo slug (CI scans).
125
+ const ghUrl = cur && cur.source_repo && cur.source_repo.includes("/") && cur.source_path
126
+ ? `github.com/${cur.source_repo}/blob/HEAD/${cur.source_path}${cur.source_line ? "#L" + cur.source_line : ""}`
127
+ : null;
118
128
  const SNIP = snippet ? Math.max(0, Math.min(8, height - 12)) : 0;
119
129
  const showSnip = SNIP >= 3; // sep + header + ≥1 code line
120
130
  const showNote = !showSnip && !!srcRef && height >= 14; // has a path but no local file
@@ -181,6 +191,7 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
181
191
  cur.is_critical ? h(Text, { color: "#ea580c" }, ` ${GLYPH.crit} critical`) : null,
182
192
  ),
183
193
  h(Text, {}, h(Text, { color: C.FG_DIM }, "project "), h(Text, { color: C.FG }, projName(cur.project_id))),
194
+ cur.source_repo ? h(Text, {}, h(Text, { color: C.FG_DIM }, "repo "), h(Text, { color: C.FG }, cur.source_repo)) : null,
184
195
  h(Text, {}, h(Text, { color: C.FG_DIM }, "where "), h(Text, { color: C.FG_DIM }, where)),
185
196
  rt && rt.text
186
197
  ? h(Text, {}, h(Text, { color: C.FG_DIM }, "retires "), h(Text, { color: rt.color, bold: rt.bold }, rt.text))
@@ -199,6 +210,19 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
199
210
  h(Box, { flexDirection: "row" }, list, h(Box, { width: 2 }), drawer),
200
211
  showSnip || showNote ? h(Text, { color: C.BORDER }, "─".repeat(width)) : null,
201
212
  ...(showSnip ? snippetLines(snippet, width, SNIP - 2) : []),
202
- showNote ? h(Text, { color: C.FG_FAINT }, cellE(` ↳ ${srcRef} — not found in this directory; open the repo to preview the code`, width)) : null,
213
+ showNote
214
+ ? h(
215
+ Text,
216
+ { color: C.FG_FAINT },
217
+ cellE(
218
+ cur && cur.discovered_by === "manual"
219
+ ? ` ↳ ${srcRef} — added manually; no source file to preview.`
220
+ : ghUrl
221
+ ? ` ↳ ${ghUrl}`
222
+ : ` ↳ ${srcRef} — not on this machine (scanned from another location).`,
223
+ width,
224
+ ),
225
+ )
226
+ : null,
203
227
  );
204
228
  }
@@ -220,7 +220,15 @@ export function LocalView({ dir, ui, width = 78, height = 14, active = true, fre
220
220
  Box,
221
221
  { flexDirection: "column" },
222
222
  strip,
223
- emptyDone ? h(Text, { color: C.FG_DIM }, search.query ? ` no matches for "${search.query}"` : " No AI model references found here.") : null,
223
+ emptyDone
224
+ ? h(
225
+ Text,
226
+ { color: C.FG_DIM },
227
+ search.query
228
+ ? ` No matches for "${search.query}" — try a broader term, or esc to clear.`
229
+ : " No AI model calls here yet — this folder looks clean.",
230
+ )
231
+ : null,
224
232
  ...rowNodes,
225
233
  showingLine,
226
234
  cur ? h(ModelDetailBar, { title: cur.slug, health: cur.health, model: cur.model, refs: cur.refs, width, height: panelH, refCursor: focus === "refs" ? clampCursor(refIdx, drefs.length) : -1, snippet }) : null,
@@ -240,7 +240,7 @@ export function ScanView({ client, dir, ui, active, width = 78, height = 14, fre
240
240
  if (scan.phase === "error") return h(StateLine, { kind: "error", text: scan.error });
241
241
  if (running && !items.length) return h(StateLine, { kind: "scanning", spin: SPINNER[tick % SPINNER.length], text: `scanning ${dir}…` });
242
242
  if (!items.length)
243
- return h(EmptyCard, { icon: GLYPH.spark, title: `No model usage found in ${path.basename(dir)}`, lines: ["Press g to rescan, or 5 Add to enter one manually."], width });
243
+ return h(EmptyCard, { icon: GLYPH.spark, title: `No models found in ${path.basename(dir)} — yet`, lines: ["We looked through code, config, and prompt files for model ids.", "Press g to rescan, 1 Here to try another project, or 5 Add to enter one by hand."], width });
244
244
 
245
245
  const L = layout(width);
246
246
  const view = filtered.slice(nav.start, nav.start + pageSize);