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.
Files changed (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/gitignore-check.test.ts +11 -29
  12. package/src/__tests__/gitignore-profile.test.ts +248 -0
  13. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  14. package/src/__tests__/mate-availability.test.ts +11 -13
  15. package/src/__tests__/models-cli.test.ts +11 -0
  16. package/src/__tests__/plugin-contents.test.ts +0 -1
  17. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  18. package/src/__tests__/plugin-row-text.test.ts +18 -21
  19. package/src/__tests__/profile-command.test.ts +2 -2
  20. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  21. package/src/__tests__/profile-skills.test.ts +17 -22
  22. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  23. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  24. package/src/__tests__/profile-state.test.ts +152 -164
  25. package/src/__tests__/scope-action.test.ts +35 -38
  26. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  27. package/src/__tests__/select-restore.test.ts +209 -0
  28. package/src/__tests__/stale-load-repair.test.ts +1 -2
  29. package/src/__tests__/update-apply.test.ts +18 -2
  30. package/src/__tests__/update-null-readback.test.ts +300 -0
  31. package/src/__tests__/update-plan-parity.test.ts +249 -0
  32. package/src/__tests__/update-plan.test.ts +66 -67
  33. package/src/__tests__/version-unknown-state.test.ts +38 -39
  34. package/src/cli/bootstrap.ts +239 -70
  35. package/src/cli/doctor.ts +7 -20
  36. package/src/cli/install.ts +83 -27
  37. package/src/cli/prompt.ts +7 -3
  38. package/src/cli/router.ts +6 -4
  39. package/src/cli/select.ts +168 -0
  40. package/src/cli/update-view.ts +118 -6
  41. package/src/cli/update.ts +10 -1
  42. package/src/services/claude-cli.ts +0 -24
  43. package/src/services/claude-settings.ts +28 -7
  44. package/src/services/gitignore-check.ts +37 -49
  45. package/src/services/gitignore-resolver.ts +10 -1
  46. package/src/services/gitignore-service.ts +67 -0
  47. package/src/services/manifest.ts +31 -0
  48. package/src/services/mate-availability.ts +23 -29
  49. package/src/services/plugin-manager.ts +247 -105
  50. package/src/services/profile-absorb.ts +41 -24
  51. package/src/services/profile-activate.ts +102 -127
  52. package/src/services/profile-startup.ts +160 -4
  53. package/src/services/profile-state.ts +7 -68
  54. package/src/services/tracked-live-files.ts +109 -74
  55. package/src/services/update-engine.ts +28 -10
  56. package/src/services/update-plan.ts +78 -94
  57. package/src/ui/App.tsx +41 -35
  58. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  59. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  60. package/src/ui/hooks/useGitignoreModal.ts +63 -17
  61. package/src/ui/hooks/useProfileStartupModal.ts +94 -9
  62. package/src/ui/keymap.ts +1 -1
  63. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  64. package/src/ui/renderers/pluginRowText.ts +6 -2
  65. package/src/ui/renderers/pluginStatusText.ts +4 -3
  66. package/src/ui/screens/PluginsScreen.tsx +30 -52
  67. package/src/ui/screens/ProfilesScreen.tsx +3 -10
  68. 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: `Found ${violationCount} managed gitignore item(s) that do not match the current repo state.\n\nMissing .gitignore entries can be added now. Items that change git tracking are reviewed and fixed from the Gitignore tab.`,
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
- onFixSafe: () => Promise<void> | void;
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()).catch((err) => {
71
- dispatch({
72
- type: "SHOW_MODAL",
73
- modal: {
74
- type: "message",
75
- title: "Safe fixes failed",
76
- message: err instanceof Error ? err.message : String(err),
77
- variant: "error",
78
- onDismiss: dismiss,
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(onQuit: () => void) {
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
- /** Apply a choice, reporting the outcome either way. */
168
- const apply = (choice: ProfileChoice, automatic: boolean): void => {
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, need.manifest, projectPath, {
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
- : need;
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
- need.declaredDefault,
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
@@ -156,7 +156,7 @@ const PLUGINS = [
156
156
  "save-profile",
157
157
  ["s"],
158
158
  "profile",
159
- "Save the enabled plugins as a profile",
159
+ "Save the installed plugins as a profile",
160
160
  60,
161
161
  ),
162
162
  bind(
@@ -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
- isEnabledButNotInstalled,
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
- const hasUser = !!plugin.userScope?.enabled;
199
- const hasProject = !!plugin.projectScope?.enabled;
200
- const hasLocal = !!plugin.localScope?.enabled;
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" has to mean the same thing here as in the list row. Reading the
348
- // `enabledPlugins` flag alone made this panel print "● Installed" for the
349
- // exact plugin the row beside it was labelling "not installed".
350
- const isInstalled =
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
- isInstalled
457
- ? theme.colors.success
458
- : brokenInstall
459
- ? theme.colors.danger
447
+ filesMissing
448
+ ? theme.colors.danger
449
+ : isInstalled
450
+ ? theme.colors.success
460
451
  : theme.colors.muted
461
452
  }
462
453
  >
463
- {isInstalled
464
- ? "● Installed"
465
- : brokenInstall
466
- ? "○ Not installed — enabled in settings, no files on disk"
454
+ {filesMissing
455
+ ? "● Installed — files missing"
456
+ : isInstalled
457
+ ? "● Installed"
467
458
  : "○ Not installed"}
468
459
  </text>
469
460
  </box>
470
- {brokenInstall ? (
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
- repair.
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 installed — registry-backed, same as everywhere else.
635
- * A scope that is enabled with nothing installed is called out explicitly
636
- * rather than shown as a plain empty dot, because those two states need
637
- * different actions from the user and used to look identical.
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 installed = isInstalledInScope(scope);
646
+ const version = versionInScope(scope);
651
647
  return (
652
648
  <text fg={theme.colors.text}>
653
649
  <ScopeKeyChip scopeKey={keyHint} />
654
- <ScopeDot installed={installed} />
650
+ <ScopeDot installed={isInstalledInScope(scope)} />
655
651
  <span>{name}</span>
656
652
  <span fg={theme.colors.muted}> {qualifier}</span>
657
- {scope?.version ? (
658
- <span fg={theme.colors.info}> v{scope.version}</span>
659
- ) : null}
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
- const installed = status?.installed ?? plugin.installedVersion;
42
- const shown = isKnownVersion(installed ?? undefined) ? installed : undefined;
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 that is merely enabled must not.
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
- : // Enabled with nothing on disk is a broken state; a missing
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: ` not installed${withNote}`, tone: "danger" };
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 installed = isInstalledInScope(scope);
876
- const ver = scope?.version;
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
- // Enabled in settings with nothing installed. Without saying so the
883
- // row reads exactly like a scope that was never enabled, and the
884
- // user cannot tell that picking it repairs rather than adds.
885
- if (!installed && scope?.enabled) label += " — not installed, repairs";
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
- // Decided from the registry-backed version, not the `enabledPlugins`
935
- // flag alone — see resolveScopeAction. Update-versus-uninstall is the
936
- // same `scopeIsBehind` the label above used.
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 planner and the SAME apply engine `magus
1069
- * update` runs. Only the selection differs, and only because a screen and a
1070
- * profile genuinely mean different sets: here it is "everything listed with
1071
- * an update", there it is "what the profile declares".
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
- // Exactly the rows that say "update", "stale — reinstall" or "BROKEN — press
1083
- // a to fix", each planned under its real pin. Content-stale plugins are
1084
- // included: they need repairing, and excluding them meant "update all" left
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
- // This used to key off `scopeData.enabled` alone, so a plugin the list
1164
- // had just labelled "not installed" answered its own scope key by
1165
- // uninstalling itself — the `enabledPlugins` flag was set, the registry
1166
- // entry was missing, and only the flag was consulted. Update-versus-
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" must mean the same thing here as in the rows beside it. This
1368
- // counted `p.enabled` — the settings flag — so the header read "11 installed"
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.