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.
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The agent tier table is keyed by agent id, and nothing else checks those ids.
2
+ * The agent tier table and the workflow list are keyed by agent id, and nothing else
3
+ * checks those ids.
3
4
  *
4
5
  * `DEFAULT_AGENT_GRADES` in `data/models-presets.ts` names
5
6
  * agents as `plugin:name`. A rename in the marketplace (`code-search:analyze` →
@@ -17,7 +18,11 @@
17
18
  import { describe, expect, test } from "bun:test";
18
19
  import { existsSync, readFileSync, readdirSync } from "node:fs";
19
20
  import { join, resolve } from "node:path";
20
- import { DEFAULT_AGENT_GRADES, VISION_AGENTS } from "../data/models-presets.js";
21
+ import {
22
+ DEFAULT_AGENT_GRADES,
23
+ VISION_AGENTS,
24
+ WORKFLOWS,
25
+ } from "../data/models-presets.js";
21
26
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
22
27
 
23
28
  const REPO = resolve(import.meta.dir, "..", "..", "..", "..");
@@ -100,6 +105,28 @@ describe.skipIf(!inMagusSrc)(
100
105
  expect(dead).toEqual([]);
101
106
  });
102
107
 
108
+ /**
109
+ * (c) `WORKFLOWS` names agent ids too, and until now nothing checked them.
110
+ *
111
+ * These ids drive the per-workflow spread graphic. A stale one matches no agent, so
112
+ * its segment silently vanishes and the chart goes on drawing a confident answer
113
+ * about a workflow it has mis-measured — the same silent-drift failure (a) exists to
114
+ * catch, one array over. Measured: the 13.0.0 `code-analysis` → `code-search` rename
115
+ * reached this array through a rebase conflict rather than through this gate.
116
+ *
117
+ * Deliberately NOT folded into `presetKeys`. Being named in a workflow is not a
118
+ * routing classification, so counting it in (b) would let a workflow mention excuse
119
+ * an agent from ever being given a tier.
120
+ */
121
+ test("(c) every workflow agent names an existing agent or a built-in", () => {
122
+ const workflowKeys = WORKFLOWS.flatMap((w) => [
123
+ ...w.agents,
124
+ ...w.external.map((step) => step.agent),
125
+ ]);
126
+ const dead = workflowKeys.filter((k) => !BUILT_IN.has(k) && !ids.has(k));
127
+ expect(dead).toEqual([]);
128
+ });
129
+
103
130
  test("(b) every marketplace agent is classified somewhere", () => {
104
131
  const classified = new Set<string>([
105
132
  ...presetKeys,
@@ -24,6 +24,11 @@ function snapshot(over: Partial<ModelsSnapshot> = {}): ModelsSnapshot {
24
24
  };
25
25
  }
26
26
 
27
+ /** Drop `//` line comments and block comments, so a source guard matches code only. */
28
+ function stripComments(source: string): string {
29
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
30
+ }
31
+
27
32
  /** Navigating away and back is what unmounts the screen. */
28
33
  const roundTrip: AppAction[] = [
29
34
  { type: "NAVIGATE", route: { screen: "plugins" } },
@@ -36,6 +41,9 @@ describe("models screen initial state", () => {
36
41
  expect(initialState.models.searchQuery).toBe("");
37
42
  expect(initialState.models.status).toBeNull();
38
43
  expect(initialState.models.isApplying).toBe(false);
44
+ // The mate slots are not drawn until something says they can be served. Starting
45
+ // true would put three rows on screen before anything had been asked.
46
+ expect(initialState.models.matesAvailable).toBe(false);
39
47
  });
40
48
 
41
49
  test("the models route is reachable and NAVIGATE keeps the screen's state", () => {
@@ -194,6 +202,44 @@ describe("apply-in-flight state", () => {
194
202
  });
195
203
  });
196
204
 
205
+ describe("the mate slots' availability", () => {
206
+ test("survives the screen unmounting, so the lookup happens once a session", () => {
207
+ // Unlike the two file reads this screen does on mount, this answer costs a
208
+ // marketplace resolution with network timeouts behind it. Held locally it would die
209
+ // on every tab switch and be paid for again on every return — for something that
210
+ // cannot change while the TUI is open.
211
+ const available = run([
212
+ { type: "MODELS_MATES_AVAILABLE", available: true },
213
+ ]);
214
+ expect(available.models.matesAvailable).toBe(true);
215
+ expect(run(roundTrip, available).models.matesAvailable).toBe(true);
216
+ });
217
+
218
+ test("a false answer is recorded, not treated as no answer", () => {
219
+ // "Not installed" and "never asked" render the same, and that is deliberate — but
220
+ // the reducer still has to hold the difference, or a later dispatch could not undo it.
221
+ const state = run([
222
+ { type: "MODELS_MATES_AVAILABLE", available: true },
223
+ { type: "MODELS_MATES_AVAILABLE", available: false },
224
+ ]);
225
+ expect(state.models.matesAvailable).toBe(false);
226
+ });
227
+
228
+ test("it touches no other part of the models state", () => {
229
+ const before = run([
230
+ { type: "MODELS_SELECT", index: 2 },
231
+ { type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
232
+ ]);
233
+ const after = run(
234
+ [{ type: "MODELS_MATES_AVAILABLE", available: true }],
235
+ before,
236
+ );
237
+ expect(after.models.selectedIndex).toBe(2);
238
+ expect(after.models.data).toBe(before.models.data);
239
+ expect(after.styles).toBe(before.styles);
240
+ });
241
+ });
242
+
197
243
  describe("selection state", () => {
198
244
  test("select records the index verbatim", () => {
199
245
  expect(
@@ -253,7 +299,17 @@ describe("ModelsScreen keybindings", () => {
253
299
  test("screen state is held in the reducer, never in useState", async () => {
254
300
  // The rule the two "survives unmounting" tests above enforce at runtime,
255
301
  // pinned at the source so a new piece of state cannot quietly opt out.
256
- const src = await fs.readFile(SCREEN, "utf8");
302
+ //
303
+ // Comments are stripped first, the way hook-import-policy.test.ts does it. The rule
304
+ // is about CODE, and this screen documents why each piece of its state lives in the
305
+ // reducer — naming the hook it is deliberately not using is the clearest way to say
306
+ // that, and a guard reading the prose would forbid the explanation along with the
307
+ // thing explained.
308
+ const src = stripComments(await fs.readFile(SCREEN, "utf8"));
257
309
  expect(src).not.toContain("useState");
310
+ // Negative control: stripping must not have eaten the code being guarded. A guard
311
+ // over an empty string passes forever.
312
+ expect(src).toContain("useEffect");
313
+ expect(src).toContain("useCallback");
258
314
  });
259
315
  });
package/src/cli/doctor.ts CHANGED
@@ -21,7 +21,6 @@ import { readManifest } from "../services/manifest.js";
21
21
  import {
22
22
  readModelsConfig,
23
23
  readModelsStatus,
24
- reapplyModels,
25
24
  } from "../services/models-manager.js";
26
25
  import { resolveAllProfiles } from "../services/resolver.js";
27
26
  import { activeProfile, profileDir } from "../services/symlink-manager.js";
@@ -172,19 +171,15 @@ async function checkModels(projectPath: string, fix: boolean): Promise<number> {
172
171
  );
173
172
  }
174
173
 
174
+ // No drift check. Settings differing from the config is what a CUSTOM config is, and
175
+ // `doctor` reports problems — a project running models no shipped preset names has none.
176
+ // So `doctor --fix` no longer reapplies anything: `invalid` returns early above (those
177
+ // are choices, not damage) and `unhooked` is repaired by registering the hook. Writing
178
+ // settings back from the config is `claudeup models use <preset>`, which is a decision
179
+ // the user makes, not a repair.
175
180
  const status = await readModelsStatus(projectPath);
176
- if (status.state === "stale") {
177
- console.log(" settings have drifted from the config:");
178
- for (const line of status.drift) console.log(` ${line}`);
179
- if (fix) {
180
- await reapplyModels(projectPath);
181
- console.log(" Re-applied the config (fixed).");
182
- } else {
183
- console.log(" Fix: claudeup doctor --fix");
184
- problems++;
185
- }
186
- } else if (status.state === "on") {
187
- console.log(` ✓ preset "${status.preset}" applied, no drift.`);
181
+ if (status.state === "on") {
182
+ console.log(" routing is applied.");
188
183
  }
189
184
 
190
185
  for (const warning of status.warnings) console.log(` ⚠ ${warning}`);
package/src/cli/models.ts CHANGED
@@ -18,10 +18,20 @@ import {
18
18
  presetLabel,
19
19
  presetNames,
20
20
  } from "../data/models-presets.js";
21
+ import {
22
+ loadMateCatalog,
23
+ stalenessNote,
24
+ unknownBindings,
25
+ } from "../services/mate-catalog.js";
21
26
  import {
22
27
  GRADES,
23
28
  type ModelsConfig,
24
29
  type ModelsState,
30
+ boundModel,
31
+ isMate,
32
+ mateEffort,
33
+ matesInUse,
34
+ sameRouting,
25
35
  } from "../services/models-core.js";
26
36
  import {
27
37
  applyModelPreset,
@@ -35,13 +45,34 @@ import { ensureManifest } from "./bootstrap.js";
35
45
  const HEADLINE: Record<ModelsState, string> = {
36
46
  off: "off — no .claude/models.json, subagents inherit the session model",
37
47
  on: "on",
38
- stale: "stale — settings no longer match the config",
39
48
  invalid: "invalid — nothing is routed",
40
49
  unhooked: "unhooked — the config is there, but nothing runs it",
41
50
  };
42
51
 
43
- /** `opus (medium)`, or `opus` when no effort is set. */
44
- function describeSpec(spec: { model: string; effort?: string }): string {
52
+ /**
53
+ * `opus (medium)`, or `opus` when no effort is set.
54
+ *
55
+ * A mate prints the SLOT and what it is bound to — `mate1 → grok-4.6` — because both are
56
+ * answers to different questions the reader has: which role this tier plays, and which model
57
+ * plays it. The slot alone was all that could be printed before the `mates` block existed,
58
+ * and a bare `kangaroo` in a column of `opus (xhigh)` read as a model id nobody recognises.
59
+ *
60
+ * An effort on a mate is qualified `(declared)`, for the reason spelled out at
61
+ * `DECLARED_SUFFIX`: claudeup writes it for claudish and nothing applies it yet, so printing
62
+ * a bare `(high)` beside a tier where `(high)` IS in force would claim they do the same
63
+ * thing.
64
+ */
65
+ function describeSpec(
66
+ config: ModelsConfig,
67
+ spec: { model: string; effort?: string },
68
+ ): string {
69
+ if (isMate(spec.model)) {
70
+ const bound = boundModel(config, spec.model);
71
+ const effort = spec.effort ?? mateEffort(config, spec.model);
72
+ const target = bound ?? "unbound";
73
+ const note = effort ? `${effort} declared, via claudish` : "via claudish";
74
+ return `${spec.model} → ${target} (${note})`;
75
+ }
45
76
  return spec.effort ? `${spec.model} (${spec.effort})` : spec.model;
46
77
  }
47
78
 
@@ -58,24 +89,82 @@ function printPreset(preset: ModelsConfig, active: boolean): void {
58
89
  console.log(
59
90
  `${mark} ${presetLabel(preset.preset)} — ${preset.preset}${isDefault}`,
60
91
  );
61
- console.log(` main ${describeSpec(preset.main)}`);
92
+ console.log(` main ${describeSpec(preset, preset.main)}`);
62
93
  for (const grade of GRADES) {
63
94
  console.log(
64
- ` ${grade.padEnd(10)} ${describeSpec(preset.grades[grade])}`,
95
+ ` ${grade.padEnd(10)} ${describeSpec(preset, preset.grades[grade])}`,
65
96
  );
66
97
  }
67
98
  }
68
99
 
69
100
  async function runList(projectPath: string): Promise<number> {
70
101
  const { config } = await readModelsConfig(projectPath);
102
+
103
+ // Has the project diverged from the built-in it NAMES?
104
+ //
105
+ // The test used to be "does it name a preset the built-ins do not", which misses the
106
+ // ordinary case: `claudeup models use opus-lead` writes that name, and every hand edit
107
+ // afterwards leaves it in place. So a config with a `mates` block was reported as the
108
+ // built-in `opus-lead`, and the `●` sat on a preset that is not what runs. Compared by
109
+ // CONTENT, shared with the TUI through `sameRouting` so the two cannot disagree about
110
+ // which routing is in force.
111
+ const builtInNamed = BUILT_IN_PRESETS.find(
112
+ (preset) => preset.preset === config?.preset,
113
+ );
114
+ const diverged =
115
+ config !== null && (!builtInNamed || !sameRouting(config, builtInNamed));
116
+
71
117
  console.log("\nModel tiers:\n");
72
118
  for (const preset of BUILT_IN_PRESETS) {
73
- printPreset(preset, config?.preset === preset.preset);
119
+ printPreset(preset, !diverged && config?.preset === preset.preset);
74
120
  console.log();
75
121
  }
76
- if (config && !presetNames().includes(config.preset)) {
77
- console.log(`● ${config.preset} (this project's own, not a built-in)\n`);
122
+ if (config && diverged) {
123
+ // The project's own config gets its tiers PRINTED, not just its name.
124
+ //
125
+ // It is the routing actually in force, and it is the only config here that can name a
126
+ // mate — no built-in does — so printing the name alone hid the one line a reader came
127
+ // for. The built-ins above are shown in full; showing the active one in less detail
128
+ // than the ones it replaced was backwards.
129
+ printPreset(config, true);
130
+ console.log(" (this project's own, not a built-in)\n");
78
131
  }
132
+
133
+ // Which agents leave Claude Code entirely.
134
+ //
135
+ // `models list` prints tiers, never agents, so a mate assigned per-agent — the ordinary
136
+ // way to use one — appears nowhere else in this command's output. It is also the one
137
+ // routing decision here that claudeup does not carry out itself, which makes it exactly
138
+ // the thing worth naming rather than leaving to be discovered in the file.
139
+ const mated = Object.entries(config?.agents ?? {}).filter(
140
+ ([, assignment]) =>
141
+ typeof assignment !== "string" && isMate(assignment.model),
142
+ );
143
+ if (config && mated.length > 0) {
144
+ console.log("Routed outside Claude Code, through claudish:\n");
145
+ for (const [agent, assignment] of mated) {
146
+ const slot =
147
+ typeof assignment === "string" ? assignment : assignment.model;
148
+ // The SLOT and the model it names. The slot is what the file says and what the
149
+ // reader greps for; the bound id is what actually runs. Printing only the slot was
150
+ // the CLI's version of the screen's `mate1 mate1 —` — a role where a model belongs.
151
+ const bound = isMate(slot) ? boundModel(config, slot) : null;
152
+ const target = bound ?? "unbound — add it to the mates block";
153
+ console.log(` ${agent.padEnd(28)} ${slot.padEnd(10)} ${target}`);
154
+ }
155
+ console.log();
156
+
157
+ // The staleness advisory, on the same terms as everywhere else: ADVISORY, and silent
158
+ // when the catalogue could not be read. `unknownBindings` returns nothing for an empty
159
+ // catalogue, so a machine without claudish prints no note rather than flagging every
160
+ // binding it cannot check.
161
+ const bound = matesInUse(config)
162
+ .map((mate) => boundModel(config, mate))
163
+ .filter((id): id is string => id !== null);
164
+ const note = stalenessNote(unknownBindings(bound, await loadMateCatalog()));
165
+ if (note) console.log(`⚠ ${note}\n`);
166
+ }
167
+
79
168
  console.log(
80
169
  "Tiers name what a subagent is FOR: `smart` takes the judgement calls, `cheap`",
81
170
  );
@@ -97,11 +186,6 @@ async function runStatus(projectPath: string): Promise<number> {
97
186
  if (status.drift.length > 0) {
98
187
  console.log(status.state === "invalid" ? "\nErrors:" : "\nDrift:");
99
188
  for (const line of status.drift) console.log(` ✗ ${line}`);
100
- if (status.state === "stale") {
101
- console.log(
102
- "\n Fix: claudeup models use <preset> (or claudeup doctor --fix)",
103
- );
104
- }
105
189
  if (status.state === "unhooked")
106
190
  console.log("\n Fix: claudeup doctor --fix");
107
191
  }
@@ -13,7 +13,7 @@
13
13
  * `medium`) rather than a harder-working one. What holds everywhere is the other half: the
14
14
  * thread dispatching the work is never strictly the biggest spend in the preset.
15
15
  */
16
- import type { Grade, ModelsConfig } from "../services/models-core.js";
16
+ import type { Effort, Grade, ModelsConfig } from "../services/models-core.js";
17
17
 
18
18
  /**
19
19
  * Which tier each subagent gets, shared by every preset.
@@ -70,8 +70,43 @@ export const DEFAULT_AGENT_GRADES: Record<string, Grade> = {
70
70
  * Read out of `plugins/dev/commands/{dev,debug,investigate}.md` — agents only. Those files
71
71
  * also name skills (`dev:context-detection`, `code-search:investigate`) which are not
72
72
  * dispatched through the Agent tool and so are not routed at all.
73
+ *
74
+ * ## `external` — the steps that leave Claude Code, whatever the preset says
75
+ *
76
+ * A workflow's use of an outside model is a property of the WORKFLOW, not of the routing
77
+ * config. `/dev:dev` runs a multi-model plan review at Phase 3 and a multi-model code review
78
+ * at Phase 5 because its command file says so, and switching from `Sonnet` to `Opus main`
79
+ * does not change that. So these steps are declared here beside the agents and are drawn
80
+ * under every preset, rather than being read out of `config.agents` — where they would
81
+ * appear only for a project that happened to have hand-bound a slot.
82
+ *
83
+ * Read out of the same command files:
84
+ * `plugins/dev/commands/dev.md:138` Plan review (external models via claudish) → dev:architect
85
+ * `plugins/dev/commands/dev.md:140` Code review (external models via claudish) → dev:architect
86
+ *
87
+ * `debug.md` and `investigate.md` contain no claudish reference at all, so both are empty
88
+ * here — and that absence is worth drawing, because "this flow stays inside Claude Code" is
89
+ * exactly as useful to know as the opposite.
90
+ *
91
+ * A STEP, not an agent: `dev:architect` appears in `agents` above as well, because the same
92
+ * agent does the Phase 3 planning on a Claude model. Marking the agent external would be
93
+ * wrong in both directions — it would move the planning outside and merge the two reviews
94
+ * into it.
73
95
  */
74
- export const WORKFLOWS: { name: string; agents: string[] }[] = [
96
+ export interface WorkflowStep {
97
+ /** What the phase is called in the command file, for the reader who goes looking. */
98
+ step: string;
99
+ /** The agent the step dispatches. Recorded for provenance; routing is claudish's. */
100
+ agent: string;
101
+ }
102
+
103
+ export interface Workflow {
104
+ name: string;
105
+ agents: string[];
106
+ external: WorkflowStep[];
107
+ }
108
+
109
+ export const WORKFLOWS: Workflow[] = [
75
110
  {
76
111
  name: "dev",
77
112
  agents: [
@@ -81,6 +116,10 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
81
116
  "dev:qa-engineer",
82
117
  "dev:reviewer",
83
118
  ],
119
+ external: [
120
+ { step: "plan review", agent: "dev:architect" },
121
+ { step: "code review", agent: "dev:architect" },
122
+ ],
84
123
  },
85
124
  {
86
125
  name: "debug",
@@ -90,10 +129,12 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
90
129
  "dev:debugger",
91
130
  "dev:developer",
92
131
  ],
132
+ external: [],
93
133
  },
94
134
  {
95
135
  name: "investigate",
96
136
  agents: ["code-search:analyze", "dev:researcher"],
137
+ external: [],
97
138
  },
98
139
  ];
99
140
 
@@ -113,10 +154,25 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
113
154
  */
114
155
  export const VISION_AGENTS = ["designer:review", "designer:ui"] as const;
115
156
 
157
+ /**
158
+ * The effort the external slots run at, per preset.
159
+ *
160
+ * A preset cannot say WHICH model serves a mate — a catalogue id shipped in this repo goes
161
+ * stale, which is the whole reason bindings live in a project's own `mates` block. It can say
162
+ * how hard that work should try, and that half does not depend on which model answers.
163
+ *
164
+ * `xhigh` almost everywhere: a slot is reached for the judgement calls — plan review, code
165
+ * review — and those are the calls worth spending on whichever model takes them. `Sonnet` is
166
+ * the exception because it is the economy preset, and a preset whose whole point is to spend
167
+ * less would be lying if the work it sent outside ignored that.
168
+ */
169
+ const MATE_EFFORT_DEFAULT: Effort = "xhigh";
170
+
116
171
  function preset(
117
172
  name: string,
118
173
  main: ModelsConfig["main"],
119
174
  grades: ModelsConfig["grades"],
175
+ mateEffort: Effort = MATE_EFFORT_DEFAULT,
120
176
  ): ModelsConfig {
121
177
  return {
122
178
  version: 1,
@@ -125,6 +181,7 @@ function preset(
125
181
  grades,
126
182
  agents: { ...DEFAULT_AGENT_GRADES },
127
183
  fallback: "normal",
184
+ mateEffort,
128
185
  };
129
186
  }
130
187
 
@@ -247,6 +304,8 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
247
304
  // effort is not throttled on top of it. Every routed agent runs sonnet at `xhigh`,
248
305
  // including `cheap` — so the hook still pins the model (a session started on opus does
249
306
  // not leak into its subagents) while the tier table itself makes no distinction.
307
+ // The one preset whose mates run at `medium` rather than `xhigh`: an economy preset that
308
+ // spent freely the moment work left Claude Code would not be one.
250
309
  preset(
251
310
  "sonnet-economy",
252
311
  { model: "sonnet", effort: "xhigh" },
@@ -255,6 +314,7 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
255
314
  normal: { model: "sonnet", effort: "xhigh" },
256
315
  cheap: { model: "sonnet", effort: "xhigh" },
257
316
  },
317
+ "medium",
258
318
  ),
259
319
  ];
260
320
 
@@ -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
+ }