magus-cli 7.1.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.
Files changed (48) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
  3. package/src/__tests__/doctor.test.ts +31 -0
  4. package/src/__tests__/plugin-resolution.test.ts +431 -0
  5. package/src/__tests__/plugin-row-text.test.ts +66 -0
  6. package/src/__tests__/profile-migration-offer.test.ts +211 -0
  7. package/src/__tests__/profile-migration.test.ts +242 -0
  8. package/src/__tests__/profile-startup.test.ts +458 -0
  9. package/src/__tests__/stale-load-repair.test.ts +1076 -0
  10. package/src/__tests__/update-apply.test.ts +146 -1
  11. package/src/__tests__/update-plan.test.ts +338 -0
  12. package/src/__tests__/useStaleLoadModal.test.ts +177 -0
  13. package/src/cli/ansi.ts +39 -0
  14. package/src/cli/bootstrap.ts +169 -3
  15. package/src/cli/doctor.ts +128 -2
  16. package/src/cli/install.ts +5 -1
  17. package/src/cli/profile.ts +5 -1
  18. package/src/cli/prompt.ts +44 -12
  19. package/src/cli/tui-prompt.tsx +143 -0
  20. package/src/cli/update-view.ts +32 -1
  21. package/src/cli/update.ts +50 -7
  22. package/src/main.tsx +10 -1
  23. package/src/services/active-closure.ts +23 -0
  24. package/src/services/claude-settings.ts +645 -34
  25. package/src/services/manifest.ts +21 -0
  26. package/src/services/plugin-manager.ts +61 -0
  27. package/src/services/plugin-resolution.ts +280 -0
  28. package/src/services/profile-activate.ts +147 -0
  29. package/src/services/profile-adopt.ts +50 -4
  30. package/src/services/profile-startup.ts +311 -0
  31. package/src/services/profiles.ts +49 -11
  32. package/src/services/symlink-manager.ts +184 -30
  33. package/src/services/update-engine.ts +104 -2
  34. package/src/services/update-plan.ts +188 -1
  35. package/src/types/index.ts +44 -23
  36. package/src/ui/App.tsx +123 -0
  37. package/src/ui/components/layout/FooterHints.tsx +21 -9
  38. package/src/ui/components/modals/ModalContainer.tsx +1 -0
  39. package/src/ui/components/modals/SelectModal.tsx +142 -21
  40. package/src/ui/hooks/useProfileStartupModal.ts +260 -0
  41. package/src/ui/hooks/useStaleLoadModal.ts +157 -0
  42. package/src/ui/renderers/pluginRenderers.tsx +29 -0
  43. package/src/ui/renderers/pluginStatusText.ts +14 -0
  44. package/src/ui/renderers/profileRenderers.tsx +27 -4
  45. package/src/ui/screens/PluginsScreen.tsx +46 -13
  46. package/src/ui/screens/ProfilesScreen.tsx +1 -1
  47. package/src/ui/state/types.ts +18 -0
  48. package/src/ui/theme-mode.ts +54 -0
@@ -1,10 +1,41 @@
1
- import React from "react";
1
+ import type React from "react";
2
2
  import { useDimensions } from "../../state/DimensionsContext.js";
3
3
  import type { SelectOption } from "../../state/types.js";
4
+ import { type ReportBadgeKind, reportBadge } from "../../theme-mode.js";
4
5
  import { theme } from "../../theme.js";
6
+ import { FooterHints } from "../layout/FooterHints.js";
7
+
8
+ /** Pad a label so every badge and meta column starts at the same cell. */
9
+ function labelPad(label: string, to: number): string {
10
+ return " ".repeat(Math.max(0, to - label.length) + 2);
11
+ }
12
+
13
+ /**
14
+ * A discrete state as a chip: dark ink on a saturated fill, padded OUTSIDE the
15
+ * fill.
16
+ *
17
+ * Padding inside the label would extend the background, and a column of chips
18
+ * padded that way fuses into one solid rectangle with the words floating in it.
19
+ */
20
+ function Chip({
21
+ badge,
22
+ width,
23
+ }: {
24
+ badge: { label: string; kind: ReportBadgeKind };
25
+ width: number;
26
+ }): React.ReactNode {
27
+ const ink = reportBadge(badge.kind);
28
+ const pad = Math.max(0, width - badge.label.length - 2);
29
+ return (
30
+ <>
31
+ <span bg={ink.fill} fg={ink.ink}>{` ${badge.label} `}</span>
32
+ <span fg={theme.colors.dim}>{" ".repeat(pad)}</span>
33
+ </>
34
+ );
35
+ }
5
36
 
6
37
  interface SelectModalProps {
7
- /** Modal title */
38
+ /** Modal title — drawn in the border, not as a body row. */
8
39
  title: string;
9
40
  /** Modal message */
10
41
  message: string;
@@ -16,6 +47,8 @@ interface SelectModalProps {
16
47
  onSelect: (value: string) => void;
17
48
  /** Callback when cancelled */
18
49
  onCancel: () => void;
50
+ /** False for a question that must be answered — drops the `esc` hint. */
51
+ cancellable?: boolean;
19
52
  }
20
53
 
21
54
  export function SelectModal({
@@ -25,6 +58,7 @@ export function SelectModal({
25
58
  defaultIndex,
26
59
  onSelect: _onSelect,
27
60
  onCancel: _onCancel,
61
+ cancellable = true,
28
62
  }: SelectModalProps) {
29
63
  // Keyboard handling is done by ModalContainer
30
64
  // defaultIndex is the live selectedIndex from ModalContainer state
@@ -40,48 +74,135 @@ export function SelectModal({
40
74
  Math.min(90, Math.floor(dimensions.terminalWidth * 0.6)),
41
75
  );
42
76
 
77
+ // Columns a list row may paint: the box width less its border and padding.
78
+ // The selected row is a FULL-WIDTH fill, so it has to know the row width
79
+ // rather than ending wherever its label happens to end.
80
+ const inner = width - 6;
81
+
82
+ // Column widths measured over the whole list, so badges and bars line up
83
+ // down the column instead of drifting with each label's length.
84
+ const labelWidth = Math.max(...options.map((o) => o.label.length), 0);
85
+ const badgeWidth = Math.max(
86
+ ...options.map((o) => (o.badge ? o.badge.label.length + 2 : 0)),
87
+ 0,
88
+ );
89
+
90
+ // Meta is dim trailing detail, so it is the first thing to go when the row
91
+ // will not fit — dropped for EVERY row, not per row, because a column that
92
+ // appears on some rows and not others is worse than no column.
93
+ //
94
+ // Without this the row simply overflowed and the terminal wrapped it:
95
+ // MEASURED on screen, a 41-cell label put "14 plugins" on a line of its own
96
+ // under the option, reading as a second list item. The longest label sets the
97
+ // column, so one long option breaks every row's meta at once.
98
+ const metaWidth = Math.max(
99
+ ...options.map((o) => (o.meta ? o.meta.length + 2 : 0)),
100
+ 0,
101
+ );
102
+ const showMeta = 2 + labelWidth + 2 + badgeWidth + metaWidth <= inner;
103
+
43
104
  return (
44
105
  <box
45
106
  flexDirection="column"
46
107
  border
47
108
  borderStyle="rounded"
48
- borderColor={theme.colors.dim}
109
+ // A modal is the focused surface by definition, and accent means
110
+ // focus everywhere else in this app — so the border wears it here
111
+ // too, and the title drawn in it comes along in the same tone.
112
+ borderColor={theme.colors.accent}
49
113
  backgroundColor={theme.surface.bg}
50
- paddingLeft={3}
51
- paddingRight={3}
114
+ title={title}
115
+ titleAlignment="left"
116
+ paddingLeft={2}
117
+ paddingRight={2}
52
118
  paddingTop={1}
53
119
  paddingBottom={1}
54
120
  width={width}
55
121
  >
56
- <box marginBottom={1}>
57
- <text fg={theme.colors.text}>
58
- <strong>{title}</strong>
59
- </text>
60
- </box>
61
-
62
122
  <box marginBottom={1}>
63
123
  <text fg={theme.colors.muted}>{message}</text>
64
124
  </box>
65
125
 
66
- <box flexDirection="column" paddingLeft={1}>
126
+ <box flexDirection="column">
67
127
  {options.map((option, idx) => {
68
128
  const isSelected = idx === selectedIndex;
129
+ const pad = labelPad(option.label, labelWidth);
130
+ // The plain width of everything the row draws, so the
131
+ // selected fill can be padded out to the full row width.
132
+ const drawn =
133
+ 2 +
134
+ option.label.length +
135
+ pad.length +
136
+ badgeWidth +
137
+ (showMeta && option.meta ? option.meta.length + 2 : 0);
138
+ const fill = " ".repeat(Math.max(0, inner - drawn));
139
+
140
+ if (isSelected) {
141
+ // The selected row owns the colour channel — see
142
+ // `theme.selection`. On its fill no page-level tone
143
+ // clears contrast, so the row has exactly two inks:
144
+ // `fg` for the choice, `dim` for what surrounds it.
145
+ // A badge surrenders its chip the same way a model
146
+ // ink does on the Models screen.
147
+ return (
148
+ <text
149
+ key={option.value}
150
+ bg={theme.selection.bg}
151
+ fg={theme.selection.fg}
152
+ >
153
+ <span>❯ </span>
154
+ <strong>{option.label}</strong>
155
+ <span>{pad}</span>
156
+ {option.badge ? (
157
+ <span fg={theme.selection.dim}>{option.badge.label}</span>
158
+ ) : null}
159
+ {badgeWidth > 0 ? (
160
+ <span fg={theme.selection.dim}>{" ".repeat(badgeWidth)}</span>
161
+ ) : null}
162
+ {showMeta && option.meta ? (
163
+ <span fg={theme.selection.dim}>{` ${option.meta}`}</span>
164
+ ) : null}
165
+ <span>{fill}</span>
166
+ </text>
167
+ );
168
+ }
169
+
170
+ // Unselected rows sit in the terminal's own foreground —
171
+ // NOT muted. Muting every unselected row turned the list
172
+ // into the wall of grey this dialog was rejected for; the
173
+ // choices are the signal, and only the chrome is dim.
69
174
  return (
70
- <text
71
- key={option.value}
72
- fg={isSelected ? theme.colors.text : theme.colors.muted}
73
- >
74
- <span fg={isSelected ? theme.colors.text : theme.colors.muted}>
75
- {isSelected ? "❯ " : " "}
76
- </span>
77
- {isSelected ? <strong>{option.label}</strong> : option.label}
175
+ <text key={option.value} fg={theme.colors.text}>
176
+ <span>{" "}</span>
177
+ <span>{option.label}</span>
178
+ <span>{pad}</span>
179
+ {option.badge ? (
180
+ <Chip badge={option.badge} width={badgeWidth} />
181
+ ) : (
182
+ <span fg={theme.colors.dim}>{" ".repeat(badgeWidth)}</span>
183
+ )}
184
+ {showMeta && option.meta ? (
185
+ <span fg={theme.colors.muted}>{` ${option.meta}`}</span>
186
+ ) : null}
78
187
  </text>
79
188
  );
80
189
  })}
81
190
  </box>
82
191
 
192
+ {/* The shared footer key chips, not a prose hint line: a chip reads
193
+ as a pressable key at a glance, and every other footer in the
194
+ app already renders this way. */}
83
195
  <box marginTop={1}>
84
- <text fg={theme.colors.muted}>↑↓ Select • ↵ Confirm • Esc Cancel</text>
196
+ <FooterHints
197
+ hints={[
198
+ { keys: ["↑", "↓"], label: "select" },
199
+ { keys: ["↵"], label: "confirm" },
200
+ // Omitted when there is nothing to cancel to. A hint for a key
201
+ // that does nothing is worse than no hint: the user presses it,
202
+ // nothing happens, and they read the app as hung.
203
+ ...(cancellable ? [{ keys: ["esc"], label: "cancel" }] : []),
204
+ ]}
205
+ />
85
206
  </box>
86
207
  </box>
87
208
  );
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Startup profile modal: magus opens, finds the project is not running a
3
+ * profile, and either applies the repo's declared answer or requires one.
4
+ *
5
+ * This file is only the React wiring. What to ask, which options to offer, which
6
+ * row to start on and what a chosen value means all live in
7
+ * `services/profile-startup.ts`, and carrying a choice out lives in
8
+ * `services/profile-activate.ts` — both shared with the CLI, so the two
9
+ * surfaces cannot ask different questions or write different things in answer
10
+ * to the same one.
11
+ *
12
+ * The modal cannot be dismissed. Without an active profile there is no profile
13
+ * state to read and nowhere to write updates, so letting the user past it would
14
+ * drop them into a magus that cannot do its job. Its only non-action is to
15
+ * leave, which is why it needs the app's own exit.
16
+ */
17
+
18
+ import { useCallback } from "react";
19
+ import {
20
+ type ProfileChoice,
21
+ applyProfileChoice,
22
+ } from "../../services/profile-activate.js";
23
+ import {
24
+ type ProfileStartupNeed,
25
+ buildProfileOptions,
26
+ defaultOptionIndex,
27
+ describeNeed,
28
+ parseProfileChoice,
29
+ } from "../../services/profile-startup.js";
30
+ import type { DisplacedArtifact } from "../../services/symlink-manager.js";
31
+ import { useApp, useNavigation } from "../state/AppContext.js";
32
+ import type { ModalState } from "../state/types.js";
33
+
34
+ export interface BuildProfileModalArgs {
35
+ need: ProfileStartupNeed;
36
+ onChoose: (choice: ProfileChoice) => void;
37
+ /**
38
+ * Leave magus. NOT "dismiss" — there is deliberately no way to carry on
39
+ * without a profile, because there is nothing to carry on with.
40
+ */
41
+ onQuit: () => void;
42
+ }
43
+
44
+ export function buildProfileStartupModal(
45
+ args: BuildProfileModalArgs,
46
+ ): ModalState {
47
+ const { need, onChoose, onQuit } = args;
48
+ const { title, message } = describeNeed(need);
49
+ return {
50
+ type: "select",
51
+ title,
52
+ message,
53
+ options: buildProfileOptions(need),
54
+ // The row the repo declares, so Enter does the intended thing for a
55
+ // developer who has local work and therefore still gets asked.
56
+ defaultIndex: defaultOptionIndex(need),
57
+ onSelect: (value: string) => {
58
+ const choice = parseProfileChoice(value, need);
59
+ if (choice) onChoose(choice);
60
+ else onQuit();
61
+ },
62
+ // Escape and `q` route here, and here they do NOTHING. Dismissing would
63
+ // drop the user into the app this modal exists to say is unusable. Leaving
64
+ // is the "Quit magus" row, which says so on screen rather than making the
65
+ // user guess a keystroke.
66
+ onCancel: () => {},
67
+ // ...and the footer must not offer `esc cancel` when that is what Escape
68
+ // does. The two have to agree or the app reads as hung.
69
+ cancellable: false,
70
+ };
71
+ }
72
+
73
+ /** What changed, phrased for a message modal rather than a terminal. */
74
+ export function describeOutcome(
75
+ profileId: string,
76
+ displaced: DisplacedArtifact[],
77
+ projectPath: string,
78
+ /** True when this happened without asking, following the committed default. */
79
+ automatic = false,
80
+ ): string {
81
+ const lines = [
82
+ automatic
83
+ ? `This project declares "${profileId}" as its profile, so magus activated it.`
84
+ : `"${profileId}" is now the active profile.`,
85
+ ];
86
+ if (displaced.length > 0) {
87
+ lines.push(
88
+ "",
89
+ "These were moved aside to make room for links into the profile, and are kept:",
90
+ ...displaced.map((d) => {
91
+ const from = d.link.startsWith(projectPath)
92
+ ? d.link.slice(projectPath.length + 1)
93
+ : d.link;
94
+ const to = d.savedTo.startsWith(projectPath)
95
+ ? d.savedTo.slice(projectPath.length + 1)
96
+ : d.savedTo;
97
+ return ` ${from} → ${to}`;
98
+ }),
99
+ );
100
+ }
101
+ return lines.join("\n");
102
+ }
103
+
104
+ /** Project-relative, so a path does not wrap over two lines. */
105
+ function rel(p: string, projectPath: string): string {
106
+ return p.startsWith(projectPath) ? p.slice(projectPath.length + 1) : p;
107
+ }
108
+
109
+ /**
110
+ * What to say when activation threw partway through.
111
+ *
112
+ * The hard part is that a failure is NOT always a no-op. Activation moves up to
113
+ * four artifacts in sequence and `settings.json` goes first, so a throw on the
114
+ * second leaves it already in the backup. The old message said "Nothing was
115
+ * activated" unconditionally, which in exactly that case is false about the
116
+ * user's own file — and `.magus-backups/` is gitignored, so `git status` showed
117
+ * the deletion with no sign of the copy. It read as "magus deleted my settings".
118
+ *
119
+ * So the sentence is chosen by what actually moved, and when anything did, the
120
+ * backup paths are named here rather than left in a return value the error path
121
+ * never receives.
122
+ */
123
+ export function describeFailure(
124
+ error: unknown,
125
+ moved: DisplacedArtifact[],
126
+ projectPath: string,
127
+ declaredDefault: string | null,
128
+ ): string {
129
+ const reason = error instanceof Error ? error.message : String(error);
130
+ if (moved.length === 0) {
131
+ return `${reason}\n\nNothing was moved, so this project is exactly as it was.`;
132
+ }
133
+ return [
134
+ reason,
135
+ "",
136
+ "This did not finish, and these were already moved aside. They are kept:",
137
+ ...moved.map(
138
+ (d) => ` ${rel(d.link, projectPath)} → ${rel(d.savedTo, projectPath)}`,
139
+ ),
140
+ "",
141
+ // The trap the reviewer found: re-choosing "save what you have" after a
142
+ // partial failure runs adoption again, and adoption reads settings.json —
143
+ // which is now in the backup. It would capture an empty plugin set into a
144
+ // second profile and call that the user's setup.
145
+ declaredDefault !== null
146
+ ? `Activating ${declaredDefault} again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.`
147
+ : "Activating a profile again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.",
148
+ ].join("\n");
149
+ }
150
+
151
+ export function useProfileStartupModal(onQuit: () => void) {
152
+ const { state, dispatch } = useApp();
153
+ const { navigateToScreen } = useNavigation();
154
+ const projectPath = state.projectPath;
155
+
156
+ const show = useCallback(
157
+ (need: ProfileStartupNeed) => {
158
+ /** Apply a choice, reporting the outcome either way. */
159
+ const apply = (choice: ProfileChoice, automatic: boolean): void => {
160
+ dispatch({
161
+ type: "SHOW_MODAL",
162
+ modal: {
163
+ type: "loading",
164
+ message:
165
+ choice.kind === "adopt"
166
+ ? `Creating "${choice.id}"…`
167
+ : `Activating ${choice.id}…`,
168
+ },
169
+ });
170
+ // Collected as they move, NOT read from the resolved result.
171
+ //
172
+ // Activation moves up to four artifacts in sequence and
173
+ // `settings.json` goes first, so a throw on the second leaves the
174
+ // user's file already in the backup — and the resolved value that
175
+ // would have listed it never arrives. This array is the only record
176
+ // the error path has, and without it the failure modal claimed
177
+ // "Nothing was activated" while their settings.json was gone from
178
+ // where they left it. `.magus-backups/` is gitignored, so git status
179
+ // showed the deletion and no sign of the copy.
180
+ const moved: DisplacedArtifact[] = [];
181
+ void applyProfileChoice(
182
+ choice,
183
+ need.manifest,
184
+ projectPath,
185
+ (artifact) => moved.push(artifact),
186
+ )
187
+ .then(({ profileId, displaced }) => {
188
+ dispatch({
189
+ type: "SHOW_MODAL",
190
+ modal: {
191
+ type: "message",
192
+ title: "Profile active",
193
+ message: describeOutcome(
194
+ profileId,
195
+ displaced,
196
+ projectPath,
197
+ automatic,
198
+ ),
199
+ variant: "success",
200
+ onDismiss: () => {
201
+ dispatch({ type: "HIDE_MODAL" });
202
+ // The Profiles tab is where the result is visible, and
203
+ // where the user will want to look next.
204
+ navigateToScreen("profiles");
205
+ },
206
+ },
207
+ });
208
+ })
209
+ .catch((error: unknown) => {
210
+ dispatch({
211
+ type: "SHOW_MODAL",
212
+ modal: {
213
+ type: "message",
214
+ title:
215
+ choice.kind === "adopt"
216
+ ? "Could not create the profile"
217
+ : "Could not activate the profile",
218
+ message: describeFailure(
219
+ error,
220
+ moved,
221
+ projectPath,
222
+ need.declaredDefault,
223
+ ),
224
+ variant: "error",
225
+ // A failure leaves the project without an active profile, so
226
+ // dismissing returns to the question — including when the
227
+ // attempt was automatic, which is the one case where there
228
+ // was no question on screen yet.
229
+ onDismiss: ask,
230
+ },
231
+ });
232
+ });
233
+ };
234
+
235
+ function ask(): void {
236
+ dispatch({
237
+ type: "SHOW_MODAL",
238
+ modal: buildProfileStartupModal({
239
+ need,
240
+ onChoose: (choice) => apply(choice, false),
241
+ onQuit,
242
+ }),
243
+ });
244
+ }
245
+
246
+ // The repo already answered and there is nothing of the user's to
247
+ // replace: asking here would ask someone to re-decide what their team
248
+ // decided, with nothing at stake. Reported in the outcome modal, so it
249
+ // is never invisible.
250
+ if (need.autoActivate && need.declaredDefault !== null) {
251
+ apply({ kind: "activate", id: need.declaredDefault }, true);
252
+ return;
253
+ }
254
+ ask();
255
+ },
256
+ [dispatch, navigateToScreen, projectPath, onQuit],
257
+ );
258
+
259
+ return { show };
260
+ }
@@ -0,0 +1,157 @@
1
+ import { useCallback } from "react";
2
+ import { useApp, useNavigation } from "../state/AppContext.js";
3
+ import type { ModalState } from "../state/types.js";
4
+
5
+ /**
6
+ * Startup broken-plugin modal: magus launches, finds that Claude Code will load
7
+ * an older version of a plugin than the one installed here, and offers to fix it.
8
+ *
9
+ * Same shape as the gitignore modal beside it — a startup repair magus can make
10
+ * without being asked to, surfaced as a choice rather than done silently. The
11
+ * difference is the DEFAULT: fixing is the first option and `defaultIndex` says
12
+ * so explicitly, because a broken plugin means every session in this directory
13
+ * runs code nobody installed, and there is no reason to prefer leaving it.
14
+ *
15
+ * The copy names the CAUSE, not magus's internal state. An earlier version read
16
+ * "Claude Code loads an older version here than the one installed", which is a
17
+ * report on two stores disagreeing and leaves the reader with nothing to do. It
18
+ * is a defect in Claude Code's plugin loader, the user did nothing to cause it,
19
+ * and the sentence they need is what is wrong and how far it reaches.
20
+ *
21
+ * Keep it plain and keep it accurate. A draft said "your sessions run code you
22
+ * did not install", which is both overstated and wrong: nothing happens to the
23
+ * user's code, an old cached plugin version is used instead of the current one.
24
+ * Say that, in those words.
25
+ *
26
+ * The reach is not this directory. Relevance is decided by repository root, so
27
+ * every worktree of this repo — and the main checkout — resolve identically and
28
+ * are affected together. Saying "in this directory" understated it.
29
+ *
30
+ * NO UPSTREAM LINK YET, deliberately. anthropics/claude-code#45997 is the
31
+ * nearest issue and is CLOSED, and its title states the `[0]`-index diagnosis,
32
+ * which measurement disproved: the loader filters by repository root first and
33
+ * takes the first RELEVANT row. Linking a closed issue with a wrong cause sends
34
+ * the reader somewhere that contradicts this dialog. Add a link here when an
35
+ * accurate issue exists.
36
+ *
37
+ * Pure builder is exported separately for tests.
38
+ */
39
+
40
+ export interface BuildStaleLoadModalArgs {
41
+ brokenCount: number;
42
+ onFix: () => void;
43
+ onOpenTab: () => void;
44
+ onDismiss: () => void;
45
+ }
46
+
47
+ export function buildStaleLoadModal(args: BuildStaleLoadModalArgs): ModalState {
48
+ const { brokenCount, onFix, onOpenTab, onDismiss } = args;
49
+ const plural = brokenCount === 1 ? "plugin" : "plugins";
50
+ return {
51
+ type: "select",
52
+ title: "Claude Code is loading old plugin versions",
53
+ message: `It picks up a stale cached version — the one recorded by another directory in this repo, including worktrees you deleted. ${brokenCount} ${plural} ${brokenCount === 1 ? "is" : "are"} on an older version than you installed, in every worktree and the main checkout.\n\nFix updates this repo's install records to the versions you installed. installed_plugins.json is backed up first. Nothing is deleted or downgraded.`,
54
+ options: [
55
+ { label: `Fix it (${brokenCount} ${plural})`, value: "fix" },
56
+ { label: "Open the Plugins tab to review", value: "open-tab" },
57
+ { label: "Not now", value: "dismiss" },
58
+ ],
59
+ // FIX, stated rather than inherited from being first — a reordering must not
60
+ // silently make "leave it broken" the thing Enter does.
61
+ defaultIndex: 0,
62
+ onSelect: (value: string) => {
63
+ if (value === "fix") onFix();
64
+ else if (value === "open-tab") onOpenTab();
65
+ else onDismiss();
66
+ },
67
+ onCancel: onDismiss,
68
+ };
69
+ }
70
+
71
+ export function useStaleLoadModal() {
72
+ const { dispatch } = useApp();
73
+ const { navigateToScreen } = useNavigation();
74
+
75
+ const show = useCallback(
76
+ (args: {
77
+ brokenCount: number;
78
+ /** Runs the repair. Resolves to what to tell the user afterwards. */
79
+ onFix: () => Promise<string> | string;
80
+ }) => {
81
+ if (args.brokenCount === 0) return;
82
+ const dismiss = () => dispatch({ type: "HIDE_MODAL" });
83
+ const modal = buildStaleLoadModal({
84
+ brokenCount: args.brokenCount,
85
+ onFix: () => {
86
+ dispatch({
87
+ type: "SHOW_MODAL",
88
+ modal: { type: "loading", message: "Repairing plugins…" },
89
+ });
90
+ // The outcome is REPORTED, never assumed. `repairStaleLoad` re-asks
91
+ // the loader after writing and can legitimately answer "still not
92
+ // loading the target"; swallowing that would leave the user believing
93
+ // a broken plugin was fixed.
94
+ void Promise.resolve(args.onFix())
95
+ .then((summary) => {
96
+ dispatch({
97
+ type: "SHOW_MODAL",
98
+ modal: {
99
+ type: "message",
100
+ title: "Plugin repair",
101
+ message: summary,
102
+ variant: "info",
103
+ onDismiss: dismiss,
104
+ },
105
+ });
106
+ })
107
+ .catch((err) => {
108
+ dispatch({
109
+ type: "SHOW_MODAL",
110
+ modal: {
111
+ type: "message",
112
+ title: "Plugin repair failed",
113
+ message: err instanceof Error ? err.message : String(err),
114
+ variant: "error",
115
+ onDismiss: dismiss,
116
+ },
117
+ });
118
+ });
119
+ },
120
+ onOpenTab: () => {
121
+ dispatch({ type: "HIDE_MODAL" });
122
+ navigateToScreen("plugins");
123
+ },
124
+ onDismiss: dismiss,
125
+ });
126
+ dispatch({ type: "SHOW_MODAL", modal });
127
+ },
128
+ [dispatch, navigateToScreen],
129
+ );
130
+
131
+ return { show };
132
+ }
133
+
134
+ /**
135
+ * One line per repair attempt, for the modal that follows the fix.
136
+ *
137
+ * Exported and pure so the wording is tested rather than inspected by eye. Each
138
+ * line says what the loader resolves NOW, because that — not the row count — is
139
+ * the thing the user came here to change.
140
+ */
141
+ export function staleLoadRepairSummary(
142
+ results: ReadonlyArray<{
143
+ pluginId: string;
144
+ target: string;
145
+ ok: boolean;
146
+ error?: string;
147
+ }>,
148
+ ): string {
149
+ if (results.length === 0) return "Nothing to repair.";
150
+ return results
151
+ .map((r) =>
152
+ r.ok
153
+ ? `✓ ${r.pluginId} now loads v${r.target}`
154
+ : `✗ ${r.pluginId} — ${r.error ?? "repair did not take effect"}`,
155
+ )
156
+ .join("\n");
157
+ }
@@ -278,6 +278,9 @@ const ACTION_LABEL: Record<PluginUpdateAction, string> = {
278
278
  update: "UPDATE AVAILABLE",
279
279
  repair: "NEEDS REINSTALL",
280
280
  current: "UP TO DATE",
281
+ // The DETAIL panel's label, not the row's — this is where the mechanism is
282
+ // allowed to be named, and the note beside it spells out both versions.
283
+ "stale-load": "BROKEN — OLDER VERSION LOADS",
281
284
  unknown: "UNKNOWN",
282
285
  };
283
286
 
@@ -463,6 +466,32 @@ function pluginDetail(
463
466
  </text>
464
467
  </box>
465
468
  ) : null}
469
+ {/*
470
+ * THE panel where the mechanism is spelled out in full. The row above
471
+ * says only "BROKEN — press a to fix", on purpose; a reader who wants to
472
+ * know why comes here, so this names both versions, says which registry
473
+ * row wins, and says what the fix does. A reinstall is measured NOT to
474
+ * fix it — it writes another row for this project while the winner
475
+ * belongs to a different one — so the fix rewrites those rows instead.
476
+ */}
477
+ {status?.action === "stale-load" ? (
478
+ <box flexDirection="column">
479
+ <text fg={theme.colors.danger}>
480
+ ✗ Broken: Claude Code loads v{status.loaded ?? "?"} in this
481
+ directory, not the v{status.installed ?? "?"} that is installed.
482
+ </text>
483
+ <text fg={theme.colors.muted}>
484
+ An install row belonging to another directory in this repository —
485
+ often a worktree that no longer exists — is resolved first. `magus
486
+ doctor` names which one.
487
+ </text>
488
+ <text fg={theme.colors.muted}>
489
+ Press a to fix: magus moves those rows forward to v
490
+ {status.target ?? status.installed ?? "?"}, backing up
491
+ installed_plugins.json first. Nothing is deleted or downgraded.
492
+ </text>
493
+ </box>
494
+ ) : null}
466
495
 
467
496
  {/* Description */}
468
497
  <box marginTop={1} marginBottom={1}>