@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 +1 -1
- package/src/detect/project.js +88 -0
- package/src/index.js +41 -3
- package/src/tui/snippet.js +16 -0
- package/src/tui/views/inventory.js +24 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelstatus/cli",
|
|
3
|
-
"version": "0.1.
|
|
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) /  → 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 =
|
|
231
|
+
if (!projectId) projectId = await ensureProject(flags.project);
|
|
204
232
|
} else {
|
|
205
|
-
|
|
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/snippet.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|