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
@@ -1,4 +1,9 @@
1
1
  import type { ModelsConfig } from "../services/models-core.js";
2
+ import type {
3
+ InstallOption,
4
+ Requirement,
5
+ Via,
6
+ } from "../shared/plugin/hooks/lib/requires-probe.js";
2
7
 
3
8
  export interface McpServer {
4
9
  name: string;
@@ -409,14 +414,52 @@ export interface ProfileManifest {
409
414
  profiles: Record<string, ProfileManifestEntry>;
410
415
  }
411
416
 
412
- // ─── Plugin binary requirements (plugin.json `requires.bin`) ────────────────────
417
+ // ─── Binary requirements (profile cliTools, and plugin requires) ────────────────
413
418
 
414
- /** How a binary dependency is installed. */
415
- export type BinInstaller = "bun" | "brew" | "npm" | "pip" | "go";
419
+ /**
420
+ * What a plugin or a profile needs on the machine. One shape for both: a
421
+ * plugin's `requires` entry as requires-probe parses it, and a profile
422
+ * cliTools entry mapped onto the profile-only `npm`/`pip`/`go` kinds (or
423
+ * `bun`/`brew`) by `cliToolToBin`.
424
+ */
425
+ export type BinRequirement = Requirement;
426
+ export type BinInstallOption = InstallOption;
427
+
428
+ /**
429
+ * A requirement with the install option chosen for this machine — selected
430
+ * once, where the requirement is resolved — and the plugin directory a
431
+ * `${CLAUDE_PLUGIN_ROOT}` check or a plugin-relative script resolves against
432
+ * (absent for a profile cliTool).
433
+ */
434
+ export interface SelectedBin extends Requirement {
435
+ /** Null when no option can be used here (`whyUnselectable` says why). */
436
+ chosen: InstallOption | null;
437
+ root?: string;
438
+ /**
439
+ * Profile pins the chosen option cannot install at: a tool a plugin owns
440
+ * and installs from a release, Homebrew or apt, whose version is the
441
+ * declaration's. Never applied, never dropped: `planDeps` reports each as
442
+ * unfixable and `magus update` names it.
443
+ */
444
+ refusedPins?: string[];
445
+ }
446
+
447
+ /** A selected requirement with its provenance: `plugin:<id>` / `profile:<name>.cliTools`. */
448
+ export interface ResolvedBin extends SelectedBin {
449
+ sources: string[];
450
+ }
451
+
452
+ /** The install kinds whose installed version magus tracks and updates. */
453
+ export type BinInstaller = Extract<Via, "bun" | "brew" | "npm" | "pip" | "go">;
416
454
 
417
- /** A single binary dependency a plugin (or profile) declares. */
418
- export interface BinRequirement {
419
- /** The executable name expected on PATH (also the dedup key). */
455
+ /**
456
+ * A requirement whose chosen option is one of the {@link BinInstaller} kinds,
457
+ * in the shape `magus update` and the CLI Tools screen track versions in.
458
+ * Derived from a {@link SelectedBin} by `managedTool` (services/toolchain.ts)
459
+ * or built from a catalogue entry; never declared by hand.
460
+ */
461
+ export interface ManagedTool {
462
+ /** The executable name expected on PATH. */
420
463
  name: string;
421
464
  via: BinInstaller;
422
465
  /** Package id for bun/npm/pip (defaults to `name`). */
@@ -425,25 +468,19 @@ export interface BinRequirement {
425
468
  formula?: string;
426
469
  /** Module path for `go install` (defaults to `name`). */
427
470
  module?: string;
428
- /** Command that verifies presence, e.g. "tmux -V" (defaults to `<name> --version`). */
471
+ /** The check, as one display line, e.g. "tmux -V" (defaults to `<name> --version`). */
429
472
  check?: string;
430
473
  /** Optional version pin (real for bun/npm/pip; advisory for brew/go). */
431
474
  version?: string;
432
475
  }
433
476
 
434
- /** The `requires` block a plugin may declare in its plugin.json. */
435
- export interface PluginRequires {
436
- bin?: BinRequirement[];
477
+ /** A {@link ManagedTool} with its provenance. */
478
+ export interface ManagedBin extends ManagedTool {
479
+ sources: string[];
437
480
  }
438
481
 
439
482
  // ─── Resolved install closure (resolver output) ─────────────────────────────────
440
483
 
441
- /** A binary requirement after dedup + pin precedence, with provenance. */
442
- export interface ResolvedBin extends BinRequirement {
443
- /** Where this requirement came from, for diagnostics. */
444
- sources: string[];
445
- }
446
-
447
484
  /**
448
485
  * The fully-resolved set of things to install for a profile (or the union of
449
486
  * all profiles). Produced by the resolver; consumed by `magus install`.
@@ -486,6 +523,13 @@ export interface ResolvedClosure {
486
523
  export interface DependencyReport {
487
524
  /** Enabled only because an enabled plugin depends on them. Sorted. */
488
525
  derived: string[];
526
+ /**
527
+ * For every dependency an enabled plugin declares — derived ones and ones
528
+ * the profile also names — the plugin whose plugin.json first asked for it,
529
+ * so a report can say which plugin still needs it. Optional because a
530
+ * hand-built report may carry no provenance.
531
+ */
532
+ requiredBy?: Record<string, string>;
489
533
  /** Enabled plugins whose plugin.json could not be read here. Sorted. */
490
534
  unreadable: string[];
491
535
  /** A dependency the profile itself turns off: `plugin` will not load. */
package/src/ui/App.tsx CHANGED
@@ -3,8 +3,6 @@ import { activePluginPins } from "../services/active-closure.js";
3
3
  import { isClaudeAvailable } from "../services/claude-cli.js";
4
4
  import {
5
5
  findStaleLoads,
6
- migrateMarketplaceRename,
7
- recoverMarketplaceSettings,
8
6
  repairAllStaleLoads,
9
7
  } from "../services/claude-settings.js";
10
8
  import {
@@ -12,7 +10,6 @@ import {
12
10
  startupGitignoreNeed,
13
11
  } from "../services/gitignore-check.js";
14
12
  import { applyStartupFixes } from "../services/gitignore-service.js";
15
- import { repairAllMarketplaces } from "../services/local-marketplace.js";
16
13
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
17
14
  import {
18
15
  type ProfileStartupNeed,
@@ -24,22 +21,19 @@ import {
24
21
  checkForUpdates,
25
22
  getCurrentVersion,
26
23
  } from "../services/version-check.js";
24
+ import {
25
+ gitignoreStartupReport,
26
+ profileStartupReport,
27
+ staleRepairReport,
28
+ } from "./adapters/startupReports.js";
27
29
  import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
28
30
  import { TABS } from "./components/TabBar.js";
29
31
  import { ProgressBar } from "./components/layout/ProgressBar.js";
30
32
  import { ModalContainer } from "./components/modals/index.js";
31
- import {
32
- startupFixNotices,
33
- useGitignoreModal,
34
- } from "./hooks/useGitignoreModal.js";
35
- import {
36
- activationWarnings,
37
- useProfileStartupModal,
38
- } from "./hooks/useProfileStartupModal.js";
39
- import {
40
- staleLoadRepairNotices,
41
- useStaleLoadModal,
42
- } from "./hooks/useStaleLoadModal.js";
33
+ import { nextStartupStep } from "./hooks/startupQueue.js";
34
+ import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
35
+ import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
36
+ import { useStaleLoadModal } from "./hooks/useStaleLoadModal.js";
43
37
  import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
44
38
  import { type GlobalHandlers, runGlobalBindings } from "./keymap.js";
45
39
  import {
@@ -60,14 +54,12 @@ import {
60
54
  useApp,
61
55
  useModal,
62
56
  useNavigation,
63
- useNotify,
64
57
  } from "./state/AppContext.js";
65
58
  import {
66
59
  DimensionsProvider,
67
60
  useDimensions,
68
61
  } from "./state/DimensionsContext.js";
69
- import type { Notice, Screen } from "./state/types.js";
70
- import { truncate } from "./text-width.js";
62
+ import type { FailureReport, Screen } from "./state/types.js";
71
63
  import { theme } from "./theme.js";
72
64
 
73
65
  export const VERSION = getCurrentVersion();
@@ -185,72 +177,6 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
185
177
  );
186
178
  }
187
179
 
188
- /**
189
- * NoticeBanner Component
190
- * The app's one notice line (or a few): what an action did, from `useNotify`.
191
- * It sits above the screen and outside the modal slot, so it never takes a key
192
- * and never stands in the way of a startup question. Each line clears itself
193
- * at its `expiresAt` — counting only time with no modal open.
194
- *
195
- * One notice is exactly ONE row: the layout reserves one row per notice
196
- * (`transientBannerCount`), so a line that wrapped would push the screen's
197
- * last rows off the terminal. Hence no wrapping, and the text clipped to the
198
- * width — the start of a line says what happened, and that is what survives.
199
- */
200
- function NoticeBanner({ notices }: { notices: readonly Notice[] }) {
201
- const { state, dispatch } = useApp();
202
- const { terminalWidth } = useDimensions();
203
- const modalOpen = state.modal !== null;
204
- const paused = state.noticesPausedAt !== null;
205
-
206
- // Hold the notices while a modal is open. A warning posted just before a
207
- // startup question, or under it, would otherwise run down behind it and be
208
- // gone by the time the user has answered.
209
- useEffect(() => {
210
- dispatch({
211
- type: modalOpen ? "PAUSE_NOTICES" : "RESUME_NOTICES",
212
- now: Date.now(),
213
- });
214
- }, [modalOpen, dispatch]);
215
-
216
- // One timer, for the line that expires first; the reducer then drops every
217
- // line past its time, and the next render schedules the one after. None
218
- // while paused: RESUME_NOTICES moves every `expiresAt` on first.
219
- useEffect(() => {
220
- if (notices.length === 0 || paused) return;
221
- const next = Math.min(...notices.map((n) => n.expiresAt));
222
- const timer = setTimeout(
223
- () => dispatch({ type: "EXPIRE_NOTICES", now: Date.now() }),
224
- Math.max(0, next - Date.now()),
225
- );
226
- return () => clearTimeout(timer);
227
- }, [notices, paused, dispatch]);
228
-
229
- // The box's left and right padding.
230
- const width = Math.max(0, terminalWidth - 2);
231
- return (
232
- <>
233
- {notices.map((notice) => (
234
- <box key={notice.id} paddingLeft={1} paddingRight={1} height={1}>
235
- <text
236
- wrapMode="none"
237
- fg={
238
- notice.tone === "warning"
239
- ? theme.colors.warning
240
- : theme.colors.success
241
- }
242
- >
243
- {truncate(
244
- `${notice.tone === "warning" ? "⚠" : "✓"} ${notice.text}`,
245
- width,
246
- )}
247
- </text>
248
- </box>
249
- ))}
250
- </>
251
- );
252
- }
253
-
254
180
  /**
255
181
  * ProgressIndicator Component
256
182
  * Shows progress bar when operations are running
@@ -282,13 +208,12 @@ interface AppContentInnerProps {
282
208
 
283
209
  function AppContentInner({ updateInfo, onExit }: AppContentInnerProps) {
284
210
  const { state } = useApp();
285
- const { progress, notices } = state;
211
+ const { progress } = state;
286
212
  const dimensions = useDimensions();
287
213
 
288
214
  return (
289
215
  <box flexDirection="column" height={dimensions.terminalHeight}>
290
216
  {updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
291
- <NoticeBanner notices={notices} />
292
217
  {progress && <ProgressIndicator {...progress} />}
293
218
  <box
294
219
  flexDirection="column"
@@ -336,7 +261,6 @@ interface AppContentProps {
336
261
  function AppContent({ onExit }: AppContentProps) {
337
262
  const { state, dispatch } = useApp();
338
263
  const { progress } = state;
339
- const notify = useNotify();
340
264
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
341
265
  const [gitignoreData, setGitignoreData] = useState<QueuedGitignore | null>(
342
266
  null,
@@ -354,6 +278,19 @@ function AppContent({ onExit }: AppContentProps) {
354
278
  const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
355
279
  null,
356
280
  );
281
+ // What startup work could not do — the profile gate's refusals and its
282
+ // activations, a marketplace refresh — waiting for the modal slot, oldest
283
+ // first. Each is the one failure popup; the TUI says nothing else.
284
+ const [startupReports, setStartupReports] = useState<FailureReport[]>([]);
285
+ const queueReports = React.useCallback(
286
+ (...reports: Array<FailureReport | null>) => {
287
+ const present = reports.filter((r): r is FailureReport => r !== null);
288
+ if (present.length > 0) {
289
+ setStartupReports((queued) => [...queued, ...present]);
290
+ }
291
+ },
292
+ [],
293
+ );
357
294
  const gitignoreModal = useGitignoreModal();
358
295
  const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
359
296
  null,
@@ -409,8 +346,8 @@ function AppContent({ onExit }: AppContentProps) {
409
346
  // The hooks keep the slot occupied for their whole flow (a loading modal
410
347
  // while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
411
348
  // what lets the next question in. A flow that succeeds ends by hiding its
412
- // loading modal and reporting through `useNotify`: a notice is not in the
413
- // modal slot, so it cannot hold the next question back or be replaced by it. That does not stop a SCREEN from emptying
349
+ // loading modal and reports nothing: the screen it leaves shows the result.
350
+ // That does not stop a SCREEN from emptying
414
351
  // it: `useModal().confirm` hides its modal when answered and the screen shows
415
352
  // its loading modal a microtask later. A key pressed before the profile check
416
353
  // resolved lands the profile question in exactly that gap, and the screen's
@@ -436,8 +373,32 @@ function AppContent({ onExit }: AppContentProps) {
436
373
  // it and then the fresh one asked the same question twice. The fresh result
437
374
  // replaces the queued one instead (see the check effect below).
438
375
  useEffect(() => {
439
- if (state.modal) return;
440
- if (gitignoreData && !gitignorePending) {
376
+ // The order lives in `nextStartupStep`, where it is tested: failure
377
+ // reports first — something that already happened, and the question
378
+ // after it may be answered differently once it is known — so the profile
379
+ // gate's refusals land before the profile question, which they do not
380
+ // replace; then gitignore, broken plugins, and the profile last.
381
+ const step = nextStartupStep({
382
+ modalOpen: !!state.modal,
383
+ reports: startupReports,
384
+ gitignoreReady: !!gitignoreData && !gitignorePending,
385
+ brokenCount: brokenPluginCount,
386
+ profileNeed,
387
+ });
388
+ if (!step) return;
389
+ if (step.kind === "report") {
390
+ setStartupReports(step.rest);
391
+ dispatch({
392
+ type: "SHOW_MODAL",
393
+ modal: {
394
+ type: "failures",
395
+ report: step.report,
396
+ onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
397
+ },
398
+ });
399
+ return;
400
+ }
401
+ if (step.kind === "gitignore" && gitignoreData) {
441
402
  // Anything older than what is now on screen is dropped on arrival.
442
403
  gitignoreCheck.current.shown = gitignoreData.request;
443
404
  const need = gitignoreData.need;
@@ -447,15 +408,18 @@ function AppContent({ onExit }: AppContentProps) {
447
408
  generated: need.generated,
448
409
  // In place, then back to the app: tracked generated files are
449
410
  // untracked, missing entries added; other items stay for the tab.
411
+ // A file git would not untrack is named with git's reason.
450
412
  onFixSafe: async () =>
451
- startupFixNotices(await applyStartupFixes(state.projectPath)),
413
+ gitignoreStartupReport(
414
+ (await applyStartupFixes(state.projectPath)).failed,
415
+ ),
452
416
  });
453
417
  setGitignoreData(null);
454
418
  return;
455
419
  }
456
- if (brokenPluginCount !== null) {
420
+ if (step.kind === "stale-load") {
457
421
  staleLoadModal.show({
458
- brokenCount: brokenPluginCount,
422
+ brokenCount: step.brokenCount,
459
423
  // Re-surveys before repairing, like the gitignore fixer: the registry
460
424
  // may have moved between the startup scan and the user pressing Enter,
461
425
  // and repairing from the stale count would act on rows that are no
@@ -465,8 +429,10 @@ function AppContent({ onExit }: AppContentProps) {
465
429
  // The pins are re-read with it, from the same helper the survey used: a
466
430
  // repair that capped at a different ceiling than the offer was counted
467
431
  // against would move rows the dialog never mentioned.
432
+ //
433
+ // A repair that did not take is named, with its reason.
468
434
  onFix: async () =>
469
- staleLoadRepairNotices(
435
+ staleRepairReport(
470
436
  await repairAllStaleLoads(
471
437
  state.projectPath,
472
438
  undefined,
@@ -479,10 +445,12 @@ function AppContent({ onExit }: AppContentProps) {
479
445
  }
480
446
  // Not cleared here — see above. Re-called on every empty slot until the
481
447
  // hook reports it answered.
482
- if (profileNeed) profileModal.show(profileNeed);
448
+ if (step.kind === "profile") profileModal.show(step.need);
483
449
  }, [
484
450
  state.modal,
485
451
  state.projectPath,
452
+ dispatch,
453
+ startupReports,
486
454
  gitignoreData,
487
455
  gitignorePending,
488
456
  gitignoreModal,
@@ -572,39 +540,32 @@ function AppContent({ onExit }: AppContentProps) {
572
540
  // edits into the active profile, regenerate from a changed
573
541
  // profiles.json, restore a lost marker — then the question, if one
574
542
  // is left to ask.
575
- const { need, notices, problems, activations } =
576
- await prepareProfileStartup(process.cwd());
577
- // A refusal never blocks the TUI at startup: it is shown beside what
578
- // the gate did, as a warning, and the user is still asked whatever
579
- // is left. What a re-render left to act on — a plugin that will not
580
- // load, one not downloaded yet — is a warning too, in user terms.
581
- for (const line of notices) notify(line);
582
- for (const activation of activations) {
583
- for (const line of activationWarnings(activation)) {
584
- notify(line.text, line.tone);
585
- }
586
- }
587
- for (const line of problems) notify(line, "warning");
543
+ // A refusal never blocks the TUI at startup: the user is still asked
544
+ // whatever is left, and the screens show the state as it is. What
545
+ // the gate refused, and what its re-render could not do, go into ONE
546
+ // failure popup first — one step, one popup — queued in the same
547
+ // tick as the question, so it is shown before it.
548
+ const { need, problems, activations } = await prepareProfileStartup(
549
+ process.cwd(),
550
+ );
551
+ queueReports(profileStartupReport(problems, activations ?? []));
588
552
  if (!needsNothing(need)) setProfileNeed(need);
589
553
  } catch {
590
554
  // non-fatal: the Profiles tab still works by hand
591
555
  }
592
556
  })();
593
- }, [notify]);
557
+ }, [queueReports]);
594
558
 
595
559
  // Auto-refresh marketplaces on startup
596
560
  useEffect(() => {
597
561
  const noRefresh = process.argv.includes("--no-refresh");
598
562
  if (noRefresh) return;
599
563
 
600
- dispatch({
601
- type: "SHOW_PROGRESS",
602
- state: { message: "Scanning marketplaces..." },
603
- });
604
-
605
- // Migrate old marketplace names -> magus (idempotent), then repair plugin.json files
606
- migrateMarketplaceRename().catch(() => {}); // non-blocking, best-effort
607
-
564
+ // Opening magus reads Claude Code's files and never writes them: the
565
+ // marketplace registry, its clones, installed_plugins.json and every
566
+ // settings.json are Claude Code's. The one change made here goes through
567
+ // Claude Code's own CLI.
568
+ //
608
569
  // Auto-add marketplaces referenced by enabledPlugins but missing from
609
570
  // the local registry. Fixes the "fresh clone of a project whose
610
571
  // .claude/settings.json enables @magus plugins but this machine has
@@ -612,68 +573,17 @@ function AppContent({ onExit }: AppContentProps) {
612
573
  (async () => {
613
574
  try {
614
575
  if (!(await isClaudeAvailable())) return;
615
- const added = await autoAddMissingMarketplaces(process.cwd());
616
- if (added.length > 0) {
617
- notify(`Fixed: added marketplace(s): ${added.join(", ")}`);
618
- }
576
+ await autoAddMissingMarketplaces(process.cwd());
619
577
  } catch {
620
578
  // non-fatal: user can still install marketplaces manually
621
579
  }
622
580
  })();
623
-
624
- // Recover stale marketplace registry entries (e.g. "directory" -> "github")
625
- recoverMarketplaceSettings()
626
- .then(async (recovery) => {
627
- const parts: string[] = [];
628
- // Apart from `parts`: a success line would carry them in green,
629
- // and a marketplace left stale is something the user may act on.
630
- const failed: string[] = [];
631
- if (recovery.reregistered.length > 0) {
632
- // Update the marketplace clone now that the source is fixed
633
- const { updateMarketplace } = await import(
634
- "../services/claude-cli.js"
635
- );
636
- for (const mp of recovery.reregistered) {
637
- try {
638
- await updateMarketplace(mp);
639
- parts.push(`${mp} refreshed`);
640
- } catch {
641
- failed.push(mp);
642
- }
643
- }
644
- }
645
- if (recovery.removed.length > 0) {
646
- parts.push(`removed: ${recovery.removed.join(", ")}`);
647
- }
648
- if (parts.length > 0) {
649
- notify(`Fixed: ${parts.join(" | ")}`);
650
- }
651
- if (failed.length > 0) {
652
- notify(
653
- `Could not refresh marketplace(s): ${failed.join(", ")}`,
654
- "warning",
655
- );
656
- }
657
- })
658
- .catch(() => {}); // non-fatal
659
-
660
- repairAllMarketplaces()
661
- .then(async () => {
662
- dispatch({ type: "HIDE_PROGRESS" });
663
- dispatch({ type: "DATA_REFRESH_COMPLETE" });
664
- })
665
- .catch(() => {
666
- dispatch({ type: "HIDE_PROGRESS" });
667
- });
668
- }, [dispatch, notify]);
581
+ }, []);
669
582
 
670
583
  return (
671
584
  <DimensionsProvider
672
585
  showProgress={!!progress}
673
586
  showUpdateBanner={!!updateInfo?.updateAvailable}
674
- // One row per notice line, so the screen below shrinks rather than
675
- // being pushed off the bottom of the terminal.
676
- transientBannerCount={state.notices.length}
677
587
  >
678
588
  <AppContentInner updateInfo={updateInfo} onExit={onExit} />
679
589
  </DimensionsProvider>