@modelstatus/cli 0.1.27 → 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.27",
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);
@@ -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 {
@@ -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.
@@ -118,6 +121,10 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
118
121
  // LOCAL file at source_path). Reserve rows only with room to keep the bordered
119
122
  // drawer (~11 rows) intact, so small terminals never overflow + scroll.
120
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;
121
128
  const SNIP = snippet ? Math.max(0, Math.min(8, height - 12)) : 0;
122
129
  const showSnip = SNIP >= 3; // sep + header + ≥1 code line
123
130
  const showNote = !showSnip && !!srcRef && height >= 14; // has a path but no local file
@@ -184,6 +191,7 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
184
191
  cur.is_critical ? h(Text, { color: "#ea580c" }, ` ${GLYPH.crit} critical`) : null,
185
192
  ),
186
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,
187
195
  h(Text, {}, h(Text, { color: C.FG_DIM }, "where "), h(Text, { color: C.FG_DIM }, where)),
188
196
  rt && rt.text
189
197
  ? h(Text, {}, h(Text, { color: C.FG_DIM }, "retires "), h(Text, { color: rt.color, bold: rt.bold }, rt.text))
@@ -202,6 +210,19 @@ export function InventoryView({ client, ui, dir = ".", active, width = 78, heigh
202
210
  h(Box, { flexDirection: "row" }, list, h(Box, { width: 2 }), drawer),
203
211
  showSnip || showNote ? h(Text, { color: C.BORDER }, "─".repeat(width)) : null,
204
212
  ...(showSnip ? snippetLines(snippet, width, SNIP - 2) : []),
205
- 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,
206
227
  );
207
228
  }