claudeup 6.7.1 → 6.8.1

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,133 @@
1
+ /**
2
+ * Is there anything on this machine that could serve a mate?
3
+ *
4
+ * A mate (`mate1`, `mate2`, `kangaroo`) names a third-party model reached through claudish,
5
+ * and claudish is reached through the `multimodel@magus` plugin. Without it, drawing the
6
+ * three slots on the Models screen advertises routing that cannot happen — so the renderers
7
+ * take this as a boolean and draw them only when it is true.
8
+ *
9
+ * ## Three things this module deliberately is NOT
10
+ *
11
+ * It is not in `models-core.ts`. That module is pure — no fs, no process, no network — and
12
+ * has to stay that way: Claude Code runs the hook on every single Agent call, and the hook
13
+ * imports the core.
14
+ *
15
+ * It is not in the renderers either. Answering the question needs the plugin registry, which
16
+ * is the adapter layer's to read, and a renderer that reached for it could not be tested
17
+ * without a machine to read.
18
+ *
19
+ * And it is not part of VALIDATION. A committed `models.json` naming `mate1` is valid
20
+ * everywhere, whether or not the plugin is installed — see `validateModelsConfig`. Gating
21
+ * validity on a local install would make one file valid on one teammate's laptop and invalid
22
+ * on the next one's.
23
+ *
24
+ * ## Why the registry is read through `plugin-manager`
25
+ *
26
+ * `installed_plugins.json`, `enabledPlugins` and `installedPluginVersions` are Claude Code's
27
+ * to own, and the last of those is maintained by nothing but claudeup, so it goes stale
28
+ * silently. `getAvailablePlugins` resolves all three scopes and hands back both facts per
29
+ * scope, which is all this module takes from it.
30
+ *
31
+ * The test on those facts is `servesAMate`, below, and it is deliberately NOT
32
+ * `plugin-manager`'s `isInstalledInScope` — that one answers a narrower question. See the
33
+ * comment on the predicate for why the two must not share a helper.
34
+ */
35
+
36
+ import {
37
+ type PluginInfo,
38
+ type ScopeStatus,
39
+ getAvailablePlugins,
40
+ } from "./plugin-manager.js";
41
+
42
+ /** The plugin that puts claudish's model tools in reach. */
43
+ export const MATE_PLUGIN_ID = "multimodel@magus";
44
+
45
+ /** How the plugins are listed. A seam, for the reason below. */
46
+ export type PluginLister = (projectPath?: string) => Promise<PluginInfo[]>;
47
+
48
+ /** The env var that forces the slots on, and the values it accepts. */
49
+ export const MATE_FORCE_ENV = "CLAUDEUP_MATES";
50
+ const FORCE_ON = new Set(["1", "true", "on", "yes"]);
51
+
52
+ /**
53
+ * Draw the slots regardless of what is installed: `CLAUDEUP_MATES=1 claudeup`.
54
+ *
55
+ * The three rows ARE the feature, and on a machine without `multimodel@magus` there is no way
56
+ * to put them on screen — which leaves the one part of this layout that cannot be checked by
57
+ * eye being the one most likely to be wrong, since `kangaroo` is eight cells and every column
58
+ * here was sized for six.
59
+ *
60
+ * Read at CALL time, never captured at module load. Bun loads a cwd `.env` before user code
61
+ * runs, so a value read at import is a snapshot of whichever directory the process started in
62
+ * — and this process is a TUI a user starts from anywhere.
63
+ *
64
+ * It forces the ANSWER and nothing else. No plugin appears because a flag was set, so a mate
65
+ * still passes straight through the hook and the agent still runs on whatever it would have
66
+ * run on. The flag moves pixels, not routing.
67
+ */
68
+ export function matesForced(env: NodeJS.ProcessEnv = process.env): boolean {
69
+ const raw = env[MATE_FORCE_ENV];
70
+ return raw !== undefined && FORCE_ON.has(raw.trim().toLowerCase());
71
+ }
72
+
73
+ /**
74
+ * True when `multimodel@magus` is installed in ANY scope.
75
+ *
76
+ * Any scope, because a mate is served by whatever Claude Code has loaded when the agent is
77
+ * spawned, and that is the union of the three — a user-scope install serves a project that
78
+ * enables nothing of its own.
79
+ *
80
+ * FAILS CLOSED. This reaches the marketplace resolution path, which does network work and can
81
+ * time out; a failure means "we do not know", and the honest rendering of not knowing is the
82
+ * screen exactly as it was before mates existed. Returning true on a failure would put three
83
+ * rows on screen on the strength of an error.
84
+ *
85
+ * `listPlugins` is injected the same way `buildSettingsPatch` takes its `resolveFullId`: the
86
+ * DECISION here — which scopes count, what an enabled-but-not-installed plugin means, what a
87
+ * thrown error means — is the part worth testing, and it cannot be tested through a function
88
+ * that reaches the network and a real machine's registry. The default is the real one, so no
89
+ * caller has to know the seam exists.
90
+ */
91
+ /**
92
+ * Can this scope actually serve a mate? Both facts, not either one.
93
+ *
94
+ * Deliberately NOT `isInstalledInScope`, which asks a narrower question: that one means the
95
+ * files are on disk, and says so — it dropped `enabled` from its test on purpose, so the
96
+ * plugin list would stop offering to install what was already there.
97
+ *
98
+ * Presence on disk is not what this module needs. A plugin the user has switched off is not
99
+ * loaded, so claudish is not reachable through it, so the routing a mate row advertises
100
+ * cannot happen — which is the one thing these rows must never claim. The other half matters
101
+ * for the mirror-image state: a settings flag with no registry version is the broken
102
+ * enabled-but-not-installed case, and a row drawn on the strength of it promises a plugin
103
+ * that never loaded.
104
+ *
105
+ * So both, and locally, rather than reaching for a shared helper whose meaning is owned by a
106
+ * different question.
107
+ */
108
+ function servesAMate(scope: ScopeStatus | undefined): boolean {
109
+ return !!scope?.enabled && !!scope.version;
110
+ }
111
+
112
+ export async function areMatesAvailable(
113
+ projectPath?: string,
114
+ listPlugins: PluginLister = getAvailablePlugins,
115
+ ): Promise<boolean> {
116
+ // Before the registry, so the flag also skips the network work behind it. Someone who
117
+ // set it has already said what answer they want; making them wait for a lookup whose
118
+ // result is discarded would be the slowest possible way to agree with them.
119
+ if (matesForced()) return true;
120
+ try {
121
+ const plugin = (await listPlugins(projectPath)).find(
122
+ (candidate) => candidate.id === MATE_PLUGIN_ID,
123
+ );
124
+ if (!plugin) return false;
125
+ return (
126
+ servesAMate(plugin.userScope) ||
127
+ servesAMate(plugin.projectScope) ||
128
+ servesAMate(plugin.localScope)
129
+ );
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
@@ -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
+ }