magus-cli 7.7.0 → 7.9.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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -17,10 +17,7 @@
17
17
 
18
18
  import { useCallback, useRef } from "react";
19
19
  import { getManifestPath } from "../../services/manifest.js";
20
- import type {
21
- ActivationResult,
22
- ProfileChoice,
23
- } from "../../services/profile-activate.js";
20
+ import type { ProfileChoice } from "../../services/profile-activate.js";
24
21
  import type { DisplacedArtifact } from "../../services/profile-live.js";
25
22
  import {
26
23
  type ProfileStartupNeed,
@@ -36,8 +33,17 @@ import {
36
33
  } from "../../services/profile-startup.js";
37
34
  import { applyProfileChoice } from "../../services/profile-state.js";
38
35
  import { SettingsParseError } from "../../services/settings-file.js";
39
- import { useApp, useNotify } from "../state/AppContext.js";
40
- import type { ModalState, NoticeLine } from "../state/types.js";
36
+ import { exceptionReason } from "../adapters/pluginsAdapter.js";
37
+ import {
38
+ activationFailureReport,
39
+ profileProblemsReport,
40
+ stepFailureReport,
41
+ } from "../adapters/startupReports.js";
42
+ import { useApp } from "../state/AppContext.js";
43
+ import type { FailureReport, ModalState } from "../state/types.js";
44
+
45
+ /** The headline for every way "Update it now" can fail. */
46
+ const FORMAT_NOT_UPDATED = "The profile format was not updated";
41
47
 
42
48
  export interface BuildProfileModalArgs {
43
49
  need: ProfileStartupNeed;
@@ -84,81 +90,6 @@ export function buildProfileStartupModal(
84
90
  };
85
91
  }
86
92
 
87
- /** `typescript-lsp@claude-plugins-official` → `typescript-lsp`. */
88
- function pluginName(pluginId: string): string {
89
- const at = pluginId.lastIndexOf("@");
90
- return at > 0 ? pluginId.slice(0, at) : pluginId;
91
- }
92
-
93
- /** "a", "a and b", "a, b and c". */
94
- function listed(names: readonly string[]): string {
95
- if (names.length <= 1) return names.join("");
96
- return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
97
- }
98
-
99
- /**
100
- * What an activation did, as notices — for the startup question and the
101
- * Profiles tab alike: one line saying which profile is in use, then
102
- * {@link activationWarnings}.
103
- */
104
- export function activationNotices(result: ActivationResult): NoticeLine[] {
105
- return [
106
- { text: `Using profile "${result.profileId}"`, tone: "success" },
107
- ...activationWarnings(result),
108
- ];
109
- }
110
-
111
- /**
112
- * What an activation left for the user to act on, one warning per thing, in
113
- * user terms. Alone, without {@link activationNotices}' "Using profile" line,
114
- * for an activation the user did not ask for — a re-render at startup, a
115
- * profile format update — where which profile is in use is not news.
116
- *
117
- * Deliberately NOT {@link activationNotes}, which the CLI prints. Several of
118
- * its lines are for a terminal log and would only be noise on this banner:
119
- * where displaced files were backed up (the backup still happens; nobody needs
120
- * to be told where), edits recorded into the profile being left (the switch
121
- * confirmation already said so), skills handed back to the team. Tracked
122
- * generated files are left out too: every activation signals
123
- * PROFILE_ACTIVATED, and the app re-runs its gitignore check on it — the
124
- * startup question and a Profiles-tab switch alike — which asks about exactly
125
- * those files, with a way to fix them.
126
- */
127
- export function activationWarnings(result: ActivationResult): NoticeLine[] {
128
- const lines: NoticeLine[] = [];
129
- const report = result.dependencies;
130
- for (const { plugin, dependency } of report?.blocked ?? []) {
131
- lines.push({
132
- text: `${pluginName(plugin)} won't load: profile "${result.profileId}" turns off ${pluginName(dependency)}, which it needs`,
133
- tone: "warning",
134
- });
135
- }
136
- // The plugin's details are not on this machine yet, so what it depends on
137
- // could not be enabled. One line for all of them: the fix is the same.
138
- const missing = (report?.unreadable ?? []).map(pluginName);
139
- if (missing.length > 0) {
140
- lines.push({
141
- text: `${listed(missing)} ${missing.length === 1 ? "isn't" : "aren't"} downloaded yet — run magus install`,
142
- tone: "warning",
143
- });
144
- }
145
- for (const f of result.skills.failed) {
146
- lines.push({
147
- text: `Skill "${f.skill}" could not be installed: ${f.reason}`,
148
- tone: "warning",
149
- });
150
- }
151
- // The one case that names a path: two copies of a skill, and which to keep
152
- // is the user's call — they need to know where the other one is.
153
- for (const c of result.conversion.conflicts) {
154
- lines.push({
155
- text: `Skill "${c.folder}" exists in two versions; kept ${c.kept}, the other is at ${c.other}`,
156
- tone: "warning",
157
- });
158
- }
159
- return lines;
160
- }
161
-
162
93
  /** Project-relative, so a path does not wrap over two lines. */
163
94
  function rel(p: string, projectPath: string): string {
164
95
  return p.startsWith(projectPath) ? p.slice(projectPath.length + 1) : p;
@@ -184,27 +115,50 @@ export function describeFailure(
184
115
  moved: DisplacedArtifact[],
185
116
  projectPath: string,
186
117
  declaredDefault: string | null,
187
- ): string {
188
- const reason = error instanceof Error ? error.message : String(error);
118
+ choice: ProfileChoice,
119
+ ): FailureReport {
120
+ const profile =
121
+ choice.kind === "adopt" ? (choice.name ?? choice.id) : choice.id;
122
+ const headline =
123
+ choice.kind === "adopt"
124
+ ? `Profile "${profile}" was not created`
125
+ : `Profile "${profile}" was not activated`;
126
+ const e = exceptionReason(
127
+ error instanceof Error ? error.message : String(error),
128
+ );
189
129
  if (moved.length === 0) {
190
- return `${reason}\n\nNothing was moved, so this project is exactly as it was.`;
130
+ return {
131
+ headline,
132
+ rows: [
133
+ {
134
+ subject: profile,
135
+ reason: `${e.reason}; nothing was moved, so this project is exactly as it was`,
136
+ },
137
+ ],
138
+ fix: e.fix ?? null,
139
+ };
191
140
  }
192
- return [
193
- reason,
194
- "",
195
- "This did not finish, and these were already moved aside. They are kept:",
196
- ...moved.map(
197
- (d) => ` ${rel(d.link, projectPath)} → ${rel(d.savedTo, projectPath)}`,
198
- ),
199
- "",
141
+ return {
142
+ headline,
143
+ rows: [
144
+ {
145
+ subject: profile,
146
+ reason: `${e.reason}; this did not finish, and these files were already moved aside`,
147
+ },
148
+ ...moved.map((d) => ({
149
+ subject: rel(d.link, projectPath),
150
+ reason: `is kept at ${rel(d.savedTo, projectPath)}`,
151
+ })),
152
+ ],
200
153
  // The trap the reviewer found: re-choosing "save what you have" after a
201
154
  // partial failure runs adoption again, and adoption reads settings.json —
202
155
  // which is now in the backup. It would capture an empty plugin set into a
203
156
  // second profile and call that the user's setup.
204
- declaredDefault !== null
205
- ? `Activating ${declaredDefault} again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.`
206
- : "Activating a profile again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.",
207
- ].join("\n");
157
+ fix:
158
+ declaredDefault !== null
159
+ ? `Activating ${declaredDefault} again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.`
160
+ : "Activating a profile again is safe. Do NOT save-what-you-have — the files above have moved, so it would capture an empty set.",
161
+ };
208
162
  }
209
163
 
210
164
  export interface ProfileStartupModalCallbacks {
@@ -228,8 +182,8 @@ export interface ProfileStartupModalCallbacks {
228
182
  * question on screen NOW, which is not always the one magus opened with: "Update
229
183
  * it now" asks a new one, and a manifest that stopped parsing turns the question
230
184
  * into the unparseable one. `working` is true while an activation or a format
231
- * update is running — when it settles it empties the slot (success, reported as
232
- * a notice) or puts its error modal there.
185
+ * update is running — when it settles it empties the slot (success) or puts its
186
+ * error modal there.
233
187
  */
234
188
  interface ProfileFlow {
235
189
  need: ProfileStartupNeed;
@@ -242,7 +196,6 @@ export function useProfileStartupModal({
242
196
  onAnswered,
243
197
  }: ProfileStartupModalCallbacks) {
244
198
  const { state, dispatch } = useApp();
245
- const notify = useNotify();
246
199
  const projectPath = state.projectPath;
247
200
  // A ref, not state: `show` is recreated on every render, and the flow must
248
201
  // survive that — a re-show has to know what was last asked, and that work is
@@ -271,6 +224,26 @@ export function useProfileStartupModal({
271
224
  answered();
272
225
  onQuit();
273
226
  };
227
+ /**
228
+ * End a flow that activated a profile: empty the slot, or — when the
229
+ * activation could not do something — put the failure popup in it.
230
+ * Either way the question is already answered, so dismissing the popup
231
+ * lets the next startup question in.
232
+ */
233
+ const settle = (report: FailureReport | null): void => {
234
+ if (!report) {
235
+ dispatch({ type: "HIDE_MODAL" });
236
+ return;
237
+ }
238
+ dispatch({
239
+ type: "SHOW_MODAL",
240
+ modal: {
241
+ type: "failures",
242
+ report,
243
+ onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
244
+ },
245
+ });
246
+ };
274
247
 
275
248
  /**
276
249
  * Apply a choice, reporting the outcome either way. `current` is the
@@ -306,20 +279,17 @@ export function useProfileStartupModal({
306
279
  void applyProfileChoice(choice, current.manifest, projectPath, {
307
280
  onDisplace: (artifact) => moved.push(artifact),
308
281
  })
309
- .then((result: ActivationResult) => {
282
+ .then((result) => {
310
283
  // Answered here, on success — NOT when the question was shown.
311
284
  // Clearing it on show let a screen's modal replace it and the
312
285
  // question never came back.
313
286
  answered();
314
287
  onActivated?.();
315
288
  // No result modal and no jump to the Profiles tab: the user
316
- // stays where they are, told in the notice banner. An automatic
317
- // activation is reported the same way, so it is never
318
- // invisible.
319
- dispatch({ type: "HIDE_MODAL" });
320
- for (const line of activationNotices(result)) {
321
- notify(line.text, line.tone);
322
- }
289
+ // stays where they are, and the screen reloads under them —
290
+ // unless the activation could not do something, which the one
291
+ // failure popup says once it has settled.
292
+ settle(activationFailureReport([result]));
323
293
  })
324
294
  .catch((error: unknown) => {
325
295
  // The manifest stopped parsing between launch and this answer —
@@ -337,18 +307,14 @@ export function useProfileStartupModal({
337
307
  dispatch({
338
308
  type: "SHOW_MODAL",
339
309
  modal: {
340
- type: "message",
341
- title:
342
- choice.kind === "adopt"
343
- ? "Could not create the profile"
344
- : "Could not activate the profile",
345
- message: describeFailure(
310
+ type: "failures",
311
+ report: describeFailure(
346
312
  error,
347
313
  moved,
348
314
  projectPath,
349
315
  current.declaredDefault,
316
+ choice,
350
317
  ),
351
- variant: "error",
352
318
  // A failure leaves the project without an active profile, so
353
319
  // dismissing returns to the question — including when the
354
320
  // attempt was automatic, which is the one case where there
@@ -373,37 +339,47 @@ export function useProfileStartupModal({
373
339
  void prepareProfileStartup(projectPath, { convert: true })
374
340
  .then((result) => {
375
341
  const outcome = formatUpdateOutcome(result);
376
- // What the conversion's activation warns about — a plugin that
377
- // will not load, one not downloaded yet, a skill that failed —
378
- // in user terms. Posted whichever way it went, except a refusal,
379
- // which converted nothing. Posting under the next question is
380
- // safe: notices do not run down while a modal is open.
381
- const warnings = (): void => {
382
- for (const activation of result.activations) {
383
- for (const line of activationWarnings(activation)) {
384
- notify(line.text, line.tone);
385
- }
386
- }
387
- };
342
+ const report = activationFailureReport(result.activations ?? []);
388
343
  if (outcome.kind === "ask") {
389
344
  // Converted, but no profile is active yet: a new question,
390
- // still unanswered.
345
+ // still unanswered — asked once what the conversion could not
346
+ // do has been read.
391
347
  onActivated?.();
348
+ if (report) {
349
+ flowRef.current = { need: outcome.need, working: false };
350
+ dispatch({
351
+ type: "SHOW_MODAL",
352
+ modal: {
353
+ type: "failures",
354
+ report,
355
+ onDismiss: () => ask(outcome.need),
356
+ },
357
+ });
358
+ return;
359
+ }
392
360
  ask(outcome.need);
393
- warnings();
394
361
  return;
395
362
  }
396
363
  if (outcome.kind === "failed") {
397
364
  // Refused, so still the old format: back to the question,
398
365
  // as the CLI stops on the same lines.
366
+ // Each refusal in the failure popup, the file it is about
367
+ // named, and what to do about it.
368
+ // A "failed" outcome always carries problems; the fallback
369
+ // only keeps the popup's type honest.
399
370
  flowRef.current = { need, working: false };
371
+ const refused =
372
+ profileProblemsReport(result.problems, FORMAT_NOT_UPDATED) ??
373
+ stepFailureReport(
374
+ FORMAT_NOT_UPDATED,
375
+ "profile",
376
+ outcome.message,
377
+ );
400
378
  dispatch({
401
379
  type: "SHOW_MODAL",
402
380
  modal: {
403
- type: "message",
404
- title: outcome.title,
405
- message: outcome.message,
406
- variant: "error",
381
+ type: "failures",
382
+ report: refused,
407
383
  onDismiss: () => ask(need),
408
384
  },
409
385
  });
@@ -412,19 +388,20 @@ export function useProfileStartupModal({
412
388
  // Nothing left to ask: the converted profile is active.
413
389
  answered();
414
390
  onActivated?.();
415
- dispatch({ type: "HIDE_MODAL" });
416
- notify(outcome.notice);
417
- warnings();
391
+ settle(report);
418
392
  })
419
393
  .catch((error: unknown) => {
420
394
  flowRef.current = { need, working: false };
421
395
  dispatch({
422
396
  type: "SHOW_MODAL",
423
397
  modal: {
424
- type: "message",
425
- title: "Could not update the profile format",
426
- message: `${error instanceof Error ? error.message : String(error)}\n\nYour profiles are kept. Open magus again to retry.`,
427
- variant: "error",
398
+ type: "failures",
399
+ report: stepFailureReport(
400
+ FORMAT_NOT_UPDATED,
401
+ "profile",
402
+ error,
403
+ "Your profiles are kept. Open magus again to retry",
404
+ ),
428
405
  onDismiss: () => ask(need),
429
406
  },
430
407
  });
@@ -494,15 +471,15 @@ export function useProfileStartupModal({
494
471
 
495
472
  // The repo already answered and there is nothing of the user's to
496
473
  // replace: asking here would ask someone to re-decide what their team
497
- // decided, with nothing at stake. Reported in the notice banner, so it
498
- // is never invisible.
474
+ // decided, with nothing at stake. The Profiles tab names the profile
475
+ // in use.
499
476
  if (need.autoActivate && need.declaredDefault !== null) {
500
477
  apply({ kind: "activate", id: need.declaredDefault });
501
478
  return;
502
479
  }
503
480
  ask();
504
481
  },
505
- [dispatch, notify, projectPath, onQuit, onActivated, onAnswered],
482
+ [dispatch, projectPath, onQuit, onActivated, onAnswered],
506
483
  );
507
484
 
508
485
  return { show };
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from "react";
2
- import { useApp, useNavigation, useNotify } from "../state/AppContext.js";
3
- import type { ModalState, NoticeLine } from "../state/types.js";
2
+ import { stepFailureReport } from "../adapters/startupReports.js";
3
+ import { useApp, useNavigation } from "../state/AppContext.js";
4
+ import type { FailureReport, ModalState } from "../state/types.js";
4
5
 
5
6
  /**
6
7
  * Startup broken-plugin modal: magus launches, finds that Claude Code will load
@@ -71,13 +72,20 @@ export function buildStaleLoadModal(args: BuildStaleLoadModalArgs): ModalState {
71
72
  export function useStaleLoadModal() {
72
73
  const { dispatch } = useApp();
73
74
  const { navigateToScreen } = useNavigation();
74
- const notify = useNotify();
75
75
 
76
76
  const show = useCallback(
77
77
  (args: {
78
78
  brokenCount: number;
79
- /** Runs the repair. Resolves to what to tell the user afterwards. */
80
- onFix: () => Promise<readonly NoticeLine[]> | readonly NoticeLine[];
79
+ /**
80
+ * Runs the repair and answers what it could not fix — null when every
81
+ * plugin now loads its installed version. The Plugins screen shows
82
+ * what now loads; the failures go into the one failure popup.
83
+ */
84
+ onFix: () =>
85
+ | Promise<FailureReport | null | undefined>
86
+ | FailureReport
87
+ | null
88
+ | undefined;
81
89
  }) => {
82
90
  if (args.brokenCount === 0) return;
83
91
  const dismiss = () => dispatch({ type: "HIDE_MODAL" });
@@ -88,33 +96,34 @@ export function useStaleLoadModal() {
88
96
  type: "SHOW_MODAL",
89
97
  modal: { type: "loading", message: "Repairing plugins…" },
90
98
  });
91
- // The outcome is REPORTED, never assumed. `repairStaleLoad` re-asks
92
- // the loader after writing and can legitimately answer "still not
93
- // loading the target"; swallowing that would leave the user believing
94
- // a broken plugin was fixed.
99
+ // The outcome is never assumed: `repairStaleLoad` re-asks the loader
100
+ // after writing, and a plugin it could not fix still reads BROKEN on
101
+ // the Plugins screen.
95
102
  //
96
103
  // Called inside `.then` so a synchronous throw reaches `.catch`
97
104
  // too; otherwise the loading modal stays up and swallows every key.
105
+ const fail = (report: FailureReport) =>
106
+ dispatch({
107
+ type: "SHOW_MODAL",
108
+ modal: { type: "failures", report, onDismiss: dismiss },
109
+ });
98
110
  void Promise.resolve()
99
111
  .then(() => args.onFix())
100
- .then((summary) => {
101
- // Hidden, not replaced: the loading modal swallows every key,
102
- // and the result belongs in the notice banner, outside the
103
- // modal slot the next startup question is waiting for.
104
- dispatch({ type: "HIDE_MODAL" });
105
- for (const line of summary) notify(line.text, line.tone);
112
+ .then((report) => {
113
+ // The loading modal must go — it swallows every key — and the
114
+ // next startup question is waiting for the slot. A repair
115
+ // that did not take puts the failure popup there instead.
116
+ if (report) fail(report);
117
+ else dispatch({ type: "HIDE_MODAL" });
106
118
  })
107
119
  .catch((err) => {
108
- dispatch({
109
- type: "SHOW_MODAL",
110
- modal: {
111
- type: "message",
112
- title: "Plugin repair failed",
113
- message: err instanceof Error ? err.message : String(err),
114
- variant: "error",
115
- onDismiss: dismiss,
116
- },
117
- });
120
+ fail(
121
+ stepFailureReport(
122
+ "The plugins were not repaired",
123
+ "repair",
124
+ err,
125
+ ),
126
+ );
118
127
  });
119
128
  },
120
129
  onOpenTab: () => {
@@ -125,47 +134,8 @@ export function useStaleLoadModal() {
125
134
  });
126
135
  dispatch({ type: "SHOW_MODAL", modal });
127
136
  },
128
- [dispatch, navigateToScreen, notify],
137
+ [dispatch, navigateToScreen],
129
138
  );
130
139
 
131
140
  return { show };
132
141
  }
133
-
134
- /**
135
- * What the repair did, as notices: one line for every plugin that now loads
136
- * the version installed, and a warning for each that still does not.
137
- *
138
- * Exported and pure so the wording is tested rather than inspected by eye. The
139
- * lines say what loads NOW, because that — not the row count — is the thing
140
- * the user came here to change. A failure is never folded into the success
141
- * line: `ok` is the loader re-asked after the write, and a repair that did not
142
- * take must read as one.
143
- */
144
- export function staleLoadRepairNotices(
145
- results: ReadonlyArray<{
146
- pluginId: string;
147
- target: string;
148
- ok: boolean;
149
- error?: string;
150
- }>,
151
- ): NoticeLine[] {
152
- if (results.length === 0) {
153
- return [{ text: "Nothing needed repairing", tone: "success" }];
154
- }
155
- const lines: NoticeLine[] = [];
156
- const fixed = results.filter((r) => r.ok);
157
- if (fixed.length > 0) {
158
- lines.push({
159
- text: `Now loading ${fixed.map((r) => `${r.pluginId} v${r.target}`).join(", ")}`,
160
- tone: "success",
161
- });
162
- }
163
- for (const r of results) {
164
- if (r.ok) continue;
165
- lines.push({
166
- text: `${r.pluginId} not repaired: ${r.error ?? "repair did not take effect"}`,
167
- tone: "warning",
168
- });
169
- }
170
- return lines;
171
- }
package/src/ui/keymap.ts CHANGED
@@ -166,6 +166,13 @@ const PLUGINS = [
166
166
  "Remove a deprecated plugin from every scope",
167
167
  30,
168
168
  ),
169
+ bind(
170
+ "set-env",
171
+ ["e"],
172
+ "settings",
173
+ "Set the variables a plugin's MCP server needs",
174
+ 35,
175
+ ),
169
176
  bind("add-marketplace", ["n"], "add marketplace", "Add a marketplace", 30),
170
177
  bind("team-help", ["t"], "team", "How to share marketplaces with a team", 10),
171
178
  refresh("Refresh the local marketplace caches"),
@@ -374,7 +381,13 @@ const ALIAS = [
374
381
  "Turn the flag on or off; in the flag editor, the row",
375
382
  90,
376
383
  ),
377
- bind("write", ["w"], "write", "Write the alias to your shell's rc file", 85),
384
+ bind(
385
+ "write",
386
+ ["w"],
387
+ "write",
388
+ "Write the alias to your shell's rc file; a new shell picks it up",
389
+ 85,
390
+ ),
378
391
  bind("edit", ["e"], "edit", "Edit the flag's value", 80),
379
392
  bind("reset", ["r"], "reset", "Reset the row to its default", 50),
380
393
  bind("add", ["a"], "add", "Flag editor: add a value", 80),