magus-cli 7.5.2 → 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.
- package/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +17 -22
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +152 -164
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -12,19 +12,38 @@ import type { ModalState } from "../state/types.js";
|
|
|
12
12
|
export interface BuildModalArgs {
|
|
13
13
|
violationCount: number;
|
|
14
14
|
safeCount: number;
|
|
15
|
+
/**
|
|
16
|
+
* Files magus generates from `.claude/profiles.json` that git tracks. The
|
|
17
|
+
* fix untracks these in place; every other destructive fix stays in the tab.
|
|
18
|
+
*/
|
|
19
|
+
generated?: readonly string[];
|
|
15
20
|
onFixSafe: () => void;
|
|
16
21
|
onOpenTab: () => void;
|
|
17
22
|
onDismiss: () => void;
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
function entries(n: number): string {
|
|
26
|
+
return `${n} missing .gitignore entr${n === 1 ? "y" : "ies"}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fixLabel(safeCount: number, generated: number): string {
|
|
30
|
+
if (generated > 0) {
|
|
31
|
+
const files = `${generated} generated file${generated === 1 ? "" : "s"}`;
|
|
32
|
+
return safeCount > 0
|
|
33
|
+
? `Stop tracking ${files} and add ${entries(safeCount)}`
|
|
34
|
+
: `Stop tracking ${files}`;
|
|
35
|
+
}
|
|
36
|
+
return safeCount > 0
|
|
37
|
+
? `Add ${entries(safeCount)}`
|
|
38
|
+
: "(no missing .gitignore entries)";
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
export function buildGitignoreModal(args: BuildModalArgs): ModalState {
|
|
21
42
|
const { violationCount, safeCount, onFixSafe, onOpenTab, onDismiss } = args;
|
|
43
|
+
const generated = args.generated ?? [];
|
|
22
44
|
const options = [
|
|
23
45
|
{
|
|
24
|
-
label:
|
|
25
|
-
safeCount > 0
|
|
26
|
-
? `Add ${safeCount} missing .gitignore entr${safeCount === 1 ? "y" : "ies"}`
|
|
27
|
-
: "(no missing .gitignore entries)",
|
|
46
|
+
label: fixLabel(safeCount, generated.length),
|
|
28
47
|
value: "fix-safe",
|
|
29
48
|
},
|
|
30
49
|
{
|
|
@@ -36,10 +55,20 @@ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
|
|
|
36
55
|
value: "dismiss",
|
|
37
56
|
},
|
|
38
57
|
];
|
|
58
|
+
const generatedNote =
|
|
59
|
+
generated.length > 0
|
|
60
|
+
? `magus generates ${generated.length === 1 ? "this file" : "these files"} from .claude/profiles.json for each developer, so ${
|
|
61
|
+
generated.length === 1 ? "it is" : "they are"
|
|
62
|
+
} expected to be gitignored — but git tracks ${generated.length === 1 ? "it" : "them"}:\n${generated
|
|
63
|
+
.map((p) => ` ${p}`)
|
|
64
|
+
.join(
|
|
65
|
+
"\n",
|
|
66
|
+
)}\n\nThe first choice stops tracking ${generated.length === 1 ? "it" : "them"} (git rm --cached, staged for you to commit) and adds the .gitignore entries.\n\n`
|
|
67
|
+
: "";
|
|
39
68
|
return {
|
|
40
69
|
type: "select",
|
|
41
70
|
title: "Gitignore violations detected",
|
|
42
|
-
message:
|
|
71
|
+
message: `${generatedNote}Found ${violationCount} managed gitignore item(s) that do not match the current repo state.\n\nMissing .gitignore entries can be added now. Other items that change git tracking are reviewed and fixed from the Gitignore tab.`,
|
|
43
72
|
options,
|
|
44
73
|
onSelect: (value: string) => {
|
|
45
74
|
if (value === "fix-safe") onFixSafe();
|
|
@@ -58,27 +87,44 @@ export function useGitignoreModal() {
|
|
|
58
87
|
(args: {
|
|
59
88
|
violationCount: number;
|
|
60
89
|
safeCount: number;
|
|
61
|
-
|
|
90
|
+
generated?: readonly string[];
|
|
91
|
+
/** Returns what it did, shown before returning to the app. */
|
|
92
|
+
onFixSafe: () => Promise<string | undefined> | string | undefined;
|
|
62
93
|
}) => {
|
|
63
94
|
if (args.violationCount === 0) return;
|
|
64
95
|
const dismiss = () => dispatch({ type: "HIDE_MODAL" });
|
|
65
96
|
const modal = buildGitignoreModal({
|
|
66
97
|
violationCount: args.violationCount,
|
|
67
98
|
safeCount: args.safeCount,
|
|
99
|
+
generated: args.generated,
|
|
68
100
|
onFixSafe: () => {
|
|
69
101
|
dispatch({ type: "HIDE_MODAL" });
|
|
70
|
-
void Promise.resolve(args.onFixSafe())
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
type: "
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
102
|
+
void Promise.resolve(args.onFixSafe())
|
|
103
|
+
.then((report) => {
|
|
104
|
+
if (!report) return;
|
|
105
|
+
dispatch({
|
|
106
|
+
type: "SHOW_MODAL",
|
|
107
|
+
modal: {
|
|
108
|
+
type: "message",
|
|
109
|
+
title: "Gitignore fixed",
|
|
110
|
+
message: report,
|
|
111
|
+
variant: "success",
|
|
112
|
+
onDismiss: dismiss,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
})
|
|
116
|
+
.catch((err) => {
|
|
117
|
+
dispatch({
|
|
118
|
+
type: "SHOW_MODAL",
|
|
119
|
+
modal: {
|
|
120
|
+
type: "message",
|
|
121
|
+
title: "Safe fixes failed",
|
|
122
|
+
message: err instanceof Error ? err.message : String(err),
|
|
123
|
+
variant: "error",
|
|
124
|
+
onDismiss: dismiss,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
80
127
|
});
|
|
81
|
-
});
|
|
82
128
|
},
|
|
83
129
|
onOpenTab: () => {
|
|
84
130
|
dispatch({ type: "HIDE_MODAL" });
|
|
@@ -25,10 +25,13 @@ import {
|
|
|
25
25
|
import type { DisplacedArtifact } from "../../services/profile-live.js";
|
|
26
26
|
import {
|
|
27
27
|
type ProfileStartupNeed,
|
|
28
|
+
UPDATE_FORMAT_VALUE,
|
|
28
29
|
buildProfileOptions,
|
|
29
30
|
defaultOptionIndex,
|
|
30
31
|
describeNeed,
|
|
32
|
+
formatUpdateOutcome,
|
|
31
33
|
parseProfileChoice,
|
|
34
|
+
prepareProfileStartup,
|
|
32
35
|
unparseableNeed,
|
|
33
36
|
} from "../../services/profile-startup.js";
|
|
34
37
|
import { applyProfileChoice } from "../../services/profile-state.js";
|
|
@@ -39,6 +42,8 @@ import type { ModalState } from "../state/types.js";
|
|
|
39
42
|
export interface BuildProfileModalArgs {
|
|
40
43
|
need: ProfileStartupNeed;
|
|
41
44
|
onChoose: (choice: ProfileChoice) => void;
|
|
45
|
+
/** "Update it now" on the `old-format` question — not a profile choice. */
|
|
46
|
+
onUpdateFormat?: () => void;
|
|
42
47
|
/**
|
|
43
48
|
* Leave magus. NOT "dismiss" — there is deliberately no way to carry on
|
|
44
49
|
* without a profile, because there is nothing to carry on with.
|
|
@@ -49,7 +54,7 @@ export interface BuildProfileModalArgs {
|
|
|
49
54
|
export function buildProfileStartupModal(
|
|
50
55
|
args: BuildProfileModalArgs,
|
|
51
56
|
): ModalState {
|
|
52
|
-
const { need, onChoose, onQuit } = args;
|
|
57
|
+
const { need, onChoose, onUpdateFormat, onQuit } = args;
|
|
53
58
|
const { title, message } = describeNeed(need);
|
|
54
59
|
return {
|
|
55
60
|
type: "select",
|
|
@@ -60,6 +65,10 @@ export function buildProfileStartupModal(
|
|
|
60
65
|
// developer who has local work and therefore still gets asked.
|
|
61
66
|
defaultIndex: defaultOptionIndex(need),
|
|
62
67
|
onSelect: (value: string) => {
|
|
68
|
+
if (value === UPDATE_FORMAT_VALUE && onUpdateFormat) {
|
|
69
|
+
onUpdateFormat();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
63
72
|
const choice = parseProfileChoice(value, need);
|
|
64
73
|
if (choice) onChoose(choice);
|
|
65
74
|
else onQuit();
|
|
@@ -157,15 +166,27 @@ export function describeFailure(
|
|
|
157
166
|
].join("\n");
|
|
158
167
|
}
|
|
159
168
|
|
|
160
|
-
export function useProfileStartupModal(
|
|
169
|
+
export function useProfileStartupModal(
|
|
170
|
+
onQuit: () => void,
|
|
171
|
+
/** Called once a choice has activated a profile — see App's gitignore re-check. */
|
|
172
|
+
onActivated?: () => void,
|
|
173
|
+
) {
|
|
161
174
|
const { state, dispatch } = useApp();
|
|
162
175
|
const { navigateToScreen } = useNavigation();
|
|
163
176
|
const projectPath = state.projectPath;
|
|
164
177
|
|
|
165
178
|
const show = useCallback(
|
|
166
179
|
(need: ProfileStartupNeed) => {
|
|
167
|
-
/**
|
|
168
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Apply a choice, reporting the outcome either way. `current` is the
|
|
182
|
+
* question it answers — after "Update it now" that is a NEW question,
|
|
183
|
+
* whose manifest is the converted one, not the one magus opened with.
|
|
184
|
+
*/
|
|
185
|
+
const apply = (
|
|
186
|
+
choice: ProfileChoice,
|
|
187
|
+
automatic: boolean,
|
|
188
|
+
current: ProfileStartupNeed = need,
|
|
189
|
+
): void => {
|
|
169
190
|
dispatch({
|
|
170
191
|
type: "SHOW_MODAL",
|
|
171
192
|
modal: {
|
|
@@ -187,10 +208,11 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
187
208
|
// where they left it. `.magus-backups/` is gitignored, so git status
|
|
188
209
|
// showed the deletion and no sign of the copy.
|
|
189
210
|
const moved: DisplacedArtifact[] = [];
|
|
190
|
-
void applyProfileChoice(choice,
|
|
211
|
+
void applyProfileChoice(choice, current.manifest, projectPath, {
|
|
191
212
|
onDisplace: (artifact) => moved.push(artifact),
|
|
192
213
|
})
|
|
193
214
|
.then((result: ActivationResult) => {
|
|
215
|
+
onActivated?.();
|
|
194
216
|
const { profileId, displaced } = result;
|
|
195
217
|
dispatch({
|
|
196
218
|
type: "SHOW_MODAL",
|
|
@@ -223,7 +245,7 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
223
245
|
error instanceof SettingsParseError &&
|
|
224
246
|
error.path === getManifestPath(projectPath)
|
|
225
247
|
? unparseableNeed(error)
|
|
226
|
-
:
|
|
248
|
+
: current;
|
|
227
249
|
dispatch({
|
|
228
250
|
type: "SHOW_MODAL",
|
|
229
251
|
modal: {
|
|
@@ -236,7 +258,7 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
236
258
|
error,
|
|
237
259
|
moved,
|
|
238
260
|
projectPath,
|
|
239
|
-
|
|
261
|
+
current.declaredDefault,
|
|
240
262
|
),
|
|
241
263
|
variant: "error",
|
|
242
264
|
// A failure leaves the project without an active profile, so
|
|
@@ -249,12 +271,75 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
249
271
|
});
|
|
250
272
|
};
|
|
251
273
|
|
|
274
|
+
/**
|
|
275
|
+
* "Update it now" on an old profile format: update it — the same gate
|
|
276
|
+
* the CLI runs after its own question — then ask whatever is left, or
|
|
277
|
+
* say it is done.
|
|
278
|
+
*/
|
|
279
|
+
const updateFormat = (): void => {
|
|
280
|
+
dispatch({
|
|
281
|
+
type: "SHOW_MODAL",
|
|
282
|
+
modal: { type: "loading", message: "Updating the profile format…" },
|
|
283
|
+
});
|
|
284
|
+
void prepareProfileStartup(projectPath, { convert: true })
|
|
285
|
+
.then((result) => {
|
|
286
|
+
const outcome = formatUpdateOutcome(result);
|
|
287
|
+
if (outcome.kind === "ask") {
|
|
288
|
+
onActivated?.();
|
|
289
|
+
ask(outcome.need);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (outcome.kind === "failed") {
|
|
293
|
+
// Refused, so still the old format: back to the question,
|
|
294
|
+
// as the CLI stops on the same lines.
|
|
295
|
+
dispatch({
|
|
296
|
+
type: "SHOW_MODAL",
|
|
297
|
+
modal: {
|
|
298
|
+
type: "message",
|
|
299
|
+
title: outcome.title,
|
|
300
|
+
message: outcome.message,
|
|
301
|
+
variant: "error",
|
|
302
|
+
onDismiss: () => ask(need),
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
onActivated?.();
|
|
308
|
+
dispatch({
|
|
309
|
+
type: "SHOW_MODAL",
|
|
310
|
+
modal: {
|
|
311
|
+
type: "message",
|
|
312
|
+
title: outcome.title,
|
|
313
|
+
message: outcome.message,
|
|
314
|
+
variant: "success",
|
|
315
|
+
onDismiss: () => {
|
|
316
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
317
|
+
navigateToScreen("profiles");
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
})
|
|
322
|
+
.catch((error: unknown) => {
|
|
323
|
+
dispatch({
|
|
324
|
+
type: "SHOW_MODAL",
|
|
325
|
+
modal: {
|
|
326
|
+
type: "message",
|
|
327
|
+
title: "Could not update the profile format",
|
|
328
|
+
message: `${error instanceof Error ? error.message : String(error)}\n\nYour profiles are kept. Open magus again to retry.`,
|
|
329
|
+
variant: "error",
|
|
330
|
+
onDismiss: () => ask(need),
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
252
336
|
function ask(current: ProfileStartupNeed = need): void {
|
|
253
337
|
dispatch({
|
|
254
338
|
type: "SHOW_MODAL",
|
|
255
339
|
modal: buildProfileStartupModal({
|
|
256
340
|
need: current,
|
|
257
|
-
onChoose: (choice) => apply(choice, false),
|
|
341
|
+
onChoose: (choice) => apply(choice, false, current),
|
|
342
|
+
onUpdateFormat: updateFormat,
|
|
258
343
|
onQuit,
|
|
259
344
|
}),
|
|
260
345
|
});
|
|
@@ -270,7 +355,7 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
270
355
|
}
|
|
271
356
|
ask();
|
|
272
357
|
},
|
|
273
|
-
[dispatch, navigateToScreen, projectPath, onQuit],
|
|
358
|
+
[dispatch, navigateToScreen, projectPath, onQuit, onActivated],
|
|
274
359
|
);
|
|
275
360
|
|
|
276
361
|
return { show };
|
package/src/ui/keymap.ts
CHANGED
|
@@ -2,8 +2,11 @@ import type React from "react";
|
|
|
2
2
|
import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
|
|
3
3
|
import {
|
|
4
4
|
compareVersions,
|
|
5
|
-
|
|
5
|
+
hasMissingFiles,
|
|
6
|
+
isInstalledAnywhere,
|
|
6
7
|
isInstalledInScope,
|
|
8
|
+
scopeFilesMissing,
|
|
9
|
+
versionInScope,
|
|
7
10
|
} from "../../services/plugin-manager.js";
|
|
8
11
|
import type { ScopeStatus } from "../../services/plugin-manager.js";
|
|
9
12
|
import type { PluginComponentKind } from "../../services/skills-manager.js";
|
|
@@ -195,9 +198,12 @@ function pluginRow(
|
|
|
195
198
|
isSelected: boolean,
|
|
196
199
|
): React.ReactNode {
|
|
197
200
|
const { plugin } = item;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
+
// The squares and the name colour read the one installed-at-scope test, the
|
|
202
|
+
// same one the detail panel's dots read, so the row and the panel beside it
|
|
203
|
+
// cannot show two different states.
|
|
204
|
+
const hasUser = isInstalledInScope(plugin.userScope);
|
|
205
|
+
const hasProject = isInstalledInScope(plugin.projectScope);
|
|
206
|
+
const hasLocal = isInstalledInScope(plugin.localScope);
|
|
201
207
|
const hasAnyScope = hasUser || hasProject || hasLocal;
|
|
202
208
|
|
|
203
209
|
// Everything after the name — version, history badge, status — comes from
|
|
@@ -257,24 +263,13 @@ function pluginRow(
|
|
|
257
263
|
);
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
const installedSomewhere =
|
|
261
|
-
isInstalledInScope(plugin.userScope) ||
|
|
262
|
-
isInstalledInScope(plugin.projectScope) ||
|
|
263
|
-
isInstalledInScope(plugin.localScope);
|
|
264
|
-
|
|
265
266
|
return (
|
|
266
267
|
<text fg={theme.colors.text}>
|
|
267
268
|
<span> </span>
|
|
268
269
|
<ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
|
|
269
270
|
<span> </span>
|
|
270
271
|
<span fg={theme.colors.dim}>{arrow}</span>
|
|
271
|
-
<span
|
|
272
|
-
fg={
|
|
273
|
-
hasAnyScope && installedSomewhere
|
|
274
|
-
? theme.colors.text
|
|
275
|
-
: theme.colors.muted
|
|
276
|
-
}
|
|
277
|
-
>
|
|
272
|
+
<span fg={hasAnyScope ? theme.colors.text : theme.colors.muted}>
|
|
278
273
|
{displayName}
|
|
279
274
|
</span>
|
|
280
275
|
<RowSegments segments={segments} selected={false} />
|
|
@@ -344,14 +339,10 @@ function pluginDetail(
|
|
|
344
339
|
fetched?: PluginContentsEntry,
|
|
345
340
|
): React.ReactNode {
|
|
346
341
|
const { plugin, status } = item;
|
|
347
|
-
// "Installed"
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
isInstalledInScope(plugin.userScope) ||
|
|
352
|
-
isInstalledInScope(plugin.projectScope) ||
|
|
353
|
-
isInstalledInScope(plugin.localScope);
|
|
354
|
-
const brokenInstall = isEnabledButNotInstalled(plugin);
|
|
342
|
+
// "Installed" means the same thing here as in the list row: the one
|
|
343
|
+
// installed-at-scope test, at any scope.
|
|
344
|
+
const isInstalled = isInstalledAnywhere(plugin);
|
|
345
|
+
const filesMissing = hasMissingFiles(plugin);
|
|
355
346
|
// The ⬆ marker and the U hint claim that pressing U does something. That is
|
|
356
347
|
// exactly the classifier's update/repair, and nothing else.
|
|
357
348
|
const needsWork = status?.action === "update" || status?.action === "repair";
|
|
@@ -453,25 +444,25 @@ function pluginDetail(
|
|
|
453
444
|
<box marginTop={1}>
|
|
454
445
|
<text
|
|
455
446
|
fg={
|
|
456
|
-
|
|
457
|
-
? theme.colors.
|
|
458
|
-
:
|
|
459
|
-
? theme.colors.
|
|
447
|
+
filesMissing
|
|
448
|
+
? theme.colors.danger
|
|
449
|
+
: isInstalled
|
|
450
|
+
? theme.colors.success
|
|
460
451
|
: theme.colors.muted
|
|
461
452
|
}
|
|
462
453
|
>
|
|
463
|
-
{
|
|
464
|
-
? "● Installed"
|
|
465
|
-
:
|
|
466
|
-
? "
|
|
454
|
+
{filesMissing
|
|
455
|
+
? "● Installed — files missing"
|
|
456
|
+
: isInstalled
|
|
457
|
+
? "● Installed"
|
|
467
458
|
: "○ Not installed"}
|
|
468
459
|
</text>
|
|
469
460
|
</box>
|
|
470
|
-
{
|
|
461
|
+
{filesMissing ? (
|
|
471
462
|
<box>
|
|
472
463
|
<text fg={theme.colors.muted}>
|
|
473
464
|
Claude Code cannot load it in this state. Press the scope key to
|
|
474
|
-
|
|
465
|
+
reinstall.
|
|
475
466
|
</text>
|
|
476
467
|
</box>
|
|
477
468
|
) : null}
|
|
@@ -631,10 +622,15 @@ function pluginDetail(
|
|
|
631
622
|
/**
|
|
632
623
|
* One line of the detail panel's scope breakdown.
|
|
633
624
|
*
|
|
634
|
-
* The filled dot means
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
625
|
+
* The filled dot means what the list row's square means — installed at this
|
|
626
|
+
* scope — because both read `isInstalledInScope`. The version shows only where
|
|
627
|
+
* the plugin is installed: a registry row can be inherited from another
|
|
628
|
+
* directory of the same repository, and magus run in `tools/magus` once showed
|
|
629
|
+
* "Project team v0.8.1" for a plugin that directory's settings never turn on and
|
|
630
|
+
* Claude Code started there never loads.
|
|
631
|
+
*
|
|
632
|
+
* Installed with no version is named, in the danger colour, because it needs an
|
|
633
|
+
* action — the scope key reinstalls — and a plain dot would read as healthy.
|
|
638
634
|
*/
|
|
639
635
|
function ScopeLine({
|
|
640
636
|
keyHint,
|
|
@@ -647,18 +643,16 @@ function ScopeLine({
|
|
|
647
643
|
qualifier: string;
|
|
648
644
|
scope?: ScopeStatus;
|
|
649
645
|
}): React.ReactNode {
|
|
650
|
-
const
|
|
646
|
+
const version = versionInScope(scope);
|
|
651
647
|
return (
|
|
652
648
|
<text fg={theme.colors.text}>
|
|
653
649
|
<ScopeKeyChip scopeKey={keyHint} />
|
|
654
|
-
<ScopeDot installed={
|
|
650
|
+
<ScopeDot installed={isInstalledInScope(scope)} />
|
|
655
651
|
<span>{name}</span>
|
|
656
652
|
<span fg={theme.colors.muted}> {qualifier}</span>
|
|
657
|
-
{
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
{!installed && scope?.enabled ? (
|
|
661
|
-
<span fg={theme.colors.danger}> enabled, not installed</span>
|
|
653
|
+
{version ? <span fg={theme.colors.info}> v{version}</span> : null}
|
|
654
|
+
{scopeFilesMissing(scope) ? (
|
|
655
|
+
<span fg={theme.colors.danger}> files missing</span>
|
|
662
656
|
) : null}
|
|
663
657
|
</text>
|
|
664
658
|
);
|
|
@@ -38,8 +38,12 @@ export function pluginRowText(item: PluginPluginItem): PluginRowText {
|
|
|
38
38
|
// A version is shown only when it carries information. "0.0.0" means
|
|
39
39
|
// installed with an unknown version (Anthropic's official plugins record
|
|
40
40
|
// exactly that) — it is never printed, and nothing below depends on it.
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
//
|
|
42
|
+
// The classifier's version and nothing else. A fallback to
|
|
43
|
+
// `plugin.installedVersion` printed a version where the classifier had none —
|
|
44
|
+
// "v0.8.1 files missing", with 0.8.1 read off a scope not installed here.
|
|
45
|
+
const installed = status?.installed ?? undefined;
|
|
46
|
+
const shown = isKnownVersion(installed) ? installed : undefined;
|
|
43
47
|
|
|
44
48
|
// "What just changed" and "what state it is in now" are independent facts
|
|
45
49
|
// and both can be true at once: an external update can move a plugin
|
|
@@ -57,13 +57,14 @@ export function pluginStatusText(
|
|
|
57
57
|
return { suffix: " BROKEN — press a to fix", tone: "danger" };
|
|
58
58
|
case "install":
|
|
59
59
|
// `absent` only ever comes from a profile pin (see `pinForRow`), so it
|
|
60
|
-
// may say so. An undeclared row
|
|
60
|
+
// may say so. An undeclared row whose files are missing must not.
|
|
61
61
|
return status.reason === "absent"
|
|
62
62
|
? { suffix: " not installed — profile declares it", tone: "warning" }
|
|
63
|
-
: //
|
|
63
|
+
: // Files missing is installed, and broken — never "not installed",
|
|
64
|
+
// which would contradict the filled square beside it. A missing
|
|
64
65
|
// declared plugin is routine work. Red against amber is the
|
|
65
66
|
// distinction that matters.
|
|
66
|
-
{ suffix: `
|
|
67
|
+
{ suffix: ` ${status.note ?? "files missing"}`, tone: "danger" };
|
|
67
68
|
case "current":
|
|
68
69
|
return { suffix: withNote, tone: "muted" };
|
|
69
70
|
}
|
|
@@ -26,9 +26,12 @@ import {
|
|
|
26
26
|
getAvailablePlugins,
|
|
27
27
|
getLocalMarketplacesInfo,
|
|
28
28
|
getMarketplaceFetchFailures,
|
|
29
|
+
isInstalledAnywhere,
|
|
29
30
|
isInstalledInScope,
|
|
30
31
|
refreshAllMarketplaces,
|
|
31
32
|
resolveScopeAction,
|
|
33
|
+
scopeFilesMissing,
|
|
34
|
+
versionInScope,
|
|
32
35
|
} from "../../services/plugin-manager.js";
|
|
33
36
|
import {
|
|
34
37
|
getPluginEnvRequirements,
|
|
@@ -44,7 +47,6 @@ import {
|
|
|
44
47
|
fetchPluginContents,
|
|
45
48
|
fetchPluginSkillDetail,
|
|
46
49
|
} from "../../services/skills-manager.js";
|
|
47
|
-
import { LiveFileRefusal } from "../../services/tracked-live-files.js";
|
|
48
50
|
// The one update implementation, shared with `magus update`. See the header
|
|
49
51
|
// of update-engine.ts for what drifted while the TUI had its own loop.
|
|
50
52
|
import {
|
|
@@ -872,17 +874,16 @@ export function PluginsScreen() {
|
|
|
872
874
|
scope: ScopeStatus | undefined,
|
|
873
875
|
desc: string,
|
|
874
876
|
) => {
|
|
875
|
-
const
|
|
876
|
-
|
|
877
|
-
let label = installed ? `● ${name}` : `○ ${name}`;
|
|
877
|
+
const ver = versionInScope(scope);
|
|
878
|
+
let label = isInstalledInScope(scope) ? `● ${name}` : `○ ${name}`;
|
|
878
879
|
label += ` (${desc})`;
|
|
879
880
|
if (ver) label += ` v${ver}`;
|
|
880
881
|
if (scopeIsBehind(status, scopeKey) && status?.target)
|
|
881
882
|
label += ` → v${status.target}`;
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
if (
|
|
883
|
+
// Installed with no version. Without saying so the row reads like a
|
|
884
|
+
// healthy install, and the user cannot tell that picking it
|
|
885
|
+
// reinstalls rather than uninstalls.
|
|
886
|
+
if (scopeFilesMissing(scope)) label += " — files missing, reinstalls";
|
|
886
887
|
return label;
|
|
887
888
|
};
|
|
888
889
|
|
|
@@ -931,9 +932,9 @@ export function PluginsScreen() {
|
|
|
931
932
|
? "Project"
|
|
932
933
|
: "Local";
|
|
933
934
|
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
935
|
+
// Files missing reinstalls rather than uninstalls — see
|
|
936
|
+
// resolveScopeAction. Update-versus-uninstall is the same
|
|
937
|
+
// `scopeIsBehind` the label above used.
|
|
937
938
|
const action = resolveScopeAction(
|
|
938
939
|
selectedScope,
|
|
939
940
|
scopeIsBehind(status, scopeValue as PluginScope),
|
|
@@ -983,11 +984,7 @@ export function PluginsScreen() {
|
|
|
983
984
|
fetchData();
|
|
984
985
|
} catch (error) {
|
|
985
986
|
modal.hideModal();
|
|
986
|
-
await modal.message(
|
|
987
|
-
"Error",
|
|
988
|
-
error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
|
|
989
|
-
"error",
|
|
990
|
-
);
|
|
987
|
+
await modal.message("Error", `Failed: ${error}`, "error");
|
|
991
988
|
}
|
|
992
989
|
}
|
|
993
990
|
};
|
|
@@ -1065,10 +1062,12 @@ export function PluginsScreen() {
|
|
|
1065
1062
|
};
|
|
1066
1063
|
|
|
1067
1064
|
/**
|
|
1068
|
-
* "Update all" — the SAME
|
|
1069
|
-
* update` runs
|
|
1070
|
-
*
|
|
1071
|
-
*
|
|
1065
|
+
* "Update all" — the SAME plan, the SAME selection and the SAME apply engine
|
|
1066
|
+
* `magus update` runs: every item of {@link planPluginUpdates} handed to
|
|
1067
|
+
* `applyPlugins`, which does the work each one needs (advance, repair,
|
|
1068
|
+
* reinstall missing files) and skips the rest. There used to be a different
|
|
1069
|
+
* selection here — "rows that say update" against the CLI's "what the profile
|
|
1070
|
+
* declares" — and the two surfaces disagreed about the same machine.
|
|
1072
1071
|
*
|
|
1073
1072
|
* This used to be a hand-rolled loop, and it had drifted from the CLI in
|
|
1074
1073
|
* three ways that were bugs, not choices — it touched one scope derived from
|
|
@@ -1079,20 +1078,11 @@ export function PluginsScreen() {
|
|
|
1079
1078
|
const handleUpdateAll = async () => {
|
|
1080
1079
|
if (pluginsState.plugins.status !== "success") return;
|
|
1081
1080
|
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
// the machine with silently outdated files. Broken (`stale-load`) rows are
|
|
1086
|
-
// included because the row itself now names this key — leaving them out
|
|
1087
|
-
// would make that hint a lie. `install` stays out: a plugin nothing has
|
|
1088
|
-
// installed is `s`'s business, per the note above.
|
|
1081
|
+
// Every item that needs work, by the same `pluginNeedsWork` the CLI's
|
|
1082
|
+
// apply uses: updates, content repairs, broken loads, missing files, and a
|
|
1083
|
+
// profile plugin not installed yet — exactly what `magus update` would do.
|
|
1089
1084
|
const items = [...statuses.values()]
|
|
1090
|
-
.filter(
|
|
1091
|
-
(s) =>
|
|
1092
|
-
s.action === "update" ||
|
|
1093
|
-
s.action === "repair" ||
|
|
1094
|
-
s.action === "stale-load",
|
|
1095
|
-
)
|
|
1085
|
+
.filter(pluginNeedsWork)
|
|
1096
1086
|
.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
1097
1087
|
if (items.length === 0) return;
|
|
1098
1088
|
|
|
@@ -1160,12 +1150,10 @@ export function PluginsScreen() {
|
|
|
1160
1150
|
? plugin.projectScope
|
|
1161
1151
|
: plugin.localScope;
|
|
1162
1152
|
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
// uninstall is the classifier's `scopeIsBehind`, the same rule the Enter
|
|
1168
|
-
// scope picker labels with.
|
|
1153
|
+
// A scope whose files are missing answers its key with a reinstall. It once
|
|
1154
|
+
// answered with an uninstall, removing the plugin the row was reporting as
|
|
1155
|
+
// broken. Update-versus-uninstall is the classifier's `scopeIsBehind`, the
|
|
1156
|
+
// same rule the Enter scope picker labels with.
|
|
1169
1157
|
const action = resolveScopeAction(
|
|
1170
1158
|
scopeData,
|
|
1171
1159
|
scopeIsBehind(statuses.get(plugin.id), scope),
|
|
@@ -1207,11 +1195,7 @@ export function PluginsScreen() {
|
|
|
1207
1195
|
fetchData();
|
|
1208
1196
|
} catch (error) {
|
|
1209
1197
|
modal.hideModal();
|
|
1210
|
-
await modal.message(
|
|
1211
|
-
"Error",
|
|
1212
|
-
error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
|
|
1213
|
-
"error",
|
|
1214
|
-
);
|
|
1198
|
+
await modal.message("Error", `Failed: ${error}`, "error");
|
|
1215
1199
|
}
|
|
1216
1200
|
};
|
|
1217
1201
|
|
|
@@ -1364,14 +1348,8 @@ export function PluginsScreen() {
|
|
|
1364
1348
|
|
|
1365
1349
|
const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
|
|
1366
1350
|
const plugins: PluginInfo[] = shownPlugins ?? [];
|
|
1367
|
-
// "installed"
|
|
1368
|
-
//
|
|
1369
|
-
// on a screen whose every row said "not installed". Same predicate as the
|
|
1370
|
-
// row and the detail panel now.
|
|
1371
|
-
const isInstalledAnywhere = (p: PluginInfo) =>
|
|
1372
|
-
isInstalledInScope(p.userScope) ||
|
|
1373
|
-
isInstalledInScope(p.projectScope) ||
|
|
1374
|
-
isInstalledInScope(p.localScope);
|
|
1351
|
+
// "installed" means the same thing here as in the rows beside it: the one
|
|
1352
|
+
// installed-at-scope test the row and the detail panel read.
|
|
1375
1353
|
const installedCount = plugins.filter(isInstalledAnywhere).length;
|
|
1376
1354
|
// Every state count is the classifier's, over the same map the rows read,
|
|
1377
1355
|
// so a header can never count a row the list beside it does not show.
|