magus-cli 7.5.3 → 7.5.4

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.
Files changed (59) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/mate-availability.test.ts +11 -13
  13. package/src/__tests__/plugin-contents.test.ts +0 -1
  14. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  15. package/src/__tests__/plugin-row-text.test.ts +18 -21
  16. package/src/__tests__/profile-command.test.ts +2 -2
  17. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  18. package/src/__tests__/profile-skills.test.ts +3 -3
  19. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  20. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  21. package/src/__tests__/profile-state.test.ts +27 -22
  22. package/src/__tests__/scope-action.test.ts +35 -38
  23. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  24. package/src/__tests__/select-restore.test.ts +209 -0
  25. package/src/__tests__/stale-load-repair.test.ts +1 -2
  26. package/src/__tests__/update-apply.test.ts +18 -2
  27. package/src/__tests__/update-null-readback.test.ts +300 -0
  28. package/src/__tests__/update-plan-parity.test.ts +249 -0
  29. package/src/__tests__/update-plan.test.ts +66 -67
  30. package/src/__tests__/version-unknown-state.test.ts +38 -39
  31. package/src/cli/bootstrap.ts +239 -66
  32. package/src/cli/doctor.ts +4 -3
  33. package/src/cli/install.ts +83 -27
  34. package/src/cli/prompt.ts +7 -3
  35. package/src/cli/router.ts +1 -0
  36. package/src/cli/select.ts +168 -0
  37. package/src/cli/update-view.ts +118 -6
  38. package/src/cli/update.ts +6 -4
  39. package/src/services/claude-settings.ts +28 -0
  40. package/src/services/manifest.ts +31 -0
  41. package/src/services/mate-availability.ts +23 -29
  42. package/src/services/plugin-manager.ts +247 -105
  43. package/src/services/profile-absorb.ts +10 -7
  44. package/src/services/profile-activate.ts +39 -18
  45. package/src/services/profile-startup.ts +160 -4
  46. package/src/services/profile-state.ts +7 -6
  47. package/src/services/tracked-live-files.ts +24 -3
  48. package/src/services/update-engine.ts +28 -10
  49. package/src/services/update-plan.ts +78 -94
  50. package/src/ui/App.tsx +7 -2
  51. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  52. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  53. package/src/ui/hooks/useProfileStartupModal.ts +87 -7
  54. package/src/ui/keymap.ts +1 -1
  55. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  56. package/src/ui/renderers/pluginRowText.ts +6 -2
  57. package/src/ui/renderers/pluginStatusText.ts +4 -3
  58. package/src/ui/screens/PluginsScreen.tsx +28 -41
  59. package/src/cli/tui-prompt.tsx +0 -143
package/src/cli/prompt.ts CHANGED
@@ -52,9 +52,13 @@ async function ask(question: string): Promise<string> {
52
52
  }
53
53
  }
54
54
 
55
- /** Y/n prompt. Empty input means yes. */
56
- export async function confirm(question: string): Promise<boolean> {
57
- const answer = (await ask(`${question} [Y/n] `)).toLowerCase();
55
+ /**
56
+ * A yes/no answer to `prompt`, printed as given — the caller styles it (see
57
+ * `question` in update-view.ts). Empty input means yes. Line-based, so a piped
58
+ * stdin (`yes | magus install`) answers it like a person does.
59
+ */
60
+ export async function askYesNo(prompt: string): Promise<boolean> {
61
+ const answer = (await ask(prompt)).toLowerCase();
58
62
  return answer === "" || answer === "y" || answer === "yes";
59
63
  }
60
64
 
package/src/cli/router.ts CHANGED
@@ -192,6 +192,7 @@ export async function route(
192
192
  // Both of these fix a marker naming a missing profile by activating
193
193
  // one that exists.
194
194
  allowUnknown: command === "install" || command === "profile",
195
+ yes: rest.includes("--yes") || rest.includes("-y"),
195
196
  });
196
197
  if (!ok) return HANDLED(1);
197
198
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * An arrow-key choice in the terminal — interactive, but not a TUI.
3
+ *
4
+ * No renderer and no alternate screen: the rows of `selectFrame` are written
5
+ * under the report, redrawn in place on each keypress, and collapse to one
6
+ * `selectDone` line once answered. The owner asked for this in place of typing
7
+ * a number ("not this ugly thing … not tui, interactive cli").
8
+ *
9
+ * The key handling is `selectKey`, pure, so it is testable without a terminal;
10
+ * `selectOption` is only the terminal around it.
11
+ */
12
+
13
+ import { emitKeypressEvents } from "node:readline";
14
+ import { selectDone, selectFrame } from "./update-view.js";
15
+
16
+ export interface SelectItem {
17
+ label: string;
18
+ meta?: string;
19
+ }
20
+
21
+ /** The fields of a readline keypress that the choice reads. */
22
+ export interface SelectKeypress {
23
+ name?: string;
24
+ ctrl?: boolean;
25
+ sequence?: string;
26
+ }
27
+
28
+ export type SelectStep =
29
+ | { index: number }
30
+ | { chosen: number }
31
+ | { cancelled: true };
32
+
33
+ /**
34
+ * What one keypress does: ↑/↓ (and k/j) move with wrap-around, Enter picks the
35
+ * current row, a digit picks that row at once, Esc and Ctrl+C cancel. Any other
36
+ * key leaves the cursor where it is.
37
+ */
38
+ export function selectKey(
39
+ index: number,
40
+ key: SelectKeypress,
41
+ count: number,
42
+ ): SelectStep {
43
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
44
+ return { cancelled: true };
45
+ }
46
+ if (key.name === "return" || key.name === "enter") return { chosen: index };
47
+ if (key.name === "up" || key.name === "k") {
48
+ return { index: (index - 1 + count) % count };
49
+ }
50
+ if (key.name === "down" || key.name === "j") {
51
+ return { index: (index + 1) % count };
52
+ }
53
+ const digit = Number(key.sequence);
54
+ if (Number.isInteger(digit) && digit >= 1 && digit <= count) {
55
+ return { chosen: digit - 1 };
56
+ }
57
+ return { index };
58
+ }
59
+
60
+ /** 128 + signum, the shell convention `cli/live.ts` exits with too. */
61
+ const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 } as const;
62
+
63
+ /**
64
+ * Ask. Resolves to the chosen index, or null when the user cancels — and at
65
+ * once when there is no terminal on either end, since nobody can answer and a
66
+ * redraw would write cursor codes into a pipe. Callers decide what null means.
67
+ *
68
+ * Whatever ends it — an answer, a throw, a signal, or the process exiting —
69
+ * puts the terminal back: raw mode off, the keypress listener gone, the cursor
70
+ * shown. Only Enter, Esc and Ctrl+C used to, so anything else left the shell
71
+ * typing blind until `reset`. Ctrl+C typed at the list arrives as a keypress
72
+ * in raw mode; the SIGINT handler is for one sent from outside (`kill -INT`).
73
+ */
74
+ export async function selectOption(
75
+ title: string,
76
+ items: SelectItem[],
77
+ defaultIndex = 0,
78
+ ): Promise<number | null> {
79
+ const { stdin, stdout } = process;
80
+ if (!stdin.isTTY || !stdout.isTTY || items.length === 0) return null;
81
+
82
+ let index = Math.min(Math.max(0, defaultIndex), items.length - 1);
83
+ let height = 0;
84
+ const draw = (): void => {
85
+ const lines = selectFrame(title, items, index);
86
+ // Back to the block's first row, then rewrite every row in full.
87
+ if (height > 0) stdout.write(`\x1b[${height}F`);
88
+ stdout.write(`${lines.map((l) => `\x1b[2K${l}`).join("\n")}\n`);
89
+ height = lines.length;
90
+ };
91
+
92
+ emitKeypressEvents(stdin);
93
+ const wasRaw = stdin.isRaw;
94
+ let onKey: ((_: string, key: SelectKeypress | undefined) => void) | null =
95
+ null;
96
+
97
+ // Once only: `finish`, a throw's `finally` and a signal can all reach it.
98
+ let released = false;
99
+ const release = (): void => {
100
+ if (released) return;
101
+ released = true;
102
+ process.off("exit", release);
103
+ process.off("SIGINT", onSigint);
104
+ process.off("SIGTERM", onSigterm);
105
+ process.off("SIGHUP", onSighup);
106
+ if (onKey) stdin.off("keypress", onKey);
107
+ stdin.setRawMode(wasRaw);
108
+ stdin.pause();
109
+ stdout.write("\x1b[?25h");
110
+ };
111
+ // A listener for a signal replaces its default action, so each one exits
112
+ // itself once the terminal is back.
113
+ const onSigint = (): void => {
114
+ release();
115
+ process.exit(SIGNAL_EXIT.SIGINT);
116
+ };
117
+ const onSigterm = (): void => {
118
+ release();
119
+ process.exit(SIGNAL_EXIT.SIGTERM);
120
+ };
121
+ const onSighup = (): void => {
122
+ release();
123
+ process.exit(SIGNAL_EXIT.SIGHUP);
124
+ };
125
+ process.once("exit", release);
126
+ process.once("SIGINT", onSigint);
127
+ process.once("SIGTERM", onSigterm);
128
+ process.once("SIGHUP", onSighup);
129
+
130
+ try {
131
+ stdin.setRawMode(true);
132
+ stdin.resume();
133
+ stdout.write("\x1b[?25l"); // hide the cursor while the list is open
134
+
135
+ return await new Promise<number | null>((resolve, reject) => {
136
+ const finish = (chosen: number | null): void => {
137
+ // Clear the open block; an answer leaves one line behind it.
138
+ stdout.write(`\x1b[${height}F\x1b[0J`);
139
+ if (chosen !== null) {
140
+ stdout.write(`${selectDone(title, items[chosen]?.label ?? "")}\n`);
141
+ }
142
+ // Before resolving, not in `finally`: one read can carry several
143
+ // keys, and the listener must be gone before the next is emitted.
144
+ release();
145
+ resolve(chosen);
146
+ };
147
+ onKey = (_, key) => {
148
+ try {
149
+ const step = selectKey(index, key ?? {}, items.length);
150
+ if ("cancelled" in step) finish(null);
151
+ else if ("chosen" in step) finish(step.chosen);
152
+ else {
153
+ index = step.index;
154
+ draw();
155
+ }
156
+ } catch (error) {
157
+ // Thrown inside an event listener it would reach no caller.
158
+ release();
159
+ reject(error);
160
+ }
161
+ };
162
+ stdin.on("keypress", onKey);
163
+ draw();
164
+ });
165
+ } finally {
166
+ release();
167
+ }
168
+ }
@@ -1,5 +1,7 @@
1
1
  /**
2
- * update-view.ts — every string `magus update` prints, and nothing else.
2
+ * update-view.ts — every string `magus update` prints, plus the pieces the
3
+ * other CLI commands reuse so they look the same (`commandHeader`, `field`,
4
+ * the `ok`/`warn`/`fail`/`info`/`note` rows, and `question`).
3
5
  *
4
6
  * Pure functions: plan in, lines out. No IO, no timers, no terminal. That is
5
7
  * what makes the look testable — the old report was `console.log` calls braided
@@ -128,16 +130,126 @@ function rowText(key: ActionKey, text: string): string {
128
130
 
129
131
  // -- header ------------------------------------------------------------------
130
132
 
131
- export function header(profileId: string): string[] {
133
+ /**
134
+ * `◆ magus <command> <label> <value>` — the line a CLI report opens with.
135
+ *
136
+ * Shared so `install` and the adoption question open the way `update` does,
137
+ * rather than each command printing its own plain "Plan for …:" heading.
138
+ */
139
+ export function commandHeader(
140
+ command: string,
141
+ context?: { label: string; value: string },
142
+ ): string[] {
143
+ const tail = context
144
+ ? ` ${dim(context.label)} ${fg(brand.accent, context.value)}`
145
+ : "";
132
146
  return [
133
147
  "",
134
- `${PAD}${fg(brand.accent, "◆")} ${bold("magus update")} ${dim(
135
- "profile",
136
- )} ${fg(brand.accent, profileId)}`,
148
+ `${PAD}${fg(brand.accent, "◆")} ${bold(`magus ${command}`)}${tail}`,
137
149
  "",
138
150
  ];
139
151
  }
140
152
 
153
+ export function header(profileId: string): string[] {
154
+ return commandHeader("update", { label: "profile", value: profileId });
155
+ }
156
+
157
+ /** Width of the label column in `field` rows. */
158
+ const FIELD_WIDTH = 14;
159
+
160
+ /**
161
+ * `label value`, indented to the text column of the glyph rows (`ok`, `warn`,
162
+ * …) so a block of facts lines up under the notice it belongs to.
163
+ */
164
+ export function field(label: string, value: string): string {
165
+ return `${PAD} ${dim(padEnd(label, FIELD_WIDTH))}${value}`;
166
+ }
167
+
168
+ /**
169
+ * A multi-paragraph note, word-wrapped to the terminal in the `note` column.
170
+ * Paragraphs are separated by "\n"; an empty paragraph stays an empty line. A
171
+ * note printed as one line per paragraph wrapped mid-word at the terminal edge.
172
+ */
173
+ export function noteBlock(text: string, columns = 80): string[] {
174
+ const room = Math.max(20, Math.min(columns, 100) - PAD.length - 4);
175
+ const out: string[] = [];
176
+ for (const paragraph of text.split("\n")) {
177
+ if (paragraph === "") {
178
+ out.push("");
179
+ continue;
180
+ }
181
+ const indent = /^ */.exec(paragraph)?.[0] ?? "";
182
+ let line = indent;
183
+ for (const word of paragraph.trimStart().split(" ")) {
184
+ if (line.trim() !== "" && width(line) + 1 + width(word) > room) {
185
+ out.push(note(line));
186
+ line = word;
187
+ } else {
188
+ line = line.trim() === "" ? `${line}${word}` : `${line} ${word}`;
189
+ }
190
+ }
191
+ if (line.trim() !== "") out.push(note(line));
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /** One entry of a list under a `field`, indented to the field's value column. */
197
+ export function listItem(text: string): string {
198
+ return `${PAD} ${" ".repeat(FIELD_WIDTH)}${text}`;
199
+ }
200
+
201
+ /** The scopes a user deals in. Where each is stored is magus's business. */
202
+ export type ScopeName = "user" | "project" | "local";
203
+
204
+ const SCOPE_BADGE: Record<ScopeName, [string, string]> = {
205
+ user: ["USER", brand.accent],
206
+ project: ["PROJECT", brand.info],
207
+ local: ["LOCAL", brand.warning],
208
+ };
209
+
210
+ /** A scope as a badge — the word a user knows it by, never a file name. */
211
+ export function scopeBadge(scope: ScopeName): string {
212
+ const [label, color] = SCOPE_BADGE[scope];
213
+ return badge(label, color);
214
+ }
215
+
216
+ /**
217
+ * The prompt for a yes/no question, in the glyph column: `? <question> [Y/n] `.
218
+ * Printed by readline, so the answer is typed on the same line.
219
+ */
220
+ export function question(text: string): string {
221
+ return `${PAD}${fg(brand.accent, "?")} ${bold(text)} ${dim("[Y/n]")} `;
222
+ }
223
+
224
+ /**
225
+ * An arrow-key choice while it is open: the question, one row per option with
226
+ * `❯` on the current one, then the keys. `select.ts` redraws the whole block in
227
+ * place on every keypress, so its height never changes while it is open.
228
+ */
229
+ export function selectFrame(
230
+ title: string,
231
+ options: { label: string; meta?: string }[],
232
+ index: number,
233
+ ): string[] {
234
+ const labelWidth = Math.max(0, ...options.map((o) => width(o.label)));
235
+ return [
236
+ `${PAD}${fg(brand.accent, "?")} ${bold(title)}`,
237
+ ...options.map((o, i) => {
238
+ const label = padEnd(o.label, labelWidth);
239
+ const meta = o.meta ? ` ${dim(o.meta)}` : "";
240
+ return i === index
241
+ ? `${PAD} ${fg(brand.accent, "❯")} ${bold(fg(brand.accent, label))}${meta}`
242
+ : `${PAD} ${label}${meta}`;
243
+ }),
244
+ `${PAD} ${dim("↑↓ move · enter select · esc cancel")}`,
245
+ ];
246
+ }
247
+
248
+ /** The one line an answered choice collapses to: `✓ <title> <answer>`. */
249
+ export function selectDone(title: string, answer: string): string {
250
+ return `${PAD}${fg(brand.success, "✓")} ${title} ${fg(brand.accent, answer)}`;
251
+ }
252
+
141
253
  // -- phase checklist ---------------------------------------------------------
142
254
 
143
255
  export type StepState = "pending" | "running" | "done" | "warn" | "fail";
@@ -346,7 +458,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
346
458
  *
347
459
  * Update something an apply would move forward
348
460
  * Deprecated no configured marketplace offers it
349
- * Fix required declared or enabled, with nothing on disk
461
+ * Fix required declared or installed, with its files missing
350
462
  * Unverified the check itself did not complete
351
463
  * Current settled — the version was compared, or there was none to compare
352
464
  *
package/src/cli/update.ts CHANGED
@@ -51,7 +51,7 @@ import {
51
51
  import {
52
52
  recordRefetchedSkills,
53
53
  trackFetchedSkills,
54
- trackedGeneratedWarnings,
54
+ trackedGeneratedNotices,
55
55
  updatableSkills,
56
56
  } from "../services/profile-activate.js";
57
57
  import { activeProfile } from "../services/profile-live.js";
@@ -84,7 +84,6 @@ import { brand } from "../ui/theme.js";
84
84
  import { bold, dim, fg, width } from "./ansi.js";
85
85
  import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
86
86
  import { LiveRegion, withPaused } from "./live.js";
87
- import { selectDialog } from "./tui-prompt.js";
88
87
  import {
89
88
  type ApplyState,
90
89
  type Step,
@@ -205,8 +204,9 @@ export async function runUpdateCommand(
205
204
 
206
205
  // A generated file git tracks is written like any other; say so once, with
207
206
  // the fix. magus never runs git itself.
208
- for (const line of await trackedGeneratedWarnings(projectPath)) {
209
- console.log(warnLine(line));
207
+ for (const n of await trackedGeneratedNotices(projectPath)) {
208
+ console.log(warnLine(n.problem));
209
+ for (const line of n.fix) console.log(noteLine(line));
210
210
  }
211
211
 
212
212
  // No active profile, no update: that is where plugin state is read from and
@@ -238,6 +238,8 @@ export async function runUpdateCommand(
238
238
  ? manifest
239
239
  : await requireActiveProfile(projectPath, manifest, {
240
240
  check: flags.check || flags.dryRun,
241
+ // Said once already, by the check above.
242
+ reportTracked: false,
241
243
  });
242
244
  // A refused dry run exits 0: it wrote nothing and previewed nothing, which is
243
245
  // what it promised. A refused `--check` exits 1, because that is a gate.
@@ -406,6 +406,34 @@ export function pickRegistryEntry(
406
406
  return entries.find((e) => e.scope === "user");
407
407
  }
408
408
 
409
+ /**
410
+ * The row a `claude plugin … --scope <scope>` run from `projectPath` owns: the
411
+ * user row for user scope; for project or local scope, the row of exactly that
412
+ * scope pinned to exactly this path.
413
+ *
414
+ * NOT {@link pickRegistryEntry}, which answers "what version applies here" and
415
+ * so falls back to inherited rows and then to the user row. That fallback asked
416
+ * the apply engine's "does this project hold a row?" guard made a plugin
417
+ * installed only at user scope, and turned on in project settings, look like it
418
+ * held a project row — so `claude plugin update --scope project` ran from a
419
+ * folder with none, and Claude Code updated some other project's row instead.
420
+ */
421
+ export function ownRegistryEntry(
422
+ entries: InstalledPluginEntry[] | undefined,
423
+ scope: "user" | "project" | "local",
424
+ projectPath: string,
425
+ ): InstalledPluginEntry | undefined {
426
+ if (!Array.isArray(entries)) return undefined;
427
+ if (scope === "user") return entries.find((e) => e.scope === "user");
428
+ const target = path.resolve(projectPath);
429
+ return entries.find(
430
+ (e) =>
431
+ e.scope === scope &&
432
+ e.projectPath !== undefined &&
433
+ path.resolve(e.projectPath) === target,
434
+ );
435
+ }
436
+
409
437
  async function overlayRegistryVersions(
410
438
  base: Record<string, string>,
411
439
  scope: "user" | "project" | "local",
@@ -7,6 +7,7 @@
7
7
  * structural subset of a v2 manifest entry, so migration is a version bump.
8
8
  */
9
9
 
10
+ import os from "node:os";
10
11
  import path from "node:path";
11
12
  import fs from "fs-extra";
12
13
  import {
@@ -41,6 +42,36 @@ export function getManifestPath(projectPath?: string): string {
41
42
  return path.join(base, MANIFEST_FILE);
42
43
  }
43
44
 
45
+ /**
46
+ * The nearest ANCESTOR directory holding a `.claude/profiles.json`, searched no
47
+ * higher than the repository root — so magus can say so when it runs in a
48
+ * subdirectory.
49
+ *
50
+ * magus manages the directory it runs in because that is the project Claude Code
51
+ * reads: launched in `tools/magus`, it watches `tools/magus/.claude/settings.json`
52
+ * and never the root's (MEASURED with `--debug-file`, Claude Code 2.1.281). A
53
+ * root profiles.json is therefore not this directory's — but someone who meant
54
+ * the root has to hear that it exists before creating a second one here.
55
+ *
56
+ * A directory holding `.git` (file or folder) is a repository root, a worktree's
57
+ * included: nothing above it is searched, or a worktree under
58
+ * `<repo>/.claude/worktrees/` would find the main checkout's file. The home
59
+ * directory is never checked — its `.claude/` is Claude Code's user config.
60
+ */
61
+ export async function findEnclosingManifest(
62
+ projectPath: string,
63
+ ): Promise<string | null> {
64
+ const home = os.homedir();
65
+ let dir = path.resolve(projectPath);
66
+ for (;;) {
67
+ if (await fs.pathExists(path.join(dir, ".git"))) return null;
68
+ const parent = path.dirname(dir);
69
+ if (parent === dir || parent === home) return null;
70
+ dir = parent;
71
+ if (await fs.pathExists(path.join(dir, MANIFEST_FILE))) return dir;
72
+ }
73
+ }
74
+
44
75
  /** An empty, valid v2 manifest. */
45
76
  export function emptyManifest(): ProfileManifest {
46
77
  return { version: 2, profiles: {} };
@@ -28,15 +28,17 @@
28
28
  * silently. `getAvailablePlugins` resolves all three scopes and hands back both facts per
29
29
  * scope, which is all this module takes from it.
30
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.
31
+ * The test on those facts is `servesAMate`, below, and it is built from `plugin-manager`'s
32
+ * own helpers rather than reading either fact itself, so a mate row and the plugin list
33
+ * cannot disagree about what "installed" means.
34
34
  */
35
35
 
36
36
  import {
37
37
  type PluginInfo,
38
38
  type ScopeStatus,
39
39
  getAvailablePlugins,
40
+ isInstalledInScope,
41
+ scopeFilesMissing,
40
42
  } from "./plugin-manager.js";
41
43
 
42
44
  /** The plugin that puts claudish's model tools in reach. */
@@ -71,11 +73,23 @@ export function matesForced(env: NodeJS.ProcessEnv = process.env): boolean {
71
73
  }
72
74
 
73
75
  /**
74
- * True when `multimodel@magus` is installed in ANY scope.
76
+ * Can this scope actually serve a mate? Installed there, with its files present.
77
+ *
78
+ * Installed alone is not enough. Files missing is installed with no version: Claude Code
79
+ * wants to load the plugin and has nothing to load, so claudish is not reachable through it,
80
+ * and a row drawn on the strength of it promises routing that cannot happen — the one thing
81
+ * these rows must never claim.
82
+ */
83
+ function servesAMate(scope: ScopeStatus | undefined): boolean {
84
+ return isInstalledInScope(scope) && !scopeFilesMissing(scope);
85
+ }
86
+
87
+ /**
88
+ * True when `multimodel@magus` is installed, with its files, in ANY scope.
75
89
  *
76
90
  * Any scope, because a mate is served by whatever Claude Code has loaded when the agent is
77
91
  * spawned, and that is the union of the three — a user-scope install serves a project that
78
- * enables nothing of its own.
92
+ * installs nothing of its own.
79
93
  *
80
94
  * FAILS CLOSED. This reaches the marketplace resolution path, which does network work and can
81
95
  * time out; a failure means "we do not know", and the honest rendering of not knowing is the
@@ -83,31 +97,11 @@ export function matesForced(env: NodeJS.ProcessEnv = process.env): boolean {
83
97
  * rows on screen on the strength of an error.
84
98
  *
85
99
  * `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.
100
+ * DECISION here — which scopes count, what files missing means, what a thrown error means —
101
+ * is the part worth testing, and it cannot be tested through a function that reaches the
102
+ * network and a real machine's registry. The default is the real one, so no caller has to
103
+ * know the seam exists.
90
104
  */
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
105
 
112
106
  export async function areMatesAvailable(
113
107
  projectPath?: string,