@modelstatus/cli 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LLM Status
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # @modelstatus/cli
2
+
3
+ **Track which AI models you use, where, and never get surprised by a retirement.**
4
+
5
+ The free CLI + TUI for [LLM Status](https://llmstatus.ai) — scans your repo for AI model usage (OpenAI, Anthropic, Google, Mistral, DeepSeek, xAI, Moonshot, Cohere, and dozens more) and joins it to a constantly-updated lifecycle registry so you find out *before* `gpt-4` retires, not after.
6
+
7
+ ```bash
8
+ npx @modelstatus/cli status
9
+ ```
10
+
11
+ That's it. No sign-in, no account, no telemetry — just a snapshot of every model in your repo plus health badges and replacement suggestions.
12
+
13
+ ## Install
14
+
15
+ Pick whichever fits your stack:
16
+
17
+ ```bash
18
+ # Self-contained binary, no Node required:
19
+ curl -fsSL https://llmstatus.ai/install.sh | bash
20
+
21
+ # Via npm (needs Node ≥18):
22
+ npm i -g @modelstatus/cli
23
+ ```
24
+
25
+ Or skip install entirely and one-shot it: `npx @modelstatus/cli status`.
26
+
27
+ ## Quick start
28
+
29
+ ### Free: offline health check
30
+
31
+ ```bash
32
+ mm status [dir] # if installed
33
+ npx @modelstatus/cli status [dir] # zero install (needs Node)
34
+ ```
35
+
36
+ Pulls the **signed** registry snapshot from `cdn.llmstatus.ai` (Ed25519-signed, anti-rollback, ~225 kB), scans the directory, resolves every model id, and prints what you use along with its health:
37
+
38
+ ```
39
+ LLM Status — registry 20260528T013239Z (today), 380 models
40
+ Scanned ./apps/web: 12 reference(s) → 6 model(s), 1 custom
41
+
42
+ Models in use:
43
+ 🔴 retired openai/gpt-4-0314 retires 2024-06-13 → openai/gpt-4-1 (2)
44
+ 🟠 retiring anthropic/claude-opus-4 retires 2026-06-15 → anthropic/claude-opus-4-7 (1)
45
+ 🟢 ok openai/gpt-5 (3)
46
+
47
+ ⚠ 2 model(s) need attention before they retire.
48
+ ```
49
+
50
+ Works **fully offline** after the first run (cached snapshot at `~/.config/llmstatus/registry-cache.json`).
51
+
52
+ ### Sign in for cloud features
53
+
54
+ ```bash
55
+ mm login # browser sign-in, polls for completion
56
+ mm scan # scans + uploads to your account's inventory
57
+ mm # launches the TUI: inventory, scan, what's-new, alerts
58
+ mm upgrade # Stripe checkout for Pro (alerting)
59
+ ```
60
+
61
+ You get two binaries — `mm` (short) and `llmstatus` (descriptive). Same binary, take your pick.
62
+
63
+ ## Commands
64
+
65
+ | Command | What it does |
66
+ |---|---|
67
+ | `mm status [dir]` | Free offline model-health check — no account |
68
+ | `mm` | Launch the TUI (inventory, scan, what's-new, alerts, account) |
69
+ | `mm login [api_key]` | Browser sign-in with polling (or paste a key) |
70
+ | `mm signup` | Create an account in the browser |
71
+ | `mm scan [dir]` | Scan for model usage; interactive TUI, or `--ci`/`--json` for pipelines |
72
+ | `mm sources` | List detection sources and whether each can run here |
73
+ | `mm upgrade` | Open Stripe checkout, poll until Pro is active |
74
+ | `mm logout` | Forget the saved API key |
75
+
76
+ **Scan sources** (`--sources`, default `filesystem`; `all` for everything):
77
+
78
+ | Source | Reads from |
79
+ |---|---|
80
+ | `filesystem` | repo files |
81
+ | `env` | live process env vars (`OPENAI_API_KEY`, …) |
82
+ | `aws-secrets` | AWS Secrets Manager + SSM |
83
+ | `k8s` | kubectl secrets + configmaps |
84
+ | `helm` | helm release values |
85
+ | `sql` | psql, via `--db <dsn>` |
86
+
87
+ Secret sources shell out to your already-authenticated CLIs, run **read-only**, REDACT every snippet, and only ever upload model ids — secret *values* never leave your machine. Use `--dry-run` to preview.
88
+
89
+ **Common flags:** `--api <url>` · `--key <key>` · `--project <id|name>` · `--yes` · `--json` · `--ci` · `--dry-run` · `--sources <list>` · `--region <r>` · `--namespace <ns>` · `--kube-context <c>` · `--db <dsn>` · `--sql-table <t>`
90
+
91
+ ## Free vs paid
92
+
93
+ | | Free (this CLI) | Pro (signed in) |
94
+ |---|---|---|
95
+ | `mm status` on any repo | ✓ unlimited | ✓ |
96
+ | Signed registry snapshot, offline cache | ✓ | ✓ |
97
+ | Resolve + health locally, on-device | ✓ | ✓ |
98
+ | Secret-source aware (`env`, `aws-secrets`, `k8s`, `helm`, `sql`) | ✓ | ✓ |
99
+ | Cloud inventory across projects/teams | — | ✓ |
100
+ | Alerts on deprecations/retirements (email/Slack/SMS) | — | ✓ |
101
+ | CI integrations + web dashboard | — | ✓ |
102
+
103
+ ## How the registry distribution works
104
+
105
+ The registry is published as a **date-versioned, signed snapshot** on Cloudflare R2 at `cdn.llmstatus.ai`, with a mini-TUF trust chain:
106
+
107
+ ```
108
+ pinned root key (in the CLI binary)
109
+ → root-signs ─→ keys.json (names the current signing key)
110
+ → signing-key-signs ─→ latest.json (pointer, with sha256)
111
+ → blob: <version>.json (immutable)
112
+ ```
113
+
114
+ The CLI verifies every byte before trusting the snapshot, refuses any rollback to an older version, and falls back to its local cache when the network's down. The signing key can be rotated without shipping a new CLI release.
115
+
116
+ ## Links
117
+
118
+ - Website: [llmstatus.ai](https://llmstatus.ai)
119
+ - Pricing: [llmstatus.ai/pricing](https://llmstatus.ai/pricing)
120
+ - Contact / bugs: [it@llmstatus.ai](mailto:it@llmstatus.ai)
121
+
122
+ ## License
123
+
124
+ [MIT](LICENSE) © LLM Status
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@modelstatus/cli",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "llm",
7
+ "ai",
8
+ "models",
9
+ "deprecation",
10
+ "retirement",
11
+ "registry",
12
+ "openai",
13
+ "anthropic",
14
+ "claude",
15
+ "gpt",
16
+ "gemini",
17
+ "mistral",
18
+ "cli",
19
+ "tui",
20
+ "monitoring",
21
+ "lifecycle"
22
+ ],
23
+ "homepage": "https://llmstatus.ai",
24
+ "bugs": {
25
+ "email": "it@llmstatus.ai"
26
+ },
27
+ "author": "LLM Status <it@llmstatus.ai>",
28
+ "license": "MIT",
29
+ "type": "module",
30
+ "bin": {
31
+ "mm": "src/index.js",
32
+ "llmstatus": "src/index.js"
33
+ },
34
+ "files": [
35
+ "src",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "scripts": {
46
+ "test": "node --test"
47
+ },
48
+ "dependencies": {
49
+ "ink": "^5.0.1",
50
+ "react": "^18.3.1"
51
+ },
52
+ "devDependencies": {
53
+ "ink-testing-library": "^4.0.0"
54
+ }
55
+ }
package/src/api.js ADDED
@@ -0,0 +1,85 @@
1
+ /** Thin client for the LLM Status / Model Manager public API (/api/v1). */
2
+ export function createClient({ apiBase, apiKey }) {
3
+ async function req(method, pathname, body) {
4
+ const res = await fetch(`${apiBase}/api/v1${pathname}`, {
5
+ method,
6
+ headers: {
7
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
8
+ ...(body ? { "content-type": "application/json" } : {}),
9
+ },
10
+ body: body ? JSON.stringify(body) : undefined,
11
+ });
12
+ const text = await res.text();
13
+ let data;
14
+ try {
15
+ data = text ? JSON.parse(text) : {};
16
+ } catch {
17
+ data = { raw: text };
18
+ }
19
+ if (!res.ok) {
20
+ const msg = data?.error?.message || data?.message || `HTTP ${res.status}`;
21
+ const err = new Error(`${method} ${pathname} → ${msg}`);
22
+ err.status = res.status;
23
+ err.code = data?.error?.code;
24
+ err.body = data;
25
+ throw err;
26
+ }
27
+ return data;
28
+ }
29
+
30
+ const qs = (params = {}) => {
31
+ const u = new URLSearchParams();
32
+ for (const [k, v] of Object.entries(params)) if (v != null && v !== "") u.set(k, String(v));
33
+ const s = u.toString();
34
+ return s ? `?${s}` : "";
35
+ };
36
+
37
+ return {
38
+ raw: req,
39
+
40
+ // account / auth
41
+ me: () => req("GET", "/me"),
42
+
43
+ // device auth (no key required)
44
+ authStart: (body) => req("POST", "/cli-auth/start", body),
45
+ authPoll: (deviceCode) => req("POST", "/cli-auth/poll", { device_code: deviceCode }),
46
+
47
+ // registry (shared, read-only)
48
+ detectionPatterns: () => req("GET", "/registry/detection-patterns"),
49
+ resolve: (strings) => req("POST", "/registry/resolve", { strings }),
50
+ registryEvents: (since) => req("GET", `/registry/events${qs({ since })}`),
51
+ registryModels: (params) => req("GET", `/registry/models${qs(params)}`),
52
+
53
+ // projects
54
+ listProjects: () => req("GET", "/projects"),
55
+ createProject: (name) => req("POST", "/projects", { name }),
56
+ patchProject: (id, body) => req("PATCH", `/projects/${id}`, body),
57
+
58
+ // usages
59
+ listUsages: (params) => req("GET", `/usages${qs(params)}`),
60
+ getUsage: (id) => req("GET", `/usages/${id}`),
61
+ createUsage: (body) => req("POST", "/usages", body),
62
+ patchUsage: (id, body) => req("PATCH", `/usages/${id}`, body),
63
+ deleteUsage: (id) => req("DELETE", `/usages/${id}`),
64
+ linkUsage: (id, modelId) => req("POST", `/usages/${id}/link`, { model_id: modelId }),
65
+ bulkUpload: (projectId, usages) => req("POST", "/usages/bulk", { project_id: projectId, usages }),
66
+
67
+ // notification rules
68
+ listRules: () => req("GET", "/notification-rules"),
69
+ createRule: (body) => req("POST", "/notification-rules", body),
70
+ patchRule: (id, body) => req("PATCH", `/notification-rules/${id}`, body),
71
+ deleteRule: (id) => req("DELETE", `/notification-rules/${id}`),
72
+
73
+ // channels
74
+ listChannels: () => req("GET", "/channels"),
75
+ createChannel: (body) => req("POST", "/channels", { action: "connect", ...body }),
76
+ testChannel: (body) => req("POST", "/channels", { action: "test", ...body }),
77
+
78
+ // notifications feed
79
+ listNotifications: (params) => req("GET", `/notifications${qs(params)}`),
80
+ readNotification: (id) => req("POST", `/notifications/${id}/read`),
81
+
82
+ // billing
83
+ checkout: () => req("POST", "/billing/checkout"),
84
+ };
85
+ }
package/src/auth.js ADDED
@@ -0,0 +1,44 @@
1
+ import { createClient } from "./api.js";
2
+ import { loadConfig, saveConfig, configFilePath } from "./config.js";
3
+ import { openUrl } from "./openUrl.js";
4
+
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+
7
+ /** Browser login with polling, à la Claude Code: start a device request, open
8
+ * the browser, then poll until the user authorizes (or signs up). Returns
9
+ * { apiKey, account } and saves it to the config file. */
10
+ export async function loginViaBrowser({ apiBase, signup = false, log = console.error }) {
11
+ const client = createClient({ apiBase });
12
+ const start = await client.authStart({ client_name: "mm CLI", signup });
13
+
14
+ log(`\n Opening your browser to authorize this device…`);
15
+ log(` If it doesn't open, visit:\n ${start.verification_url}`);
16
+ log(` Verification code: ${start.user_code}\n`);
17
+ openUrl(start.verification_url);
18
+
19
+ const interval = Math.max(1, start.interval || 3) * 1000;
20
+ const deadline = Date.now() + (start.expires_in || 600) * 1000;
21
+
22
+ log(` Waiting for authorization`);
23
+ while (Date.now() < deadline) {
24
+ await sleep(interval);
25
+ let res;
26
+ try {
27
+ res = await client.authPoll(start.device_code);
28
+ } catch {
29
+ continue; // transient network error — keep polling
30
+ }
31
+ if (res.status === "approved") {
32
+ const cfg = loadConfig();
33
+ cfg.apiKey = res.api_key;
34
+ cfg.apiBase = apiBase;
35
+ saveConfig(cfg);
36
+ log(`\n ✓ Authorized${res.account?.name ? ` as "${res.account.name}"` : ""}. Saved to ${configFilePath}\n`);
37
+ return { apiKey: res.api_key, account: res.account ?? null };
38
+ }
39
+ if (res.status === "denied") throw new Error("Authorization was denied.");
40
+ if (res.status === "expired") throw new Error("The login request expired. Run `mm login` again.");
41
+ process.stderr.write(".");
42
+ }
43
+ throw new Error("Timed out waiting for authorization. Run `mm login` again.");
44
+ }
package/src/config.js ADDED
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const dir = path.join(os.homedir(), ".config", "llmstatus");
6
+ const file = path.join(dir, "config.json");
7
+ // Legacy location (pre-rebrand) — read as a fallback so existing logins keep working.
8
+ const legacyFile = path.join(os.homedir(), ".config", "modelmanager", "config.json");
9
+
10
+ export function loadConfig() {
11
+ for (const f of [file, legacyFile]) {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(f, "utf8"));
14
+ } catch {
15
+ /* try next */
16
+ }
17
+ }
18
+ return {};
19
+ }
20
+
21
+ export function saveConfig(next) {
22
+ // The API key lives here in plaintext, so keep it owner-only (like gh/aws creds)
23
+ // — other local users must not be able to read it. chmod after write also fixes
24
+ // a pre-existing loose-perm file (writeFileSync's mode only applies on create).
25
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ fs.writeFileSync(file, JSON.stringify(next, null, 2), { mode: 0o600 });
27
+ try {
28
+ fs.chmodSync(file, 0o600);
29
+ } catch {
30
+ /* best-effort (e.g. Windows) */
31
+ }
32
+ return file;
33
+ }
34
+
35
+ /** Resolve {apiBase, apiKey} from flags > env > config file. Defaults to prod. */
36
+ export function resolveAuth(flags = {}) {
37
+ const cfg = loadConfig();
38
+ const apiBase =
39
+ flags.api ||
40
+ process.env.LLMSTATUS_API_BASE ||
41
+ process.env.MM_API_BASE ||
42
+ cfg.apiBase ||
43
+ "https://llmstatus.ai";
44
+ const apiKey =
45
+ flags.key || process.env.LLMSTATUS_API_KEY || process.env.MM_API_KEY || cfg.apiKey || "";
46
+ return { apiBase: apiBase.replace(/\/$/, ""), apiKey };
47
+ }
48
+
49
+ /** Forget the saved API key (keeps apiBase). */
50
+ export function clearAuth() {
51
+ const cfg = loadConfig();
52
+ delete cfg.apiKey;
53
+ saveConfig(cfg);
54
+ }
55
+
56
+ /** Persist a single key (e.g. lastEventsSeenAt for the "what's new" feed). */
57
+ export function setConfigValue(key, value) {
58
+ const cfg = loadConfig();
59
+ cfg[key] = value;
60
+ saveConfig(cfg);
61
+ }
62
+
63
+ export const configFilePath = file;
@@ -0,0 +1,58 @@
1
+ /** Pure model-string detector shared by every scan source (filesystem now,
2
+ * secrets/k8s/helm/sql later). Given text + the registry's detection patterns,
3
+ * returns the model strings found per line. No I/O. */
4
+
5
+ // File extensions / TLDs the family globs accidentally swallow
6
+ // (e.g. "command-2.0.0.tgz", "grok-free.app"). Used to reject generic matches.
7
+ const BANNED_TAIL =
8
+ /\.(tgz|tar|gz|zip|js|ts|tsx|jsx|mjs|py|go|rb|json|md|lock|sh|css|html|txt|log|yaml|yml|toml|ini|conf|cfg|env|pem|crt|key|csv|xml|pdf|sql|app|com|net|io|dev|org|ai|co)\b/;
9
+
10
+ /** Trim leading/trailing separators a greedy family glob can capture. */
11
+ function cleanGeneric(s) {
12
+ return s.replace(/^[.\-_]+/, "").replace(/[.\-_]+$/, "");
13
+ }
14
+
15
+ /** Family globs catch brand-NEW versioned models before they're in the
16
+ * registry, so a real hit virtually always carries a version digit. Requiring
17
+ * one (and rejecting filename/domain tails) kills the bulk of false positives. */
18
+ function looksLikeModel(s) {
19
+ return s.length >= 5 && /[0-9]/.test(s) && !BANNED_TAIL.test(s);
20
+ }
21
+
22
+ /** Compile detection patterns into reusable matchers (do this once per scan). */
23
+ export function compilePatterns(patterns) {
24
+ const exact = [];
25
+ for (const ms of patterns.model_strings || []) {
26
+ if (ms.match && ms.match.length >= 4) exact.push(ms.match.toLowerCase());
27
+ }
28
+ const generic = (patterns.generic_model_regexes || []).map((r) => new RegExp(r, "gi"));
29
+ return { exact, generic };
30
+ }
31
+
32
+ /** Find model strings on a single line. Returns a Set of lowercased strings. */
33
+ export function detectInLine(line, compiled) {
34
+ const lower = line.toLowerCase();
35
+ const found = new Set();
36
+ for (const s of compiled.exact) if (lower.includes(s)) found.add(s);
37
+ for (const re of compiled.generic) {
38
+ re.lastIndex = 0;
39
+ let m;
40
+ while ((m = re.exec(line))) {
41
+ const cand = cleanGeneric(m[0].toLowerCase());
42
+ if (looksLikeModel(cand)) found.add(cand);
43
+ if (re.lastIndex === m.index) re.lastIndex++;
44
+ }
45
+ }
46
+ return found;
47
+ }
48
+
49
+ /** Detect across multi-line text → [{ model_string, line, snippet }]. */
50
+ export function detectInText(text, compiled) {
51
+ const out = [];
52
+ text.split(/\r?\n/).forEach((line, idx) => {
53
+ for (const modelStr of detectInLine(line, compiled)) {
54
+ out.push({ model_string: modelStr, line: idx + 1, snippet: line.trim().slice(0, 160) });
55
+ }
56
+ });
57
+ return out;
58
+ }