magus-cli 7.7.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -11,13 +11,9 @@ import {
11
11
  repairPlugin as cliRepairPlugin,
12
12
  uninstallPlugin as cliUninstallPlugin,
13
13
  } from "../../services/claude-cli.js";
14
- import {
15
- SettingsParseError,
16
- getMcpEnvVars,
17
- readSettings,
18
- setMcpEnvVar,
19
- } from "../../services/claude-settings.js";
14
+ import { readSettings, setMcpEnvVar } from "../../services/claude-settings.js";
20
15
  import { clearContentDriftCache } from "../../services/content-drift.js";
16
+ import { missingPluginEnvVars } from "../../services/plugin-env-needs.js";
21
17
  import {
22
18
  type MarketplaceFetchFailure,
23
19
  type PluginInfo,
@@ -33,16 +29,13 @@ import {
33
29
  scopeFilesMissing,
34
30
  versionInScope,
35
31
  } from "../../services/plugin-manager.js";
36
- import {
37
- getPluginEnvRequirements,
38
- getPluginSourcePath,
39
- } from "../../services/plugin-mcp-config.js";
40
32
  import {
41
33
  checkMissingDeps,
42
34
  getPluginSetupFromSource,
43
35
  installPluginDeps,
44
36
  } from "../../services/plugin-setup.js";
45
37
  import { saveProfile } from "../../services/profiles.js";
38
+ import { requirersHere } from "../../services/resolver.js";
46
39
  import {
47
40
  fetchPluginContents,
48
41
  fetchPluginSkillDetail,
@@ -61,46 +54,54 @@ import {
61
54
  // screen shows or acts on — row text, detail panel, header counts, U / a, the
62
55
  // scope picker — reads the same map it builds.
63
56
  import {
57
+ type PluginUpdateAction,
64
58
  type PluginUpdateItem,
65
59
  classifyPluginRows,
66
60
  countPluginStates,
61
+ isKeptDeprecated,
67
62
  scopeIsBehind,
68
63
  } from "../../services/update-plan.js";
69
- import {
70
- diffVersions,
71
- loadSeenVersions,
72
- saveSeenVersions,
73
- } from "../../services/version-snapshot.js";
74
64
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
75
65
  import { createRequestGate } from "../../utils/request-gate.js";
76
66
  import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
67
+ import { counted, failureReport } from "../adapters/failureReport.js";
77
68
  import {
69
+ type FailureVerb,
78
70
  type PluginBrowserItem,
79
71
  type PluginComponentItem,
80
72
  type PluginPluginItem,
81
73
  buildPluginBrowserItems,
82
- noMoveReasons,
74
+ exceptionReason,
75
+ failureReasons,
76
+ failureVerb,
83
77
  pluginContentsRepo,
78
+ pluginFailureReport,
84
79
  } from "../adapters/pluginsAdapter.js";
80
+ import { stepFailureReport } from "../adapters/startupReports.js";
85
81
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
86
82
  import { ScrollableList } from "../components/ScrollableList.js";
87
83
  import { ScreenLayout } from "../components/layout/index.js";
88
84
  import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
89
- import { type BindingId, moveDirection, tabKey } from "../keymap.js";
85
+ import { type BindingId, moveDirection } from "../keymap.js";
90
86
  import {
91
87
  renderPluginDetail,
92
88
  renderPluginRow,
93
89
  } from "../renderers/pluginRenderers.js";
94
- import {
95
- useApp,
96
- useModal,
97
- useNotify,
98
- useProgress,
99
- } from "../state/AppContext.js";
90
+ import { useApp, useModal, useProgress } from "../state/AppContext.js";
100
91
  import { useDimensions } from "../state/DimensionsContext.js";
101
- import { asyncValue } from "../state/types.js";
92
+ import { type FailureReport, asyncValue } from "../state/types.js";
102
93
  import { theme } from "../theme.js";
103
94
 
95
+ /** What a scope key did not do, for the failure popup's headline. */
96
+ const SCOPE_ACTION_VERB: Record<
97
+ "install" | "update" | "uninstall",
98
+ FailureVerb
99
+ > = {
100
+ install: "installed",
101
+ update: "updated",
102
+ uninstall: "removed",
103
+ };
104
+
104
105
  /**
105
106
  * The latest Plugins load wins.
106
107
  *
@@ -126,7 +127,6 @@ export function PluginsScreen() {
126
127
  const { plugins: pluginsState } = state;
127
128
  const modal = useModal();
128
129
  const progress = useProgress();
129
- const notify = useNotify();
130
130
  const dimensions = useDimensions();
131
131
 
132
132
  const isSearchActive =
@@ -156,10 +156,26 @@ export function PluginsScreen() {
156
156
  const [pins, setPins] = useState<
157
157
  Readonly<Record<string, string>> | null | undefined
158
158
  >(undefined);
159
+ /**
160
+ * The profile those pins came from — the one a deprecated plugin is dropped
161
+ * from when `a` or `d` removes it. Same three states as `pins`; undefined
162
+ * lets the removal choose the active profile itself.
163
+ */
164
+ const [activeProfileId, setActiveProfileId] = useState<
165
+ string | null | undefined
166
+ >(undefined);
167
+ /**
168
+ * For each plugin another one needs — the profile's or an installed one's —
169
+ * every plugin that needs it: the same map `magus update` plans with
170
+ * (`requirersHere`), so a deprecated dependency reads the same on both
171
+ * surfaces and is never removed from under a plugin that stays.
172
+ */
173
+ const [requiredBy, setRequiredBy] = useState<
174
+ Readonly<Record<string, readonly string[]>>
175
+ >({});
159
176
 
160
177
  // Fetch data (always fetches all scopes). Only the most recently started
161
- // load may touch the screen — or the seen-versions baseline, which a stale
162
- // load would rewind.
178
+ // load may touch the screen.
163
179
  const fetchData = useCallback(async () => {
164
180
  const token = pluginsLoadGate.begin();
165
181
  dispatch({ type: "PLUGINS_DATA_LOADING" });
@@ -179,7 +195,17 @@ export function PluginsScreen() {
179
195
  }),
180
196
  ]);
181
197
  if (!pluginsLoadGate.isCurrent(token)) return;
198
+ // The same edges `magus update` plans with: the profile's and every
199
+ // installed plugin's, so a plugin one of them needs is never removed.
200
+ const required = await requirersHere(
201
+ active?.closure.dependencies?.requiredBy,
202
+ pluginData.filter(isInstalledAnywhere).map((p) => p.id),
203
+ state.projectPath || process.cwd(),
204
+ );
205
+ if (!pluginsLoadGate.isCurrent(token)) return;
182
206
  setPins(active?.closure.plugins ?? null);
207
+ setActiveProfileId(active?.profileId ?? null);
208
+ setRequiredBy(required);
183
209
 
184
210
  // Which catalogs answered, and which only appeared to. Read straight
185
211
  // after the fetch, while the record still describes this attempt.
@@ -197,32 +223,6 @@ export function PluginsScreen() {
197
223
  ),
198
224
  );
199
225
 
200
- // Surface version changes made outside magus (Claude Code's own
201
- // plugin update, a manual CLI call). Diffing against
202
- // what we last rendered catches all of them; logging only what
203
- // magus did would miss every one.
204
- const current: Record<string, string> = {};
205
- for (const p of pluginData) {
206
- if (p.installedVersion) current[p.id] = p.installedVersion;
207
- }
208
- const projectPath = state.projectPath || process.cwd();
209
- const previous = await loadSeenVersions(projectPath);
210
- if (!pluginsLoadGate.isCurrent(token)) return;
211
- for (const change of diffVersions(previous, current)) {
212
- const target = pluginData.find((p) => p.id === change.pluginId);
213
- if (!target) continue;
214
- if (change.kind === "updated") {
215
- target.recentlyUpdatedFrom = change.from;
216
- } else {
217
- target.recentlyInstalled = true;
218
- }
219
- }
220
- // Await the write. Fire-and-forget raced the next fetchData() — a
221
- // refresh or a post-action refetch could load the baseline before the
222
- // previous save landed and re-report the same change.
223
- await saveSeenVersions(projectPath, current);
224
- if (!pluginsLoadGate.isCurrent(token)) return;
225
-
226
226
  dispatch({
227
227
  type: "PLUGINS_DATA_SUCCESS",
228
228
  marketplaces: allMarketplaces,
@@ -296,10 +296,53 @@ export function PluginsScreen() {
296
296
  */
297
297
  const statuses = useMemo(
298
298
  (): Map<string, PluginUpdateItem> =>
299
- shownPlugins ? classifyPluginRows(shownPlugins, pins ?? {}) : new Map(),
300
- [shownPlugins, pins],
299
+ shownPlugins
300
+ ? classifyPluginRows(shownPlugins, pins ?? {}, requiredBy)
301
+ : new Map(),
302
+ [shownPlugins, pins, requiredBy],
301
303
  );
302
304
 
305
+ /**
306
+ * Installed plugins whose MCP server lacks variables, and how many — read
307
+ * from the current config on every load and after every attempt to set them
308
+ * (`envCheck`), so declining the question at install leaves a lasting state
309
+ * on that plugin, and setting them clears it. Nothing records the decline.
310
+ */
311
+ const [mcpSettingsNeeded, setMcpSettingsNeeded] = useState<
312
+ ReadonlyMap<string, number>
313
+ >(new Map());
314
+ const [envCheck, setEnvCheck] = useState(0);
315
+ // biome-ignore lint/correctness/useExhaustiveDependencies: envCheck is the re-read trigger after the variables were asked for; the body never reads it
316
+ useEffect(() => {
317
+ if (!shownPlugins) return;
318
+ let cancelled = false;
319
+ const installed = shownPlugins.filter(
320
+ (p) => !p.isOrphaned && isInstalledAnywhere(p),
321
+ );
322
+ void Promise.all(
323
+ installed.map(async (p) => {
324
+ try {
325
+ const missing = await missingPluginEnvVars(
326
+ p.marketplace,
327
+ p.name,
328
+ state.projectPath,
329
+ );
330
+ return [p.id, missing.length] as const;
331
+ } catch {
332
+ // Unreadable variables are reported where they are saved; a row
333
+ // that cannot tell claims nothing.
334
+ return [p.id, 0] as const;
335
+ }
336
+ }),
337
+ ).then((counts) => {
338
+ if (cancelled) return;
339
+ setMcpSettingsNeeded(new Map(counts.filter(([, n]) => n > 0)));
340
+ });
341
+ return () => {
342
+ cancelled = true;
343
+ };
344
+ }, [shownPlugins, state.projectPath, envCheck]);
345
+
303
346
  // Build list items (categories + plugins)
304
347
  const allItems = useMemo((): PluginBrowserItem[] => {
305
348
  if (!shownMarketplaces || !shownPlugins) return [];
@@ -310,6 +353,7 @@ export function PluginsScreen() {
310
353
  expandedPlugins: pluginsState.expandedPlugins,
311
354
  pluginContents: pluginsState.pluginContents,
312
355
  statuses,
356
+ mcpSettingsNeeded,
313
357
  });
314
358
  }, [
315
359
  shownMarketplaces,
@@ -318,6 +362,7 @@ export function PluginsScreen() {
318
362
  pluginsState.expandedPlugins,
319
363
  pluginsState.pluginContents,
320
364
  statuses,
365
+ mcpSettingsNeeded,
321
366
  ]);
322
367
 
323
368
  // Filter items by search query
@@ -523,6 +568,14 @@ export function PluginsScreen() {
523
568
  remove: () => {
524
569
  void handleRemoveDeprecated();
525
570
  },
571
+ // The action behind the detail panel's "needs N settings — press e".
572
+ "set-env": () => {
573
+ const item = selectableItems[pluginsState.selectedIndex];
574
+ if (item?.kind !== "plugin" || !item.mcpSettingsNeeded) return;
575
+ void collectPluginEnvVars(item.plugin.name, item.plugin.marketplace, {
576
+ ask: false,
577
+ });
578
+ },
526
579
  "add-marketplace": () => {
527
580
  void handleShowAddMarketplaceInstructions();
528
581
  },
@@ -550,12 +603,91 @@ export function PluginsScreen() {
550
603
 
551
604
  // ── Helpers ───────────────────────────────────────────────────────────────
552
605
 
606
+ /**
607
+ * Run the ONE apply engine over `items`, reload, and — only if something
608
+ * failed, or ran and changed nothing — say why in ONE popup, each plugin
609
+ * named with its reason in the same words the CLI prints. Shared by U, a
610
+ * and d, so the three say the same thing about the same outcome. A success
611
+ * says nothing: the reloaded rows show it. An item that ran and did not move
612
+ * is "not updated" — the fact the CLI lists as "ran but did not change" —
613
+ * because a row still offering the same update, with nothing said, reads as
614
+ * a key that did nothing. A throw from the engine itself — not from one
615
+ * item, which the engine catches and reports — names every item with its
616
+ * message.
617
+ */
618
+ const applyAndReportFailures = async (
619
+ items: readonly PluginUpdateItem[],
620
+ report: (capture: ApplyReporter) => ApplyReporter = (capture) => capture,
621
+ ): Promise<void> => {
622
+ const reasons = failureReasons();
623
+ const actions = new Map(items.map((i) => [i.pluginId, i.action]));
624
+ let failure: FailureReport | null;
625
+ try {
626
+ const result = await applyPlugins(
627
+ [...items],
628
+ state.projectPath || process.cwd(),
629
+ REAL_PLUGIN_DEPS,
630
+ report(reasons.reporter),
631
+ // The profile these rows were judged against — the active one — is
632
+ // the one a removed plugin leaves.
633
+ { profile: activeProfileId },
634
+ );
635
+ failure = reasons.report(result.failed, actions, result.unchanged);
636
+ } catch (error) {
637
+ const message = error instanceof Error ? error.message : String(error);
638
+ failure = pluginFailureReport(
639
+ items.map((i) => ({
640
+ pluginId: i.pluginId,
641
+ verb: failureVerb(i.action),
642
+ detail: message,
643
+ exception: true,
644
+ })),
645
+ );
646
+ }
647
+ modal.hideModal();
648
+ fetchData();
649
+ if (failure) await modal.failures(failure);
650
+ };
651
+
652
+ /**
653
+ * The same popup for a single plugin action outside the apply engine — a
654
+ * scope key, a plain uninstall — so it reads like every other failure here.
655
+ */
656
+ const reportPluginFailure = async (
657
+ pluginId: string,
658
+ verb: FailureVerb,
659
+ error: unknown,
660
+ ): Promise<void> => {
661
+ const report = pluginFailureReport([
662
+ {
663
+ pluginId,
664
+ verb,
665
+ detail: error instanceof Error ? error.message : String(error),
666
+ exception: true,
667
+ },
668
+ ]);
669
+ if (report) await modal.failures(report);
670
+ };
671
+
672
+ /**
673
+ * A marketplace that was not added. Magus runs the same command the user
674
+ * would, so running it by hand is the one real fix when magus cannot say
675
+ * why it failed.
676
+ */
677
+ const addMarketplaceFailure = (repo: string, error: unknown) =>
678
+ stepFailureReport(
679
+ `Marketplace ${repo} was not added`,
680
+ repo,
681
+ error,
682
+ `Run it yourself: claude plugin marketplace add ${repo}`,
683
+ );
684
+
553
685
  // ── Action handlers ────────────────────────────────────────────────────────
554
686
 
555
687
  const handleRefresh = async () => {
556
688
  progress.show("Refreshing cache...");
557
689
  try {
558
- const result = await refreshAllMarketplaces((p) => {
690
+ await refreshAllMarketplaces((p) => {
559
691
  progress.show(`${p.name}`, p.current, p.total);
560
692
  });
561
693
  clearMarketplaceCache();
@@ -563,20 +695,16 @@ export function PluginsScreen() {
563
695
  // that moves HEAD invalidates every cached answer.
564
696
  clearContentDriftCache();
565
697
  progress.hide();
566
-
567
- const totalRepaired = result.repair.reduce(
568
- (sum, r) => sum + r.repaired.length,
569
- 0,
570
- );
571
- notify(
572
- totalRepaired > 0
573
- ? `Refreshed — repaired ${totalRepaired} plugin(s) with missing files`
574
- : "Refreshed",
575
- );
576
698
  fetchData();
577
699
  } catch (error) {
578
700
  progress.hide();
579
- await modal.message("Error", `Refresh failed: ${error}`, "error");
701
+ await modal.failures(
702
+ stepFailureReport(
703
+ "The marketplaces were not refreshed",
704
+ "marketplaces",
705
+ error,
706
+ ),
707
+ );
580
708
  }
581
709
  };
582
710
 
@@ -617,12 +745,7 @@ export function PluginsScreen() {
617
745
  await cliAddMarketplace(choice);
618
746
  await handleRefresh();
619
747
  } catch (e) {
620
- await modal.message(
621
- "Could not add marketplace",
622
- `${(e as Error).message}\n\nRun it yourself if this persists:\n\n` +
623
- ` claude plugin marketplace add ${choice}`,
624
- "error",
625
- );
748
+ await modal.failures(addMarketplaceFailure(choice, e));
626
749
  }
627
750
  return;
628
751
  }
@@ -634,7 +757,6 @@ export function PluginsScreen() {
634
757
  " claude plugin marketplace add owner/repo\n\n" +
635
758
  "Auto-update is enabled by default for new marketplaces.\n\n" +
636
759
  "After adding, refresh magus with 'r' to see it.",
637
- "info",
638
760
  );
639
761
  };
640
762
 
@@ -663,45 +785,37 @@ export function PluginsScreen() {
663
785
  "NOTE: Individual plugin enablement is still project-specific.\n" +
664
786
  'Use "Project" scope in magus to enable for the team.';
665
787
 
666
- await modal.message("Team Configuration", helpText, "info");
788
+ await modal.message("Team Configuration", helpText);
667
789
  };
668
790
 
669
791
  /**
670
792
  * Collect environment variables required by a plugin's MCP servers.
793
+ *
794
+ * After an install it first asks whether to configure them now; from the
795
+ * `e` key (`ask: false`) the user has already said yes. Declining, or
796
+ * leaving a value empty, is not remembered: the plugin's detail panel keeps
797
+ * saying what is missing, read from the config, until it is set.
671
798
  */
672
799
  const collectPluginEnvVars = async (
673
800
  pluginName: string,
674
801
  marketplace: string,
802
+ { ask = true }: { ask?: boolean } = {},
675
803
  ): Promise<boolean> => {
676
804
  try {
677
- const pluginSource = await getPluginSourcePath(marketplace, pluginName);
678
- if (!pluginSource) return true;
679
-
680
- const requirements = await getPluginEnvRequirements(
805
+ const missingVars = await missingPluginEnvVars(
681
806
  marketplace,
682
- pluginSource,
683
- );
684
- if (requirements.length === 0) return true;
685
-
686
- const existingEnvVars = await getMcpEnvVars(state.projectPath);
687
- const missingVars = requirements.filter(
688
- (req) => !existingEnvVars[req.name] && !process.env[req.name],
807
+ pluginName,
808
+ state.projectPath,
689
809
  );
690
-
691
810
  if (missingVars.length === 0) return true;
692
811
 
693
- const serverNames = [...new Set(missingVars.map((v) => v.serverName))];
694
- const wantToConfigure = await modal.confirm(
695
- "Configure MCP Servers?",
696
- `This plugin includes MCP servers (${serverNames.join(", ")}) that need ${missingVars.length} environment variable(s).\n\nConfigure now?`,
697
- );
698
-
699
- if (!wantToConfigure) {
700
- notify(
701
- `${serverNames.join(", ")} not configured — set the variables in the Settings tab (press ${tabKey("settings")})`,
702
- "warning",
812
+ if (ask) {
813
+ const serverNames = [...new Set(missingVars.map((v) => v.serverName))];
814
+ const wantToConfigure = await modal.confirm(
815
+ "Configure MCP Servers?",
816
+ `This plugin includes MCP servers (${serverNames.join(", ")}) that need ${missingVars.length} environment variable(s).\n\nConfigure now?`,
703
817
  );
704
- return true;
818
+ if (!wantToConfigure) return true;
705
819
  }
706
820
 
707
821
  for (const req of missingVars) {
@@ -723,13 +837,7 @@ export function PluginsScreen() {
723
837
  "",
724
838
  );
725
839
 
726
- if (value === null) {
727
- notify(
728
- `${serverNames.join(", ")} partly configured — set the rest in the Settings tab (press ${tabKey("settings")})`,
729
- "warning",
730
- );
731
- return true;
732
- }
840
+ if (value === null) return true;
733
841
 
734
842
  if (value) {
735
843
  await setMcpEnvVar(req.name, value, state.projectPath);
@@ -738,19 +846,21 @@ export function PluginsScreen() {
738
846
 
739
847
  return true;
740
848
  } catch (error) {
741
- // The plugin itself is installed either way. But a settings.local.json
742
- // that does not parse is something the user has to fix by hand before
743
- // any variable can be saved, so it is said, not logged out of sight.
744
- if (error instanceof SettingsParseError) {
745
- await modal.message(
746
- "Environment variables not saved",
747
- error.message,
748
- "error",
749
- );
750
- } else {
751
- console.error("Error collecting plugin env vars:", error);
752
- }
849
+ // The plugin itself is installed either way, but its MCP server will
850
+ // not start without these — so it is said in the popup, never logged
851
+ // out of sight. A file that does not parse names itself and the fix.
852
+ modal.hideModal();
853
+ await modal.failures(
854
+ stepFailureReport(
855
+ `${pluginName} is installed, but its MCP server variables were not saved`,
856
+ pluginName,
857
+ error,
858
+ ),
859
+ );
753
860
  return true;
861
+ } finally {
862
+ // Re-read, so the panel's "needs N settings" matches what was saved.
863
+ setEnvCheck((n) => n + 1);
754
864
  }
755
865
  };
756
866
 
@@ -795,20 +905,28 @@ export function PluginsScreen() {
795
905
  const result = await installPluginDeps(missing);
796
906
  modal.hideModal();
797
907
 
798
- if (result.failed.length > 0) {
799
- const failMsg = result.failed
800
- .map((f) => `${f.pkg}: ${f.error}`)
801
- .join("\n");
802
- await modal.message(
803
- "Partial Install",
804
- `Installed: ${result.installed.length}\nFailed:\n${failMsg}`,
805
- "error",
806
- );
807
- } else if (result.installed.length > 0) {
808
- notify(`Installed ${result.installed.join(", ")}`);
809
- }
908
+ // Only what did not install, each with why; what did shows nowhere.
909
+ const report = failureReport(
910
+ `${counted(result.failed.length, "dependency", "dependencies")} of ${pluginName} ${
911
+ result.failed.length === 1 ? "was" : "were"
912
+ } not installed`,
913
+ result.failed.map((f) => ({
914
+ subject: f.pkg,
915
+ reason: exceptionReason(f.error).reason,
916
+ })),
917
+ );
918
+ if (report) await modal.failures(report);
810
919
  } catch (error) {
811
- console.error("Error installing plugin deps:", error);
920
+ // Hide the loading modal first: it would otherwise stay up forever,
921
+ // and the failure behind it would never be read.
922
+ modal.hideModal();
923
+ await modal.failures(
924
+ stepFailureReport(
925
+ `${pluginName} is installed, but its dependencies were not installed`,
926
+ pluginName,
927
+ error,
928
+ ),
929
+ );
812
930
  }
813
931
  };
814
932
 
@@ -829,7 +947,6 @@ export function PluginsScreen() {
829
947
  await modal.message(
830
948
  `Remove ${mp.displayName}?`,
831
949
  `To remove this marketplace, run in Claude Code:\n\n /plugin marketplace remove ${mp.name}\n\nAfter removing, refresh magus with 'r' to update the display.`,
832
- "info",
833
950
  );
834
951
  }
835
952
  } else {
@@ -847,11 +964,7 @@ export function PluginsScreen() {
847
964
  await cliAddMarketplace(repo);
848
965
  await handleRefresh();
849
966
  } catch (e) {
850
- await modal.message(
851
- "Could not add marketplace",
852
- `${(e as Error).message}\n\nRun it yourself if this persists:\n\n claude plugin marketplace add ${repo}`,
853
- "error",
854
- );
967
+ await modal.failures(addMarketplaceFailure(repo, e));
855
968
  }
856
969
  }
857
970
  }
@@ -978,7 +1091,7 @@ export function PluginsScreen() {
978
1091
  fetchData();
979
1092
  } catch (error) {
980
1093
  modal.hideModal();
981
- await modal.message("Error", `Failed: ${error}`, "error");
1094
+ await reportPluginFailure(plugin.id, SCOPE_ACTION_VERB[action], error);
982
1095
  }
983
1096
  }
984
1097
  };
@@ -1008,46 +1121,20 @@ export function PluginsScreen() {
1008
1121
 
1009
1122
  modal.loading(
1010
1123
  // A broken plugin runs NO claude command at all, so naming one here would
1011
- // misdescribe the work: the repair rewrites installed_plugins.json rows.
1124
+ // misdescribe the work.
1012
1125
  status.action === "stale-load"
1013
- ? `Repairing ${plugin.name}…\nmoving this repository's install records forward to v${status.target ?? "?"}`
1126
+ ? `Repairing ${plugin.name}…\nmaking Claude Code load v${status.target ?? "?"} here`
1014
1127
  : repairing
1015
1128
  ? `Repairing ${plugin.name}…\nclaude plugin uninstall ${plugin.id} --scope ${scope} && claude plugin install ${plugin.id} --scope ${scope}`
1016
1129
  : `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
1017
1130
  );
1018
- const reasons = noMoveReasons();
1019
- try {
1020
- // One plugin, but the SAME engine — so a single update records the
1021
- // version that landed and touches every scope that is behind, exactly
1022
- // as `magus update` does. The plan item IS the row's status: the
1023
- // classifier's answer for this plugin under its real pin (the
1024
- // profile's, or "latest" for an undeclared install — see pinForRow),
1025
- // so what runs is exactly what the row claimed.
1026
- const result = await applyPlugins(
1027
- [status],
1028
- state.projectPath || process.cwd(),
1029
- REAL_PLUGIN_DEPS,
1030
- reasons.reporter,
1031
- );
1032
- modal.hideModal();
1033
- if (result.failed.length > 0) {
1034
- await modal.message(
1035
- "Error",
1036
- `Failed to update ${plugin.name}`,
1037
- "error",
1038
- );
1039
- return;
1040
- }
1041
- // Ran, exited 0, moved nothing — and the row will not have moved
1042
- // either. Saying nothing here is what made "press U, press U,
1043
- // forever": the CLI names the cause, so the screen does too.
1044
- const unchanged = reasons.notice(result.unchanged);
1045
- if (unchanged) notify(unchanged, "warning");
1046
- fetchData();
1047
- } catch (error) {
1048
- modal.hideModal();
1049
- await modal.message("Error", `Failed to update: ${error}`, "error");
1050
- }
1131
+ // One plugin, but the SAME engine — so a single update records the
1132
+ // version that landed and touches every scope that is behind, exactly as
1133
+ // `magus update` does. The plan item IS the row's status: the classifier's
1134
+ // answer for this plugin under its real pin (the profile's, or "latest" for
1135
+ // an undeclared install — see pinForRow), so what runs is exactly what the
1136
+ // row claimed. A failure says why in a popup.
1137
+ await applyAndReportFailures([status]);
1051
1138
  };
1052
1139
 
1053
1140
  /**
@@ -1068,57 +1155,37 @@ export function PluginsScreen() {
1068
1155
  if (pluginsState.plugins.status !== "success") return;
1069
1156
 
1070
1157
  // Every item that needs work, by the same `pluginNeedsWork` the CLI's
1071
- // apply uses: updates, content repairs, broken loads, missing files, and a
1072
- // profile plugin not installed yet — exactly what `magus update` would do.
1158
+ // apply uses: updates, content repairs, broken loads, missing files, a
1159
+ // profile plugin not installed yet, and deprecated plugins to remove —
1160
+ // exactly what `magus update` would do.
1073
1161
  const items = [...statuses.values()]
1074
1162
  .filter(pluginNeedsWork)
1075
1163
  .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
1076
1164
  if (items.length === 0) return;
1077
1165
 
1078
- const projectPath = state.projectPath || process.cwd();
1079
- const total = items.filter(pluginNeedsWork).length;
1166
+ const total = items.length;
1080
1167
 
1081
1168
  // A broken plugin is repaired in the registry, not updated, and the progress
1082
1169
  // line is the only place the user sees which one happened to which plugin.
1083
1170
  const actionOf = new Map(items.map((i) => [i.pluginId, i.action]));
1171
+ const VERB: Partial<Record<PluginUpdateAction, string>> = {
1172
+ "stale-load": "Repairing",
1173
+ remove: "Removing",
1174
+ };
1084
1175
 
1085
1176
  let done = 0;
1086
- const reasons = noMoveReasons();
1087
- const reporter: ApplyReporter = {
1177
+ // One popup afterwards, and only for failures: each plugin that failed,
1178
+ // with why. The one this replaced listed six plugin names and not one
1179
+ // reason. Everything else shows its new state once the list reloads.
1180
+ await applyAndReportFailures(items, (capture) => ({
1088
1181
  begin: (name) => {
1089
1182
  done++;
1090
- const verb =
1091
- actionOf.get(name) === "stale-load" ? "Repairing" : "Updating";
1183
+ const verb = VERB[actionOf.get(name) ?? "update"] ?? "Updating";
1092
1184
  modal.loading(`${verb} ${name} (${done}/${total})…`);
1093
1185
  },
1094
- finish: reasons.reporter.finish,
1186
+ finish: capture.finish,
1095
1187
  detach: (work) => work(),
1096
- };
1097
-
1098
- try {
1099
- const result = await applyPlugins(
1100
- items,
1101
- projectPath,
1102
- REAL_PLUGIN_DEPS,
1103
- reporter,
1104
- );
1105
- modal.hideModal();
1106
- if (result.failed.length > 0) {
1107
- await modal.message(
1108
- "Some updates failed",
1109
- result.failed.join(", "),
1110
- "error",
1111
- );
1112
- }
1113
- // One line however many were left where they were: a line each
1114
- // filled the banner and pushed out everything else it had to say.
1115
- const unchanged = reasons.notice(result.unchanged);
1116
- if (unchanged) notify(unchanged, "warning");
1117
- fetchData();
1118
- } catch (error) {
1119
- modal.hideModal();
1120
- await modal.message("Error", `Failed to update: ${error}`, "error");
1121
- }
1188
+ }));
1122
1189
  };
1123
1190
 
1124
1191
  const handleScopeToggle = async (scope: "user" | "project" | "local") => {
@@ -1181,7 +1248,7 @@ export function PluginsScreen() {
1181
1248
  fetchData();
1182
1249
  } catch (error) {
1183
1250
  modal.hideModal();
1184
- await modal.message("Error", `Failed: ${error}`, "error");
1251
+ await reportPluginFailure(plugin.id, SCOPE_ACTION_VERB[action], error);
1185
1252
  }
1186
1253
  };
1187
1254
 
@@ -1198,6 +1265,17 @@ export function PluginsScreen() {
1198
1265
  // was already installed. An orphan is removed; where else a name happens
1199
1266
  // to appear is not this screen's guess to make.
1200
1267
 
1268
+ // A DEPRECATED one goes through the engine `a` and `magus update` use, so
1269
+ // `d` also drops it from the profile and reads the result back — the
1270
+ // uninstall-only path below left the profile declaring it, and the row
1271
+ // came straight back as "deprecated".
1272
+ const status = statuses.get(plugin.id);
1273
+ if (status?.action === "remove") {
1274
+ modal.loading(`Removing ${plugin.name}…`);
1275
+ await applyAndReportFailures([status]);
1276
+ return;
1277
+ }
1278
+
1201
1279
  try {
1202
1280
  // Remove from all scopes — try all to clean up stale references
1203
1281
  const scopes: PluginScope[] = ["user", "project", "local"];
@@ -1219,7 +1297,7 @@ export function PluginsScreen() {
1219
1297
  fetchData();
1220
1298
  } catch (error) {
1221
1299
  modal.hideModal();
1222
- await modal.message("Error", `Failed to remove: ${error}`, "error");
1300
+ await reportPluginFailure(plugin.id, "removed", error);
1223
1301
  }
1224
1302
  };
1225
1303
 
@@ -1231,10 +1309,12 @@ export function PluginsScreen() {
1231
1309
  enabledPlugins =
1232
1310
  (await readSettings(state.projectPath)).enabledPlugins ?? {};
1233
1311
  } catch (error) {
1234
- await modal.message(
1235
- "Error",
1236
- `Cannot save a profile: ${error instanceof Error ? error.message : String(error)}`,
1237
- "error",
1312
+ await modal.failures(
1313
+ stepFailureReport(
1314
+ "No profile was saved",
1315
+ "this project's plugins",
1316
+ error,
1317
+ ),
1238
1318
  );
1239
1319
  return;
1240
1320
  }
@@ -1264,19 +1344,22 @@ export function PluginsScreen() {
1264
1344
  try {
1265
1345
  await saveProfile(name.trim(), enabledPlugins, scope, state.projectPath);
1266
1346
  modal.hideModal();
1267
- notify(
1268
- `Saved profile "${name.trim()}" at ${scope === "user" ? "USER" : "PROJECT"} scope`,
1269
- );
1270
1347
  } catch (error) {
1271
1348
  modal.hideModal();
1272
- await modal.message("Error", `Failed to save profile: ${error}`, "error");
1349
+ await modal.failures(
1350
+ stepFailureReport(
1351
+ `Profile "${name.trim()}" was not saved`,
1352
+ scope.toUpperCase(),
1353
+ error,
1354
+ ),
1355
+ );
1273
1356
  }
1274
1357
  };
1275
1358
 
1276
1359
  // ── Render ─────────────────────────────────────────────────────────────────
1277
1360
 
1278
1361
  // Only a genuine first load takes over the screen. A reload keeps the list on
1279
- // screen and says so in the notice line instead.
1362
+ // screen and says so in the header's notice row instead.
1280
1363
  if (!shownMarketplaces || !shownPlugins) {
1281
1364
  if (
1282
1365
  pluginsState.marketplaces.status === "loading" ||
@@ -1328,6 +1411,9 @@ export function PluginsScreen() {
1328
1411
  ...(selectedItem?.kind === "plugin" && selectedItem.plugin.isOrphaned
1329
1412
  ? []
1330
1413
  : ["remove" as const]),
1414
+ ...(selectedItem?.kind === "plugin" && selectedItem.mcpSettingsNeeded
1415
+ ? []
1416
+ : ["set-env" as const]),
1331
1417
  ];
1332
1418
 
1333
1419
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
@@ -1342,7 +1428,10 @@ export function PluginsScreen() {
1342
1428
  // Cannot tell whether it is current. Counted and shown separately: folding
1343
1429
  // it into `updateCount` would overstate, and folding it into silence is the
1344
1430
  // "cannot tell rendered as fine" bug.
1345
- const unknownCount = counts.unknown;
1431
+ // Moved or still-required plugins are `unknown` to the planner but known to
1432
+ // the user — kept on purpose — so they are not counted as "unverified".
1433
+ const unknownCount =
1434
+ counts.unknown - [...statuses.values()].filter(isKeptDeprecated).length;
1346
1435
  const installCount = counts.install;
1347
1436
  // Broken: installed at the right version, loading an older one. Its own
1348
1437
  // number, never folded into `updateCount` or into silence — a clean header
@@ -1352,8 +1441,11 @@ export function PluginsScreen() {
1352
1441
  // a reader had to already know what "loads" versus "installed" means here
1353
1442
  // before the count told them anything. `a` clears these.
1354
1443
  const brokenCount = counts["stale-load"];
1355
- const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${brokenCount > 0 ? ` │ ${brokenCount} broken` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
1356
- const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""}${installCount > 0 ? ` │ ${installCount} +` : ""}${brokenCount > 0 ? ` │ ${brokenCount} ✗` : ""}${unknownCount > 0 ? ` │ ${unknownCount} ?` : ""} │ / to search`;
1444
+ // No longer published, and `a` removes them. Counted where the rows can be
1445
+ // seen to need work; they used to sit inside "unverified".
1446
+ const deprecatedCount = counts.remove;
1447
+ const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}${installCount > 0 ? ` │ ${installCount} to install` : ""}${brokenCount > 0 ? ` │ ${brokenCount} broken` : ""}${deprecatedCount > 0 ? ` │ ${deprecatedCount} deprecated` : ""}${unknownCount > 0 ? ` │ ${unknownCount} unverified` : ""}`;
1448
+ const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""}${installCount > 0 ? ` │ ${installCount} +` : ""}${brokenCount + deprecatedCount > 0 ? ` │ ${brokenCount + deprecatedCount} ✗` : ""}${unknownCount > 0 ? ` │ ${unknownCount} ?` : ""} │ / to search`;
1357
1449
 
1358
1450
  const catalogNotice = buildCatalogNotice({
1359
1451
  failures: catalogFailures,