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
package/src/cli/doctor.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  getProjectInstalledPluginVersions,
29
29
  readGlobalSettings,
30
30
  readInstalledPluginsRegistry,
31
+ readLocalSettings,
31
32
  readSettings,
32
33
  } from "../services/claude-settings.js";
33
34
  import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
@@ -44,7 +45,9 @@ import {
44
45
  import { isBehind } from "../services/plugin-manager.js";
45
46
  import {
46
47
  type RepoRootCache,
48
+ isRelevantInstallation,
47
49
  pickLoadedEntry,
50
+ repoRootForPath,
48
51
  } from "../services/plugin-resolution.js";
49
52
  import { trackedGeneratedNotices } from "../services/profile-activate.js";
50
53
  import {
@@ -69,6 +72,7 @@ import {
69
72
  import { resolveAllProfiles } from "../services/resolver.js";
70
73
  import type {
71
74
  ClaudeSettings,
75
+ InstalledPluginEntry,
72
76
  InstalledPluginsRegistry,
73
77
  ProfileManifest,
74
78
  } from "../types/index.js";
@@ -271,37 +275,140 @@ async function checkPluginLoads(projectPath: string): Promise<number> {
271
275
  reportUnparseable("Plugin loads", error);
272
276
  return 1;
273
277
  }
278
+ let local: ClaudeSettings;
279
+ try {
280
+ local = await readLocalSettings(projectPath);
281
+ } catch (error) {
282
+ if (!(error instanceof SettingsParseError)) throw error;
283
+ reportUnparseable("Plugin loads", error);
284
+ return 1;
285
+ }
286
+ // The global file's parse failure is section 0's finding, reported there;
287
+ // here it only means the user scope turns nothing on that we can see.
288
+ let global: ClaudeSettings = {};
289
+ try {
290
+ global = await readGlobalSettings();
291
+ } catch (error) {
292
+ if (!(error instanceof SettingsParseError)) throw error;
293
+ }
294
+ const enabled = enabledHere(global, settings, local);
274
295
  const installed = await getProjectInstalledPluginVersions(
275
296
  settings.installedPluginVersions || {},
276
297
  projectPath,
277
298
  );
278
- const ids = Object.keys(installed);
299
+ // Counted as installed only when claimed HERE — the same rule
300
+ // `findPluginLoadProblems` applies to a plugin that loads nothing.
301
+ const cache: RepoRootCache = new Map();
302
+ const ids = Object.keys(installed).filter((id) =>
303
+ isClaimedHere(id, registry.plugins?.[id], projectPath, enabled, cache),
304
+ );
279
305
  if (ids.length === 0) return 0;
280
306
 
281
- // One cache across every plugin: the same handful of project paths repeat in
282
- // hundreds of rows, and each miss walks the tree to the filesystem root.
283
- const cache: RepoRootCache = new Map();
284
- const stale: Array<{
307
+ const findings = findPluginLoadProblems(
308
+ installed,
309
+ registry.plugins,
310
+ projectPath,
311
+ enabled,
312
+ );
313
+ for (const line of pluginLoadLines(findings, ids.length)) console.log(line);
314
+ return findings.stale.length + findings.loadsNothing.length;
315
+ }
316
+
317
+ /** What `magus doctor`'s "Plugin loads" section found — see {@link findPluginLoadProblems}. */
318
+ export interface PluginLoadFindings {
319
+ /** Installed here at one version, loading an older one. */
320
+ stale: Array<{
285
321
  pluginId: string;
286
322
  loaded: string;
287
323
  recorded: string;
288
324
  winner: string;
289
- }> = [];
325
+ }>;
326
+ /**
327
+ * Installed here, and Claude Code loads NO version of it in this folder.
328
+ * Its own finding: counting it among the plugins that "load at their
329
+ * installed version" reported a plugin that loads nothing as healthy.
330
+ */
331
+ loadsNothing: string[];
332
+ }
290
333
 
291
- for (const pluginId of ids.sort()) {
334
+ /**
335
+ * `enabledPlugins` as it applies to this folder: user, then project, then
336
+ * local, a later scope overriding an earlier one — Claude Code's precedence.
337
+ */
338
+ export function enabledHere(
339
+ ...scopes: ReadonlyArray<Pick<ClaudeSettings, "enabledPlugins">>
340
+ ): Record<string, boolean> {
341
+ return Object.assign({}, ...scopes.map((s) => s.enabledPlugins ?? {}));
342
+ }
343
+
344
+ /**
345
+ * Does anything that APPLIES TO THIS FOLDER say the plugin is installed?
346
+ *
347
+ * Either a scope that applies here turns it on, or the registry holds a row
348
+ * the loader would consider here (`isRelevantInstallation` — the machine-wide
349
+ * scopes, this folder's own rows, and rows from the same repository). A row
350
+ * for some other project is not a claim about this folder: counting it made a
351
+ * stale `installedPluginVersions` entry plus an unrelated repo's install read
352
+ * as "installed here, loads nothing".
353
+ */
354
+ export function isClaimedHere(
355
+ pluginId: string,
356
+ rows: readonly InstalledPluginEntry[] | undefined,
357
+ projectPath: string,
358
+ enabled: Readonly<Record<string, boolean>>,
359
+ cache: RepoRootCache = new Map(),
360
+ ): boolean {
361
+ if (enabled[pluginId] === true) return true;
362
+ if (!Array.isArray(rows) || rows.length === 0) return false;
363
+ const root = repoRootForPath(projectPath, cache);
364
+ return rows.some((row) =>
365
+ isRelevantInstallation(row, projectPath, root, cache),
366
+ );
367
+ }
368
+
369
+ /**
370
+ * Compare what is installed here against what Claude Code will load here.
371
+ * Pure over its inputs, so the decision is pinned by a test rather than by a
372
+ * command that needs a real `~/.claude` to run.
373
+ *
374
+ * A plugin is only reported as loading nothing when something says it is
375
+ * INSTALLED HERE — see {@link isClaimedHere}: a scope that applies to this
376
+ * folder turns it on, or the registry holds a row relevant to this folder. A
377
+ * version in `installedPluginVersions` alone is not that — Claude Code never
378
+ * writes the field, so a claim with neither behind it is a leftover of
379
+ * magus's own, not an install that fails to load.
380
+ *
381
+ * @param installed plugin id → the version recorded as installed here
382
+ * @param registryPlugins the registry's rows by plugin id
383
+ * @param enabled `enabledPlugins` in the scopes that apply to this
384
+ * folder — see {@link enabledHere}
385
+ */
386
+ export function findPluginLoadProblems(
387
+ installed: Readonly<Record<string, string>>,
388
+ registryPlugins: InstalledPluginsRegistry["plugins"] | undefined,
389
+ projectPath: string,
390
+ enabled: Readonly<Record<string, boolean>> = {},
391
+ ): PluginLoadFindings {
392
+ // One cache across every plugin: the same handful of project paths repeat in
393
+ // hundreds of rows, and each miss walks the tree to the filesystem root.
394
+ const cache: RepoRootCache = new Map();
395
+ const findings: PluginLoadFindings = { stale: [], loadsNothing: [] };
396
+
397
+ for (const pluginId of Object.keys(installed).sort()) {
292
398
  const recorded = installed[pluginId];
293
399
  if (!recorded) continue;
294
- const pick = pickLoadedEntry(
295
- registry.plugins?.[pluginId],
296
- projectPath,
297
- cache,
298
- );
299
- if (!pick?.version) continue;
400
+ const rows = registryPlugins?.[pluginId];
401
+ const pick = pickLoadedEntry(rows, projectPath, cache);
402
+ if (!pick?.version) {
403
+ if (isClaimedHere(pluginId, rows, projectPath, enabled, cache))
404
+ findings.loadsNothing.push(pluginId);
405
+ continue;
406
+ }
300
407
  // The same predicate the classifier uses, so doctor and the plugin screen
301
408
  // cannot disagree about which pairs count. Unorderable pairs answer null
302
409
  // and are left alone — "cannot tell" is not a finding.
303
410
  if (isBehind(recorded, pick.version) !== true) continue;
304
- stale.push({
411
+ findings.stale.push({
305
412
  pluginId,
306
413
  loaded: pick.version,
307
414
  recorded,
@@ -310,28 +417,42 @@ async function checkPluginLoads(projectPath: string): Promise<number> {
310
417
  (pick.scope === "user" ? "user scope (no project)" : "unknown project"),
311
418
  });
312
419
  }
420
+ return findings;
421
+ }
313
422
 
314
- console.log("\nPlugin loads:");
315
- if (stale.length === 0) {
316
- console.log(
317
- ` ✓ All ${ids.length} installed plugins load at their installed version.`,
423
+ /** The "Plugin loads" section's lines, for `total` plugins installed here. */
424
+ export function pluginLoadLines(
425
+ findings: PluginLoadFindings,
426
+ total: number,
427
+ ): string[] {
428
+ const lines = ["", "Plugin loads:"];
429
+ if (findings.stale.length === 0 && findings.loadsNothing.length === 0) {
430
+ lines.push(
431
+ ` ✓ All ${total} installed plugins load at their installed version.`,
318
432
  );
319
- return 0;
433
+ return lines;
320
434
  }
321
-
322
- for (const row of stale) {
323
- console.log(
435
+ for (const row of findings.stale) {
436
+ lines.push(
324
437
  ` ✗ ${row.pluginId} — loads v${row.loaded}, installed v${row.recorded}`,
438
+ ` the entry that wins belongs to ${row.winner}`,
325
439
  );
326
- console.log(` the entry that wins belongs to ${row.winner}`);
327
440
  }
328
- console.log(
329
- " Reinstalling does not fix this. Run `magus update` — it moves those",
330
- );
331
- console.log(
332
- " install records forward, backing up installed_plugins.json first.",
333
- );
334
- return stale.length;
441
+ if (findings.stale.length > 0) {
442
+ lines.push(
443
+ " Reinstalling does not fix this. Run `magus update` — it moves those",
444
+ " install records forward, backing up installed_plugins.json first.",
445
+ );
446
+ }
447
+ for (const pluginId of findings.loadsNothing) {
448
+ lines.push(
449
+ ` ✗ ${pluginId} is installed, but Claude Code loads no version of it in this folder`,
450
+ );
451
+ }
452
+ if (findings.loadsNothing.length > 0) {
453
+ lines.push(" Run `magus update` to fix it.");
454
+ }
455
+ return lines;
335
456
  }
336
457
 
337
458
  /**
@@ -44,7 +44,10 @@ import {
44
44
  binInstallCommand,
45
45
  detectToolchains,
46
46
  } from "../services/toolchain.js";
47
- import { bringLoadForward } from "../services/update-engine.js";
47
+ import {
48
+ bringLoadForward,
49
+ describeLoadNotForward,
50
+ } from "../services/update-engine.js";
48
51
  import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
49
52
  import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
50
53
  import { runCommand, runShellScript } from "../utils/run.js";
@@ -210,11 +213,7 @@ async function bringLoadsForward(
210
213
  } moved forward)`,
211
214
  );
212
215
  } else {
213
- console.warn(
214
- `⚠ ${pluginId}: installed v${here}, but Claude Code still loads v${
215
- forward.loaded ?? "an older version"
216
- } here — ${forward.error ?? "the older records could not be moved forward"}`,
217
- );
216
+ console.warn(`⚠ ${pluginId}: ${describeLoadNotForward(forward)}`);
218
217
  }
219
218
  }
220
219
  }
@@ -27,11 +27,12 @@
27
27
  */
28
28
 
29
29
  import { binCommandFor } from "../services/update-engine.js";
30
- import type {
31
- BinUpdateItem,
32
- PluginUpdateItem,
33
- SkillUpdateItem,
34
- UpdatePlan,
30
+ import {
31
+ type BinUpdateItem,
32
+ type PluginUpdateItem,
33
+ type SkillUpdateItem,
34
+ type UpdatePlan,
35
+ isKeptDeprecated,
35
36
  } from "../services/update-plan.js";
36
37
  import { type ReportBadgeKind, reportBadge } from "../ui/theme-mode.js";
37
38
  import { brand } from "../ui/theme.js";
@@ -84,6 +85,7 @@ type ActionKey =
84
85
  | "repair"
85
86
  | "refresh"
86
87
  | "stale-load"
88
+ | "remove"
87
89
  | "unknown";
88
90
 
89
91
  const ACTIONS: Record<ActionKey, ActionStyle> = {
@@ -101,6 +103,10 @@ const ACTIONS: Record<ActionKey, ActionStyle> = {
101
103
  // a mild version of `repair`, and this is not that. Six characters, one under
102
104
  // UNKNOWN, so CHIP_WIDTH is unchanged and no other section pays for it.
103
105
  "stale-load": { glyph: "✗", label: "BROKEN", color: brand.danger },
106
+ // No longer published, and removed by the apply. ✗ in danger: the plugin is
107
+ // dead weight Claude Code refuses to load, and the row is work, not a shrug.
108
+ // The plugin table shows the group's DEPRECATED chip, not this label.
109
+ remove: { glyph: "✗", label: "REMOVE", color: brand.danger },
104
110
  // Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
105
111
  // as "broken". The summary line and the TUI already use warning for it.
106
112
  unknown: { glyph: "?", label: "UNKNOWN", color: brand.warning },
@@ -373,10 +379,15 @@ function pluginDetail(item: PluginUpdateItem): string {
373
379
  // "loads v0.3.0, installed v0.5.0" and the two columns beside it already do.
374
380
  const note =
375
381
  item.note &&
376
- item.reason !== "not-offered" &&
382
+ item.reason !== "deprecated" &&
377
383
  item.reason !== "older-version-loads"
378
384
  ? ` ${dim(`(${item.note})`)}`
379
385
  : "";
386
+ // Kept on purpose — moved, or still required. Under Deprecated, and the
387
+ // word says nothing will happen to it; the note says why.
388
+ if (isKeptDeprecated(item)) {
389
+ return `${item.installed ?? "—"} ${dim(`kept — ${item.note ?? "no longer published"}`)}`;
390
+ }
380
391
  switch (item.action) {
381
392
  case "install":
382
393
  return `${item.target ?? "latest"}${note}`;
@@ -391,6 +402,7 @@ function pluginDetail(item: PluginUpdateItem): string {
391
402
  )} ${fg(groupStyle("Update").text, item.target ?? "?")}${note}`;
392
403
  case "repair":
393
404
  case "unknown":
405
+ case "remove":
394
406
  return `${item.installed ?? "—"}${note}`;
395
407
  case "stale-load":
396
408
  // Both versions, in the order the reader needs them: what runs, then
@@ -459,7 +471,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
459
471
  * note. Five headings, and no more:
460
472
  *
461
473
  * Update something an apply would move forward
462
- * Deprecated no configured marketplace offers it
474
+ * Deprecated no longer published — an apply removes it
463
475
  * Fix required declared or installed, with its files missing
464
476
  * Unverified the check itself did not complete
465
477
  * Current settled — the version was compared, or there was none to compare
@@ -485,9 +497,11 @@ function pluginGroup(item: PluginUpdateItem): string {
485
497
  // state deceptive, and a row that reads as up to date under a CURRENT
486
498
  // heading is the defect this state was added to end.
487
499
  if (item.action === "stale-load") return "Broken";
488
- if (item.action === "unknown") {
489
- return item.reason === "not-offered" ? "Deprecated" : "Unverified";
490
- }
500
+ if (item.action === "remove") return "Deprecated";
501
+ // Moved or still required: no longer published here, and KEPT. That is a
502
+ // determined fact, not a check that failed, so never "Unverified".
503
+ if (isKeptDeprecated(item)) return "Deprecated";
504
+ if (item.action === "unknown") return "Unverified";
491
505
  return "Current";
492
506
  }
493
507
 
@@ -590,9 +604,12 @@ function pluginRow(
590
604
  : badge(groupBadge(group), style.fill ?? brand.muted, style.ink),
591
605
  chipWidth,
592
606
  );
593
- return `${PAD}${fg(style.text, ACTIONS[item.action].glyph)} ${
594
- style.chrome ? dim(name) : name
595
- } ${chip}${pluginDetail(item)}`;
607
+ // A kept row is not work: the ✗ of a removal would say it is, and the "?"
608
+ // of an unknown that nobody knows. It gets the settled row's muted glyph.
609
+ const mark = isKeptDeprecated(item)
610
+ ? fg(brand.muted, ACTIONS.current.glyph)
611
+ : fg(style.text, ACTIONS[item.action].glyph);
612
+ return `${PAD}${mark} ${style.chrome ? dim(name) : name} ${chip}${pluginDetail(item)}`;
596
613
  }
597
614
 
598
615
  /** The Plugins section: grouped by state, `Current` last. */
@@ -681,7 +698,7 @@ export function planReport(plan: UpdatePlan): string[] {
681
698
  // EVERY planned plugin gets a row. There is no filter here and there must
682
699
  // not be one.
683
700
  //
684
- // A previous revision dropped `unknown` + `not-offered` + not-installed rows
701
+ // A previous revision dropped unlisted, not-installed rows
685
702
  // — a pin naming a plugin no marketplace offers — on the grounds that a
686
703
  // notice named them instead. That notice did not exist: the function the
687
704
  // comment cited was never written. The row appeared in no table and no
@@ -778,10 +795,11 @@ export function summaryLine(
778
795
  failed: number,
779
796
  ms: number,
780
797
  unchanged = 0,
798
+ kept = 0,
781
799
  ): string[] {
782
800
  const bar = stackedBar(BAR * 2, [
783
801
  { value: changed, color: brand.success },
784
- { value: current, color: brand.muted },
802
+ { value: current + kept, color: brand.muted },
785
803
  { value: unchanged + unknown, color: brand.warning },
786
804
  { value: failed, color: brand.danger },
787
805
  ]);
@@ -789,6 +807,9 @@ export function summaryLine(
789
807
  changed > 0 ? fg(brand.success, `${changed} changed`) : "",
790
808
  unchanged > 0 ? fg(brand.warning, `${unchanged} unchanged`) : "",
791
809
  current > 0 ? dim(`${current} current`) : "",
810
+ // Deprecated but kept on purpose (moved, still required): settled, so
811
+ // grey beside `current`, and never folded into "unverified".
812
+ kept > 0 ? dim(`${kept} kept`) : "",
792
813
  unknown > 0 ? fg(brand.warning, `${unknown} unverified`) : "",
793
814
  failed > 0 ? fg(brand.danger, `${failed} failed`) : "",
794
815
  ].filter(Boolean);
@@ -860,3 +881,25 @@ export function nothingToApplyNotice(unknownPlugins: number): string {
860
881
  `Nothing to apply. ${unknownPlugins} plugin(s) could not be checked. They are NOT reported as up to date. Reasons are shown above.`,
861
882
  );
862
883
  }
884
+
885
+ /**
886
+ * The words `--check` fails with, chosen by what is pending. "Updates
887
+ * available" over a plan whose only work is removing plugins that are no
888
+ * longer published claims an update nobody can find in the table above.
889
+ */
890
+ export function pendingHeadline(plan: UpdatePlan): string {
891
+ const pendingPlugins = plan.plugins.filter(
892
+ (p) =>
893
+ p.action === "install" ||
894
+ p.action === "update" ||
895
+ p.action === "repair" ||
896
+ p.action === "stale-load" ||
897
+ p.action === "remove",
898
+ );
899
+ const onlyRemovals =
900
+ pendingPlugins.length > 0 &&
901
+ pendingPlugins.every((p) => p.action === "remove") &&
902
+ !plan.bins.some((b) => b.action === "install" || b.action === "upgrade") &&
903
+ !plan.skills.some((s) => s.action === "install");
904
+ return onlyRemovals ? "Changes pending" : "Updates available";
905
+ }
package/src/cli/update.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  type PluginInfo,
48
48
  clearMarketplaceCache,
49
49
  getAvailablePlugins,
50
+ isInstalledAnywhere,
50
51
  } from "../services/plugin-manager.js";
51
52
  import {
52
53
  recordRefetchedSkills,
@@ -61,7 +62,7 @@ import {
61
62
  switchHint,
62
63
  withCliBookkeeping,
63
64
  } from "../services/profile-state.js";
64
- import { resolveProfile } from "../services/resolver.js";
65
+ import { requirersHere, resolveProfile } from "../services/resolver.js";
65
66
  import { getInstalledSkillNames } from "../services/skills-manager.js";
66
67
  import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
67
68
  // The apply half lives in services/ so the TUI runs the SAME code — see the
@@ -69,6 +70,7 @@ import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
69
70
  import {
70
71
  type ApplyReporter,
71
72
  applyPlan,
73
+ describeRemoved,
72
74
  planWorkItems,
73
75
  } from "../services/update-engine.js";
74
76
  import {
@@ -95,6 +97,7 @@ import {
95
97
  note as noteLine,
96
98
  nothingToApplyNotice,
97
99
  ok as okLine,
100
+ pendingHeadline,
98
101
  planReport,
99
102
  stepFrame,
100
103
  stepRecord,
@@ -441,9 +444,17 @@ export async function runUpdateCommand(
441
444
  targetId,
442
445
  closure.skills,
443
446
  );
447
+ // Every installed plugin's dependencies count, not only the profile's: a
448
+ // plugin still required by one that stays is never planned for removal, and
449
+ // retired plugins that only require each other are removed together.
450
+ const requiredBy = await requirersHere(
451
+ closure.dependencies?.requiredBy,
452
+ [...catalog.values()].filter(isInstalledAnywhere).map((p) => p.id),
453
+ projectPath,
454
+ );
444
455
  const plan: UpdatePlan = {
445
456
  profileId: targetId,
446
- plugins: planPluginUpdates(closure.plugins, catalog),
457
+ plugins: planPluginUpdates(closure.plugins, catalog, requiredBy),
447
458
  bins: planBinUpdates(closure.bins, toolFacts),
448
459
  skills: planSkillUpdates(skillRefs, installedSkills),
449
460
  binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
@@ -489,7 +500,9 @@ export async function runUpdateCommand(
489
500
  return 0;
490
501
  }
491
502
  console.error(
492
- failLine(`Updates available. Run ${bold("magus update")} to apply.`),
503
+ failLine(
504
+ `${pendingHeadline(plan)}. Run ${bold("magus update")} to apply.`,
505
+ ),
493
506
  );
494
507
  return 1;
495
508
  }
@@ -506,6 +519,8 @@ export async function runUpdateCommand(
506
519
  counts.unknown,
507
520
  0,
508
521
  Date.now() - startedAt,
522
+ 0,
523
+ counts.kept,
509
524
  ))
510
525
  console.log(line);
511
526
  // `planHasWork` counts an `unknown` plugin as work, because from the
@@ -539,8 +554,10 @@ export async function runUpdateCommand(
539
554
 
540
555
  // No confirmation. `update` moves the machine toward what the profile
541
556
  // declares and can only go FORWARD: `claude plugin install` takes no version
542
- // and installs the newest published one, so there is no downgrade and no
543
- // delete for a prompt to protect against. The plan is printed above, so the
557
+ // and installs the newest published one, so there is no downgrade for a
558
+ // prompt to protect against. The one delete it makes is a DEPRECATED plugin:
559
+ // its marketplace no longer publishes it and Claude Code refuses to load it
560
+ // (measured, 2.1.284), so removing it loses nothing. The plan is printed above, so the
544
561
  // run is still legible after the fact — and `--dry-run` shows it beforehand.
545
562
  //
546
563
  // Note this also runs a profile's CLI-tool installers unattended (`brew
@@ -637,9 +654,14 @@ export async function runUpdateCommand(
637
654
  failed.length,
638
655
  Date.now() - startedAt,
639
656
  outcome.unchanged.length,
657
+ counts.kept,
640
658
  ))
641
659
  console.log(line);
642
660
 
661
+ // Only the CLI says it: on the Plugins screen the removed rows are simply gone.
662
+ const removed = describeRemoved(outcome.plugins.removed);
663
+ if (removed) console.log(okLine(removed));
664
+
643
665
  // Exited 0, read back unmoved. Reported, never fatal — the same precedent
644
666
  // as the pin mismatches below: this is a standing property of the machine
645
667
  // (a stale tap, a shadowing PATH entry), not something this run did wrong,
@@ -35,12 +35,14 @@ import type { MarketplacePlugin } from "./marketplace-catalog.js";
35
35
  /**
36
36
  * How long a cached catalog is served before we ask again.
37
37
  *
38
- * This is the ONLY refresh rhythm magus has, and it is why the TUI and
39
- * `magus update` can disagree about the same plugin for up to an hour:
40
- * `update` fast-forwards the clones and clears this cache before planning,
41
- * while the TUI serves whatever is here. That is not a bug in either path, but
42
- * anything that reports "no update" off a cached catalog has to say the answer
43
- * is up to an hour old.
38
+ * This is the ONLY refresh rhythm magus has, and it applies to both surfaces:
39
+ * `magus update` and the TUI read a catalog written here within the hour
40
+ * before they go to the network. `update` pulls the marketplace clones and
41
+ * drops its in-process memo before planning, but it does NOT clear this file,
42
+ * so a release published inside the TTL is not seen by `update` either. Only
43
+ * an explicit refresh (`r` in the TUI) clears it. Anything that reports "no
44
+ * update" — or decides a plugin is no longer published — off a cached catalog
45
+ * is working from an answer up to an hour old, by design.
44
46
  */
45
47
  export const CATALOG_TTL_MS = 60 * 60 * 1000;
46
48