claudeup 6.6.0 → 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.
- package/package.json +4 -4
- package/src/__tests__/conventions-manager.test.ts +45 -45
- package/src/__tests__/enabled-not-installed.test.ts +1 -1
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/model-visuals.test.tsx +1698 -25
- package/src/__tests__/models-adapter.test.ts +21 -6
- package/src/__tests__/models-cli.test.ts +100 -0
- package/src/__tests__/models-core.test.ts +273 -111
- package/src/__tests__/models-manager.test.ts +15 -12
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
- package/src/__tests__/resolver.test.ts +3 -3
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/data/models-presets.ts +72 -13
- package/src/data/predefined-profiles.ts +7 -7
- package/src/services/claude-settings.ts +1 -1
- package/src/services/community-styles.ts +1 -1
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/models-core.ts +371 -30
- package/src/ui/adapters/modelsAdapter.ts +58 -15
- package/src/ui/components/layout/ScreenLayout.tsx +6 -1
- package/src/ui/renderers/modelRenderers.tsx +413 -108
- package/src/ui/renderers/modelVisuals.tsx +694 -145
- package/src/ui/screens/ModelsScreen.tsx +74 -10
- package/src/ui/state/reducer.ts +20 -0
- package/src/ui/state/types.ts +31 -0
- package/src/ui/theme-mode.ts +128 -14
|
@@ -52,7 +52,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
|
|
|
52
52
|
magusPlugins: [
|
|
53
53
|
"setup",
|
|
54
54
|
"multimodel",
|
|
55
|
-
"code-
|
|
55
|
+
"code-search",
|
|
56
56
|
"dev",
|
|
57
57
|
"browser-use",
|
|
58
58
|
"designer",
|
|
@@ -90,7 +90,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
|
|
|
90
90
|
icon: "★",
|
|
91
91
|
magusPlugins: [
|
|
92
92
|
"dev",
|
|
93
|
-
"code-
|
|
93
|
+
"code-search",
|
|
94
94
|
"terminal",
|
|
95
95
|
"setup",
|
|
96
96
|
"designer",
|
|
@@ -129,7 +129,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
|
|
|
129
129
|
name: "Backend Forge",
|
|
130
130
|
description: "API development, debugging, code quality, data workflows",
|
|
131
131
|
icon: "★",
|
|
132
|
-
magusPlugins: ["dev", "code-
|
|
132
|
+
magusPlugins: ["dev", "code-search", "terminal", "setup", "multimodel"],
|
|
133
133
|
anthropicPlugins: [
|
|
134
134
|
"feature-dev",
|
|
135
135
|
"code-review",
|
|
@@ -159,7 +159,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
|
|
|
159
159
|
icon: "★",
|
|
160
160
|
magusPlugins: [
|
|
161
161
|
"dev",
|
|
162
|
-
"code-
|
|
162
|
+
"code-search",
|
|
163
163
|
"terminal",
|
|
164
164
|
"setup",
|
|
165
165
|
"multimodel",
|
|
@@ -194,11 +194,11 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
|
|
|
194
194
|
icon: "★",
|
|
195
195
|
magusPlugins: [
|
|
196
196
|
"dev",
|
|
197
|
-
"code-
|
|
197
|
+
"code-search",
|
|
198
198
|
"terminal",
|
|
199
199
|
"setup",
|
|
200
200
|
"browser-use",
|
|
201
|
-
"image
|
|
201
|
+
"image@magus-marketing",
|
|
202
202
|
"video-editing@magus-marketing",
|
|
203
203
|
],
|
|
204
204
|
anthropicPlugins: [
|
|
@@ -226,7 +226,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
|
|
|
226
226
|
description:
|
|
227
227
|
"Planning, code review, coordination, and broad repo visibility",
|
|
228
228
|
icon: "★",
|
|
229
|
-
magusPlugins: ["dev", "code-
|
|
229
|
+
magusPlugins: ["dev", "code-search", "terminal", "setup", "multimodel"],
|
|
230
230
|
anthropicPlugins: [
|
|
231
231
|
"code-review",
|
|
232
232
|
"pr-review-toolkit",
|
|
@@ -347,7 +347,7 @@ async function overlayRegistryVersions(
|
|
|
347
347
|
// binary, and `claude plugin install` writes it at no scope — so only
|
|
348
348
|
// claudeup's own past writes can be in there, and they go stale silently.
|
|
349
349
|
// Measured: user settings claimed browser-use 1.1.2 / terminal 4.0.2 /
|
|
350
|
-
// code-
|
|
350
|
+
// code-search 5.1.0 while the registry said 1.4.0 / 4.1.4 / 5.3.1, and
|
|
351
351
|
// none of the claimed versions had a cache directory. Keeping the stale
|
|
352
352
|
// value made every one of them read as a permanent pending update.
|
|
353
353
|
if (entries && entries.length > 0) delete merged[pluginId];
|
|
@@ -727,7 +727,7 @@ export function describeCommunityFailure(
|
|
|
727
727
|
// ─── Staleness: advisory, two-phase, never automatic ─────────────────────────
|
|
728
728
|
|
|
729
729
|
/**
|
|
730
|
-
* 24 hours, matching image
|
|
730
|
+
* 24 hours, matching the image plugin's staleness precedent in the root CLAUDE.md.
|
|
731
731
|
*
|
|
732
732
|
* The TTL is not only a "when to re-check" rule — it is also a "how long may we
|
|
733
733
|
* keep SAYING up-to-date" rule. A check from three days ago is evidence about
|
|
@@ -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
|
+
}
|