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
@@ -5,14 +5,18 @@ import {
5
5
  cliToolUninstall,
6
6
  uninstallBlocker,
7
7
  } from "../../services/cli-tool-commands.js";
8
+ import { doctorFixCommand } from "../../shared/plugin/hooks/lib/requires-probe.js";
8
9
  import { formatCommand } from "../../utils/command-utils.js";
9
10
  import {
10
11
  type CliToolEnterAction,
11
12
  type CliToolRow,
13
+ type CliToolsRow,
14
+ type PluginManagedRow,
12
15
  cliToolEnterAction,
13
16
  cliToolStatusText,
14
17
  displayVersion,
15
18
  isInstalled,
19
+ isPluginManagedRow,
16
20
  } from "../adapters/cliToolsAdapter.js";
17
21
  import { rowLead } from "../components/primitives/index.js";
18
22
  import { type UiColor, theme } from "../theme.js";
@@ -94,13 +98,139 @@ function toneColor(tone: StatusTone): UiColor {
94
98
  }
95
99
  }
96
100
 
101
+ // ─── Plugin-managed rows ───────────────────────────────────────────────────────
102
+
103
+ /** The mark and words for a plugin-managed row, from the dependency probe. */
104
+ function managedStatus(presence: PluginManagedRow["presence"]): {
105
+ icon: string;
106
+ color: UiColor;
107
+ text: string;
108
+ } {
109
+ switch (presence?.status) {
110
+ case undefined:
111
+ return { icon: "○", color: theme.colors.muted, text: "● Checking..." };
112
+ case "present":
113
+ return { icon: "●", color: theme.colors.success, text: "● Installed" };
114
+ case "off-path":
115
+ return {
116
+ icon: "!",
117
+ color: theme.colors.warning,
118
+ text: `! Installed in ${presence.prefix}, which is not on the PATH Claude Code starts with`,
119
+ };
120
+ case "missing":
121
+ return { icon: "○", color: theme.colors.muted, text: "○ Not installed" };
122
+ case "unknown":
123
+ return {
124
+ icon: "?",
125
+ color: theme.colors.warning,
126
+ text: `? Could not tell: ${presence.reason}`,
127
+ };
128
+ }
129
+ }
130
+
131
+ function renderPluginManagedRow(
132
+ row: PluginManagedRow,
133
+ isSelected: boolean,
134
+ ): React.ReactNode {
135
+ const { icon, color } = managedStatus(row.presence);
136
+ const suffix =
137
+ row.presence === null ? " ..." : ` · managed by ${row.managedBy}`;
138
+ if (isSelected) {
139
+ return (
140
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
141
+ {rowLead(true)}
142
+ {icon} {row.entry.displayName}
143
+ {suffix}{" "}
144
+ </text>
145
+ );
146
+ }
147
+ return (
148
+ <text fg={theme.colors.text}>
149
+ <span fg={color}>
150
+ {rowLead(false)}
151
+ {icon}
152
+ </span>
153
+ <span fg={theme.colors.text}> {row.entry.displayName}</span>
154
+ <span fg={theme.colors.dim}>{suffix}</span>
155
+ </text>
156
+ );
157
+ }
158
+
159
+ function renderPluginManagedDetail(
160
+ row: PluginManagedRow,
161
+ width: number,
162
+ ): React.ReactNode {
163
+ const { entry, managedBy, presence } = row;
164
+ const status = managedStatus(presence);
165
+ const here =
166
+ presence?.status === "present" || presence?.status === "off-path";
167
+ return (
168
+ <box flexDirection="column" width={width}>
169
+ <box flexDirection="row" marginBottom={1}>
170
+ <text fg={theme.colors.info}>
171
+ <strong>{entry.displayName}</strong>
172
+ </text>
173
+ </box>
174
+
175
+ <text fg={theme.colors.muted}>{entry.description}</text>
176
+
177
+ <box marginTop={1} flexDirection="column">
178
+ <box flexDirection="row">
179
+ <text fg={theme.colors.muted} flexShrink={0}>
180
+ {"Status "}
181
+ </text>
182
+ <text fg={status.color} minWidth={0}>
183
+ {wrapValue(status.text, width)}
184
+ </text>
185
+ </box>
186
+ <box flexDirection="row">
187
+ <text fg={theme.colors.muted} flexShrink={0}>
188
+ {"Managed "}
189
+ </text>
190
+ <text fg={theme.colors.text}>by {managedBy}</text>
191
+ </box>
192
+ {!here && presence !== null && row.installText ? (
193
+ <box flexDirection="row">
194
+ <text fg={theme.colors.muted} flexShrink={0}>
195
+ {"Install "}
196
+ </text>
197
+ <text fg={theme.colors.info} minWidth={0}>
198
+ {wrapValue(row.installText, width)}
199
+ </text>
200
+ </box>
201
+ ) : null}
202
+ <box flexDirection="row">
203
+ <text fg={theme.colors.muted} flexShrink={0}>
204
+ {"Website "}
205
+ </text>
206
+ <box flexGrow={1} minWidth={0}>
207
+ <text fg={theme.colors.info} wrapMode="char">
208
+ {entry.website}
209
+ </text>
210
+ </box>
211
+ </box>
212
+ </box>
213
+
214
+ <box marginTop={2}>
215
+ <text fg={theme.colors.muted}>
216
+ {wrapText(
217
+ `${managedBy} installs ${entry.name} and decides its version; it cannot be installed, updated or removed from here. ${doctorFixCommand(null)} installs it when it is missing.`,
218
+ width,
219
+ ).join("\n")}
220
+ </text>
221
+ </box>
222
+ </box>
223
+ );
224
+ }
225
+
97
226
  // ─── Row renderer ──────────────────────────────────────────────────────────────
98
227
 
99
228
  export function renderCliToolRow(
100
- row: CliToolRow,
229
+ row: CliToolsRow,
101
230
  _index: number,
102
231
  isSelected: boolean,
103
232
  ): React.ReactNode {
233
+ if (isPluginManagedRow(row)) return renderPluginManagedRow(row, isSelected);
104
234
  const { tool, status, install } = row;
105
235
  const installed = isInstalled(status);
106
236
  const allMethods = install?.all ?? [];
@@ -167,10 +297,13 @@ export function renderCliToolRow(
167
297
  // ─── Detail renderer ───────────────────────────────────────────────────────────
168
298
 
169
299
  export function renderCliToolDetail(
170
- row: CliToolRow | undefined,
300
+ row: CliToolsRow | undefined,
171
301
  /** Usable detail-panel columns (`detailPanelWidth`); the scrollbox does not constrain width. */
172
302
  width: number,
173
303
  ): React.ReactNode {
304
+ if (row && isPluginManagedRow(row)) {
305
+ return renderPluginManagedDetail(row, width);
306
+ }
174
307
  if (!row) {
175
308
  return (
176
309
  <box
@@ -1,5 +1,5 @@
1
1
  import type React from "react";
2
- import type { McpServer } from "../../types/index.js";
2
+ import type { McpRegistryServer, McpServer } from "../../types/index.js";
3
3
  import { rowLead } from "../components/primitives/index.js";
4
4
  import { theme } from "../theme.js";
5
5
 
@@ -10,11 +10,27 @@ import { theme } from "../theme.js";
10
10
  */
11
11
  const SERVER_ICON = "⬢";
12
12
 
13
+ /**
14
+ * A server added or removed this magus session, on its row: a running Claude
15
+ * Code picks the change up only after it restarts. One string for the curated
16
+ * and the registry rows alike.
17
+ */
18
+ const RESTART_MARK = " ↻ restart Claude Code";
19
+
13
20
  // ─── List item types ───────────────────────────────────────────────────────────
14
21
 
15
22
  export type McpListItem =
16
23
  | { kind: "category"; label: string }
17
- | { kind: "server"; server: McpServer; isInstalled: boolean };
24
+ | {
25
+ kind: "server";
26
+ server: McpServer;
27
+ isInstalled: boolean;
28
+ /**
29
+ * Added or removed in this magus session: a running Claude Code picks
30
+ * it up only after it restarts. Absent means false.
31
+ */
32
+ needsRestart?: boolean;
33
+ };
18
34
 
19
35
  // ─── Row renderers ─────────────────────────────────────────────────────────────
20
36
 
@@ -39,13 +55,15 @@ export function renderMcpRow(
39
55
  const { server, isInstalled } = item;
40
56
  const icon = isInstalled ? "●" : "○";
41
57
  const iconColor = isInstalled ? theme.colors.success : theme.colors.muted;
58
+ const restart = item.needsRestart ? RESTART_MARK : "";
42
59
 
43
60
  if (isSelected) {
44
61
  return (
45
62
  <text bg={theme.selection.bg} fg={theme.selection.fg}>
46
63
  {rowLead(true)}
47
64
  {icon} {server.name}
48
- {server.requiresConfig ? " ⚙" : ""}{" "}
65
+ {server.requiresConfig ? " ⚙" : ""}
66
+ {restart}{" "}
49
67
  </text>
50
68
  );
51
69
  }
@@ -58,10 +76,91 @@ export function renderMcpRow(
58
76
  </span>
59
77
  <span fg={theme.colors.text}> {server.name}</span>
60
78
  {server.requiresConfig ? <span fg={theme.colors.warning}> ⚙</span> : null}
79
+ {restart ? <span fg={theme.colors.warning}>{restart}</span> : null}
61
80
  </text>
62
81
  );
63
82
  }
64
83
 
84
+ // ─── Registry rows ─────────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * One server on the MCP Registry screen, with the same two states a curated
88
+ * row on the MCP screen carries: installed in this project, and — when it was
89
+ * added this magus session — that a running Claude Code picks it up only after
90
+ * it restarts.
91
+ *
92
+ * Adding from the registry used to show nothing at all: the restart state was
93
+ * drawn only on curated rows, and a registry server is not one, so the promise
94
+ * "shown on the MCP screen" had no row to be shown on.
95
+ */
96
+ export interface McpRegistryListItem {
97
+ server: McpRegistryServer;
98
+ isInstalled: boolean;
99
+ needsRestart: boolean;
100
+ }
101
+
102
+ export function renderMcpRegistryRow(
103
+ item: McpRegistryListItem,
104
+ _index: number,
105
+ isSelected: boolean,
106
+ ): React.ReactNode {
107
+ const { server, isInstalled, needsRestart } = item;
108
+ const version = server.version ? ` v${server.version}` : "";
109
+ const icon = isInstalled ? "● " : "";
110
+ const restart = needsRestart ? RESTART_MARK : "";
111
+ if (isSelected) {
112
+ return (
113
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
114
+ {rowLead(true)}
115
+ {icon}
116
+ {server.name}
117
+ {version}
118
+ {restart}{" "}
119
+ </text>
120
+ );
121
+ }
122
+ return (
123
+ <text fg={theme.colors.text}>
124
+ <span>{rowLead(false)}</span>
125
+ {icon ? <span fg={theme.colors.success}>{icon}</span> : null}
126
+ <span>
127
+ <strong>{server.name}</strong>
128
+ </span>
129
+ <span fg={theme.colors.info}>{version}</span>
130
+ {restart ? <span fg={theme.colors.warning}>{restart}</span> : null}
131
+ </text>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The registry detail panel's state lines — installed or not, and the
137
+ * restart — in the same words and colours as a curated server's detail.
138
+ */
139
+ export function renderMcpRegistryStatus(
140
+ item: McpRegistryListItem,
141
+ ): React.ReactNode {
142
+ return (
143
+ <box flexDirection="column">
144
+ <box flexDirection="row">
145
+ <text fg={theme.colors.muted}>{"Status "}</text>
146
+ {item.isInstalled ? (
147
+ <text fg={theme.colors.success}>{"● Installed"}</text>
148
+ ) : (
149
+ <text fg={theme.colors.muted}>{"○ Not installed"}</text>
150
+ )}
151
+ </box>
152
+ {item.needsRestart ? (
153
+ <box flexDirection="row">
154
+ <text fg={theme.colors.muted}>{"Takes effect "}</text>
155
+ <text fg={theme.colors.warning} wrapMode="word">
156
+ {"↻ after restarting Claude Code"}
157
+ </text>
158
+ </box>
159
+ ) : null}
160
+ </box>
161
+ );
162
+ }
163
+
65
164
  // ─── Detail renderer ───────────────────────────────────────────────────────────
66
165
 
67
166
  export function renderMcpDetail(
@@ -112,6 +211,14 @@ export function renderMcpDetail(
112
211
  <text fg={theme.colors.muted}>{"○ Not installed"}</text>
113
212
  )}
114
213
  </box>
214
+ {item.needsRestart ? (
215
+ <box flexDirection="row">
216
+ <text fg={theme.colors.muted}>{"Takes effect "}</text>
217
+ <text fg={theme.colors.warning} wrapMode="word">
218
+ {"↻ after restarting Claude Code"}
219
+ </text>
220
+ </box>
221
+ ) : null}
115
222
  <box flexDirection="row">
116
223
  <text fg={theme.colors.muted}>{"Type "}</text>
117
224
  <text fg={theme.colors.text}>
@@ -1,5 +1,6 @@
1
1
  import type React from "react";
2
2
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
3
+ import { mcpSettingsNeededText } from "../../services/plugin-env-needs.js";
3
4
  import {
4
5
  compareVersions,
5
6
  hasMissingFiles,
@@ -10,11 +11,11 @@ import {
10
11
  } from "../../services/plugin-manager.js";
11
12
  import type { ScopeStatus } from "../../services/plugin-manager.js";
12
13
  import type { PluginComponentKind } from "../../services/skills-manager.js";
13
- import type {
14
- PluginUpdateAction,
15
- PluginUpdateItem,
14
+ import {
15
+ type PluginUpdateAction,
16
+ type PluginUpdateItem,
17
+ isKeptDeprecated,
16
18
  } from "../../services/update-plan.js";
17
- import { isKnownVersion } from "../../services/version-snapshot.js";
18
19
  import type { PluginRelease } from "../../types/index.js";
19
20
  import { highlightMatches } from "../../utils/fuzzy-search.js";
20
21
  import type {
@@ -43,7 +44,11 @@ import type {
43
44
  import { componentChip } from "../theme-mode.js";
44
45
  import { type UiColor, theme } from "../theme.js";
45
46
  import { renderMarkdown } from "./markdownRenderer.js";
46
- import { type PluginRowSegment, pluginRowText } from "./pluginRowText.js";
47
+ import {
48
+ type PluginRowSegment,
49
+ isKnownVersion,
50
+ pluginRowText,
51
+ } from "./pluginRowText.js";
47
52
  import { type StatusTone, pluginStatusText } from "./pluginStatusText.js";
48
53
 
49
54
  // ─── Category renderers ───────────────────────────────────────────────────────
@@ -285,6 +290,7 @@ const ACTION_LABEL: Record<PluginUpdateAction, string> = {
285
290
  // The DETAIL panel's label, not the row's — this is where the mechanism is
286
291
  // allowed to be named, and the note beside it spells out both versions.
287
292
  "stale-load": "BROKEN — OLDER VERSION LOADS",
293
+ remove: "DEPRECATED",
288
294
  unknown: "UNKNOWN",
289
295
  };
290
296
 
@@ -321,12 +327,14 @@ function StatusLine({
321
327
  : status.action === "update" && status.target
322
328
  ? `→ v${status.target}`
323
329
  : "";
330
+ // Moved or still required: kept on purpose, which is known — never UNKNOWN.
331
+ const label = isKeptDeprecated(status) ? "KEPT" : ACTION_LABEL[status.action];
324
332
  return (
325
333
  <KeyValueLine
326
334
  label="Status"
327
335
  value={
328
336
  <span>
329
- <span fg={toneColor(tone)}>{ACTION_LABEL[status.action]}</span>
337
+ <span fg={toneColor(tone)}>{label}</span>
330
338
  {detail ? <span fg={theme.colors.text}> {detail}</span> : null}
331
339
  </span>
332
340
  }
@@ -349,6 +357,57 @@ function pluginDetail(
349
357
 
350
358
  // Published by no marketplace we read. The status line says which of the
351
359
  // three things that means — moved, withdrawn, or its marketplace unread.
360
+ //
361
+ // Deprecated — a marketplace we read no longer lists it — is the one of the
362
+ // three that is a fact, and gets one plain line in the user's terms. The row
363
+ // already says `a` removes it; `d` removes just this one.
364
+ if (plugin.isOrphaned && status?.action === "remove") {
365
+ return (
366
+ <box flexDirection="column">
367
+ <box justifyContent="center">
368
+ <text bg={theme.colors.danger} fg={theme.hints.fg}>
369
+ <strong> {plugin.name} — DEPRECATED </strong>
370
+ </text>
371
+ </box>
372
+ {/* No Status line: the heading says DEPRECATED and the line below
373
+ says why, so the note would be the same fact a third time. */}
374
+ <box marginTop={1}>
375
+ <text fg={theme.colors.text}>
376
+ No longer published by{" "}
377
+ {plugin.marketplaceDisplay || plugin.marketplace}. Claude Code does
378
+ not load it.
379
+ </text>
380
+ </box>
381
+ <ActionHints hints={[{ key: "d", label: "Remove", tone: "danger" }]} />
382
+ </box>
383
+ );
384
+ }
385
+ // Kept on purpose: it moved to another marketplace, or a plugin that stays
386
+ // still requires it. The status line's note says which; nothing is wrong
387
+ // enough for amber, and `d` would only undo a deliberate keep.
388
+ if (plugin.isOrphaned && status && isKeptDeprecated(status)) {
389
+ return (
390
+ <box flexDirection="column">
391
+ <box justifyContent="center">
392
+ <text bg={theme.colors.muted} fg={theme.hints.fg}>
393
+ <strong> {plugin.name} — KEPT </strong>
394
+ </text>
395
+ </box>
396
+ <box marginTop={1}>
397
+ <StatusLine status={status} />
398
+ </box>
399
+ <box marginTop={1}>
400
+ <text fg={theme.colors.muted}>
401
+ No longer published by{" "}
402
+ {plugin.marketplaceDisplay || plugin.marketplace}.{" "}
403
+ {status.reason === "moved"
404
+ ? "magus leaves this copy installed; the plugin is still published elsewhere."
405
+ : "magus keeps it installed while a plugin that needs it stays."}
406
+ </text>
407
+ </box>
408
+ </box>
409
+ );
410
+ }
352
411
  if (plugin.isOrphaned) {
353
412
  return (
354
413
  <box flexDirection="column">
@@ -466,6 +525,18 @@ function pluginDetail(
466
525
  </text>
467
526
  </box>
468
527
  ) : null}
528
+ {/*
529
+ * A lasting state, not a message: derived from the current config each
530
+ * load, so it is here for as long as the variables are unset — however
531
+ * the install question was answered — and gone once they are set.
532
+ */}
533
+ {item.mcpSettingsNeeded ? (
534
+ <box>
535
+ <text fg={theme.colors.warning}>
536
+ ⚙ {mcpSettingsNeededText(item.mcpSettingsNeeded)}
537
+ </text>
538
+ </box>
539
+ ) : null}
469
540
  {/*
470
541
  * THE panel where the mechanism is spelled out in full. The row above
471
542
  * says only "BROKEN — press a to fix", on purpose; a reader who wants to
@@ -12,27 +12,43 @@
12
12
  * - an orphan's text depended on the cursor — the selected row said
13
13
  * "moved → X", the unselected one always said "deprecated".
14
14
  *
15
+ * A row shows the current version and the state, nothing about history. It
16
+ * used to badge what changed since magus last looked ("v1.8.0 → v1.7.4
17
+ * updated"), from a snapshot of versions seen, and rendered a downgrade as
18
+ * "updated" beside an offer to update again.
19
+ *
15
20
  * Pure and `.ts`, so a test can assert on exactly what the row prints.
16
21
  */
17
22
 
18
- import { isKnownVersion } from "../../services/version-snapshot.js";
19
23
  import type { PluginPluginItem } from "../adapters/pluginsAdapter.js";
20
24
  import { type StatusTone, pluginStatusText } from "./pluginStatusText.js";
21
25
 
26
+ /**
27
+ * Sentinel recorded by Claude Code for a plugin that is installed but whose
28
+ * version is unknown — every plugin in the official Anthropic marketplace uses
29
+ * it. It is not a real version and must never be rendered as one.
30
+ */
31
+ export const UNKNOWN_VERSION = "0.0.0";
32
+
33
+ /** True when a version string carries information worth printing. */
34
+ export function isKnownVersion(v: string | undefined): v is string {
35
+ return !!v && v !== UNKNOWN_VERSION;
36
+ }
37
+
22
38
  export interface PluginRowSegment {
23
39
  text: string;
24
40
  tone: StatusTone | "text";
25
41
  }
26
42
 
27
43
  export interface PluginRowText {
28
- /** After the name: the version, a history badge, then the status suffix. */
44
+ /** After the name: the version, then the status suffix. */
29
45
  segments: PluginRowSegment[];
30
46
  /** The segments joined — exactly what the row renders after the name. */
31
47
  text: string;
32
48
  }
33
49
 
34
50
  export function pluginRowText(item: PluginPluginItem): PluginRowText {
35
- const { plugin, status } = item;
51
+ const { status } = item;
36
52
  const segments: PluginRowSegment[] = [];
37
53
 
38
54
  // A version is shown only when it carries information. "0.0.0" means
@@ -43,32 +59,16 @@ export function pluginRowText(item: PluginPluginItem): PluginRowText {
43
59
  // `plugin.installedVersion` printed a version where the classifier had none —
44
60
  // "v0.8.1 files missing", with 0.8.1 read off a scope not installed here.
45
61
  const installed = status?.installed ?? undefined;
46
- const shown = isKnownVersion(installed) ? installed : undefined;
47
-
48
- // "What just changed" and "what state it is in now" are independent facts
49
- // and both can be true at once: an external update can move a plugin
50
- // 2.9.0 → 3.0.1 while the marketplace already offers 3.3.1. So the badge
51
- // replaces only the version, and the status suffix is always appended.
52
- if (plugin.recentlyUpdatedFrom && shown) {
53
- // Show what actually happened, not just where it landed — the update may
54
- // have come from Claude Code or a direct CLI call without telling us.
55
- segments.push({
56
- text: ` v${plugin.recentlyUpdatedFrom} → v${shown} updated`,
57
- tone: "success",
58
- });
59
- } else if (plugin.recentlyInstalled) {
60
- // Newly seen. A plugin can be genuinely installed with no version we can
61
- // name, so the version is optional here.
62
- segments.push({
63
- text: shown ? ` v${shown} new` : " new",
64
- tone: "success",
65
- });
66
- } else if (shown) {
67
- segments.push({ text: ` v${shown}`, tone: "muted" });
62
+ if (isKnownVersion(installed)) {
63
+ segments.push({ text: ` v${installed}`, tone: "muted" });
68
64
  }
69
65
 
70
66
  const suffix = pluginStatusText(status);
71
67
  if (suffix.suffix) segments.push({ text: suffix.suffix, tone: suffix.tone });
72
68
 
69
+ // Its MCP server lacks variables. The same ⚙ the MCP screen puts on a
70
+ // server that needs configuring; the detail panel says how many, and the key.
71
+ if (item.mcpSettingsNeeded) segments.push({ text: " ⚙", tone: "warning" });
72
+
73
73
  return { segments, text: segments.map((s) => s.text).join("") };
74
74
  }
@@ -11,7 +11,10 @@
11
11
  * which is what makes the two front ends say the same thing.
12
12
  */
13
13
 
14
- import type { PluginUpdateItem } from "../../services/update-plan.js";
14
+ import {
15
+ type PluginUpdateItem,
16
+ isKeptDeprecated,
17
+ } from "../../services/update-plan.js";
15
18
 
16
19
  export type StatusTone = "muted" | "warning" | "danger" | "success" | "info";
17
20
 
@@ -27,6 +30,16 @@ export function pluginStatusText(
27
30
  if (!status) return { suffix: "", tone: "muted" };
28
31
  const withNote = status.note ? ` (${status.note})` : "";
29
32
 
33
+ // No longer published here, and kept on purpose — it moved, or a plugin
34
+ // that stays still requires it. A fact, not "cannot tell": muted, with the
35
+ // note ("moved to Magus Marketing"), never the amber "?".
36
+ if (isKeptDeprecated(status)) {
37
+ return {
38
+ suffix: ` ${status.note ?? "no longer published"}`,
39
+ tone: "muted",
40
+ };
41
+ }
42
+
30
43
  switch (status.action) {
31
44
  case "unknown":
32
45
  // Amber, not red: "cannot tell" is not a failure.
@@ -55,6 +68,10 @@ export function pluginStatusText(
55
68
  // whose session runs a version nobody installed is broken, and there is
56
69
  // now a key that fixes it.
57
70
  return { suffix: " BROKEN — press a to fix", tone: "danger" };
71
+ case "remove":
72
+ // No longer published. Red, like BROKEN: Claude Code refuses to load it,
73
+ // and `a` removes it. Which marketplace dropped it is the detail panel's.
74
+ return { suffix: " deprecated — press a to remove", tone: "danger" };
58
75
  case "install":
59
76
  // `absent` only ever comes from a profile pin (see `pinForRow`), so it
60
77
  // may say so. An undeclared row whose files are missing must not.