claudeup 6.7.1 → 6.8.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.
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The LIVE external-model catalogue, read from claudish.
3
+ *
4
+ * A slot is bound to a bare catalogue id (`grok-4.6`). This module answers one question about
5
+ * such an id — is it still in the catalogue — and does nothing else with the answer.
6
+ *
7
+ * ## Why there is no table of model ids in this repo
8
+ *
9
+ * There was one once, elsewhere, and it went four months stale and silently resolved dead
10
+ * ids. A committed snapshot of a catalogue that turns over weekly is not a cache, it is a
11
+ * liability with a plausible face: it keeps answering after it stops being right. So the ids
12
+ * come from the catalogue at call time or they do not come at all.
13
+ *
14
+ * ## Why `claudish --models` and nothing else
15
+ *
16
+ * It is a READ-ONLY DIAGNOSTIC. The rule in this project is that work goes through claudish's
17
+ * MCP tools, never its CLI — with exactly three exceptions, `--help`, `--version` and
18
+ * `--models`, none of which may run a task. This module uses one of the three and runs no
19
+ * task: it prints a table and we read it. claudeup is a TUI with no MCP client in it, so the
20
+ * MCP route is not available here at all; if it were, this would still be the right call,
21
+ * because listing a catalogue IS the diagnostic those three exist for.
22
+ *
23
+ * ## The contract this module holds to
24
+ *
25
+ * FAILS SILENT, ALWAYS. claudish absent, a non-zero exit, unparseable output, a timeout —
26
+ * every one of them returns an empty catalogue, which reads downstream as "unknown". Never
27
+ * throws, never blocks, never empties the Model column because a lookup failed. The screen
28
+ * with no catalogue is the screen showing every bound id exactly as written, unannotated,
29
+ * which is what it showed before this module existed.
30
+ *
31
+ * ADVISORY ONLY. It marks a bound id the catalogue does not list. It never rebinds a slot,
32
+ * never substitutes a model, never fails a validation. This is deliberately the same contract
33
+ * as the image-model staleness check in the repo's CLAUDE.md, and for the same reason: a
34
+ * check that can act on a catalogue it may have failed to read can act on nothing.
35
+ *
36
+ * ## Shape
37
+ *
38
+ * Mirrors `mate-availability.ts`: a pure decision, plus an injected seam for the thing that
39
+ * touches the machine. The PARSING and the DECISIONS are what is worth testing — a format
40
+ * change, a truncated table, an empty result — and none of them can be tested through a
41
+ * function that shells out. The default runner is the real one, so no caller needs to know
42
+ * the seam is there.
43
+ *
44
+ * This is NOT in `models-core.ts`. That module is pure — no fs, no process, no network —
45
+ * because Claude Code runs the hook that imports it on every single Agent call.
46
+ */
47
+
48
+ import { spawn } from "node:child_process";
49
+
50
+ /** One row of `claudish --models`, as printed. */
51
+ export interface CatalogModel {
52
+ /** The bare catalogue id — the only field a binding stores. */
53
+ id: string;
54
+ provider: string;
55
+ /** As printed: `$4.00/1M`, or `FREE`. Kept as text; nothing here does arithmetic on it. */
56
+ pricing: string;
57
+ /** As printed: `1M`, `500K`, `N/A`. */
58
+ context: string;
59
+ /** As printed: `TRV`, `TR`, `·`. */
60
+ caps: string;
61
+ /** ISO date, or "" when the table printed an em dash for an unknown release. */
62
+ released: string;
63
+ }
64
+
65
+ /** How the catalogue is fetched. A seam — see the header. */
66
+ export type CatalogFetcher = () => Promise<string | null>;
67
+
68
+ /** Milliseconds before a wedged claudish is abandoned. It is drawing three rows, not a page. */
69
+ export const CATALOG_TIMEOUT_MS = 8000;
70
+
71
+ /**
72
+ * A table row: rank, id, provider, pricing, context, caps, released.
73
+ *
74
+ * Every field claudish prints is whitespace-free — providers are slugs (`thinking-machines`),
75
+ * pricing has no spaces (`$4.00/1M`, `FREE`), caps is a letter run or `·`, and an unknown
76
+ * release date is a single em dash. So a row is EXACTLY seven tokens with a number first, and
77
+ * that is the whole rule.
78
+ *
79
+ * Requiring exactly seven is the defensive choice, not a brittle one. If claudish adds a
80
+ * column, every row stops matching, the catalogue comes back empty, and empty means
81
+ * "unknown" — which suppresses the advisory note and changes nothing else on screen. The
82
+ * alternative, accepting `>= 7` and taking the first seven, would keep matching while
83
+ * silently reading the wrong field into `id` and marking live models as retired.
84
+ */
85
+ const ROW = /^\s*\d+\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*$/;
86
+
87
+ /**
88
+ * Strip SGR sequences.
89
+ *
90
+ * MEASURED: `claudish --models | cat` emits none, so on the path this module actually takes
91
+ * (a pipe, never a TTY) there is nothing to strip. Kept anyway, because claudish is free to
92
+ * start colouring a piped table, and an ESC left in the string would ride into an `id` and
93
+ * make a live model look retired — the exact false positive this module must never produce.
94
+ *
95
+ * The ESC is written as an escape rather than as the raw byte it first was: a literal control
96
+ * character in source survives no reformat and no copy-paste, and losing it silently turns
97
+ * this into a function that strips square brackets out of model ids.
98
+ */
99
+ function stripAnsi(text: string): string {
100
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: an SGR sequence starts with ESC
101
+ return text.replace(/\u001b\[[0-9;]*m/g, "");
102
+ }
103
+
104
+ /**
105
+ * Parse the table `claudish --models` prints. Never throws.
106
+ *
107
+ * Non-row lines fall out on their own rather than needing a section parser: the header starts
108
+ * with `#`, the rule is one long dash run, the `Caps:` legend and the local-provider block
109
+ * have the wrong token count, and the footer lines start with words. A section-aware parser
110
+ * would have to be taught each of those and would break when one moved.
111
+ */
112
+ export function parseModelsTable(text: string): CatalogModel[] {
113
+ const out: CatalogModel[] = [];
114
+ const seen = new Set<string>();
115
+ for (const line of stripAnsi(text).split("\n")) {
116
+ const m = ROW.exec(line);
117
+ if (!m) continue;
118
+ const [, id, provider, pricing, context, caps, released] = m;
119
+ if (!id || seen.has(id)) continue;
120
+ seen.add(id);
121
+ out.push({
122
+ id,
123
+ provider: provider ?? "",
124
+ pricing: pricing ?? "",
125
+ context: context ?? "",
126
+ caps: caps ?? "",
127
+ // An em dash means claudish does not know the date. "" says that; carrying the
128
+ // dash through would put a glyph in a field every reader treats as a date.
129
+ released: released === "—" ? "" : (released ?? ""),
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Run `claudish --models` and hand back its stdout, or null.
137
+ *
138
+ * Null for every failure mode there is: no claudish on PATH (`error`), a non-zero exit, or a
139
+ * process still running after the timeout. The caller cannot tell them apart and does not
140
+ * need to — all three mean the catalogue is unknown, and unknown has exactly one behaviour.
141
+ */
142
+ const runClaudishModels: CatalogFetcher = () =>
143
+ new Promise((resolve) => {
144
+ let settled = false;
145
+ const done = (value: string | null) => {
146
+ if (settled) return;
147
+ settled = true;
148
+ resolve(value);
149
+ };
150
+ let child: ReturnType<typeof spawn>;
151
+ try {
152
+ child = spawn("claudish", ["--models"], {
153
+ stdio: ["ignore", "pipe", "ignore"],
154
+ });
155
+ } catch {
156
+ // `spawn` throws rather than emitting `error` for some argv/permission failures.
157
+ done(null);
158
+ return;
159
+ }
160
+ let out = "";
161
+ child.stdout?.on("data", (chunk) => {
162
+ out += String(chunk);
163
+ });
164
+ child.on("error", () => done(null));
165
+ child.on("close", (code) => done(code === 0 ? out : null));
166
+ // Never let a wedged claudish hold the TUI. `unref` so a pending timer cannot keep
167
+ // the CLI's process alive after it has printed everything it means to print.
168
+ setTimeout(() => {
169
+ child.kill();
170
+ done(null);
171
+ }, CATALOG_TIMEOUT_MS).unref?.();
172
+ });
173
+
174
+ /**
175
+ * Session cache, including the empty result.
176
+ *
177
+ * Caching a FAILURE is deliberate. The catalogue cannot change while claudeup is open, and a
178
+ * machine with no claudish would otherwise pay the spawn — and its timeout — once per screen
179
+ * that asks. One failed lookup answers for the session, which is the same reasoning
180
+ * `content-drift.ts` uses for its own session cache.
181
+ */
182
+ let cached: CatalogModel[] | null = null;
183
+ let inFlight: Promise<CatalogModel[]> | null = null;
184
+
185
+ /** Drop the cached catalogue. For tests, and for an explicit reload. */
186
+ export function clearMateCatalogCache(): void {
187
+ cached = null;
188
+ inFlight = null;
189
+ }
190
+
191
+ /**
192
+ * The live catalogue, or [] when it could not be read.
193
+ *
194
+ * [] is not distinguished from "claudish returned nothing", by design: both mean the same
195
+ * thing to every caller, and a tri-state would invite someone to branch on it and then have
196
+ * to decide what an error should do — which is the decision this module exists to refuse.
197
+ *
198
+ * Concurrent callers share one in-flight promise. Both screens on this tab ask at mount, and
199
+ * two spawns of the same read-only diagnostic is pure waste.
200
+ */
201
+ export async function loadMateCatalog(
202
+ fetch: CatalogFetcher = runClaudishModels,
203
+ ): Promise<CatalogModel[]> {
204
+ if (cached !== null) return cached;
205
+ if (inFlight) return inFlight;
206
+ inFlight = (async () => {
207
+ let models: CatalogModel[] = [];
208
+ try {
209
+ const text = await fetch();
210
+ if (text) models = parseModelsTable(text);
211
+ } catch {
212
+ // A thrown fetcher is a failed lookup like any other. Nothing above this line is
213
+ // allowed to see an exception from a catalogue read.
214
+ models = [];
215
+ }
216
+ cached = models;
217
+ inFlight = null;
218
+ return models;
219
+ })();
220
+ return inFlight;
221
+ }
222
+
223
+ /** Every id the catalogue lists. The shape the staleness check and the UI actually want. */
224
+ export function catalogIds(models: CatalogModel[]): Set<string> {
225
+ return new Set(models.map((model) => model.id));
226
+ }
227
+
228
+ /**
229
+ * Which of these bound ids the catalogue does not list.
230
+ *
231
+ * RETURNS NOTHING WHEN THE CATALOGUE IS EMPTY. That is the whole contract in one line: an
232
+ * unread catalogue lists nothing, so a naive "not in the set" test would mark every binding
233
+ * on the machine as retired the moment claudish was missing — an advisory that fires hardest
234
+ * exactly when it knows least. Same rule as the image-model staleness check: return nothing
235
+ * when you cannot reach the catalogue.
236
+ *
237
+ * Exact match only. A near-match would be the failure this is meant to catch, wearing a
238
+ * helpful face: `grok-4.5` is not a stand-in for a retired `grok-4.6`.
239
+ */
240
+ export function unknownBindings(
241
+ ids: Iterable<string>,
242
+ catalog: CatalogModel[],
243
+ ): string[] {
244
+ if (catalog.length === 0) return [];
245
+ const known = catalogIds(catalog);
246
+ const out: string[] = [];
247
+ for (const id of ids) {
248
+ if (!known.has(id) && !out.includes(id)) out.push(id);
249
+ }
250
+ return out;
251
+ }
252
+
253
+ /**
254
+ * The advisory sentence, or null when there is nothing to say.
255
+ *
256
+ * Worded as an observation, never as an instruction: claudeup has not checked whether the
257
+ * model was renamed, retired, or simply sits outside the top 100 rows this diagnostic prints
258
+ * — and `--models` is a TOP-N view, so absence is genuinely weaker evidence than it looks.
259
+ * "may no longer exist" is the strongest claim the evidence supports.
260
+ */
261
+ export function stalenessNote(unknown: string[]): string | null {
262
+ if (unknown.length === 0) return null;
263
+ const list = unknown.join(", ");
264
+ return `${list} ${unknown.length === 1 ? "is" : "are"} not in claudish's live catalogue — the id may have changed, or it may sit outside the models it lists. Check with \`claudish --models\`.`;
265
+ }