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.
- package/package.json +4 -4
- package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/__tests__/stale-load-repair.test.ts +1076 -0
- package/src/__tests__/update-apply.test.ts +146 -1
- package/src/__tests__/update-plan.test.ts +338 -0
- package/src/__tests__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/doctor.ts +128 -2
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +32 -1
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/manifest.ts +21 -0
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -0
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +123 -0
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/hooks/useStaleLoadModal.ts +157 -0
- package/src/ui/renderers/pluginRenderers.tsx +29 -0
- package/src/ui/renderers/pluginStatusText.ts +14 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +46 -13
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- 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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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"
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
<
|
|
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}>
|