magus-cli 7.2.0 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ buildStaleLoadModal,
4
+ staleLoadRepairSummary,
5
+ } from "../ui/hooks/useStaleLoadModal";
6
+
7
+ /**
8
+ * The startup popup for a broken plugin.
9
+ *
10
+ * Same shape as buildGitignoreModal beside it, with one difference that is the
11
+ * whole point of the popup: FIXING is the default. A plugin the loader resolves
12
+ * at the wrong version means every session in this directory runs code nobody
13
+ * installed, so Enter must repair it rather than dismiss.
14
+ */
15
+ describe("buildStaleLoadModal", () => {
16
+ const args = (
17
+ over: Partial<Parameters<typeof buildStaleLoadModal>[0]> = {},
18
+ ) => ({
19
+ brokenCount: 2,
20
+ onFix: () => {},
21
+ onOpenTab: () => {},
22
+ onDismiss: () => {},
23
+ ...over,
24
+ });
25
+
26
+ it("offers fix, review and dismiss, with FIX first", () => {
27
+ const modal = buildStaleLoadModal(args());
28
+ if (modal.type !== "select") throw new Error("type guard");
29
+
30
+ expect(modal.options.map((o) => o.value)).toEqual([
31
+ "fix",
32
+ "open-tab",
33
+ "dismiss",
34
+ ]);
35
+ });
36
+
37
+ it("defaults to FIX explicitly, not by accident of ordering", () => {
38
+ // Stated in the data so a future reordering cannot quietly make "leave it
39
+ // broken" the thing Enter does.
40
+ const modal = buildStaleLoadModal(args());
41
+ if (modal.type !== "select") throw new Error("type guard");
42
+
43
+ expect(modal.defaultIndex).toBe(0);
44
+ expect(modal.options[modal.defaultIndex ?? -1]?.value).toBe("fix");
45
+ });
46
+
47
+ it("blames the Claude Code bug, not magus's own state", () => {
48
+ // The title is the whole message for anyone who reads one line. It has to
49
+ // say what is wrong — a defect in Claude Code's loader — rather than
50
+ // report that two of magus's stores disagree, which is not actionable and
51
+ // reads as though the user misconfigured something.
52
+ const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
53
+ if (modal.type !== "select") throw new Error("type guard");
54
+
55
+ expect(modal.title).toContain("Claude Code");
56
+ expect(modal.message).toContain("stale cached version");
57
+ expect(modal.options[0].label).toBe("Fix it (3 plugins)");
58
+ expect(modal.message).toContain("3 plugins are on an older version");
59
+ });
60
+
61
+ it("says the reach is every worktree, not just this directory", () => {
62
+ // Relevance is decided by repository root, so every worktree and the main
63
+ // checkout resolve identically and break together. A reader told only
64
+ // about "this directory" would fix one worktree and expect the others to
65
+ // differ — they cannot.
66
+ const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
67
+ if (modal.type !== "select") throw new Error("type guard");
68
+
69
+ expect(modal.message).toContain("every worktree");
70
+ expect(modal.message).toContain("main checkout");
71
+ });
72
+
73
+ it("states the effect plainly, without overstating it", () => {
74
+ // A draft claimed "your sessions run code you did not install". Nothing
75
+ // happens to the user's code; an old cached plugin version is used. The
76
+ // overstatement is pinned here so it cannot come back on the next edit.
77
+ const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
78
+ if (modal.type !== "select") throw new Error("type guard");
79
+
80
+ expect(modal.message).not.toContain("run code");
81
+ expect(modal.message).not.toContain("did not install");
82
+ });
83
+
84
+ it("reads correctly for a single plugin", () => {
85
+ const modal = buildStaleLoadModal(args({ brokenCount: 1 }));
86
+ if (modal.type !== "select") throw new Error("type guard");
87
+
88
+ expect(modal.options[0].label).toBe("Fix it (1 plugin)");
89
+ expect(modal.message).toContain("1 plugin is on an older version");
90
+ });
91
+
92
+ it("promises the backup and promises not to delete or downgrade", () => {
93
+ // The popup is asking permission to edit Claude Code-owned state. What it
94
+ // will and will not do has to be on the screen, not only in a code comment.
95
+ const modal = buildStaleLoadModal(args());
96
+ if (modal.type !== "select") throw new Error("type guard");
97
+
98
+ expect(modal.message).toContain(
99
+ "installed_plugins.json is backed up first",
100
+ );
101
+ // Case-insensitive: the promise has to be on screen, but whether it opens a
102
+ // sentence or trails one is a wording choice, and pinning the capital made
103
+ // an ordinary copy edit fail for nothing.
104
+ expect(modal.message.toLowerCase()).toContain(
105
+ "nothing is deleted or downgraded",
106
+ );
107
+ });
108
+
109
+ it("routes each choice to its callback, and cancel to dismiss", () => {
110
+ let fixed = 0;
111
+ let opened = 0;
112
+ let dismissed = 0;
113
+ const modal = buildStaleLoadModal(
114
+ args({
115
+ onFix: () => {
116
+ fixed++;
117
+ },
118
+ onOpenTab: () => {
119
+ opened++;
120
+ },
121
+ onDismiss: () => {
122
+ dismissed++;
123
+ },
124
+ }),
125
+ );
126
+ if (modal.type !== "select") throw new Error("type guard");
127
+
128
+ modal.onSelect("fix");
129
+ modal.onSelect("open-tab");
130
+ modal.onSelect("dismiss");
131
+ modal.onCancel();
132
+
133
+ expect({ fixed, opened, dismissed }).toEqual({
134
+ fixed: 1,
135
+ opened: 1,
136
+ dismissed: 2,
137
+ });
138
+ });
139
+ });
140
+
141
+ describe("staleLoadRepairSummary", () => {
142
+ it("says what loads NOW for each repaired plugin", () => {
143
+ expect(
144
+ staleLoadRepairSummary([
145
+ { pluginId: "madbench@magus", target: "0.5.0", ok: true },
146
+ { pluginId: "dev@magus", target: "7.4.0", ok: true },
147
+ ]),
148
+ ).toBe("✓ madbench@magus now loads v0.5.0\n✓ dev@magus now loads v7.4.0");
149
+ });
150
+
151
+ it("reports a failed repair with its own reason, never as a success", () => {
152
+ expect(
153
+ staleLoadRepairSummary([
154
+ {
155
+ pluginId: "madbench@magus",
156
+ target: "0.5.0",
157
+ ok: false,
158
+ error: "the loader still resolves v0.3.0 here, not v0.5.0",
159
+ },
160
+ ]),
161
+ ).toBe(
162
+ "✗ madbench@magus — the loader still resolves v0.3.0 here, not v0.5.0",
163
+ );
164
+ });
165
+
166
+ it("falls back to a plain reason rather than an empty line", () => {
167
+ expect(
168
+ staleLoadRepairSummary([
169
+ { pluginId: "a@magus", target: "1.0.0", ok: false },
170
+ ]),
171
+ ).toBe("✗ a@magus — repair did not take effect");
172
+ });
173
+
174
+ it("says so when there was nothing to repair", () => {
175
+ expect(staleLoadRepairSummary([])).toBe("Nothing to repair.");
176
+ });
177
+ });
package/src/cli/doctor.ts CHANGED
@@ -7,10 +7,16 @@
7
7
  * 2. Profile symlink integrity — the active profile's links resolve.
8
8
  * 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
9
9
  * 4. Model routing — config validity, hook registration, and drift.
10
+ * 5. Plugin loads — what Claude Code will load here vs what is installed.
10
11
  */
11
12
 
12
13
  import path from "node:path";
13
14
  import fs from "fs-extra";
15
+ import {
16
+ getProjectInstalledPluginVersions,
17
+ readInstalledPluginsRegistry,
18
+ readSettings,
19
+ } from "../services/claude-settings.js";
14
20
  import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
15
21
  import { checkProject, fixDoctorIssues } from "../services/doctor.js";
16
22
  import {
@@ -22,6 +28,11 @@ import {
22
28
  readModelsConfig,
23
29
  readModelsStatus,
24
30
  } from "../services/models-manager.js";
31
+ import { isBehind } from "../services/plugin-manager.js";
32
+ import {
33
+ type RepoRootCache,
34
+ pickLoadedEntry,
35
+ } from "../services/plugin-resolution.js";
25
36
  import { resolveAllProfiles } from "../services/resolver.js";
26
37
  import { activeProfile, profileDir } from "../services/symlink-manager.js";
27
38
  import { resolveExecutable } from "../utils/command-utils.js";
@@ -111,8 +122,123 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
111
122
  // 4. Model routing.
112
123
  problems += await checkModels(projectPath, fix);
113
124
 
114
- if (problems === 0) console.log("\n✓ No problems found.");
115
- return problems > 0 && !fix ? 1 : 0;
125
+ // 5. Stale plugin loads. Counted SEPARATELY, because `--fix` does not repair
126
+ // them — see doctorExitCode.
127
+ const staleLoads = await checkPluginLoads(projectPath);
128
+
129
+ if (problems === 0 && staleLoads === 0) console.log("\n✓ No problems found.");
130
+ return doctorExitCode({ fixable: problems, unfixable: staleLoads, fix });
131
+ }
132
+
133
+ /**
134
+ * Doctor's exit code.
135
+ *
136
+ * `--fix` suppresses the exit code for what it repaired — that is the point of
137
+ * the flag, and a caller that asked for repairs does not want a red exit over
138
+ * problems that are now gone. It must NOT suppress a class it never touches.
139
+ *
140
+ * Stale plugin loads are exactly that class: `checkPluginLoads` prints them and
141
+ * says so, and the repair lives in `repairStaleLoad`, reached by `magus update`
142
+ * and the Plugins screen. Folding them into the same counter meant `magus doctor
143
+ * --fix` printed red lines and exited 0 — so a CI or pre-flight step went green
144
+ * over a plugin whose sessions run a version nobody installed, which is the same
145
+ * deception the stale-load work exists to end.
146
+ *
147
+ * Pure and exported so the decision is pinned by a test rather than inferred from
148
+ * a command that needs a real `~/.claude` to run.
149
+ */
150
+ export function doctorExitCode(args: {
151
+ /** Problems `--fix` can act on. */
152
+ fixable: number;
153
+ /** Problems it cannot — reported either way, and never silenced by `--fix`. */
154
+ unfixable: number;
155
+ fix: boolean;
156
+ }): number {
157
+ if (args.unfixable > 0) return 1;
158
+ return args.fixable > 0 && !args.fix ? 1 : 0;
159
+ }
160
+
161
+ /**
162
+ * Plugins whose installed version is NOT the version that will load here.
163
+ *
164
+ * Reported, never repaired — `--fix` deliberately does nothing about it, and
165
+ * that is a division of labour rather than a dead end. A reinstall does not
166
+ * clear this (measured): it writes another `installed_plugins.json` row for THIS
167
+ * project, while the row the loader picks belongs to a different one — usually a
168
+ * worktree that has since been deleted, whose entry nothing prunes. Moving those
169
+ * rows forward is what fixes it, and that lives in `repairStaleLoad`, reached by
170
+ * `magus update` and by the Plugins screen's `a` key. Doctor's job is to name
171
+ * the winner, which is the one thing neither of those prints.
172
+ *
173
+ * Both halves come from the registry rather than the catalog, so this section
174
+ * makes no network call and holds even when GitHub is unreachable:
175
+ * installed — magus's own resolution, `pickRegistryEntry` via settings overlay
176
+ * loads — the loader's, `pickLoadedEntry`
177
+ */
178
+ async function checkPluginLoads(projectPath: string): Promise<number> {
179
+ const registry = await readInstalledPluginsRegistry();
180
+ const settings = await readSettings(projectPath);
181
+ const installed = await getProjectInstalledPluginVersions(
182
+ settings.installedPluginVersions || {},
183
+ projectPath,
184
+ );
185
+ const ids = Object.keys(installed);
186
+ if (ids.length === 0) return 0;
187
+
188
+ // One cache across every plugin: the same handful of project paths repeat in
189
+ // hundreds of rows, and each miss walks the tree to the filesystem root.
190
+ const cache: RepoRootCache = new Map();
191
+ const stale: Array<{
192
+ pluginId: string;
193
+ loaded: string;
194
+ recorded: string;
195
+ winner: string;
196
+ }> = [];
197
+
198
+ for (const pluginId of ids.sort()) {
199
+ const recorded = installed[pluginId];
200
+ if (!recorded) continue;
201
+ const pick = pickLoadedEntry(
202
+ registry.plugins?.[pluginId],
203
+ projectPath,
204
+ cache,
205
+ );
206
+ if (!pick?.version) continue;
207
+ // The same predicate the classifier uses, so doctor and the plugin screen
208
+ // cannot disagree about which pairs count. Unorderable pairs answer null
209
+ // and are left alone — "cannot tell" is not a finding.
210
+ if (isBehind(recorded, pick.version) !== true) continue;
211
+ stale.push({
212
+ pluginId,
213
+ loaded: pick.version,
214
+ recorded,
215
+ winner:
216
+ pick.projectPath ??
217
+ (pick.scope === "user" ? "user scope (no project)" : "unknown project"),
218
+ });
219
+ }
220
+
221
+ console.log("\nPlugin loads:");
222
+ if (stale.length === 0) {
223
+ console.log(
224
+ ` ✓ All ${ids.length} installed plugins load at their installed version.`,
225
+ );
226
+ return 0;
227
+ }
228
+
229
+ for (const row of stale) {
230
+ console.log(
231
+ ` ✗ ${row.pluginId} — loads v${row.loaded}, installed v${row.recorded}`,
232
+ );
233
+ console.log(` the entry that wins belongs to ${row.winner}`);
234
+ }
235
+ console.log(
236
+ " Reinstalling does not fix this. Run `magus update` — it moves those",
237
+ );
238
+ console.log(
239
+ " install records forward, backing up installed_plugins.json first.",
240
+ );
241
+ return stale.length;
116
242
  }
117
243
 
118
244
  /**
@@ -81,6 +81,7 @@ type ActionKey =
81
81
  | "upgrade"
82
82
  | "repair"
83
83
  | "refresh"
84
+ | "stale-load"
84
85
  | "unknown";
85
86
 
86
87
  const ACTIONS: Record<ActionKey, ActionStyle> = {
@@ -93,6 +94,11 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
93
94
  upgrade: { glyph: "↑", label: "UPGRADE", color: brand.success },
94
95
  repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
95
96
  refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
97
+ // The installed version is right and an older one loads, so this session runs
98
+ // a version nobody installed. Danger, and the word is BROKEN: "STALE" read as
99
+ // a mild version of `repair`, and this is not that. Six characters, one under
100
+ // UNKNOWN, so CHIP_WIDTH is unchanged and no other section pays for it.
101
+ "stale-load": { glyph: "✗", label: "BROKEN", color: brand.danger },
96
102
  // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
97
103
  // as "broken". The summary line and the TUI already use warning for it.
98
104
  unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
@@ -249,8 +255,12 @@ function pluginDetail(item: PluginUpdateItem): string {
249
255
  // The note stays on the item. The TUI has no heading to lean on and renders
250
256
  // it as that row's only explanation — this is a rendering decision about a
251
257
  // table that carries the fact twice, not a change to what is known.
258
+ // `older-version-loads` is excluded for the same reason: its note says
259
+ // "loads v0.3.0, installed v0.5.0" and the two columns beside it already do.
252
260
  const note =
253
- item.note && item.reason !== "not-offered"
261
+ item.note &&
262
+ item.reason !== "not-offered" &&
263
+ item.reason !== "older-version-loads"
254
264
  ? ` ${dim(`(${item.note})`)}`
255
265
  : "";
256
266
  switch (item.action) {
@@ -268,6 +278,16 @@ function pluginDetail(item: PluginUpdateItem): string {
268
278
  case "repair":
269
279
  case "unknown":
270
280
  return `${item.installed ?? "—"}${note}`;
281
+ case "stale-load":
282
+ // Both versions, in the order the reader needs them: what runs, then
283
+ // what is installed. This is a report, not a one-line picker row — the
284
+ // TUI's row deliberately shows neither, because there the verdict and
285
+ // the key are all that fit. The note says the same thing in words; the
286
+ // columns are what makes a table of these scannable.
287
+ return `${fg(
288
+ groupStyle("Broken").text,
289
+ item.loaded ?? "?",
290
+ )} ${dim("≠")} ${item.installed ?? "—"}${note}`;
271
291
  case "current":
272
292
  return `${item.installed ?? "—"}${note}`;
273
293
  }
@@ -347,6 +367,10 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
347
367
  function pluginGroup(item: PluginUpdateItem): string {
348
368
  if (item.action === "update" || item.action === "repair") return "Update";
349
369
  if (item.action === "install") return "Fix required";
370
+ // Never "Current". The installed version matching is exactly what makes this
371
+ // state deceptive, and a row that reads as up to date under a CURRENT
372
+ // heading is the defect this state was added to end.
373
+ if (item.action === "stale-load") return "Broken";
350
374
  if (item.action === "unknown") {
351
375
  return item.reason === "not-offered" ? "Deprecated" : "Unverified";
352
376
  }
@@ -354,6 +378,7 @@ function pluginGroup(item: PluginUpdateItem): string {
354
378
  }
355
379
 
356
380
  const GROUP_ORDER = [
381
+ "Broken",
357
382
  "Update",
358
383
  "Deprecated",
359
384
  "Fix required",
@@ -373,6 +398,11 @@ const GROUP_KIND: Record<string, ReportBadgeKind> = {
373
398
  Update: "update",
374
399
  Deprecated: "deprecated",
375
400
  "Fix required": "fix",
401
+ // Red earth, borrowed from `deprecated`. The kinds are measured contrast
402
+ // pairs, not meanings — "Hue, not meaning; the badge says the word", as
403
+ // theme-mode.ts puts it — so an unmeasured fourth palette would be the only
404
+ // thing here nobody had checked against both pages.
405
+ Broken: "deprecated",
376
406
  };
377
407
 
378
408
  interface GroupStyle {
@@ -115,3 +115,26 @@ export async function loadActiveClosure(
115
115
  return null;
116
116
  }
117
117
  }
118
+
119
+ /**
120
+ * The active profile's plugin pins — `pluginId -> exact version | "latest"` — and
121
+ * `{}` when no profile resolves.
122
+ *
123
+ * The one shape every surface that judges a plugin needs: `classifyPlugin`'s
124
+ * `pin`, the Plugins screen's `pins`, and the ceiling `findStaleLoads` caps its
125
+ * repair target at. Reading it through one helper is what keeps the startup popup
126
+ * and the Plugins header from capping differently — a pinned plugin read as
127
+ * BROKEN in one and current in the other is exactly the drift the classifier was
128
+ * centralised to end.
129
+ *
130
+ * `{}` means "latest for everything", which is what `pinForRow` assumes for an
131
+ * installed plugin no profile declares. `resolveBins: false` because no caller of
132
+ * this needs a plugin's binary requirements, and the default resolver reads every
133
+ * declared plugin's `plugin.json` to build them.
134
+ */
135
+ export async function activePluginPins(
136
+ projectPath: string,
137
+ ): Promise<Readonly<Record<string, string>>> {
138
+ const active = await loadActiveClosure(projectPath, { resolveBins: false });
139
+ return active?.closure.plugins ?? {};
140
+ }