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
@@ -4,8 +4,9 @@
4
4
  * Put a committed team profile from .claude/profiles.json onto this machine:
5
5
  * register marketplaces, activate the profile (render its settings.json,
6
6
  * .mcp.json and models.json, install its skills), install its pinned plugins
7
- * and the binaries plugins declare (requires.bin) plus the profile's cliTools,
8
- * then collect required env.
7
+ * and what those plugins declare in `requires` plus the profile's cliTools
8
+ * (through the deps executor that `magus doctor --fix` uses), then collect
9
+ * required env.
9
10
  *
10
11
  * With no argument it installs the union of every profile's deps (so `profile
11
12
  * switch` needs no reinstall) and keeps the active profile active, asking only
@@ -13,6 +14,7 @@
13
14
  * the confirmation prompt.
14
15
  */
15
16
 
17
+ import { homedir } from "node:os";
16
18
  import { installPlugin, isClaudeAvailable } from "../services/claude-cli.js";
17
19
  import {
18
20
  getLocalSettingsPath,
@@ -20,6 +22,9 @@ import {
20
22
  readLocalSettings,
21
23
  writeLocalSettings,
22
24
  } from "../services/claude-settings.js";
25
+ import type { PlanEnv } from "../services/deps-plan.js";
26
+ import { assessBins } from "../services/deps-report.js";
27
+ import { machineEnv } from "../services/doctor-bins.js";
23
28
  import {
24
29
  computeEnvStatus,
25
30
  computeVersionDrift,
@@ -40,16 +45,13 @@ import {
40
45
  import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
41
46
  import { SettingsParseError, writableMap } from "../services/settings-file.js";
42
47
  import {
43
- TOOLCHAIN_BOOTSTRAP,
44
- binInstallCommand,
45
- detectToolchains,
46
- } from "../services/toolchain.js";
47
- import { bringLoadForward } from "../services/update-engine.js";
48
- import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
49
- import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
50
- import { runCommand, runShellScript } from "../utils/run.js";
48
+ bringLoadForward,
49
+ describeLoadNotForward,
50
+ } from "../services/update-engine.js";
51
+ import type { ResolvedClosure } from "../types/index.js";
51
52
  import { bold } from "./ansi.js";
52
53
  import { ensureManifest, printActivationNotes } from "./bootstrap.js";
54
+ import { depsSectionLines, fixDeps, renderApplyText } from "./deps-view.js";
53
55
  import { askYesNo, promptValue } from "./prompt.js";
54
56
  import { selectOption } from "./select.js";
55
57
  import {
@@ -105,48 +107,6 @@ async function runCheck(
105
107
 
106
108
  // ── install steps ───────────────────────────────────────────────────────────
107
109
 
108
- async function ensureToolchains(
109
- closure: ResolvedClosure,
110
- yes: boolean,
111
- ): Promise<void> {
112
- // Only the binaries actually missing can need a toolchain. Checking all of
113
- // them warns "pip isn't installed" on a machine whose pip-provided binaries
114
- // are already on PATH — an alarming message about work that will not happen.
115
- const missing: ResolvedBin[] = [];
116
- for (const bin of closure.bins) {
117
- if (!(await resolveExecutable(bin.name))) missing.push(bin);
118
- }
119
- if (missing.length === 0) return;
120
-
121
- const toolchains = await detectToolchains(missing);
122
- for (const tc of toolchains) {
123
- if (tc.present) continue;
124
- const bootstrap = TOOLCHAIN_BOOTSTRAP[tc.name];
125
- if (!bootstrap) {
126
- console.warn(
127
- `⚠ ${tc.name} is required by ${tc.requiredBy.join(", ")} but isn't installed and can't be auto-installed. Install it and re-run.`,
128
- );
129
- continue;
130
- }
131
- const ok =
132
- yes ||
133
- (await askYesNo(
134
- question(
135
- `${tc.name} is missing — ${tc.requiredBy.join(", ")} need${tc.requiredBy.length === 1 ? "s" : ""} it. Install it?`,
136
- ),
137
- ));
138
- if (ok) {
139
- console.log(`Installing ${tc.name}…`);
140
- // A bootstrap line IS shell text — bun's contains a pipe, Homebrew's a
141
- // command substitution — and it is a constant in this repo, not
142
- // profile data. This is the only legitimate caller.
143
- if (!(await runShellScript(bootstrap))) {
144
- console.warn(`⚠ Failed to install ${tc.name}. Continuing.`);
145
- }
146
- }
147
- }
148
- }
149
-
150
110
  /**
151
111
  * Install the closure's plugins at PROJECT scope.
152
112
  *
@@ -210,28 +170,47 @@ async function bringLoadsForward(
210
170
  } moved forward)`,
211
171
  );
212
172
  } 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
- );
173
+ console.warn(`⚠ ${pluginId}: ${describeLoadNotForward(forward)}`);
218
174
  }
219
175
  }
220
176
  }
221
177
 
222
- async function installBins(bins: ResolvedBin[]): Promise<void> {
223
- for (const bin of bins) {
224
- if (await resolveExecutable(bin.name)) {
225
- continue; // already installed and runnable (not a dangling link)
226
- }
227
- const cmd = binInstallCommand(bin);
228
- // Printed rendered, executed as argv. The package name in `cmd.args` came
229
- // from a profile or a plugin manifest, so it must never reach a shell.
230
- console.log(`+ ${bin.name} (${formatCommand(cmd)})`);
231
- if (!(await runCommand(cmd))) {
232
- console.warn(`⚠ failed to install ${bin.name} via ${bin.via}`);
233
- }
178
+ /**
179
+ * Install what the closure's requirements say is missing here: probe, plan,
180
+ * list every step and ask once (unless `--yes`), apply, then report from a
181
+ * fresh probe — the same flow and rendering as `magus doctor --fix`.
182
+ */
183
+ async function installDeps(
184
+ closure: ResolvedClosure,
185
+ env: PlanEnv,
186
+ yes: boolean,
187
+ ): Promise<void> {
188
+ const collected = {
189
+ plugins: Object.keys(closure.plugins),
190
+ bins: closure.bins,
191
+ problems: [],
192
+ };
193
+ const state = await assessBins(collected, env);
194
+ if (state.plan.steps.length === 0) {
195
+ for (const line of depsSectionLines(state, env).lines) console.log(line);
196
+ return;
197
+ }
198
+ const result = await fixDeps(state, env, { yes, interactive: true }, () =>
199
+ assessBins(collected, env),
200
+ );
201
+ if (result.kind === "busy") {
202
+ console.warn(`⚠ ${result.message}`);
203
+ return;
204
+ }
205
+ if (result.kind === "declined") {
206
+ console.log(
207
+ "Plugin dependencies not installed — `magus doctor --fix` installs them later.",
208
+ );
209
+ return;
234
210
  }
211
+ for (const line of renderApplyText(result.outcomes)) console.log(line);
212
+ for (const line of depsSectionLines(result.after, env).lines)
213
+ console.log(line);
235
214
  }
236
215
 
237
216
  async function collectEnv(
@@ -374,7 +353,6 @@ export async function runInstallCommand(
374
353
  return 1;
375
354
  }
376
355
 
377
- await ensureToolchains(closure, flags.yes);
378
356
  await registerClosureMarketplaces(closure.marketplaces);
379
357
  // A marketplace added just now carries the plugin.json files that name
380
358
  // each plugin's dependencies; resolve again so those are installed too.
@@ -439,7 +417,13 @@ export async function runInstallCommand(
439
417
  );
440
418
  // Only what installed: a failed install already printed its own warning.
441
419
  await bringLoadsForward(installed, projectPath);
442
- await installBins(closure.bins);
420
+ // Resolved again: the requirements are read from the copies just installed.
421
+ const env = machineEnv(
422
+ process.env.HOME || homedir(),
423
+ process.env.PATH ?? "",
424
+ process.env.SHELL ?? "",
425
+ );
426
+ if (env) await installDeps(await resolveClosure(), env, flags.yes);
443
427
 
444
428
  if (activeId) {
445
429
  await collectEnv(
@@ -7,7 +7,12 @@
7
7
  * live config first goes into the profile being left.
8
8
  */
9
9
 
10
- import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
10
+ import { homedir } from "node:os";
11
+ import {
12
+ checkBinaries,
13
+ machineEnv,
14
+ missingBinaries,
15
+ } from "../services/doctor-bins.js";
11
16
  import { summarizeClosure } from "../services/install-plan.js";
12
17
  import { getManifestPath, readManifest } from "../services/manifest.js";
13
18
  import { reportDisplaced } from "../services/profile-activate.js";
@@ -173,7 +178,20 @@ export async function switchProfile(
173
178
  // succeeds (the config is in place), but warn so "switched" is not misleading.
174
179
  try {
175
180
  const closure = await resolveProfile(manifest, name);
176
- const missing = missingBinaries(await checkBinaries(closure.bins));
181
+ const env = machineEnv(
182
+ process.env.HOME || homedir(),
183
+ process.env.PATH ?? "",
184
+ process.env.SHELL ?? "",
185
+ );
186
+ const missing = env
187
+ ? missingBinaries(
188
+ await checkBinaries(closure.bins, {
189
+ env,
190
+ depth: "presence",
191
+ capMs: 3_000,
192
+ }),
193
+ )
194
+ : [];
177
195
  if (missing.length > 0) {
178
196
  console.log(
179
197
  `\n⚠ ${missing.length} binary dependenc${missing.length === 1 ? "y is" : "ies are"} missing:`,
package/src/cli/router.ts CHANGED
@@ -282,8 +282,12 @@ without one is offered adoption on first run. See docs/team-configuration.md.
282
282
  Changes Claude Code makes to .claude/settings.json or .mcp.json (a plugin
283
283
  installed, a setting changed) are changes to the active profile: every command
284
284
  above records them in .claude/profiles.json before it acts.
285
- doctor Check binary deps, the active profile, and conventions
286
- --fix Apply the repairs it can make itself
285
+ doctor Check plugin dependencies, the active profile, and conventions
286
+ --fix Apply the repairs it can make itself, including installing
287
+ missing plugin dependencies (asks once, listing each)
288
+ --yes Install without asking
289
+ --json Plugin dependencies only, as one JSON document
290
+ --project <dir> Check the project in <dir> instead of this directory
287
291
 
288
292
  Per-subagent model routing — give each subagent its own model and effort. The
289
293
  routing lives on the active profile (committed in .claude/profiles.json) and is
@@ -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
@@ -37,6 +37,7 @@
37
37
  import { selectProfileId } from "../services/active-closure.js";
38
38
  import { isClaudeAvailable } from "../services/claude-cli.js";
39
39
  import { clearContentDriftCache } from "../services/content-drift.js";
40
+ import { refusedPinWhy } from "../services/deps-plan.js";
40
41
  import { validateManifest } from "../services/manifest.js";
41
42
  import {
42
43
  refreshRegisteredMarketplaces,
@@ -47,6 +48,7 @@ import {
47
48
  type PluginInfo,
48
49
  clearMarketplaceCache,
49
50
  getAvailablePlugins,
51
+ isInstalledAnywhere,
50
52
  } from "../services/plugin-manager.js";
51
53
  import {
52
54
  recordRefetchedSkills,
@@ -61,14 +63,16 @@ import {
61
63
  switchHint,
62
64
  withCliBookkeeping,
63
65
  } from "../services/profile-state.js";
64
- import { resolveProfile } from "../services/resolver.js";
66
+ import { requirersHere, resolveProfile } from "../services/resolver.js";
65
67
  import { getInstalledSkillNames } from "../services/skills-manager.js";
66
68
  import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
69
+ import { managedBins } from "../services/toolchain.js";
67
70
  // The apply half lives in services/ so the TUI runs the SAME code — see the
68
71
  // header of update-engine.ts for what drifted while it did not.
69
72
  import {
70
73
  type ApplyReporter,
71
74
  applyPlan,
75
+ describeRemoved,
72
76
  planWorkItems,
73
77
  } from "../services/update-engine.js";
74
78
  import {
@@ -95,6 +99,7 @@ import {
95
99
  note as noteLine,
96
100
  nothingToApplyNotice,
97
101
  ok as okLine,
102
+ pendingHeadline,
98
103
  planReport,
99
104
  stepFrame,
100
105
  stepRecord,
@@ -315,6 +320,14 @@ export async function runUpdateCommand(
315
320
  return resolved;
316
321
  });
317
322
 
323
+ // A pin the owning plugin's option cannot carry: whatever installs or
324
+ // upgrades the tool ignores it, so say so rather than let it read as kept.
325
+ for (const bin of closure.bins) {
326
+ for (const pin of bin.refusedPins ?? []) {
327
+ notices.push(warnLine(`${bold(bin.name)}: ${refusedPinWhy(bin, pin)}`));
328
+ }
329
+ }
330
+
318
331
  // "latest" is only as fresh as the catalog. Fast-forward the marketplace
319
332
  // clones first, then drop every cached answer derived from the old HEAD —
320
333
  // skipping this is how a clone sits days behind while every plugin reads as
@@ -386,13 +399,23 @@ export async function runUpdateCommand(
386
399
  // "behind?" answer — the same probe the TUI's CLI Tools screen uses. It used
387
400
  // to be a presence check, which is why every present unpinned tool planned
388
401
  // as an upgrade on every run.
402
+ //
403
+ // Only the requirements installed by a versioned package manager have a
404
+ // version to move. The rest (apt, a pinned release, a toolchain bootstrap,
405
+ // a plugin's uv env) are as current as their declaration, and
406
+ // `magus doctor --fix` installs them.
407
+ const tools = managedBins(closure.bins);
408
+ const declaredOnly = closure.bins.length - tools.length;
389
409
  const toolFacts = await runStep(steps.binaries, async (say) => {
390
- if (closure.bins.length === 0) {
391
- steps.binaries.detail = "none declared";
410
+ if (tools.length === 0) {
411
+ steps.binaries.detail =
412
+ declaredOnly > 0
413
+ ? `${declaredOnly} managed by their declaration`
414
+ : "none declared";
392
415
  return [];
393
416
  }
394
- steps.binaries.progress = { done: 0, total: closure.bins.length };
395
- const facts = await probeTools(closure.bins, REAL_TOOL_PROBE_IO, {
417
+ steps.binaries.progress = { done: 0, total: tools.length };
418
+ const facts = await probeTools(tools, REAL_TOOL_PROBE_IO, {
396
419
  // `settled`, not `index`: results arrive in completion order, and a
397
420
  // meter driven by index would jump backwards.
398
421
  onResult: (f, { settled, total }) => {
@@ -400,7 +423,7 @@ export async function runUpdateCommand(
400
423
  say(f.name);
401
424
  },
402
425
  });
403
- const items = planBinUpdates(closure.bins, facts);
426
+ const items = planBinUpdates(tools, facts);
404
427
  const n = (a: string) => items.filter((i) => i.action === a).length;
405
428
  const behind = n("upgrade");
406
429
  const unknown = n("unknown");
@@ -410,6 +433,7 @@ export async function runUpdateCommand(
410
433
  behind > 0 ? `${behind} behind` : "",
411
434
  unknown > 0 ? `${unknown} unknown` : "",
412
435
  missing > 0 ? `${missing} missing` : "",
436
+ declaredOnly > 0 ? `${declaredOnly} managed by their declaration` : "",
413
437
  ]
414
438
  .filter(Boolean)
415
439
  .join(" · ");
@@ -441,12 +465,20 @@ export async function runUpdateCommand(
441
465
  targetId,
442
466
  closure.skills,
443
467
  );
468
+ // Every installed plugin's dependencies count, not only the profile's: a
469
+ // plugin still required by one that stays is never planned for removal, and
470
+ // retired plugins that only require each other are removed together.
471
+ const requiredBy = await requirersHere(
472
+ closure.dependencies?.requiredBy,
473
+ [...catalog.values()].filter(isInstalledAnywhere).map((p) => p.id),
474
+ projectPath,
475
+ );
444
476
  const plan: UpdatePlan = {
445
477
  profileId: targetId,
446
- plugins: planPluginUpdates(closure.plugins, catalog),
447
- bins: planBinUpdates(closure.bins, toolFacts),
478
+ plugins: planPluginUpdates(closure.plugins, catalog, requiredBy),
479
+ bins: planBinUpdates(tools, toolFacts),
448
480
  skills: planSkillUpdates(skillRefs, installedSkills),
449
- binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
481
+ binSpecs: new Map(tools.map((b) => [b.name, b])),
450
482
  };
451
483
 
452
484
  for (const line of planReport(plan)) console.log(line);
@@ -489,7 +521,9 @@ export async function runUpdateCommand(
489
521
  return 0;
490
522
  }
491
523
  console.error(
492
- failLine(`Updates available. Run ${bold("magus update")} to apply.`),
524
+ failLine(
525
+ `${pendingHeadline(plan)}. Run ${bold("magus update")} to apply.`,
526
+ ),
493
527
  );
494
528
  return 1;
495
529
  }
@@ -506,6 +540,8 @@ export async function runUpdateCommand(
506
540
  counts.unknown,
507
541
  0,
508
542
  Date.now() - startedAt,
543
+ 0,
544
+ counts.kept,
509
545
  ))
510
546
  console.log(line);
511
547
  // `planHasWork` counts an `unknown` plugin as work, because from the
@@ -539,8 +575,10 @@ export async function runUpdateCommand(
539
575
 
540
576
  // No confirmation. `update` moves the machine toward what the profile
541
577
  // 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
578
+ // and installs the newest published one, so there is no downgrade for a
579
+ // prompt to protect against. The one delete it makes is a DEPRECATED plugin:
580
+ // its marketplace no longer publishes it and Claude Code refuses to load it
581
+ // (measured, 2.1.284), so removing it loses nothing. The plan is printed above, so the
544
582
  // run is still legible after the fact — and `--dry-run` shows it beforehand.
545
583
  //
546
584
  // Note this also runs a profile's CLI-tool installers unattended (`brew
@@ -637,9 +675,14 @@ export async function runUpdateCommand(
637
675
  failed.length,
638
676
  Date.now() - startedAt,
639
677
  outcome.unchanged.length,
678
+ counts.kept,
640
679
  ))
641
680
  console.log(line);
642
681
 
682
+ // Only the CLI says it: on the Plugins screen the removed rows are simply gone.
683
+ const removed = describeRemoved(outcome.plugins.removed);
684
+ if (removed) console.log(okLine(removed));
685
+
643
686
  // Exited 0, read back unmoved. Reported, never fatal — the same precedent
644
687
  // as the pin mismatches below: this is a standing property of the machine
645
688
  // (a stale tap, a shadowing PATH entry), not something this run did wrong,
@@ -13,13 +13,16 @@ import { MAGUS_NPM_PACKAGE } from "./npm-package.js";
13
13
  * catalogue entry can no longer smuggle shell syntax (that same entry's command
14
14
  * was a `&&` chain) into something that gets executed.
15
15
  */
16
- export interface CliTool {
16
+ interface CatalogueDisplay {
17
17
  name: string;
18
18
  displayName: string;
19
19
  description: string;
20
- checkCommand: string;
21
20
  website: string;
22
21
  category: "ai-coding" | "utility";
22
+ }
23
+
24
+ export interface CliTool extends CatalogueDisplay {
25
+ checkCommand: string;
23
26
  /**
24
27
  * Reuses `BinInstaller` rather than restating its members, so the
25
28
  * correspondence with the resolver in `services/toolchain.ts` — which is
@@ -29,6 +32,22 @@ export interface CliTool {
29
32
  packageName: string;
30
33
  }
31
34
 
35
+ /**
36
+ * A catalogue tool a plugin declares in its `requires`. The entry carries
37
+ * display fields only: how the tool is checked and installed is that plugin's
38
+ * declaration, read through requires-probe (`resolveCatalogue`), so the two
39
+ * can never disagree.
40
+ */
41
+ export interface OwnedCatalogueEntry extends CatalogueDisplay {
42
+ /** `<plugin>@<marketplace>` whose `requires` declares this tool. */
43
+ ownedBy: string;
44
+ }
45
+
46
+ export type CatalogueEntry = CliTool | OwnedCatalogueEntry;
47
+
48
+ export const isOwnedEntry = (e: CatalogueEntry): e is OwnedCatalogueEntry =>
49
+ "ownedBy" in e;
50
+
32
51
  /**
33
52
  * Claude Code's npm package. Named once because two readers need it: the
34
53
  * catalogue entry below, and the probe, which asks this package's `latest`
@@ -37,7 +56,7 @@ export interface CliTool {
37
56
  */
38
57
  export const CLAUDE_CODE_NPM_PACKAGE = "@anthropic-ai/claude-code";
39
58
 
40
- export const cliTools: CliTool[] = [
59
+ export const cliTools: CatalogueEntry[] = [
41
60
  {
42
61
  name: "magus",
43
62
  displayName: "magus",
@@ -56,21 +75,17 @@ export const cliTools: CliTool[] = [
56
75
  displayName: "Mnemex",
57
76
  description:
58
77
  "AST-aware code search with PageRank, callers/callees, and semantic embeddings",
59
- checkCommand: "mnemex --version",
60
78
  website: "https://github.com/MadAppGang/mnemex",
61
79
  category: "ai-coding",
62
- packageManager: "bun",
63
- packageName: "mnemex",
80
+ ownedBy: "mnemex@magus",
64
81
  },
65
82
  {
66
83
  name: "claudish",
67
84
  displayName: "Claudish",
68
85
  description: "Run Claude Code with OpenRouter models (Grok, GPT-5, Gemini)",
69
- checkCommand: "claudish --version",
70
86
  website: "https://github.com/MadAppGang/claudish",
71
87
  category: "ai-coding",
72
- packageManager: "bun",
73
- packageName: "claudish",
88
+ ownedBy: "claudish@magus",
74
89
  },
75
90
  {
76
91
  name: "claude",
@@ -15,9 +15,9 @@
15
15
  */
16
16
 
17
17
  import type {
18
- BinRequirement,
19
18
  ProfileManifest,
20
19
  ResolvedClosure,
20
+ SelectedBin,
21
21
  } from "../types/index.js";
22
22
  import { readManifest, validateManifest } from "./manifest.js";
23
23
  import { activeProfile } from "./profile-live.js";
@@ -54,7 +54,7 @@ export interface ActiveClosureDeps {
54
54
  manifest: ProfileManifest,
55
55
  id: string,
56
56
  opts?: {
57
- binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
57
+ binResolver?: (pluginId: string) => Promise<SelectedBin[]>;
58
58
  },
59
59
  ): Promise<ResolvedClosure>;
60
60
  }
@@ -79,7 +79,7 @@ export interface LoadActiveClosureOptions {
79
79
  deps?: Partial<ActiveClosureDeps>;
80
80
  }
81
81
 
82
- const NO_BINS = async (): Promise<BinRequirement[]> => [];
82
+ const NO_BINS = async (): Promise<SelectedBin[]> => [];
83
83
 
84
84
  /**
85
85
  * The active profile's closure for a project.