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
@@ -6,12 +6,12 @@ import {
6
6
  useContext,
7
7
  useReducer,
8
8
  } from "react";
9
- import { NOTICE_DURATION_MS, appReducer, initialState } from "./reducer.js";
9
+ import { appReducer, initialState } from "./reducer.js";
10
10
  import type {
11
11
  AppAction,
12
12
  AppState,
13
+ FailureReport,
13
14
  ModalState,
14
- NoticeTone,
15
15
  ProgressState,
16
16
  Route,
17
17
  Screen,
@@ -90,6 +90,44 @@ export function useNavigation() {
90
90
  };
91
91
  }
92
92
 
93
+ // ============================================================================
94
+ // Hook: useFailurePopup
95
+ // ============================================================================
96
+
97
+ /**
98
+ * The failure popup as ONE stable callback: show `report`, or do nothing for
99
+ * null — the "nothing failed" answer every report builder gives. Stable
100
+ * across renders, unlike `useModal()`, so a `useCallback` that reports a
101
+ * failure can list it without being rebuilt — and without re-running an
102
+ * effect that depends on that callback.
103
+ */
104
+ export function useFailurePopup(): (
105
+ report: FailureReport | null,
106
+ ) => Promise<void> {
107
+ const { dispatch } = useApp();
108
+ return useCallback(
109
+ (report: FailureReport | null) =>
110
+ new Promise<void>((resolve) => {
111
+ if (!report) {
112
+ resolve();
113
+ return;
114
+ }
115
+ dispatch({
116
+ type: "SHOW_MODAL",
117
+ modal: {
118
+ type: "failures",
119
+ report,
120
+ onDismiss: () => {
121
+ dispatch({ type: "HIDE_MODAL" });
122
+ resolve();
123
+ },
124
+ },
125
+ });
126
+ }),
127
+ [dispatch],
128
+ );
129
+ }
130
+
93
131
  // ============================================================================
94
132
  // Hook: useModal
95
133
  // ============================================================================
@@ -181,11 +219,11 @@ export function useModal() {
181
219
  });
182
220
  },
183
221
 
184
- message: (
185
- title: string,
186
- message: string,
187
- variant: "info" | "error" = "info",
188
- ): Promise<void> => {
222
+ /**
223
+ * Information to read and dismiss. Never a failure: that is `failures`,
224
+ * which has the one shape every failure uses.
225
+ */
226
+ message: (title: string, message: string): Promise<void> => {
189
227
  return new Promise((resolve) => {
190
228
  dispatch({
191
229
  type: "SHOW_MODAL",
@@ -193,7 +231,27 @@ export function useModal() {
193
231
  type: "message",
194
232
  title,
195
233
  message,
196
- variant,
234
+ onDismiss: () => {
235
+ dispatch({ type: "HIDE_MODAL" });
236
+ resolve();
237
+ },
238
+ },
239
+ });
240
+ });
241
+ },
242
+
243
+ /**
244
+ * The failure popup — see {@link FailureReport}. Resolves once dismissed.
245
+ * Use it for anything that did not happen and should have; `message` is
246
+ * for information.
247
+ */
248
+ failures: (report: FailureReport): Promise<void> => {
249
+ return new Promise((resolve) => {
250
+ dispatch({
251
+ type: "SHOW_MODAL",
252
+ modal: {
253
+ type: "failures",
254
+ report,
197
255
  onDismiss: () => {
198
256
  dispatch({ type: "HIDE_MODAL" });
199
257
  resolve();
@@ -220,42 +278,6 @@ export function useModal() {
220
278
  };
221
279
  }
222
280
 
223
- // ============================================================================
224
- // Hook: useNotify
225
- // ============================================================================
226
-
227
- export { NOTICE_DURATION_MS };
228
-
229
- let nextNoticeId = 0;
230
-
231
- /**
232
- * `notify(text, tone)` puts one line in the app's notice banner (rendered by
233
- * `NoticeBanner` in ui/App.tsx) and returns at once. It is how a screen or hook
234
- * reports the result of an action: it never opens a modal, never takes a key,
235
- * and the line clears itself after {@link NOTICE_DURATION_MS}. Time spent
236
- * under a modal does not count (see PAUSE_NOTICES in the reducer), so a line
237
- * posted while a question is on screen is still there once it is answered.
238
- *
239
- * Stable across renders, so it is safe in a `useCallback` dependency list.
240
- */
241
- export function useNotify(): (text: string, tone?: NoticeTone) => void {
242
- const { dispatch } = useApp();
243
- return useCallback(
244
- (text: string, tone: NoticeTone = "success") => {
245
- dispatch({
246
- type: "NOTIFY",
247
- notice: {
248
- id: nextNoticeId++,
249
- text,
250
- tone,
251
- expiresAt: Date.now() + NOTICE_DURATION_MS[tone],
252
- },
253
- });
254
- },
255
- [dispatch],
256
- );
257
- }
258
-
259
281
  // ============================================================================
260
282
  // Hook: useProgress
261
283
  // ============================================================================
@@ -29,8 +29,6 @@ interface DimensionsProviderProps {
29
29
  showProgress?: boolean;
30
30
  /** Whether update banner is visible */
31
31
  showUpdateBanner?: boolean;
32
- /** Number of transient banners (recovery, mismatch) currently visible */
33
- transientBannerCount?: number;
34
32
  }
35
33
 
36
34
  function calculateDimensions(
@@ -38,7 +36,6 @@ function calculateDimensions(
38
36
  rows: number,
39
37
  showProgress: boolean,
40
38
  showUpdateBanner: boolean,
41
- transientBannerCount: number,
42
39
  ): Dimensions {
43
40
  const terminalWidth = columns;
44
41
  const terminalHeight = rows;
@@ -47,7 +44,6 @@ function calculateDimensions(
47
44
  let contentHeight = terminalHeight - APP_MARGINS;
48
45
  if (showProgress) contentHeight -= PROGRESS_HEIGHT;
49
46
  if (showUpdateBanner) contentHeight -= 1;
50
- contentHeight -= transientBannerCount;
51
47
  contentHeight = Math.max(10, contentHeight); // Minimum 10 lines for full layout
52
48
 
53
49
  // Calculate available content width (accounting for padding)
@@ -74,7 +70,6 @@ export function DimensionsProvider({
74
70
  children,
75
71
  showProgress = false,
76
72
  showUpdateBanner = false,
77
- transientBannerCount = 0,
78
73
  }: DimensionsProviderProps) {
79
74
  const renderer = useRenderer();
80
75
 
@@ -84,24 +79,17 @@ export function DimensionsProvider({
84
79
  renderer.height,
85
80
  showProgress,
86
81
  showUpdateBanner,
87
- transientBannerCount,
88
82
  ),
89
83
  );
90
84
 
91
85
  // Handle terminal resize
92
86
  useOnResize((width, height) => {
93
87
  setDimensions(
94
- calculateDimensions(
95
- width,
96
- height,
97
- showProgress,
98
- showUpdateBanner,
99
- transientBannerCount,
100
- ),
88
+ calculateDimensions(width, height, showProgress, showUpdateBanner),
101
89
  );
102
90
  });
103
91
 
104
- // Update when showProgress/showUpdateBanner/transientBannerCount changes
92
+ // Update when showProgress/showUpdateBanner changes
105
93
  useEffect(() => {
106
94
  setDimensions(
107
95
  calculateDimensions(
@@ -109,16 +97,9 @@ export function DimensionsProvider({
109
97
  renderer.height,
110
98
  showProgress,
111
99
  showUpdateBanner,
112
- transientBannerCount,
113
100
  ),
114
101
  );
115
- }, [
116
- renderer.width,
117
- renderer.height,
118
- showProgress,
119
- showUpdateBanner,
120
- transientBannerCount,
121
- ]);
102
+ }, [renderer.width, renderer.height, showProgress, showUpdateBanner]);
122
103
 
123
104
  return (
124
105
  <DimensionsContext.Provider value={dimensions}>
@@ -1,38 +1,6 @@
1
- import type { AppAction, AppState, Notice, NoticeTone } from "./types.js";
1
+ import type { AppAction, AppState } from "./types.js";
2
2
  import { asyncValue } from "./types.js";
3
3
 
4
- /** How many notice lines the banner shows at once. */
5
- export const MAX_NOTICES = 4;
6
-
7
- /**
8
- * How long a notice stays. A warning may ask the user to do something, so it
9
- * gets long enough to read and act on; a success only confirms what happened.
10
- *
11
- * Here rather than beside `useNotify`, because RESUME_NOTICES caps a held line
12
- * at its full duration and the reducer cannot import from the context module
13
- * that imports it.
14
- */
15
- export const NOTICE_DURATION_MS: Record<NoticeTone, number> = {
16
- success: 4000,
17
- warning: 10000,
18
- };
19
-
20
- /**
21
- * Add `notice` and keep at most {@link MAX_NOTICES}, dropping successes first.
22
- *
23
- * A success only confirms what the screen already shows; a warning may be the
24
- * one thing telling the user to act. So a burst of lines evicts the oldest
25
- * success, and the oldest warning goes only when nothing but warnings is left.
26
- */
27
- function withNotice(notices: readonly Notice[], notice: Notice): Notice[] {
28
- const next = [...notices, notice];
29
- while (next.length > MAX_NOTICES) {
30
- const success = next.findIndex((n) => n.tone === "success");
31
- next.splice(success === -1 ? 0 : success, 1);
32
- }
33
- return next;
34
- }
35
-
36
4
  export const initialState: AppState = {
37
5
  // Navigation - start on plugins screen
38
6
  currentRoute: { screen: "plugins" },
@@ -42,8 +10,6 @@ export const initialState: AppState = {
42
10
 
43
11
  // Global UI state
44
12
  modal: null,
45
- notices: [],
46
- noticesPausedAt: null,
47
13
  progress: null,
48
14
  isSearching: false,
49
15
 
@@ -70,6 +36,7 @@ export const initialState: AppState = {
70
36
  selectedIndex: 0,
71
37
  servers: { status: "idle" },
72
38
  installedServers: {},
39
+ changedThisSession: [],
73
40
  },
74
41
 
75
42
  mcpRegistry: {
@@ -106,7 +73,6 @@ export const initialState: AppState = {
106
73
  searchQuery: "",
107
74
  snapshot: { status: "idle" },
108
75
  selected: new Set(),
109
- status: null,
110
76
  isFilling: false,
111
77
  },
112
78
 
@@ -118,7 +84,6 @@ export const initialState: AppState = {
118
84
  selectedIndex: 0,
119
85
  searchQuery: "",
120
86
  data: { status: "idle" },
121
- status: null,
122
87
  isApplying: false,
123
88
  // False until the plugin lookup answers, and false again if it fails. Both cases
124
89
  // render the screen as it was before mates existed, which is the honest drawing of
@@ -331,6 +296,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
331
296
  },
332
297
  };
333
298
 
299
+ case "MCP_SERVER_CHANGED":
300
+ if (state.mcp.changedThisSession.includes(action.name)) return state;
301
+ return {
302
+ ...state,
303
+ mcp: {
304
+ ...state.mcp,
305
+ changedThisSession: [...state.mcp.changedThisSession, action.name],
306
+ },
307
+ };
308
+
334
309
  // =========================================================================
335
310
  // MCP Registry Screen
336
311
  // =========================================================================
@@ -684,12 +659,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
684
659
  },
685
660
  };
686
661
 
687
- case "STYLES_STATUS_SET":
688
- return { ...state, styles: { ...state.styles, status: action.status } };
689
-
690
- case "STYLES_STATUS_CLEAR":
691
- return { ...state, styles: { ...state.styles, status: null } };
692
-
693
662
  case "STYLES_FILL_START":
694
663
  return { ...state, styles: { ...state.styles, isFilling: true } };
695
664
 
@@ -779,12 +748,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
779
748
  },
780
749
  };
781
750
 
782
- case "MODELS_STATUS_SET":
783
- return { ...state, models: { ...state.models, status: action.status } };
784
-
785
- case "MODELS_STATUS_CLEAR":
786
- return { ...state, models: { ...state.models, status: null } };
787
-
788
751
  case "MODELS_APPLY_START":
789
752
  return { ...state, models: { ...state.models, isApplying: true } };
790
753
 
@@ -814,57 +777,6 @@ export function appReducer(state: AppState, action: AppAction): AppState {
814
777
  case "HIDE_MODAL":
815
778
  return { ...state, modal: null };
816
779
 
817
- // =========================================================================
818
- // Notices
819
- // =========================================================================
820
- // The same text again replaces the old line rather than stacking a copy,
821
- // and only a few are kept: the banner takes rows from the screen, and a
822
- // burst must not push the content off the terminal. Which few is
823
- // `withNotice`'s call — warnings outlast successes.
824
- case "NOTIFY": {
825
- const rest = state.notices.filter((n) => n.text !== action.notice.text);
826
- return { ...state, notices: withNotice(rest, action.notice) };
827
- }
828
-
829
- case "EXPIRE_NOTICES": {
830
- // Held behind a modal: nothing runs down, so nothing expires.
831
- if (state.noticesPausedAt !== null) return state;
832
- const live = state.notices.filter((n) => n.expiresAt > action.now);
833
- // Same state when nothing expired, so the timer effect is not re-run.
834
- return live.length === state.notices.length
835
- ? state
836
- : { ...state, notices: live };
837
- }
838
-
839
- // A modal opened. Only the first pause counts: modal to modal (a confirm
840
- // followed by its loading modal) is one uninterrupted hold.
841
- case "PAUSE_NOTICES":
842
- return state.noticesPausedAt !== null
843
- ? state
844
- : { ...state, noticesPausedAt: action.now };
845
-
846
- // The modal slot emptied: every line gets back the time it spent held.
847
- // A line posted DURING the hold would be over-credited by the time
848
- // between the pause and its posting, so it is capped at its full
849
- // duration from now — which is exactly what it would have had, had it
850
- // been posted the moment the modal closed.
851
- case "RESUME_NOTICES": {
852
- const pausedAt = state.noticesPausedAt;
853
- if (pausedAt === null) return state;
854
- const held = action.now - pausedAt;
855
- return {
856
- ...state,
857
- noticesPausedAt: null,
858
- notices: state.notices.map((n) => ({
859
- ...n,
860
- expiresAt: Math.min(
861
- n.expiresAt + held,
862
- action.now + NOTICE_DURATION_MS[n.tone],
863
- ),
864
- })),
865
- };
866
- }
867
-
868
780
  // =========================================================================
869
781
  // Progress
870
782
  // =========================================================================
@@ -86,6 +86,35 @@ export interface SelectOption {
86
86
  meta?: string;
87
87
  }
88
88
 
89
+ /**
90
+ * What failed, as ONE popup shape every failure report on every screen uses:
91
+ * the state counted in the headline, one row per thing that failed (or per
92
+ * group sharing a reason), and — only when every row shares it — the one fix.
93
+ * Subjects are whatever failed: plugins, marketplaces, skills, files, the
94
+ * profile. Rendered by `FailureListModal`; built by pure functions
95
+ * (`ui/adapters/failureReport.ts`), so the words are tested without a renderer.
96
+ */
97
+ export interface FailureReport {
98
+ /** The state, counted: "3 plugins were not updated", "claudish was not updated". */
99
+ headline: string;
100
+ rows: FailureRow[];
101
+ /** The one thing to do about ALL of them, or null when they differ. */
102
+ fix: string | null;
103
+ }
104
+
105
+ export interface FailureRow {
106
+ /** What failed — a plugin name, a file, or several sharing the reason. */
107
+ subject: string;
108
+ /** Why, in the user's terms. One line: an exception keeps its first line only. */
109
+ reason: string;
110
+ /**
111
+ * What to do about THIS row, when it differs from the others'. Absent when
112
+ * there is none, or when every row shares it — the report's `fix` then says
113
+ * it once.
114
+ */
115
+ fix?: string;
116
+ }
117
+
89
118
  export type ModalState =
90
119
  | {
91
120
  type: "confirm";
@@ -123,14 +152,20 @@ export type ModalState =
123
152
  }
124
153
  | {
125
154
  /**
126
- * A dialog the user dismisses. No "success" variant, deliberately: a
127
- * result the screen already shows needs nothing, and one it does not
128
- * is a notice (see {@link Notice}), which never takes a key.
155
+ * Information the user dismisses: help text, a command to run. It has
156
+ * no variants, deliberately. A success shows as the new state of the
157
+ * screen and needs nothing more; a failure goes in the failure popup
158
+ * (`failures`), so every failure reads the same way.
129
159
  */
130
160
  type: "message";
131
161
  title: string;
132
162
  message: string;
133
- variant: "info" | "error";
163
+ onDismiss: () => void;
164
+ }
165
+ | {
166
+ /** What failed and why — see {@link FailureReport}. Any key dismisses it. */
167
+ type: "failures";
168
+ report: FailureReport;
134
169
  onDismiss: () => void;
135
170
  }
136
171
  | {
@@ -143,28 +178,6 @@ export type ModalState =
143
178
  screen: Screen;
144
179
  };
145
180
 
146
- // ============================================================================
147
- // Notice Types
148
- // ============================================================================
149
-
150
- /** A warning is something the user may need to act on, so it stays longer. */
151
- export type NoticeTone = "success" | "warning";
152
-
153
- /**
154
- * One line of the app-level notice banner: the result of an action, shown
155
- * without taking the modal slot or any key, and gone by itself at `expiresAt`.
156
- */
157
- export interface Notice {
158
- id: number;
159
- text: string;
160
- tone: NoticeTone;
161
- /** Epoch ms after which the banner drops it. */
162
- expiresAt: number;
163
- }
164
-
165
- /** A notice as a pure function words it, before `notify` stamps it. */
166
- export type NoticeLine = Pick<Notice, "text" | "tone">;
167
-
168
181
  // ============================================================================
169
182
  // Progress Types
170
183
  // ============================================================================
@@ -215,6 +228,13 @@ export interface McpScreenState {
215
228
  selectedIndex: number;
216
229
  servers: AsyncData<McpServer[]>;
217
230
  installedServers: Record<string, boolean>;
231
+ /**
232
+ * Servers added or removed in THIS magus session. A running Claude Code
233
+ * reads its MCP configuration at startup, so each of these takes effect
234
+ * only after it restarts — a property of the server, shown on its row and
235
+ * in its detail rather than said once as a message and lost.
236
+ */
237
+ changedThisSession: string[];
218
238
  }
219
239
 
220
240
  export interface McpRegistryScreenState {
@@ -279,16 +299,6 @@ export interface StylesScreenState {
279
299
  * style, which is not something a stray Space press should do.
280
300
  */
281
301
  selected: Set<string>;
282
- /**
283
- * The status line's current message, or null for the default line.
284
- *
285
- * App state for the same reason `isFilling` is: `Router` swaps the component
286
- * type on a tab change, so anything held in the screen's own `useState` is
287
- * destroyed by `9 → 1 → 9`. It also stops being cleared on a wall clock —
288
- * the message now survives until the next action replaces it, so the last
289
- * thing you did is still on screen whenever you look.
290
- */
291
- status: { text: string; tone: "success" | "error" } | null;
292
302
  /**
293
303
  * True while a template fill (Claude Code over the whole project) is running.
294
304
  *
@@ -320,15 +330,6 @@ export interface ModelsScreenState {
320
330
  searchQuery: string;
321
331
  /** Routing status + the config behind it, in one read. */
322
332
  data: AsyncData<ModelsSnapshot>;
323
- /**
324
- * The status line's current message, or null for the default line.
325
- *
326
- * App state for the same reason the Styles screen's is: `Router` swaps the
327
- * component type on a tab change, so anything held in the screen's own
328
- * `useState` is destroyed by `0 → 1 → 0`. The message survives until the next
329
- * action replaces it, so the last thing you did is still on screen.
330
- */
331
- status: { text: string; tone: "success" | "error" } | null;
332
333
  /**
333
334
  * True while an apply or a clear is writing.
334
335
  *
@@ -379,14 +380,6 @@ export interface AppState {
379
380
 
380
381
  // Global UI state
381
382
  modal: ModalState | null;
382
- /** The notice banner, oldest first. Never occupies the modal slot. */
383
- notices: Notice[];
384
- /**
385
- * When the notices stopped running down, or null while they run. They are
386
- * held while a modal is open: a line posted under a startup question, or
387
- * just before one, would otherwise expire behind it unread.
388
- */
389
- noticesPausedAt: number | null;
390
383
  progress: ProgressState | null;
391
384
  /**
392
385
  * The current screen's search box has focus. It decides what the box draws
@@ -465,6 +458,8 @@ export type AppAction =
465
458
  installedServers: Record<string, boolean>;
466
459
  }
467
460
  | { type: "MCP_DATA_ERROR"; error: Error }
461
+ /** A server was added or removed — it needs a Claude Code restart. */
462
+ | { type: "MCP_SERVER_CHANGED"; name: string }
468
463
 
469
464
  // MCP Registry screen
470
465
  | { type: "MCP_REGISTRY_SET_QUERY"; query: string }
@@ -492,12 +487,6 @@ export type AppAction =
492
487
  | { type: "SHOW_MODAL"; modal: ModalState }
493
488
  | { type: "HIDE_MODAL" }
494
489
 
495
- // Notices
496
- | { type: "NOTIFY"; notice: Notice }
497
- | { type: "EXPIRE_NOTICES"; now: number }
498
- | { type: "PAUSE_NOTICES"; now: number }
499
- | { type: "RESUME_NOTICES"; now: number }
500
-
501
490
  // Progress
502
491
  | { type: "SHOW_PROGRESS"; state: ProgressState }
503
492
  | { type: "UPDATE_PROGRESS"; state: ProgressState }
@@ -536,11 +525,6 @@ export type AppAction =
536
525
  | { type: "STYLES_DATA_SUCCESS"; snapshot: StylesSnapshot }
537
526
  | { type: "STYLES_DATA_ERROR"; error: Error }
538
527
  | { type: "STYLES_TOGGLE"; id: string }
539
- | {
540
- type: "STYLES_STATUS_SET";
541
- status: { text: string; tone: "success" | "error" };
542
- }
543
- | { type: "STYLES_STATUS_CLEAR" }
544
528
  | { type: "STYLES_FILL_START" }
545
529
  | { type: "STYLES_FILL_END" }
546
530
  | { type: "STYLES_SET_SELECTION"; ids: string[] }
@@ -557,11 +541,6 @@ export type AppAction =
557
541
  | { type: "MODELS_DATA_LOADING" }
558
542
  | { type: "MODELS_DATA_SUCCESS"; snapshot: ModelsSnapshot }
559
543
  | { type: "MODELS_DATA_ERROR"; error: Error }
560
- | {
561
- type: "MODELS_STATUS_SET";
562
- status: { text: string; tone: "success" | "error" };
563
- }
564
- | { type: "MODELS_STATUS_CLEAR" }
565
544
  | { type: "MODELS_APPLY_START" }
566
545
  | { type: "MODELS_APPLY_END" }
567
546
  // Dispatched once, when the plugin lookup behind the mate slots lands.
@@ -24,8 +24,8 @@ import { join } from "node:path";
24
24
  * characters it contains, and `cmd` must name a real executable rather than
25
25
  * anything PATH-plus-a-profile might have meant.
26
26
  *
27
- * `services/plugin-setup.ts` arrived at this same shape independently for the
28
- * MCP dependency installer. This is the codebase agreeing with itself.
27
+ * Plugin dependency installs are argv for the same reason: requires-probe
28
+ * renders every install step as one, and `utils/run.ts` runs it.
29
29
  */
30
30
  export interface Command {
31
31
  readonly cmd: string;
@@ -71,9 +71,8 @@ function quoteToken(token: string): string {
71
71
  * Async only for its callers' sake — it spawns nothing. This used to run
72
72
  * `execFileAsync("which", [command])`, a real `which(1)` process with no
73
73
  * timeout, on every one of its dozen call sites. Answering "is bun on PATH?"
74
- * does not need a process, and under load that process is what stalls:
75
- * `detectToolchains` calls this per toolchain, and in a 103-file parallel test
76
- * run the spawn queue backed up until the test failed at **20,007 ms** — after
74
+ * does not need a process, and under load that process is what stalls: in a
75
+ * 103-file parallel test run the spawn queue backed up until the test failed at **20,007 ms** — after
77
76
  * the per-test timeout had already been raised from 5s to 20s to accommodate
78
77
  * the earlier 5s failures. Raising the limit moved the goalposts; removing the
79
78
  * spawn removes the stall.