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
@@ -20,7 +20,7 @@
20
20
  * So the engine lives here, in `services/`, and both front ends drive it.
21
21
  * `cli/` and `ui/` are sibling presentation layers: they own how progress looks
22
22
  * and nothing else. Everything IO-shaped is injected — `PluginApplyDeps` for
23
- * the five Claude Code calls, `ApplyReporter` for progress, `runCommand` for
23
+ * the five Claude Code calls, `ApplyReporter` for progress, `runArgv` for
24
24
  * installers — so the same code drives an animated terminal meter, a TUI modal,
25
25
  * and a test that wants silence.
26
26
  *
@@ -32,18 +32,28 @@
32
32
  import path from "node:path";
33
33
  import type {
34
34
  InstalledPluginEntry,
35
+ ManagedBin,
35
36
  ProfileSkillRef,
36
- ResolvedBin,
37
37
  SkillInfo,
38
38
  } from "../types/index.js";
39
39
  import type { Command } from "../utils/command-utils.js";
40
40
  import { formatCommand } from "../utils/command-utils.js";
41
- import { runCommand } from "../utils/run.js";
41
+ import { runArgv, succeeded } from "../utils/run.js";
42
42
  import type { PluginScope } from "./claude-cli.js";
43
- import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
43
+ import {
44
+ installPlugin,
45
+ repairPlugin,
46
+ uninstallPluginViaCli,
47
+ updatePlugin,
48
+ } from "./claude-cli.js";
44
49
  import {
45
50
  type StaleLoadRepairResult,
51
+ forgetInstalledPluginVersion,
52
+ getEnabledPlugins,
53
+ getGlobalEnabledPlugins,
54
+ getLocalEnabledPlugins,
46
55
  loadedVersionHere,
56
+ notDownloadedReason,
47
57
  readInstalledPluginsRegistry,
48
58
  repairStaleLoad,
49
59
  rowBelongsToRepository,
@@ -54,6 +64,7 @@ import {
54
64
  readInstalledVersionForScope,
55
65
  saveInstalledPluginVersionForScope,
56
66
  } from "./plugin-manager.js";
67
+ import { removePluginFromProfile } from "./profile-state.js";
57
68
  import { installSkill } from "./skills-manager.js";
58
69
  import {
59
70
  type ToolFacts,
@@ -95,6 +106,13 @@ export interface PinMismatch {
95
106
 
96
107
  export interface PluginApplyResult extends ApplyResult {
97
108
  mismatches: PinMismatch[];
109
+ /**
110
+ * Deprecated plugins removed — installed at no scope here afterwards, and no
111
+ * longer listed by the profile. Also counted in `ok`; named separately so the
112
+ * CLI can say "Removed …" ({@link describeRemoved}). The Plugins screen needs
113
+ * no line: the rows are gone.
114
+ */
115
+ removed: string[];
98
116
  }
99
117
 
100
118
  /** Everything one full apply produced, for whichever front end is reporting. */
@@ -121,19 +139,36 @@ export interface UpdateOutcome {
121
139
  export interface ApplyReporter {
122
140
  /** An item is starting. Called before any of its work. */
123
141
  begin(name: string): void;
124
- /** An item finished. `detail` is already formatted for display. */
125
- finish(ok: boolean, name: string, detail: string, ms: number): void;
142
+ /**
143
+ * An item finished. `detail` is already formatted for display, in the user's
144
+ * terms: a failure's detail is the REASON — the CLI prints it, and the
145
+ * Plugins screen shows it on the plugin's row.
146
+ *
147
+ * `cause` is `"exception"` when the detail is instead an error thrown from
148
+ * something the engine called — a Claude Code command's own output, a file
149
+ * that would not parse — rather than a reason the engine worded itself.
150
+ */
151
+ finish(
152
+ ok: boolean,
153
+ name: string,
154
+ detail: string,
155
+ ms: number,
156
+ cause?: FinishCause,
157
+ ): void;
126
158
  /**
127
159
  * Run `work` with the terminal released.
128
160
  *
129
- * A binary upgrade spawns `brew`/`uv` with inherited stdio: it writes and
130
- * moves the cursor itself, so any animated block must be erased first or the
131
- * two interleave into garbage. A TUI implementation suspends its renderer;
132
- * a silent one just awaits.
161
+ * The installer's output is captured (`runArgv`), so nothing a child
162
+ * prints reaches the terminal; an implementation pauses its animation for
163
+ * the duration only so the meter does not tick over a step that has no
164
+ * progress of its own to show. A silent one just awaits.
133
165
  */
134
166
  detach<T>(work: () => Promise<T>): Promise<T>;
135
167
  }
136
168
 
169
+ /** Where a finished item's detail came from — see {@link ApplyReporter.finish}. */
170
+ export type FinishCause = "reason" | "exception";
171
+
137
172
  /** Reports nothing. The right default for a caller that renders its own state. */
138
173
  export const SILENT_REPORTER: ApplyReporter = {
139
174
  begin: () => {},
@@ -211,8 +246,83 @@ export interface PluginApplyDeps {
211
246
  row: InstalledPluginEntry,
212
247
  projectPath: string,
213
248
  ) => boolean;
249
+ /**
250
+ * What removing a deprecated plugin takes. Optional so the many doubles that
251
+ * model only installs and updates need not grow three stubs each; a `remove`
252
+ * item applied without it FAILS, saying so, rather than reporting a removal
253
+ * that did not happen.
254
+ */
255
+ remove?: PluginRemoveDeps;
256
+ }
257
+
258
+ /** The three calls a `remove` makes — see {@link applyRemove}. */
259
+ export interface PluginRemoveDeps {
260
+ /**
261
+ * `claude plugin uninstall <id> --scope <scope>`, and only that
262
+ * (`uninstallPluginViaCli`, claude-cli.ts): it THROWS when the command
263
+ * fails. The wrapper with a fallback (`uninstallPlugin`) edits the settings
264
+ * files itself on failure, which is a direct write of Claude Code's files and
265
+ * hides the command's error from the read-back. Measured on Claude Code
266
+ * 2.1.284: the command uninstalls a plugin its marketplace no longer lists,
267
+ * deleting the plugin from that scope's settings.
268
+ */
269
+ uninstall: (
270
+ pluginId: string,
271
+ scope: PluginScope,
272
+ projectPath: string,
273
+ ) => Promise<void>;
274
+ /**
275
+ * Installed at `scope` here, by the one definition the classifier uses: that
276
+ * scope's settings turn the plugin on. The read-back after uninstalling.
277
+ */
278
+ isInstalled: (
279
+ pluginId: string,
280
+ scope: PluginScope,
281
+ projectPath: string,
282
+ ) => Promise<boolean>;
283
+ /**
284
+ * Stop `profile` listing the plugin; answers whether it no longer does.
285
+ * `profile` undefined means the active one — see `removePluginFromProfile`.
286
+ */
287
+ dropFromProfile: (
288
+ pluginId: string,
289
+ projectPath: string,
290
+ profile: string | null | undefined,
291
+ ) => Promise<boolean>;
292
+ /**
293
+ * Clear magus's OWN `installedPluginVersions` claim for the plugin at a
294
+ * scope it was removed from (`forgetInstalledPluginVersion`). Claude Code
295
+ * never writes that field, so its uninstall leaves the claim behind, and a
296
+ * leftover claim is a plugin that still reads as installed. Optional so a
297
+ * double that does not model it skips it; best-effort, because the removal
298
+ * itself is judged by `isInstalled`, never by this field.
299
+ */
300
+ forgetVersion?: (
301
+ pluginId: string,
302
+ scope: PluginScope,
303
+ projectPath: string,
304
+ ) => Promise<void>;
305
+ }
306
+
307
+ /** The settings a scope's install lives in — the classifier's definition. */
308
+ async function enabledAt(
309
+ scope: PluginScope,
310
+ projectPath: string,
311
+ ): Promise<Record<string, boolean>> {
312
+ if (scope === "user") return getGlobalEnabledPlugins();
313
+ if (scope === "local") return getLocalEnabledPlugins(projectPath);
314
+ return getEnabledPlugins(projectPath);
214
315
  }
215
316
 
317
+ export const REAL_REMOVE_DEPS: PluginRemoveDeps = {
318
+ uninstall: uninstallPluginViaCli,
319
+ isInstalled: async (pluginId, scope, projectPath) =>
320
+ (await enabledAt(scope, projectPath))[pluginId] === true,
321
+ dropFromProfile: (pluginId, projectPath, profile) =>
322
+ removePluginFromProfile(projectPath, pluginId, profile),
323
+ forgetVersion: forgetInstalledPluginVersion,
324
+ };
325
+
216
326
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
217
327
  install: installPlugin,
218
328
  update: updatePlugin,
@@ -225,6 +335,7 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
225
335
  const rows = (await readInstalledPluginsRegistry()).plugins?.[pluginId];
226
336
  return Array.isArray(rows) ? rows : [];
227
337
  },
338
+ remove: REAL_REMOVE_DEPS,
228
339
  };
229
340
 
230
341
  /**
@@ -330,18 +441,23 @@ async function followMisdirectedUpdate(
330
441
  return {
331
442
  elsewhere,
332
443
  version,
333
- notFollowed: repair.ok
334
- ? null
335
- : (repair.error ?? "the registry was not rewritten"),
444
+ notFollowed: repair.ok ? null : (repair.error ?? "nothing was changed"),
336
445
  };
337
446
  }
338
447
 
339
- /** The words for a misdirected update, for an item's detail. */
448
+ /**
449
+ * The words for a misdirected update, for an item's detail. In the user's
450
+ * terms: which OTHER folders were touched is counted, not listed — their paths
451
+ * are the mechanism, and the fact is that this folder was not the one updated.
452
+ */
340
453
  function describeMisdirected(m: MisdirectedUpdate): string {
341
- const where = m.elsewhere.join(", ");
454
+ const where =
455
+ m.elsewhere.length === 1
456
+ ? "another folder of this repository"
457
+ : `${m.elsewhere.length} other folders of this repository`;
342
458
  return m.notFollowed === null
343
- ? `Claude Code updated ${where} instead of this project — moved this project's record to v${m.version} too`
344
- : `Claude Code updated ${where} instead of this project, and this project's record could not follow: ${m.notFollowed}`;
459
+ ? `Claude Code updated ${where} instead of this one — this folder now loads v${m.version} too`
460
+ : `Claude Code updated ${where} instead of this one, and this folder could not follow: ${m.notFollowed}`;
345
461
  }
346
462
 
347
463
  /**
@@ -377,9 +493,49 @@ export async function bringLoadForward(
377
493
 
378
494
  /** The words for a load brought forward, appended to an item's detail. */
379
495
  function describeLoadForward(outcome: StaleLoadRepairResult): string {
380
- return `Claude Code now loads v${outcome.target} here (${outcome.rewritten} older record${
381
- outcome.rewritten === 1 ? "" : "s"
382
- } moved forward)`;
496
+ return `Claude Code now loads v${outcome.target} in this folder`;
497
+ }
498
+
499
+ /**
500
+ * The words for an install that landed but does not load — the failure detail
501
+ * `magus update`, the Plugins screen and `magus install` all print.
502
+ *
503
+ * When the repair's own check named the version still loading, that version IS
504
+ * the reason, and nothing is appended: the repair's message says the same thing
505
+ * a second time. When it refused before writing (`loaded` is then null), its
506
+ * message is the only reason there is, so it follows — except the refusal for
507
+ * a version with no download, which names the version again: "installed
508
+ * v1.3.0, but v1.3.0 is not downloaded" reads as a contradiction, so that one
509
+ * says the install did not download.
510
+ */
511
+ export function describeLoadNotForward(forward: StaleLoadRepairResult): string {
512
+ // `target` is the version that landed: the repair was asked to make it load.
513
+ const installed = forward.target;
514
+ if (forward.loaded) {
515
+ return `installed v${installed}, but Claude Code still loads v${forward.loaded} in this folder`;
516
+ }
517
+ if (forward.error === notDownloadedReason(installed)) {
518
+ return `installed v${installed}, but it did not download — run magus install`;
519
+ }
520
+ return `installed v${installed}, but ${
521
+ forward.error ?? "Claude Code still loads an older version in this folder"
522
+ }`;
523
+ }
524
+
525
+ /**
526
+ * The line `magus update` prints for the deprecated plugins an apply removed,
527
+ * or null when it removed none. Plugin names, not ids: the line is
528
+ * about what the user knew them as.
529
+ */
530
+ export function describeRemoved(removed: readonly string[]): string | null {
531
+ if (removed.length === 0) return null;
532
+ return `Removed ${removed.map(pluginName).join(", ")} — no longer published`;
533
+ }
534
+
535
+ /** A plugin id's name part: `kanban@magus` → `kanban`. */
536
+ export function pluginName(pluginId: string): string {
537
+ const at = pluginId.indexOf("@");
538
+ return at > 0 ? pluginId.slice(0, at) : pluginId;
383
539
  }
384
540
 
385
541
  // -- selection ---------------------------------------------------------------
@@ -409,10 +565,15 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
409
565
  // measured NOT to clear it — it writes another row for this project while the
410
566
  // row the loader picks belongs to a different one — which is exactly why it
411
567
  // must not come through here. {@link pluginNeedsWork} counts it separately.
568
+ //
569
+ // `remove` has its own branch too ({@link applyRemove}): its scopes are where
570
+ // to UNINSTALL, and a declared plugin installed nowhere still has work — the
571
+ // profile edit — with none at all.
412
572
  if (
413
573
  item.action === "current" ||
414
574
  item.action === "unknown" ||
415
- item.action === "stale-load"
575
+ item.action === "stale-load" ||
576
+ item.action === "remove"
416
577
  )
417
578
  return [];
418
579
  return item.action === "install" && item.reason === "absent"
@@ -429,10 +590,16 @@ function scopesToApply(item: PluginUpdateItem): PluginScope[] {
429
590
  * looks exactly like a hang.
430
591
  *
431
592
  * `stale-load` is work without scopes — see {@link scopesToApply} — so it is
432
- * named here rather than counted through the scope list.
593
+ * named here rather than counted through the scope list. So is `remove`: a
594
+ * deprecated plugin the profile declares and nothing installed is still work,
595
+ * because the profile must stop declaring it.
433
596
  */
434
597
  export function pluginNeedsWork(item: PluginUpdateItem): boolean {
435
- return item.action === "stale-load" || scopesToApply(item).length > 0;
598
+ return (
599
+ item.action === "stale-load" ||
600
+ item.action === "remove" ||
601
+ scopesToApply(item).length > 0
602
+ );
436
603
  }
437
604
 
438
605
  // -- plugins -----------------------------------------------------------------
@@ -555,7 +722,7 @@ async function applyStaleLoad(
555
722
  report.finish(
556
723
  false,
557
724
  item.pluginId,
558
- `no installed version to point ${item.pluginId}'s registry rows at`,
725
+ "no version is installed in this folder for Claude Code to load instead",
559
726
  Date.now() - startedAt,
560
727
  );
561
728
  result.failed.push(item.pluginId);
@@ -571,9 +738,7 @@ async function applyStaleLoad(
571
738
  report.finish(
572
739
  true,
573
740
  item.pluginId,
574
- `registry → v${outcome.target} (${outcome.rewritten} row${
575
- outcome.rewritten === 1 ? "" : "s"
576
- } moved forward)`,
741
+ describeLoadForward(outcome),
577
742
  Date.now() - startedAt,
578
743
  );
579
744
  result.ok++;
@@ -581,32 +746,159 @@ async function applyStaleLoad(
581
746
  report.finish(
582
747
  false,
583
748
  item.pluginId,
584
- outcome.error ?? `${item.pluginId} still does not load v${item.target}`,
749
+ outcome.error ??
750
+ `Claude Code still does not load v${item.target} in this folder`,
585
751
  Date.now() - startedAt,
586
752
  );
587
753
  result.failed.push(item.pluginId);
588
754
  }
589
755
  } catch (error) {
590
756
  const msg = error instanceof Error ? error.message : String(error);
591
- report.finish(false, item.pluginId, msg, Date.now() - startedAt);
757
+ report.finish(
758
+ false,
759
+ item.pluginId,
760
+ msg,
761
+ Date.now() - startedAt,
762
+ "exception",
763
+ );
592
764
  result.failed.push(item.pluginId);
593
765
  }
594
766
  }
595
767
 
768
+ /**
769
+ * Remove ONE deprecated plugin — the `remove` half of {@link applyPlugins}.
770
+ *
771
+ * Uninstalls it at every scope the plan found it installed at, through the
772
+ * Claude Code CLI wrapper, then stops the profile listing it. Both halves run
773
+ * even when the first fails part-way: a profile that keeps a plugin no
774
+ * marketplace publishes only ever asks for an install that cannot happen.
775
+ *
776
+ * Success is READ BACK, never "the calls returned": installed at no scope here
777
+ * afterwards — all three are checked, not just the planned ones — and the
778
+ * profile no longer lists it.
779
+ */
780
+ async function applyRemove(
781
+ item: PluginUpdateItem,
782
+ projectPath: string,
783
+ deps: PluginApplyDeps,
784
+ report: ApplyReporter,
785
+ result: PluginApplyResult,
786
+ profile: string | null | undefined,
787
+ ): Promise<void> {
788
+ report.begin(item.pluginId);
789
+ const startedAt = Date.now();
790
+ const fail = (detail: string, cause?: FinishCause) => {
791
+ report.finish(false, item.pluginId, detail, Date.now() - startedAt, cause);
792
+ result.failed.push(item.pluginId);
793
+ };
794
+ const remove = deps.remove;
795
+ if (!remove) {
796
+ fail("magus cannot remove plugins from here");
797
+ return;
798
+ }
799
+ try {
800
+ let uninstallError: unknown = null;
801
+ for (const scope of item.scopes) {
802
+ try {
803
+ await remove.uninstall(item.pluginId, scope, projectPath);
804
+ } catch (error) {
805
+ uninstallError ??= error;
806
+ }
807
+ }
808
+ const unlisted = await remove.dropFromProfile(
809
+ item.pluginId,
810
+ projectPath,
811
+ profile,
812
+ );
813
+
814
+ const still: PluginScope[] = [];
815
+ for (const scope of REMOVE_SCOPES) {
816
+ if (await remove.isInstalled(item.pluginId, scope, projectPath)) {
817
+ still.push(scope);
818
+ }
819
+ }
820
+ // magus's own claim goes with the install, at each scope it was removed
821
+ // from. Best-effort: a claim left behind no longer counts as an install
822
+ // anywhere, so failing to clear it must not fail a removal that happened.
823
+ if (remove.forgetVersion) {
824
+ for (const scope of item.scopes) {
825
+ if (still.includes(scope)) continue;
826
+ await remove
827
+ .forgetVersion(item.pluginId, scope, projectPath)
828
+ .catch(() => {});
829
+ }
830
+ }
831
+ if (still.length > 0) {
832
+ // The CLI's own words when it said anything; ours otherwise.
833
+ if (uninstallError !== null) {
834
+ fail(
835
+ uninstallError instanceof Error
836
+ ? uninstallError.message
837
+ : String(uninstallError),
838
+ "exception",
839
+ );
840
+ } else {
841
+ fail(
842
+ `still installed at ${still.map((s) => s.toUpperCase()).join(", ")} scope`,
843
+ );
844
+ }
845
+ return;
846
+ }
847
+ if (!unlisted) {
848
+ fail("the profile still lists it");
849
+ return;
850
+ }
851
+ report.finish(
852
+ true,
853
+ item.pluginId,
854
+ item.scopes.length > 0
855
+ ? `removed at ${item.scopes.map((s) => s.toUpperCase()).join(", ")} · no longer published`
856
+ : "removed from the profile · no longer published",
857
+ Date.now() - startedAt,
858
+ );
859
+ result.ok++;
860
+ result.removed.push(item.pluginId);
861
+ } catch (error) {
862
+ fail(error instanceof Error ? error.message : String(error), "exception");
863
+ }
864
+ }
865
+
866
+ /** Every scope a removal must leave the plugin uninstalled at. */
867
+ const REMOVE_SCOPES: readonly PluginScope[] = ["user", "project", "local"];
868
+
869
+ /** Options for {@link applyPlugins}. */
870
+ export interface ApplyPluginsOptions {
871
+ /**
872
+ * The profile the plan was made from — the one a removed plugin is dropped
873
+ * from. Undefined means the active profile; `magus update <name>` passes the
874
+ * named one, which need not be active.
875
+ */
876
+ profile?: string | null;
877
+ }
878
+
596
879
  export async function applyPlugins(
597
880
  items: PluginUpdateItem[],
598
881
  projectPath: string,
599
882
  deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
600
883
  report: ApplyReporter = SILENT_REPORTER,
884
+ opts: ApplyPluginsOptions = {},
601
885
  ): Promise<PluginApplyResult> {
602
886
  const result: PluginApplyResult = {
603
887
  ok: 0,
604
888
  failed: [],
605
889
  unchanged: [],
606
890
  mismatches: [],
891
+ removed: [],
607
892
  };
608
893
 
609
894
  for (const item of items) {
895
+ // A deprecated plugin is uninstalled and dropped from the profile — no
896
+ // installer, no version to read back — so it too has its own branch.
897
+ if (item.action === "remove") {
898
+ await applyRemove(item, projectPath, deps, report, result, opts.profile);
899
+ continue;
900
+ }
901
+
610
902
  // A stale load is repaired in the REGISTRY, not by any installer, so it
611
903
  // leaves the scope loop entirely. Its own branch rather than a scope with a
612
904
  // special case inside: everything below reads back a per-scope version and
@@ -695,9 +987,7 @@ export async function applyPlugins(
695
987
  report.finish(
696
988
  false,
697
989
  item.pluginId,
698
- `installed v${here}, but Claude Code still loads v${
699
- forward.loaded ?? "an older version"
700
- } here — ${forward.error ?? "the older records could not be moved forward"}`,
990
+ describeLoadNotForward(forward),
701
991
  Date.now() - startedAt,
702
992
  );
703
993
  result.failed.push(item.pluginId);
@@ -726,7 +1016,13 @@ export async function applyPlugins(
726
1016
  }
727
1017
  } catch (error) {
728
1018
  const msg = error instanceof Error ? error.message : String(error);
729
- report.finish(false, item.pluginId, msg, Date.now() - startedAt);
1019
+ report.finish(
1020
+ false,
1021
+ item.pluginId,
1022
+ msg,
1023
+ Date.now() - startedAt,
1024
+ "exception",
1025
+ );
730
1026
  result.failed.push(item.pluginId);
731
1027
  }
732
1028
  }
@@ -764,11 +1060,7 @@ async function applyOneInScope(
764
1060
  if (actual) {
765
1061
  const forward = await bringLoadForward(pluginId, actual, projectPath, deps);
766
1062
  if (forward && !forward.ok) {
767
- throw new Error(
768
- `installed v${actual}, but Claude Code still loads v${
769
- forward.loaded ?? "an older version"
770
- } here — ${forward.error ?? "the older records could not be moved forward"}`,
771
- );
1063
+ throw new Error(describeLoadNotForward(forward));
772
1064
  }
773
1065
  }
774
1066
  return actual;
@@ -836,7 +1128,7 @@ export async function updatePluginInScope(
836
1128
  */
837
1129
  export function binCommandFor(
838
1130
  item: BinUpdateItem,
839
- spec: ResolvedBin,
1131
+ spec: ManagedBin,
840
1132
  ): Command | null {
841
1133
  if (!binNeedsWork(item)) return null;
842
1134
  if (item.action === "upgrade" && item.upgradeCommand)
@@ -893,7 +1185,14 @@ function binNoMove(item: BinUpdateItem, after: ToolFacts): string | null {
893
1185
  * which R6 counts as changed — the safe answer, but never "no change — still
894
1186
  * 3.7c", the one the user needs.
895
1187
  */
896
- const REAL_READ_BACK = (spec: ResolvedBin): Promise<ToolFacts> =>
1188
+ /** How long one `magus update` installer may run before it is stopped. */
1189
+ const INSTALLER_TIMEOUT_MS = 15 * 60_000;
1190
+
1191
+ /** The default installer runner: argv, output captured, true on success. */
1192
+ const runInstaller = async (command: Command): Promise<boolean> =>
1193
+ succeeded(await runArgv(command, { timeoutMs: INSTALLER_TIMEOUT_MS }));
1194
+
1195
+ const REAL_READ_BACK = (spec: ManagedBin): Promise<ToolFacts> =>
897
1196
  probeTool(spec, undefined, { allowCheckCommand: true });
898
1197
 
899
1198
  export async function applyBins(
@@ -903,9 +1202,9 @@ export async function applyBins(
903
1202
  // profile-supplied package name reach `/bin/sh`: a seam called "shell" reads
904
1203
  // as an invitation to hand it a string. It takes argv, and nothing here
905
1204
  // builds a shell line.
906
- run: (command: Command) => Promise<boolean> = runCommand,
1205
+ run: (command: Command) => Promise<boolean> = runInstaller,
907
1206
  /** Read-back seam: re-probe ONE tool after its installer ran. */
908
- probe: (spec: ResolvedBin) => Promise<ToolFacts> = REAL_READ_BACK,
1207
+ probe: (spec: ManagedBin) => Promise<ToolFacts> = REAL_READ_BACK,
909
1208
  ): Promise<ApplyResult> {
910
1209
  const result: ApplyResult = { ok: 0, failed: [], unchanged: [] };
911
1210
  for (const item of plan.bins) {
@@ -930,8 +1229,6 @@ export async function applyBins(
930
1229
  result.failed.push(item.name);
931
1230
  continue;
932
1231
  }
933
- // The installer owns the terminal for the duration: it streams its own
934
- // output through inherited stdio.
935
1232
  const succeeded = await report.detach(() => run(cmd));
936
1233
  if (!succeeded) {
937
1234
  report.finish(
@@ -1023,7 +1320,7 @@ export interface ApplyPlanOptions {
1023
1320
  /** Installer execution seam — see the note on {@link applyBins}. */
1024
1321
  run?: (command: Command) => Promise<boolean>;
1025
1322
  /** Read-back seam — see {@link applyBins}. Default: `probeTool(spec)`. */
1026
- probe?: (spec: ResolvedBin) => Promise<ToolFacts>;
1323
+ probe?: (spec: ManagedBin) => Promise<ToolFacts>;
1027
1324
  }
1028
1325
 
1029
1326
  /**
@@ -1045,6 +1342,9 @@ export async function applyPlan(
1045
1342
  projectPath,
1046
1343
  opts.deps ?? REAL_PLUGIN_DEPS,
1047
1344
  report,
1345
+ // The profile planned is the one a removed plugin leaves — which, under
1346
+ // `magus update <name>`, need not be the active one.
1347
+ { profile: plan.profileId },
1048
1348
  );
1049
1349
  const bins = await applyBins(plan, report, opts.run, opts.probe);
1050
1350
  const skills = await applySkills(plan, projectPath, report);