@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 +1 -1
- package/src/detect/project.js +88 -0
- package/src/index.js +41 -3
- package/src/tui/app.js +2 -2
- package/src/tui/snippet.js +16 -0
- package/src/tui/ui.js +7 -1
- package/src/tui/views/alerts.js +1 -1
- package/src/tui/views/inventory.js +29 -5
- package/src/tui/views/local.js +9 -1
- package/src/tui/views/scan.js +1 -1
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/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:
|
|
175
|
-
lines: [
|
|
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;
|
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 {
|
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(
|
package/src/tui/views/alerts.js
CHANGED
|
@@ -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: ["
|
|
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
|
-
|
|
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: "
|
|
99
|
-
lines: [
|
|
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
|
|
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
|
}
|
package/src/tui/views/local.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/tui/views/scan.js
CHANGED
|
@@ -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
|
|
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);
|