magus-cli 7.7.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -39,6 +39,7 @@ import {
39
39
  installedScopeVersions,
40
40
  isBehind,
41
41
  isInstalledAnywhere,
42
+ isInstalledInScope,
42
43
  isRepairablyBehind,
43
44
  } from "./plugin-manager.js";
44
45
  import {
@@ -77,6 +78,17 @@ export type PluginUpdateAction =
77
78
  * every row, whichever scope or project each belongs to.
78
79
  */
79
80
  | "stale-load"
81
+ /**
82
+ * DEPRECATED: a marketplace we READ does not list it. Claude Code refuses to
83
+ * load such a plugin ("Plugin X not found in marketplace Y" in its debug log,
84
+ * measured on 2.1.284), so it is dead weight and removing it loses nothing.
85
+ *
86
+ * Applied by uninstalling at every scope in `scopes` and dropping it from the
87
+ * profile, so the next activation does not add it back. `scopes` is empty for
88
+ * a plugin the profile declares and nothing installed: the profile edit is
89
+ * then the whole of the work.
90
+ */
91
+ | "remove"
80
92
  /** The catalog check failed; we cannot claim either way. */
81
93
  | "unknown";
82
94
 
@@ -89,8 +101,19 @@ export type PluginReason =
89
101
  | "absent"
90
102
  /** install: installed at a scope, but no version resolves at any installed scope. */
91
103
  | "files-missing"
92
- /** unknown: a marketplace we READ does not list it. */
93
- | "not-offered"
104
+ /** remove: a marketplace we READ does not list it — no longer published. */
105
+ | "deprecated"
106
+ /**
107
+ * unknown: its marketplace no longer lists it, but another marketplace we
108
+ * read lists the same name — it moved, and removing it would lose it.
109
+ */
110
+ | "moved"
111
+ /**
112
+ * unknown: no longer published, but it is in the plan only because another
113
+ * plugin the profile turns on requires it. Removing it would not stick: the
114
+ * next resolve derives it again.
115
+ */
116
+ | "still-required"
94
117
  /** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
95
118
  | "marketplace-unread"
96
119
  /** unknown: the catalog fetch failed ("latest" pins only). */
@@ -243,6 +266,11 @@ export interface CatalogIndex {
243
266
  isListed(pluginId: string): boolean;
244
267
  /** Some OTHER listed id shares this id's "@marketplace" suffix. False for ids without "@". */
245
268
  marketplaceIsKnown(pluginId: string): boolean;
269
+ /**
270
+ * A LISTED plugin with the same name in a DIFFERENT marketplace, or
271
+ * undefined. The first in catalog order when several do.
272
+ */
273
+ listedElsewhere(pluginId: string): PluginInfo | undefined;
246
274
  }
247
275
 
248
276
  export function indexCatalog(
@@ -269,6 +297,16 @@ export function indexCatalog(
269
297
  return !!info && !info.isOrphaned;
270
298
  };
271
299
 
300
+ // Listed plugins by bare name, for "did it move?".
301
+ const listedByName = new Map<string, PluginInfo[]>();
302
+ for (const [id, info] of byId) {
303
+ if (info.isOrphaned) continue;
304
+ const name = bareName(id);
305
+ const same = listedByName.get(name);
306
+ if (same) same.push(info);
307
+ else listedByName.set(name, [info]);
308
+ }
309
+
272
310
  return {
273
311
  get: (id) => byId.get(id),
274
312
  isListed,
@@ -279,23 +317,223 @@ export function indexCatalog(
279
317
  (listedPerSuffix.get(suffix) ?? 0) - (isListed(id) ? 1 : 0);
280
318
  return others > 0;
281
319
  },
320
+ listedElsewhere: (id) => {
321
+ const suffix = marketplaceSuffix(id);
322
+ if (!suffix) return undefined;
323
+ return listedByName
324
+ .get(bareName(id))
325
+ ?.find((info) => marketplaceSuffix(info.id) !== suffix);
326
+ },
327
+ };
328
+ }
329
+
330
+ /** An id's name part: `kanban@magus` → `kanban`. */
331
+ function bareName(id: string): string {
332
+ const at = id.indexOf("@");
333
+ return at > 0 ? id.slice(0, at) : id;
334
+ }
335
+
336
+ const ALL_SCOPES: readonly PluginScope[] = ["user", "project", "local"];
337
+
338
+ /**
339
+ * Every scope the plugin is installed at here — files missing included, since
340
+ * an install with no version is still an install Claude Code tries to load.
341
+ */
342
+ function installedScopes(info: PluginInfo | undefined): PluginScope[] {
343
+ const byScope: Record<PluginScope, PluginInfo["userScope"]> = {
344
+ user: info?.userScope,
345
+ project: info?.projectScope,
346
+ local: info?.localScope,
347
+ };
348
+ return ALL_SCOPES.filter((scope) => isInstalledInScope(byScope[scope]));
349
+ }
350
+
351
+ /** The fields every classification carries, whatever its action. */
352
+ type ItemBase = Omit<
353
+ PluginUpdateItem,
354
+ "target" | "action" | "reason" | "scopes" | "note"
355
+ >;
356
+
357
+ /**
358
+ * Plugin id → the plugin(s) whose plugin.json requires it — `DependencyReport
359
+ * .requiredBy` for the profile's plugins, merged with the same edges read from
360
+ * every installed plugin (`requirersHere`). Every edge counts, a plugin the
361
+ * profile also names included.
362
+ *
363
+ * A single id is one requirer; a list is every requirer known. The list is
364
+ * what lets {@link planPluginUpdates} remove a deprecated plugin whose only
365
+ * requirers are being removed too, and keep one that anything else needs.
366
+ */
367
+ export type RequiredBy = Readonly<Record<string, string | readonly string[]>>;
368
+
369
+ /** Every plugin `requiredBy` records as requiring `pluginId`, first first. */
370
+ export function requirersOf(
371
+ requiredBy: RequiredBy,
372
+ pluginId: string,
373
+ ): string[] {
374
+ if (!Object.hasOwn(requiredBy, pluginId)) return [];
375
+ const value = requiredBy[pluginId];
376
+ if (value === undefined) return [];
377
+ return typeof value === "string" ? [value] : [...value];
378
+ }
379
+
380
+ /**
381
+ * Kept, not removed, although its marketplace no longer lists it: it MOVED to
382
+ * another marketplace, or another plugin STILL REQUIRES it. Both are facts,
383
+ * not a failed check — the CLI lists them under Deprecated as "kept" and never
384
+ * counts them as "could not be checked"; the TUI shows the note in a muted
385
+ * tone, not the amber "?" of a real unknown.
386
+ */
387
+ export function isKeptDeprecated(item: PluginUpdateItem): boolean {
388
+ return (
389
+ item.action === "unknown" &&
390
+ (item.reason === "moved" || item.reason === "still-required")
391
+ );
392
+ }
393
+
394
+ /**
395
+ * No longer published: a marketplace we read does not list it. Removed at
396
+ * every scope it is installed at here, and from the profile — unless removing
397
+ * it would lose something or would not stick, which are both `unknown` and
398
+ * never applied:
399
+ *
400
+ * - MOVED: another marketplace we read lists the same name. The plugin still
401
+ * exists; uninstalling this copy is not the user's intent, and installing
402
+ * the other one is not magus's guess to make.
403
+ * - STILL REQUIRED: another plugin — one the profile turns on, or one
404
+ * installed here — requires this one. Removing it breaks that plugin, and
405
+ * when the profile never named it the next resolve derives it again, so
406
+ * `--check` would never clear. That holds whether or not the profile also
407
+ * names it.
408
+ *
409
+ * The notes are in the user's terms because they are the TUI's row text
410
+ * verbatim. Which marketplace stopped publishing it is the detail panel's to
411
+ * say, from the plugin's own display name.
412
+ */
413
+ function notPublished(
414
+ base: ItemBase,
415
+ info: PluginInfo | undefined,
416
+ index: CatalogIndex,
417
+ requiredBy: RequiredBy,
418
+ ): PluginUpdateItem {
419
+ const unknown = (reason: PluginReason, note: string): PluginUpdateItem => ({
420
+ ...base,
421
+ target: null,
422
+ action: "unknown",
423
+ reason,
424
+ scopes: [],
425
+ note,
426
+ });
427
+ const elsewhere = index.listedElsewhere(base.pluginId);
428
+ if (elsewhere) {
429
+ return unknown(
430
+ "moved",
431
+ `moved to ${elsewhere.marketplaceDisplay || elsewhere.marketplace}`,
432
+ );
433
+ }
434
+ const [requirer] = requirersOf(requiredBy, base.pluginId);
435
+ if (requirer) {
436
+ return unknown("still-required", stillRequiredNote(requirer));
437
+ }
438
+ return deprecatedRemove(base, info);
439
+ }
440
+
441
+ /** The kept row's words: which plugin still needs it. */
442
+ function stillRequiredNote(requirer: string): string {
443
+ return `no longer published, but ${bareName(requirer)} still requires it`;
444
+ }
445
+
446
+ /** A deprecated plugin, removed at every scope it is installed at here. */
447
+ function deprecatedRemove(
448
+ base: ItemBase,
449
+ info: PluginInfo | undefined,
450
+ ): PluginUpdateItem {
451
+ return {
452
+ ...base,
453
+ target: null,
454
+ action: "remove",
455
+ reason: "deprecated",
456
+ scopes: installedScopes(info),
457
+ note: "no longer published",
282
458
  };
283
459
  }
284
460
 
461
+ /**
462
+ * Deprecated plugins that require EACH OTHER are removed together.
463
+ *
464
+ * {@link classifyPlugin} judges one plugin at a time, so any recorded
465
+ * requirer keeps a deprecated plugin. That is right when the requirer stays
466
+ * and wrong when it is being removed too: a chain A → B of retired plugins
467
+ * removed A and kept B "because A still requires it", and a cycle A ↔ B kept
468
+ * both forever. Either way `magus update --check` right after `magus update`
469
+ * still reported work.
470
+ *
471
+ * So the removal set is settled as a whole. Start from every no-longer-
472
+ * published plugin that was not moved, and take out each one something OUTSIDE
473
+ * the set requires, until nothing changes. What is left — nobody who stays
474
+ * needs any of it — is removed in one pass. What was taken out stays, and its
475
+ * note names a plugin that genuinely stays and needs it.
476
+ *
477
+ * `moved` is never in the set: the plugin still exists elsewhere, and a
478
+ * plugin that requires it is a reason to keep, not to remove.
479
+ */
480
+ function settleDeprecated(
481
+ items: readonly PluginUpdateItem[],
482
+ index: CatalogIndex,
483
+ requiredBy: RequiredBy,
484
+ ): PluginUpdateItem[] {
485
+ const removable = new Set(
486
+ items
487
+ .filter((i) => i.reason === "deprecated" || i.reason === "still-required")
488
+ .map((i) => i.pluginId),
489
+ );
490
+ let changed = true;
491
+ while (changed) {
492
+ changed = false;
493
+ for (const id of removable) {
494
+ if (requirersOf(requiredBy, id).some((r) => !removable.has(r))) {
495
+ removable.delete(id);
496
+ changed = true;
497
+ }
498
+ }
499
+ }
500
+ return items.map((item) => {
501
+ if (item.reason !== "still-required") return item;
502
+ const {
503
+ target: _t,
504
+ action: _a,
505
+ reason: _r,
506
+ scopes: _s,
507
+ note: _n,
508
+ ...base
509
+ } = item;
510
+ if (removable.has(item.pluginId)) {
511
+ return deprecatedRemove(base, index.get(item.pluginId));
512
+ }
513
+ const keeper = requirersOf(requiredBy, item.pluginId).find(
514
+ (r) => !removable.has(r),
515
+ );
516
+ return keeper ? { ...item, note: stillRequiredNote(keeper) } : item;
517
+ });
518
+ }
519
+
285
520
  /**
286
521
  * THE plugin classifier. Pure.
287
522
  *
288
523
  * The rules are evaluated in order and the first match wins. Each branch below
289
524
  * returns, so reading top to bottom is reading the precedence.
290
525
  *
291
- * @param pluginId the plugin
292
- * @param pin what it is judged against: an exact version, or "latest"
293
- * @param index the catalog — see {@link indexCatalog}
526
+ * @param pluginId the plugin
527
+ * @param pin what it is judged against: an exact version, or "latest"
528
+ * @param index the catalog — see {@link indexCatalog}
529
+ * @param requiredBy the plugins the profile has only as dependencies — see
530
+ * {@link RequiredBy}
294
531
  */
295
532
  export function classifyPlugin(
296
533
  pluginId: string,
297
534
  pin: string,
298
535
  index: CatalogIndex,
536
+ requiredBy: RequiredBy = {},
299
537
  ): PluginUpdateItem {
300
538
  const info = index.get(pluginId);
301
539
  const listed = index.isListed(pluginId);
@@ -344,15 +582,13 @@ export function classifyPlugin(
344
582
  // the marketplace. `marketplaceIsKnown` separates the two: holding other
345
583
  // LISTED ids from that marketplace proves we genuinely read its catalog, so
346
584
  // absence is an answer; holding none proves nothing.
585
+ //
586
+ // Such a pin is DEPRECATED, and removing it from the profile is the work:
587
+ // Claude Code will not load the plugin, so the pin only ever asks for an
588
+ // install that cannot happen. A plugin installed with its files missing lands
589
+ // here too (no installed scope has a version), and its scopes are uninstalled.
347
590
  if (!listed && !installed && index.marketplaceIsKnown(pluginId)) {
348
- return {
349
- ...base,
350
- target: null,
351
- action: "unknown",
352
- reason: "not-offered",
353
- scopes: [],
354
- note: "not offered by any configured marketplace",
355
- };
591
+ return notPublished(base, info, index, requiredBy);
356
592
  }
357
593
 
358
594
  // No installed scope has a version — the "checked but not installed" case.
@@ -390,25 +626,22 @@ export function classifyPlugin(
390
626
  // text: it moved to a marketplace that does publish it; a marketplace we
391
627
  // did read no longer lists it; or its marketplace contributed nothing we
392
628
  // could read, so its absence proves nothing either way.
629
+ //
630
+ // A marketplace we read that does not list it is a fact, not a guess: the
631
+ // plugin is DEPRECATED and is removed. A marketplace that contributed nothing
632
+ // readable proves nothing, so that case stays `unknown` and nothing is done.
393
633
  if (!listed) {
394
- const orphan = {
395
- ...base,
396
- target: null,
397
- action: "unknown" as const,
398
- scopes: [],
399
- };
400
634
  if (index.marketplaceIsKnown(pluginId)) {
401
- return {
402
- ...orphan,
403
- reason: "not-offered",
404
- note: "not offered by any configured marketplace",
405
- };
635
+ return notPublished(base, info, index, requiredBy);
406
636
  }
407
637
  const mp =
408
638
  info?.marketplace || marketplaceSuffix(pluginId)?.slice(1) || pluginId;
409
639
  return {
410
- ...orphan,
640
+ ...base,
641
+ target: null,
642
+ action: "unknown",
411
643
  reason: "marketplace-unread",
644
+ scopes: [],
412
645
  note: `its marketplace ${mp} was not read, so whether it still offers this plugin is unknown`,
413
646
  };
414
647
  }
@@ -654,12 +887,15 @@ export function classifyPlugin(
654
887
  * `magus update`, which then reported everything up to date. The owner asked
655
888
  * for "absolutely the same code" on both sides; this is it.
656
889
  *
657
- * @param pinned closure.plugins — pluginId -> exact version | "latest"
658
- * @param catalog getAvailablePlugins() output — keyed by id, or as a list
890
+ * @param pinned closure.plugins — pluginId -> exact version | "latest"
891
+ * @param catalog getAvailablePlugins() output — keyed by id, or as a list
892
+ * @param requiredBy closure.dependencies?.requiredBy — which plugin needs each
893
+ * dependency the profile does not name itself
659
894
  */
660
895
  export function planPluginUpdates(
661
896
  pinned: Readonly<Record<string, string>>,
662
897
  catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
898
+ requiredBy: RequiredBy = {},
663
899
  ): PluginUpdateItem[] {
664
900
  const index = indexCatalog(catalog);
665
901
  const plugins =
@@ -672,9 +908,13 @@ export function planPluginUpdates(
672
908
  const pin = pinForRow(info, pinned);
673
909
  if (pin !== null) pins.set(info.id, pin);
674
910
  }
675
- return [...pins]
676
- .map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
677
- .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
911
+ return settleDeprecated(
912
+ [...pins].map(([pluginId, pin]) =>
913
+ classifyPlugin(pluginId, pin, index, requiredBy),
914
+ ),
915
+ index,
916
+ requiredBy,
917
+ ).sort((a, b) => a.pluginId.localeCompare(b.pluginId));
678
918
  }
679
919
 
680
920
  /**
@@ -710,8 +950,11 @@ export function pinForRow(
710
950
  export function classifyPluginRows(
711
951
  plugins: readonly PluginInfo[],
712
952
  pins: Readonly<Record<string, string>>,
953
+ requiredBy: RequiredBy = {},
713
954
  ): Map<string, PluginUpdateItem> {
714
- return new Map(planPluginUpdates(pins, plugins).map((i) => [i.pluginId, i]));
955
+ return new Map(
956
+ planPluginUpdates(pins, plugins, requiredBy).map((i) => [i.pluginId, i]),
957
+ );
715
958
  }
716
959
 
717
960
  export interface PluginStateCounts {
@@ -725,6 +968,8 @@ export interface PluginStateCounts {
725
968
  * header would report a clean bill of health for a session running old code.
726
969
  */
727
970
  "stale-load": number;
971
+ /** Deprecated — no longer published, and removed by an apply. */
972
+ remove: number;
728
973
  unknown: number;
729
974
  }
730
975
 
@@ -738,6 +983,7 @@ export function countPluginStates(
738
983
  repair: 0,
739
984
  current: 0,
740
985
  "stale-load": 0,
986
+ remove: 0,
741
987
  unknown: 0,
742
988
  };
743
989
  for (const item of items) counts[item.action]++;
@@ -953,6 +1199,11 @@ export function planHasWork(plan: UpdatePlan): boolean {
953
1199
  * (`summarizePlan`'s `unknownPlugins`/`unknownTools`, rendered by
954
1200
  * `cli/update-view.ts`), and every unknown row carries its own reason above.
955
1201
  *
1202
+ * A `remove` DOES count. It used to be an `unknown` ("not offered"), and the
1203
+ * argument above kept it out while nothing could act on it. `magus update` now
1204
+ * removes it, so the gate closes — and a deprecated plugin is not a guess: its
1205
+ * marketplace was read and does not list it, and Claude Code refuses to load it.
1206
+ *
956
1207
  * Both unknowns are listed positively rather than as `!== "current"`, so a new
957
1208
  * action has to be classified here deliberately instead of defaulting into the
958
1209
  * gate.
@@ -964,7 +1215,8 @@ export function planIsBehind(plan: UpdatePlan): boolean {
964
1215
  p.action === "install" ||
965
1216
  p.action === "update" ||
966
1217
  p.action === "repair" ||
967
- p.action === "stale-load",
1218
+ p.action === "stale-load" ||
1219
+ p.action === "remove",
968
1220
  ) ||
969
1221
  plan.bins.some((b) => b.action === "install" || b.action === "upgrade") ||
970
1222
  plan.skills.some((s) => s.action === "install")
@@ -979,14 +1231,21 @@ export function summarizePlan(plan: UpdatePlan): {
979
1231
  current: number;
980
1232
  /** unknownPlugins + unknownTools. */
981
1233
  unknown: number;
1234
+ /** Plugins that could not be checked — never a {@link isKeptDeprecated} one. */
982
1235
  unknownPlugins: number;
983
1236
  unknownTools: number;
1237
+ /**
1238
+ * No longer published by their marketplace, and kept on purpose: moved, or
1239
+ * still required. A determined fact, so not part of `unknown`.
1240
+ */
1241
+ kept: number;
984
1242
  } {
985
1243
  const count = (a: PluginUpdateAction) =>
986
1244
  plan.plugins.filter((p) => p.action === a).length;
987
1245
  const bins = (a: BinUpdateAction) =>
988
1246
  plan.bins.filter((b) => b.action === a).length;
989
- const unknownPlugins = count("unknown");
1247
+ const kept = plan.plugins.filter(isKeptDeprecated).length;
1248
+ const unknownPlugins = count("unknown") - kept;
990
1249
  const unknownTools = bins("unknown");
991
1250
  return {
992
1251
  installed: count("install") + bins("install"),
@@ -996,5 +1255,6 @@ export function summarizePlan(plan: UpdatePlan): {
996
1255
  unknown: unknownPlugins + unknownTools,
997
1256
  unknownPlugins,
998
1257
  unknownTools,
1258
+ kept,
999
1259
  };
1000
1260
  }
@@ -486,6 +486,13 @@ export interface ResolvedClosure {
486
486
  export interface DependencyReport {
487
487
  /** Enabled only because an enabled plugin depends on them. Sorted. */
488
488
  derived: string[];
489
+ /**
490
+ * For every dependency an enabled plugin declares — derived ones and ones
491
+ * the profile also names — the plugin whose plugin.json first asked for it,
492
+ * so a report can say which plugin still needs it. Optional because a
493
+ * hand-built report may carry no provenance.
494
+ */
495
+ requiredBy?: Record<string, string>;
489
496
  /** Enabled plugins whose plugin.json could not be read here. Sorted. */
490
497
  unreadable: string[];
491
498
  /** A dependency the profile itself turns off: `plugin` will not load. */