magus-cli 7.6.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +9 -34
  16. package/src/__tests__/gitignore-profile.test.ts +0 -4
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -42
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/gitignore-service.ts +0 -19
  36. package/src/services/plugin-env-needs.ts +69 -0
  37. package/src/services/plugin-manager.ts +31 -19
  38. package/src/services/plugin-resolution.ts +34 -42
  39. package/src/services/profile-absorb.ts +9 -0
  40. package/src/services/profile-activate.ts +1 -1
  41. package/src/services/profile-startup.ts +30 -27
  42. package/src/services/profile-state.ts +81 -2
  43. package/src/services/resolver.ts +95 -1
  44. package/src/services/update-engine.ts +326 -31
  45. package/src/services/update-plan.ts +293 -33
  46. package/src/types/index.ts +7 -0
  47. package/src/ui/App.tsx +186 -123
  48. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  49. package/src/ui/adapters/failureReport.ts +137 -0
  50. package/src/ui/adapters/pluginsAdapter.ts +299 -28
  51. package/src/ui/adapters/startupReports.ts +282 -0
  52. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  53. package/src/ui/components/modals/MessageModal.tsx +7 -13
  54. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  55. package/src/ui/components/modals/index.ts +1 -0
  56. package/src/ui/hooks/startupQueue.ts +56 -0
  57. package/src/ui/hooks/useGitignoreModal.ts +31 -31
  58. package/src/ui/hooks/useProfileStartupModal.ts +127 -122
  59. package/src/ui/hooks/useStaleLoadModal.ts +33 -53
  60. package/src/ui/keymap.ts +14 -1
  61. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  62. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  63. package/src/ui/renderers/pluginRowText.ts +25 -25
  64. package/src/ui/renderers/pluginStatusText.ts +18 -1
  65. package/src/ui/screens/AliasScreen.tsx +38 -44
  66. package/src/ui/screens/CliToolsScreen.tsx +65 -51
  67. package/src/ui/screens/EnvVarsScreen.tsx +21 -6
  68. package/src/ui/screens/GitignoreScreen.tsx +49 -75
  69. package/src/ui/screens/McpRegistryScreen.tsx +72 -40
  70. package/src/ui/screens/McpScreen.tsx +55 -24
  71. package/src/ui/screens/ModelsScreen.tsx +46 -86
  72. package/src/ui/screens/PluginsScreen.tsx +316 -245
  73. package/src/ui/screens/ProfilesScreen.tsx +88 -73
  74. package/src/ui/screens/SkillsScreen.tsx +83 -80
  75. package/src/ui/screens/StylesScreen.tsx +211 -275
  76. package/src/ui/state/AppContext.tsx +72 -7
  77. package/src/ui/state/DimensionsContext.tsx +3 -22
  78. package/src/ui/state/reducer.ts +25 -14
  79. package/src/ui/state/types.ts +61 -31
  80. package/src/utils/user-path.ts +60 -0
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
package/src/ui/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from "react";
1
+ import React, { useEffect, useRef, useState } from "react";
2
2
  import { activePluginPins } from "../services/active-closure.js";
3
3
  import { isClaudeAvailable } from "../services/claude-cli.js";
4
4
  import {
@@ -11,10 +11,7 @@ import {
11
11
  type StartupGitignoreNeed,
12
12
  startupGitignoreNeed,
13
13
  } from "../services/gitignore-check.js";
14
- import {
15
- applyStartupFixes,
16
- describeStartupFix,
17
- } from "../services/gitignore-service.js";
14
+ import { applyStartupFixes } from "../services/gitignore-service.js";
18
15
  import { repairAllMarketplaces } from "../services/local-marketplace.js";
19
16
  import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
20
17
  import {
@@ -27,16 +24,20 @@ import {
27
24
  checkForUpdates,
28
25
  getCurrentVersion,
29
26
  } from "../services/version-check.js";
27
+ import {
28
+ gitignoreStartupReport,
29
+ marketplaceRefreshReport,
30
+ profileStartupReport,
31
+ staleRepairReport,
32
+ } from "./adapters/startupReports.js";
30
33
  import { ScreenErrorBoundary } from "./components/ScreenErrorBoundary.js";
31
34
  import { TABS } from "./components/TabBar.js";
32
35
  import { ProgressBar } from "./components/layout/ProgressBar.js";
33
36
  import { ModalContainer } from "./components/modals/index.js";
37
+ import { nextStartupStep } from "./hooks/startupQueue.js";
34
38
  import { useGitignoreModal } from "./hooks/useGitignoreModal.js";
35
39
  import { useProfileStartupModal } from "./hooks/useProfileStartupModal.js";
36
- import {
37
- staleLoadRepairSummary,
38
- useStaleLoadModal,
39
- } from "./hooks/useStaleLoadModal.js";
40
+ import { useStaleLoadModal } from "./hooks/useStaleLoadModal.js";
40
41
  import { KeyRouter, useKeyLayer } from "./input/KeyRouter.js";
41
42
  import { type GlobalHandlers, runGlobalBindings } from "./keymap.js";
42
43
  import {
@@ -62,7 +63,7 @@ import {
62
63
  DimensionsProvider,
63
64
  useDimensions,
64
65
  } from "./state/DimensionsContext.js";
65
- import type { Screen } from "./state/types.js";
66
+ import type { FailureReport, Screen } from "./state/types.js";
66
67
  import { theme } from "./theme.js";
67
68
 
68
69
  export const VERSION = getCurrentVersion();
@@ -207,17 +208,9 @@ function ProgressIndicator({
207
208
  interface AppContentInnerProps {
208
209
  updateInfo: VersionCheckResult | null;
209
210
  onExit: () => void;
210
- recoveryReport: string | null;
211
- /** What the startup profile gate did: absorbed edits, regeneration, repairs. */
212
- profileNotices: string[];
213
211
  }
214
212
 
215
- function AppContentInner({
216
- updateInfo,
217
- onExit,
218
- recoveryReport,
219
- profileNotices,
220
- }: AppContentInnerProps) {
213
+ function AppContentInner({ updateInfo, onExit }: AppContentInnerProps) {
221
214
  const { state } = useApp();
222
215
  const { progress } = state;
223
216
  const dimensions = useDimensions();
@@ -225,16 +218,6 @@ function AppContentInner({
225
218
  return (
226
219
  <box flexDirection="column" height={dimensions.terminalHeight}>
227
220
  {updateInfo?.updateAvailable && <UpdateBanner result={updateInfo} />}
228
- {recoveryReport && (
229
- <box paddingLeft={1} paddingRight={1}>
230
- <text fg={theme.colors.success}>✓ Fixed: {recoveryReport}</text>
231
- </box>
232
- )}
233
- {profileNotices.map((line) => (
234
- <box key={line} paddingLeft={1} paddingRight={1}>
235
- <text fg={theme.colors.success}>• {line}</text>
236
- </box>
237
- ))}
238
221
  {progress && <ProgressIndicator {...progress} />}
239
222
  <box
240
223
  flexDirection="column"
@@ -250,6 +233,27 @@ function AppContentInner({
250
233
  );
251
234
  }
252
235
 
236
+ /** A gitignore check's answer, waiting for the modal slot. */
237
+ interface QueuedGitignore {
238
+ need: StartupGitignoreNeed;
239
+ /** The `gitignoreCheckRequest` it answers. */
240
+ request: number;
241
+ }
242
+
243
+ /**
244
+ * The gitignore check's bookkeeping. A ref, not state: a landing check reads
245
+ * the latest request and what was last shown at that moment, not as of the
246
+ * render that started it.
247
+ */
248
+ interface GitignoreCheck {
249
+ /** A check is running. There is never more than one. */
250
+ inFlight: boolean;
251
+ /** The newest `gitignoreCheckRequest` seen. */
252
+ latest: number;
253
+ /** The request whose answer was last shown as a question; -1 for none. */
254
+ shown: number;
255
+ }
256
+
253
257
  /**
254
258
  * AppContent Component
255
259
  * Wraps app with DimensionsProvider and manages state for debug/updates
@@ -262,38 +266,55 @@ function AppContent({ onExit }: AppContentProps) {
262
266
  const { state, dispatch } = useApp();
263
267
  const { progress } = state;
264
268
  const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
265
- const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
266
- const [profileNotices, setProfileNotices] = useState<string[]>([]);
267
- const [gitignoreData, setGitignoreData] =
268
- useState<StartupGitignoreNeed | null>(null);
269
+ const [gitignoreData, setGitignoreData] = useState<QueuedGitignore | null>(
270
+ null,
271
+ );
272
+ // The last request whose check has landed. Behind the latest request, a
273
+ // check is pending, and `gitignoreData` — if any — is an answer to an
274
+ // earlier question about a project that has since changed.
275
+ const [gitignoreSettled, setGitignoreSettled] = useState(-1);
276
+ const gitignorePending = gitignoreSettled < state.gitignoreCheckRequest;
277
+ const gitignoreCheck = useRef<GitignoreCheck>({
278
+ inFlight: false,
279
+ latest: 0,
280
+ shown: -1,
281
+ });
269
282
  const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
270
283
  null,
271
284
  );
285
+ // What startup work could not do — the profile gate's refusals and its
286
+ // activations, a marketplace refresh — waiting for the modal slot, oldest
287
+ // first. Each is the one failure popup; the TUI says nothing else.
288
+ const [startupReports, setStartupReports] = useState<FailureReport[]>([]);
289
+ const queueReports = React.useCallback(
290
+ (...reports: Array<FailureReport | null>) => {
291
+ const present = reports.filter((r): r is FailureReport => r !== null);
292
+ if (present.length > 0) {
293
+ setStartupReports((queued) => [...queued, ...present]);
294
+ }
295
+ },
296
+ [],
297
+ );
272
298
  const gitignoreModal = useGitignoreModal();
273
299
  const [brokenPluginCount, setBrokenPluginCount] = useState<number | null>(
274
300
  null,
275
301
  );
276
302
  const staleLoadModal = useStaleLoadModal();
277
- // The gitignore question, asked fresh: on every open, and again once the
278
- // profile modal has activated a profile — a first adoption is what turns a
279
- // committed settings.json into a generated file git should not track.
280
- // Nothing remembers an earlier answer.
281
- const checkGitignoreNow = React.useCallback(() => {
282
- startupGitignoreNeed(process.cwd())
283
- .then((need) => {
284
- if (need) setGitignoreData(need);
285
- })
286
- .catch(() => {
287
- // non-fatal: the Git State tab still shows the same rows
288
- });
289
- }, []);
290
303
  // Stable, so `profileModal.show` is not rebuilt for it on every render.
291
304
  const profileAnswered = React.useCallback(() => setProfileNeed(null), []);
305
+ // Activating a profile changes which plugins are on, so the open screen must
306
+ // reload. The result modal that used to follow sent the user to the Profiles
307
+ // tab, which remounted it; without that modal the user stays where they are,
308
+ // and the screen under them would keep showing the pre-activation state.
309
+ // The same action re-runs the gitignore check — see the effect below.
310
+ const profileActivated = React.useCallback(() => {
311
+ dispatch({ type: "PROFILE_ACTIVATED" });
312
+ }, [dispatch]);
292
313
  // The profile modal cannot be dismissed, so it needs the app's own exit: its
293
314
  // only non-action is to leave.
294
315
  const profileModal = useProfileStartupModal({
295
316
  onQuit: onExit,
296
- onActivated: checkGitignoreNow,
317
+ onActivated: profileActivated,
297
318
  onAnswered: profileAnswered,
298
319
  });
299
320
 
@@ -315,21 +336,6 @@ function AppContent({ onExit }: AppContentProps) {
315
336
  return () => clearInterval(id);
316
337
  }, []);
317
338
 
318
- // Auto-dismiss the profile gate's notices after 10 seconds — long enough to
319
- // read a line per absorbed change.
320
- useEffect(() => {
321
- if (profileNotices.length === 0) return;
322
- const timer = setTimeout(() => setProfileNotices([]), 10000);
323
- return () => clearTimeout(timer);
324
- }, [profileNotices]);
325
-
326
- // Auto-dismiss recovery banner after 5 seconds
327
- useEffect(() => {
328
- if (!recoveryReport) return;
329
- const timer = setTimeout(() => setRecoveryReport(null), 5000);
330
- return () => clearTimeout(timer);
331
- }, [recoveryReport]);
332
-
333
339
  // The startup questions — gitignore, then broken plugins, then profile — are
334
340
  // asked ONE at a time, from ONE effect, and only while the modal slot is free.
335
341
  //
@@ -339,12 +345,13 @@ function AppContent({ onExit }: AppContentProps) {
339
345
  // need had already been cleared — so it never came back. The profile question
340
346
  // was lost exactly that way, and the user updated plugins believing a profile
341
347
  // was active. So: show at most one per run and return; the next question
342
- // follows once that one's last modal is dismissed and the slot is empty
343
- // again.
348
+ // follows once that one's flow has ended and the slot is empty again.
344
349
  //
345
350
  // The hooks keep the slot occupied for their whole flow (a loading modal
346
351
  // while their fix runs, never HIDE_MODAL mid-flow), because an empty slot is
347
- // what lets the next question in. That does not stop a SCREEN from emptying
352
+ // what lets the next question in. A flow that succeeds ends by hiding its
353
+ // loading modal and reports nothing: the screen it leaves shows the result.
354
+ // That does not stop a SCREEN from emptying
348
355
  // it: `useModal().confirm` hides its modal when answered and the screen shows
349
356
  // its loading modal a microtask later. A key pressed before the profile check
350
357
  // resolved lands the profile question in exactly that gap, and the screen's
@@ -363,24 +370,60 @@ function AppContent({ onExit }: AppContentProps) {
363
370
  // re-showing would re-ask a question the user just declined. Losing one
364
371
  // costs nothing lasting: the Git State and Plugins tabs show the same rows,
365
372
  // and gitignore is asked again after a profile activates.
373
+ //
374
+ // The gitignore question also waits while a check is pending. An activation
375
+ // both empties the slot and asks for a fresh check, so the slot is free
376
+ // while an answer from BEFORE the activation may still be queued: showing
377
+ // it and then the fresh one asked the same question twice. The fresh result
378
+ // replaces the queued one instead (see the check effect below).
366
379
  useEffect(() => {
367
- if (state.modal) return;
368
- if (gitignoreData) {
380
+ // The order lives in `nextStartupStep`, where it is tested: failure
381
+ // reports first — something that already happened, and the question
382
+ // after it may be answered differently once it is known — so the profile
383
+ // gate's refusals land before the profile question, which they do not
384
+ // replace; then gitignore, broken plugins, and the profile last.
385
+ const step = nextStartupStep({
386
+ modalOpen: !!state.modal,
387
+ reports: startupReports,
388
+ gitignoreReady: !!gitignoreData && !gitignorePending,
389
+ brokenCount: brokenPluginCount,
390
+ profileNeed,
391
+ });
392
+ if (!step) return;
393
+ if (step.kind === "report") {
394
+ setStartupReports(step.rest);
395
+ dispatch({
396
+ type: "SHOW_MODAL",
397
+ modal: {
398
+ type: "failures",
399
+ report: step.report,
400
+ onDismiss: () => dispatch({ type: "HIDE_MODAL" }),
401
+ },
402
+ });
403
+ return;
404
+ }
405
+ if (step.kind === "gitignore" && gitignoreData) {
406
+ // Anything older than what is now on screen is dropped on arrival.
407
+ gitignoreCheck.current.shown = gitignoreData.request;
408
+ const need = gitignoreData.need;
369
409
  gitignoreModal.show({
370
- violationCount: gitignoreData.violationCount,
371
- safeCount: gitignoreData.safeCount,
372
- generated: gitignoreData.generated,
410
+ violationCount: need.violationCount,
411
+ safeCount: need.safeCount,
412
+ generated: need.generated,
373
413
  // In place, then back to the app: tracked generated files are
374
414
  // untracked, missing entries added; other items stay for the tab.
415
+ // A file git would not untrack is named with git's reason.
375
416
  onFixSafe: async () =>
376
- describeStartupFix(await applyStartupFixes(state.projectPath)),
417
+ gitignoreStartupReport(
418
+ (await applyStartupFixes(state.projectPath)).failed,
419
+ ),
377
420
  });
378
421
  setGitignoreData(null);
379
422
  return;
380
423
  }
381
- if (brokenPluginCount !== null) {
424
+ if (step.kind === "stale-load") {
382
425
  staleLoadModal.show({
383
- brokenCount: brokenPluginCount,
426
+ brokenCount: step.brokenCount,
384
427
  // Re-surveys before repairing, like the gitignore fixer: the registry
385
428
  // may have moved between the startup scan and the user pressing Enter,
386
429
  // and repairing from the stale count would act on rows that are no
@@ -390,8 +433,10 @@ function AppContent({ onExit }: AppContentProps) {
390
433
  // The pins are re-read with it, from the same helper the survey used: a
391
434
  // repair that capped at a different ceiling than the offer was counted
392
435
  // against would move rows the dialog never mentioned.
436
+ //
437
+ // A repair that did not take is named, with its reason.
393
438
  onFix: async () =>
394
- staleLoadRepairSummary(
439
+ staleRepairReport(
395
440
  await repairAllStaleLoads(
396
441
  state.projectPath,
397
442
  undefined,
@@ -404,11 +449,14 @@ function AppContent({ onExit }: AppContentProps) {
404
449
  }
405
450
  // Not cleared here — see above. Re-called on every empty slot until the
406
451
  // hook reports it answered.
407
- if (profileNeed) profileModal.show(profileNeed);
452
+ if (step.kind === "profile") profileModal.show(step.need);
408
453
  }, [
409
454
  state.modal,
410
455
  state.projectPath,
456
+ dispatch,
457
+ startupReports,
411
458
  gitignoreData,
459
+ gitignorePending,
412
460
  gitignoreModal,
413
461
  brokenPluginCount,
414
462
  staleLoadModal,
@@ -439,12 +487,46 @@ function AppContent({ onExit }: AppContentProps) {
439
487
  })();
440
488
  }, []);
441
489
 
442
- // The gitignore question on every open. Its own effect, NOT part of the
443
- // marketplace block below, which returns early on `--no-refresh`: that flag
444
- // is about network calls and must not silence this question.
490
+ // The gitignore question, asked fresh: on every open (request 0), and again
491
+ // on each PROFILE_ACTIVATED — a first adoption is what turns a committed
492
+ // settings.json into a generated file git should not track, and a
493
+ // Profiles-tab switch can do the same. Nothing remembers an earlier answer.
494
+ //
495
+ // At most ONE check runs at a time. A request that arrives while one runs
496
+ // is not started beside it: the running check, on landing, sees that it is
497
+ // no longer the latest, throws its answer away — it describes the project
498
+ // before whatever prompted the request — and runs again. Only the latest
499
+ // request's answer is kept, and it REPLACES anything still queued, including
500
+ // with nothing when the project no longer needs the question.
501
+ //
502
+ // Its own effect, NOT part of the marketplace block below, which returns
503
+ // early on `--no-refresh`: that flag is about network calls and must not
504
+ // silence this question.
445
505
  useEffect(() => {
446
- checkGitignoreNow();
447
- }, [checkGitignoreNow]);
506
+ const check = gitignoreCheck.current;
507
+ check.latest = state.gitignoreCheckRequest;
508
+ if (check.inFlight) return;
509
+ const run = (request: number): void => {
510
+ check.inFlight = true;
511
+ startupGitignoreNeed(process.cwd())
512
+ // non-fatal: the Git State tab still shows the same rows
513
+ .catch(() => null)
514
+ .then((need) => {
515
+ if (request !== check.latest) {
516
+ run(check.latest);
517
+ return;
518
+ }
519
+ check.inFlight = false;
520
+ setGitignoreSettled(request);
521
+ // Defence in depth: an answer to a request no newer than the
522
+ // question last shown predates the user's reply to it.
523
+ setGitignoreData(
524
+ need && request > check.shown ? { need, request } : null,
525
+ );
526
+ });
527
+ };
528
+ run(check.latest);
529
+ }, [state.gitignoreCheckRequest]);
448
530
 
449
531
  // Is this project actually running a profile? Detection only — nothing is
450
532
  // written until the user picks something in the modal. The Profiles tab could
@@ -462,19 +544,21 @@ function AppContent({ onExit }: AppContentProps) {
462
544
  // edits into the active profile, regenerate from a changed
463
545
  // profiles.json, restore a lost marker — then the question, if one
464
546
  // is left to ask.
465
- const { need, notices, problems } = await prepareProfileStartup(
547
+ // A refusal never blocks the TUI at startup: the user is still asked
548
+ // whatever is left, and the screens show the state as it is. What
549
+ // the gate refused, and what its re-render could not do, go into ONE
550
+ // failure popup first — one step, one popup — queued in the same
551
+ // tick as the question, so it is shown before it.
552
+ const { need, problems, activations } = await prepareProfileStartup(
466
553
  process.cwd(),
467
554
  );
468
- // A refusal never blocks the TUI at startup: it is shown beside what
469
- // the gate did, and the user is still asked whatever is left.
470
- const shown = [...notices, ...problems];
471
- if (shown.length > 0) setProfileNotices(shown);
555
+ queueReports(profileStartupReport(problems, activations ?? []));
472
556
  if (!needsNothing(need)) setProfileNeed(need);
473
557
  } catch {
474
558
  // non-fatal: the Profiles tab still works by hand
475
559
  }
476
560
  })();
477
- }, []);
561
+ }, [queueReports]);
478
562
 
479
563
  // Auto-refresh marketplaces on startup
480
564
  useEffect(() => {
@@ -496,41 +580,29 @@ function AppContent({ onExit }: AppContentProps) {
496
580
  (async () => {
497
581
  try {
498
582
  if (!(await isClaudeAvailable())) return;
499
- const added = await autoAddMissingMarketplaces(process.cwd());
500
- if (added.length > 0) {
501
- setRecoveryReport(`added marketplace(s): ${added.join(", ")}`);
502
- }
583
+ await autoAddMissingMarketplaces(process.cwd());
503
584
  } catch {
504
585
  // non-fatal: user can still install marketplaces manually
505
586
  }
506
587
  })();
507
588
 
508
- // Recover stale marketplace registry entries (e.g. "directory" -> "github")
589
+ // Recover stale marketplace registry entries (e.g. "directory" -> "github"),
590
+ // then refresh the clones whose source that fixed. A refresh that fails
591
+ // leaves that marketplace's plugins read from an old copy, so it is named
592
+ // in the failure popup with why.
509
593
  recoverMarketplaceSettings()
510
594
  .then(async (recovery) => {
511
- const parts: string[] = [];
512
- if (recovery.reregistered.length > 0) {
513
- // Update the marketplace clone now that the source is fixed
514
- const { updateMarketplace } = await import(
515
- "../services/claude-cli.js"
516
- );
517
- for (const mp of recovery.reregistered) {
518
- try {
519
- await updateMarketplace(mp);
520
- parts.push(`${mp} refreshed`);
521
- } catch {
522
- parts.push(`${mp} (update failed)`);
523
- }
524
- }
525
- }
526
- if (recovery.removed.length > 0) {
527
- parts.push(`removed: ${recovery.removed.join(", ")}`);
528
- }
529
- if (parts.length > 0) {
530
- setRecoveryReport(parts.join(" | "));
595
+ if (recovery.reregistered.length === 0) return;
596
+ const { updateMarketplace } = await import("../services/claude-cli.js");
597
+ const failed: Array<{ marketplace: string; error: unknown }> = [];
598
+ for (const mp of recovery.reregistered) {
599
+ await updateMarketplace(mp).catch((error: unknown) => {
600
+ failed.push({ marketplace: mp, error });
601
+ });
531
602
  }
603
+ queueReports(marketplaceRefreshReport(failed));
532
604
  })
533
- .catch(() => {}); // non-fatal
605
+ .catch(() => {}); // the recovery itself: non-fatal
534
606
 
535
607
  repairAllMarketplaces()
536
608
  .then(async () => {
@@ -540,23 +612,14 @@ function AppContent({ onExit }: AppContentProps) {
540
612
  .catch(() => {
541
613
  dispatch({ type: "HIDE_PROGRESS" });
542
614
  });
543
- }, [dispatch]);
544
-
545
- // Count transient banners for dimension calculation
546
- const transientBannerCount = recoveryReport ? 1 : 0;
615
+ }, [dispatch, queueReports]);
547
616
 
548
617
  return (
549
618
  <DimensionsProvider
550
619
  showProgress={!!progress}
551
620
  showUpdateBanner={!!updateInfo?.updateAvailable}
552
- transientBannerCount={transientBannerCount}
553
621
  >
554
- <AppContentInner
555
- updateInfo={updateInfo}
556
- onExit={onExit}
557
- recoveryReport={recoveryReport}
558
- profileNotices={profileNotices}
559
- />
622
+ <AppContentInner updateInfo={updateInfo} onExit={onExit} />
560
623
  </DimensionsProvider>
561
624
  );
562
625
  }