magus-cli 7.11.4 → 7.11.6

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.
@@ -285,6 +285,37 @@ export function resolveExtends(
285
285
  };
286
286
  }
287
287
 
288
+ /** Whether a profile entry, `extends` included, turns the plugin on. */
289
+ export function profileLists(
290
+ entry: ProfileManifestEntry | undefined,
291
+ pluginId: string,
292
+ ): boolean {
293
+ if (!entry) return false;
294
+ const value = resolveExtends(entry).plugins?.[pluginId];
295
+ return value !== undefined && value !== false;
296
+ }
297
+
298
+ /**
299
+ * Every plugin id some profile in the manifest turns on, `extends` included —
300
+ * by {@link profileLists}, the test a removal uses to find the profiles it
301
+ * must edit.
302
+ *
303
+ * The planner's view of the profiles that are NOT active. The render writes
304
+ * `false` for each of these into the live settings.json, so a deprecated one
305
+ * shows as a row on the Plugins screen even though the active profile never
306
+ * names it; `magus update` and the screen both pass this set to the planner,
307
+ * which is how such a plugin gets planned for removal at all.
308
+ */
309
+ export function pluginsAnyProfileLists(manifest: ProfileManifest): string[] {
310
+ const out = new Set<string>();
311
+ for (const entry of Object.values(manifest.profiles)) {
312
+ for (const pluginId of Object.keys(resolveExtends(entry).plugins ?? {})) {
313
+ if (profileLists(entry, pluginId)) out.add(pluginId);
314
+ }
315
+ }
316
+ return [...out];
317
+ }
318
+
288
319
  /** The pin a selected requirement installs at, if its chosen option carries one. */
289
320
  export function pinOfBin(bin: SelectedBin): string | undefined {
290
321
  const option = bin.chosen;
@@ -57,6 +57,7 @@ import {
57
57
  inheritedRowsOf,
58
58
  loadedVersionHere,
59
59
  notDownloadedReason,
60
+ readInheritedRegistryRows,
60
61
  readInstalledPluginsRegistry,
61
62
  repairStaleLoad,
62
63
  rowBelongsToRepository,
@@ -69,9 +70,15 @@ import {
69
70
  readInstalledVersionForScope,
70
71
  saveInstalledPluginVersionForScope,
71
72
  } from "./plugin-manager.js";
72
- import { activeProfile } from "./profile-live.js";
73
+ import { reconcileActiveProfile, refuseOnProblems } from "./profile-absorb.js";
74
+ import { regenerateLiveFiles } from "./profile-activate.js";
75
+ import {
76
+ activeProfile,
77
+ hasCurrentMarker,
78
+ readActiveMarker,
79
+ } from "./profile-live.js";
73
80
  import { removePluginFromProfile } from "./profile-state.js";
74
- import { profileDependentsOf } from "./resolver.js";
81
+ import { profileDependentsOf, profileLists } from "./resolver.js";
75
82
  import { installSkill } from "./skills-manager.js";
76
83
  import {
77
84
  type ToolFacts,
@@ -296,6 +303,17 @@ export interface PluginRemoveDeps {
296
303
  projectPath: string,
297
304
  profile: string | null | undefined,
298
305
  ) => Promise<boolean>;
306
+ /**
307
+ * Stop EVERY profile listing a deprecated plugin, the live files absorbed
308
+ * first (`unlistFromEveryProfile`); answers whether none lists it, read
309
+ * back, and throws the first failure. What {@link applyRemove} runs in
310
+ * place of `dropFromProfile` when present. Optional so a double that models
311
+ * one profile keeps doing it through `dropFromProfile`.
312
+ */
313
+ unlistEverywhere?: (
314
+ pluginId: string,
315
+ projectPath: string,
316
+ ) => Promise<boolean>;
299
317
  /**
300
318
  * Clear magus's OWN `installedPluginVersions` claim for the plugin at a
301
319
  * scope it was removed from (`forgetInstalledPluginVersion`). Claude Code
@@ -338,6 +356,12 @@ export const REAL_REMOVE_DEPS: PluginRemoveDeps = {
338
356
  (await enabledAt(scope, projectPath))[pluginId] === true,
339
357
  dropFromProfile: (pluginId, projectPath, profile) =>
340
358
  removePluginFromProfile(projectPath, pluginId, profile),
359
+ unlistEverywhere: async (pluginId, projectPath) => {
360
+ const { problems } = await unlistFromEveryProfile(pluginId, projectPath);
361
+ if (problems.length > 0) throw problems[0];
362
+ const { profiles } = await readManifest(projectPath);
363
+ return !Object.values(profiles).some((e) => profileLists(e, pluginId));
364
+ },
341
365
  forgetVersion: forgetInstalledPluginVersion,
342
366
  // The profile picked as `removePluginFromProfile` picks it, so the check and
343
367
  // the drop judge the same profile. A parse error throws, as it does there.
@@ -856,13 +880,14 @@ async function applyStaleLoad(
856
880
  * Remove ONE deprecated plugin — the `remove` half of {@link applyPlugins}.
857
881
  *
858
882
  * Uninstalls it at every scope the plan found it installed at, through the
859
- * Claude Code CLI wrapper, then stops the profile listing it. Both halves run
860
- * even when the first fails part-way: a profile that keeps a plugin no
861
- * marketplace publishes only ever asks for an install that cannot happen.
883
+ * Claude Code CLI wrapper, then stops every profile listing it
884
+ * (`unlistEverywhere`). Both halves run even when the first fails part-way: a
885
+ * profile that keeps a plugin no marketplace publishes only ever asks for an
886
+ * install that cannot happen.
862
887
  *
863
888
  * Success is READ BACK, never "the calls returned": installed at no scope here
864
- * afterwards — all three are checked, not just the planned ones — and the
865
- * profile no longer lists it.
889
+ * afterwards — all three are checked, not just the planned ones — and no
890
+ * profile lists it.
866
891
  */
867
892
  async function applyRemove(
868
893
  item: PluginUpdateItem,
@@ -914,11 +939,11 @@ async function applyRemove(
914
939
  inheritedError ??= error;
915
940
  }
916
941
  }
917
- const unlisted = await remove.dropFromProfile(
918
- item.pluginId,
919
- projectPath,
920
- profile,
921
- );
942
+ // Every profile that lists it, or one left listing it brings the row back
943
+ // on the next render.
944
+ const unlisted = remove.unlistEverywhere
945
+ ? await remove.unlistEverywhere(item.pluginId, projectPath)
946
+ : await remove.dropFromProfile(item.pluginId, projectPath, profile);
922
947
 
923
948
  const still: PluginScope[] = [];
924
949
  for (const scope of REMOVE_SCOPES) {
@@ -1007,6 +1032,115 @@ async function applyRemove(
1007
1032
  /** Every scope a removal must leave the plugin uninstalled at. */
1008
1033
  const REMOVE_SCOPES: readonly PluginScope[] = ["user", "project", "local"];
1009
1034
 
1035
+ /**
1036
+ * Remove an orphan the plan holds no `remove` item for — the Plugins screen's
1037
+ * `d` on a row the planner keeps rather than removes: it moved to another
1038
+ * marketplace, another plugin still requires it, or its marketplace was not
1039
+ * read. The user asked for it gone, so it goes.
1040
+ *
1041
+ * Uninstalls at every scope here, then each inherited row from its own folder,
1042
+ * then {@link unlistFromEveryProfile}: a key the render writes `false` for is
1043
+ * cleared only once no profile lists the id.
1044
+ *
1045
+ * `problems` holds every real failure; the CLI's "nothing at this scope here"
1046
+ * answer is not one. `unlistedFrom` names the profiles that listed it.
1047
+ */
1048
+ export async function removeDeprecatedPlugin(
1049
+ pluginId: string,
1050
+ projectPath: string,
1051
+ deps: {
1052
+ uninstall?: (id: string, scope: PluginScope, cwd: string) => Promise<void>;
1053
+ inheritedRows?: (
1054
+ id: string,
1055
+ projectPath: string,
1056
+ ) => Promise<InstalledPluginEntry[]>;
1057
+ } = {},
1058
+ ): Promise<{ problems: unknown[]; unlistedFrom: string[] }> {
1059
+ const uninstall = deps.uninstall ?? uninstallPluginViaCli;
1060
+ const inheritedRows = deps.inheritedRows ?? readInheritedRegistryRows;
1061
+ const problems: unknown[] = [];
1062
+ const unlistedFrom: string[] = [];
1063
+
1064
+ for (const scope of REMOVE_SCOPES) {
1065
+ try {
1066
+ await uninstall(pluginId, scope, projectPath);
1067
+ } catch (error) {
1068
+ if (!isNotInstalledAnswer(error)) problems.push(error);
1069
+ }
1070
+ }
1071
+ // A folder of the same repository may hold the install this one only
1072
+ // shows: the CLI removes it only when run from that folder.
1073
+ for (const row of await inheritedRows(pluginId, projectPath)) {
1074
+ if (!row.projectPath) continue;
1075
+ try {
1076
+ await uninstall(pluginId, row.scope, row.projectPath);
1077
+ } catch (error) {
1078
+ problems.push(error);
1079
+ }
1080
+ }
1081
+
1082
+ const unlisted = await unlistFromEveryProfile(pluginId, projectPath);
1083
+ problems.push(...unlisted.problems);
1084
+ return { problems, unlistedFrom: unlisted.unlistedFrom };
1085
+ }
1086
+
1087
+ /**
1088
+ * Stop EVERY profile in the project listing a deprecated plugin, keeping the
1089
+ * user's live edits — the profile half of both removals, {@link applyRemove}
1090
+ * (`magus update`, the Plugins screen's `a` and `d`) and
1091
+ * {@link removeDeprecatedPlugin}.
1092
+ *
1093
+ * Every profile, not only the active one: the render writes `false` for each
1094
+ * id ANY profile lists, so one profile left listing it brings the row back on
1095
+ * the next render. A deprecated plugin can never be installed, so no profile
1096
+ * loses anything it could use.
1097
+ *
1098
+ * The live files are absorbed into the active profile BEFORE any profile is
1099
+ * written, and rendered again after, so what Claude Code or the user wrote
1100
+ * since the last render survives and the id's key is gone. Under
1101
+ * `withCliBookkeeping` (`magus update`) both are left to the wrapper, whose
1102
+ * reconcile absorbs before it renders: reconciling here would write a fresh
1103
+ * marker mid-run, clearing the bookkeeping flag, so the installs that follow
1104
+ * would read as the user's own edits.
1105
+ *
1106
+ * `unlistedFrom` names the profiles that listed it, each READ BACK as no longer
1107
+ * listing it; `problems` holds every failure, a refused reconcile included,
1108
+ * in which case nothing was written.
1109
+ */
1110
+ async function unlistFromEveryProfile(
1111
+ pluginId: string,
1112
+ projectPath: string,
1113
+ ): Promise<{ problems: unknown[]; unlistedFrom: string[] }> {
1114
+ const problems: unknown[] = [];
1115
+ const unlistedFrom: string[] = [];
1116
+ try {
1117
+ const marker = (await hasCurrentMarker(projectPath))
1118
+ ? await readActiveMarker(projectPath)
1119
+ : null;
1120
+ const wrapperReconciles = marker?.cliBookkeeping === true;
1121
+ // Refuses, writing nothing, on an unreadable live file.
1122
+ if (!wrapperReconciles) {
1123
+ refuseOnProblems(await reconcileActiveProfile(projectPath));
1124
+ }
1125
+ const manifest = await readManifest(projectPath);
1126
+ for (const [id, entry] of Object.entries(manifest.profiles)) {
1127
+ if (!profileLists(entry, pluginId)) continue;
1128
+ if (await removePluginFromProfile(projectPath, pluginId, id)) {
1129
+ unlistedFrom.push(id);
1130
+ } else {
1131
+ problems.push(
1132
+ new Error(`the ${entry.name ?? id} profile still lists it`),
1133
+ );
1134
+ }
1135
+ }
1136
+ // Renders nothing, and answers false, when no profile is active.
1137
+ if (!wrapperReconciles) await regenerateLiveFiles(projectPath);
1138
+ } catch (error) {
1139
+ problems.push(error);
1140
+ }
1141
+ return { problems, unlistedFrom };
1142
+ }
1143
+
1010
1144
  /** Options for {@link applyPlugins}. */
1011
1145
  export interface ApplyPluginsOptions {
1012
1146
  /**
@@ -879,6 +879,8 @@ export function classifyPlugin(
879
879
  * Every plugin the active profile declares, under its pin, and every plugin
880
880
  * installed in any scope (user, project, local), under "latest" — the rule
881
881
  * {@link pinForRow} states — each classified by {@link classifyPlugin}, sorted.
882
+ * A plugin only other profiles list joins the plan only to be removed, when it
883
+ * is deprecated ({@link onlyOtherProfilesList}).
882
884
  *
883
885
  * ONE list, read by both surfaces: the screen's rows, header counts and
884
886
  * "update all", and the CLI's report and apply. It used to be two — the CLI
@@ -891,11 +893,16 @@ export function classifyPlugin(
891
893
  * @param catalog getAvailablePlugins() output — keyed by id, or as a list
892
894
  * @param requiredBy closure.dependencies?.requiredBy — which plugin needs each
893
895
  * dependency the profile does not name itself
896
+ * @param listedByProfiles every plugin id ANY profile in the manifest lists
897
+ * (`pluginsAnyProfileLists`). One neither pinned nor
898
+ * installed is planned only as a deprecated `remove`; see
899
+ * {@link onlyOtherProfilesList}.
894
900
  */
895
901
  export function planPluginUpdates(
896
902
  pinned: Readonly<Record<string, string>>,
897
903
  catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
898
904
  requiredBy: RequiredBy = {},
905
+ listedByProfiles: readonly string[] = [],
899
906
  ): PluginUpdateItem[] {
900
907
  const index = indexCatalog(catalog);
901
908
  const plugins =
@@ -908,13 +915,42 @@ export function planPluginUpdates(
908
915
  const pin = pinForRow(info, pinned);
909
916
  if (pin !== null) pins.set(info.id, pin);
910
917
  }
918
+ const othersOnly = new Set<string>();
919
+ for (const pluginId of listedByProfiles) {
920
+ if (pins.has(pluginId)) continue;
921
+ othersOnly.add(pluginId);
922
+ pins.set(pluginId, "latest");
923
+ }
911
924
  return settleDeprecated(
912
925
  [...pins].map(([pluginId, pin]) =>
913
926
  classifyPlugin(pluginId, pin, index, requiredBy),
914
927
  ),
915
928
  index,
916
929
  requiredBy,
917
- ).sort((a, b) => a.pluginId.localeCompare(b.pluginId));
930
+ )
931
+ .filter(
932
+ (item) => !othersOnly.has(item.pluginId) || onlyOtherProfilesList(item),
933
+ )
934
+ .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
935
+ }
936
+
937
+ /**
938
+ * The one item a plugin gets when only profiles that are NOT active list it,
939
+ * and nothing here installs it: a deprecated `remove`.
940
+ *
941
+ * The render writes `false` for it into the live settings.json, so the Plugins
942
+ * screen shows it as a row, and a profile that keeps a plugin no marketplace
943
+ * publishes only ever asks for an install that cannot happen. Removing it here
944
+ * drops it from every profile that lists it, which is what clears the row.
945
+ *
946
+ * Anything else — published, moved, still required, a marketplace we did not
947
+ * read — is no work of this profile's: it is not active, so nothing is
948
+ * installed, updated or kept for it, and it gets no item at all. Judged after
949
+ * {@link settleDeprecated}, so retired plugins that only require each other are
950
+ * still removed together.
951
+ */
952
+ function onlyOtherProfilesList(item: PluginUpdateItem): boolean {
953
+ return item.action === "remove" && item.reason === "deprecated";
918
954
  }
919
955
 
920
956
  /**
@@ -951,9 +987,13 @@ export function classifyPluginRows(
951
987
  plugins: readonly PluginInfo[],
952
988
  pins: Readonly<Record<string, string>>,
953
989
  requiredBy: RequiredBy = {},
990
+ listedByProfiles: readonly string[] = [],
954
991
  ): Map<string, PluginUpdateItem> {
955
992
  return new Map(
956
- planPluginUpdates(pins, plugins, requiredBy).map((i) => [i.pluginId, i]),
993
+ planPluginUpdates(pins, plugins, requiredBy, listedByProfiles).map((i) => [
994
+ i.pluginId,
995
+ i,
996
+ ]),
957
997
  );
958
998
  }
959
999
 
package/src/ui/keymap.ts CHANGED
@@ -294,6 +294,7 @@ const MCP_REGISTRY = [
294
294
  bind("install", ["Enter"], "install", "Add the server to this project", 90),
295
295
  search("Search the registry; the list reloads as you type"),
296
296
  bind("local", ["l"], "local", "Back to this project's MCP servers", 50),
297
+ bind("remove", ["d"], "remove", "Remove the server from this project", 45),
297
298
  bind("reload", ["R"], "reload", "Reload the results", 40),
298
299
  CLEAR_FILTER,
299
300
  MOVE,
@@ -97,6 +97,8 @@ export interface McpRegistryListItem {
97
97
  server: McpRegistryServer;
98
98
  isInstalled: boolean;
99
99
  needsRestart: boolean;
100
+ /** Run by the owner of the domain its name claims. */
101
+ isOfficial: boolean;
100
102
  }
101
103
 
102
104
  export function renderMcpRegistryRow(
@@ -104,10 +106,11 @@ export function renderMcpRegistryRow(
104
106
  _index: number,
105
107
  isSelected: boolean,
106
108
  ): React.ReactNode {
107
- const { server, isInstalled, needsRestart } = item;
109
+ const { server, isInstalled, needsRestart, isOfficial } = item;
108
110
  const version = server.version ? ` v${server.version}` : "";
109
111
  const icon = isInstalled ? "● " : "";
110
112
  const restart = needsRestart ? RESTART_MARK : "";
113
+ const official = isOfficial ? " official" : "";
111
114
  if (isSelected) {
112
115
  return (
113
116
  <text bg={theme.selection.bg} fg={theme.selection.fg}>
@@ -115,6 +118,7 @@ export function renderMcpRegistryRow(
115
118
  {icon}
116
119
  {server.name}
117
120
  {version}
121
+ {official}
118
122
  {restart}{" "}
119
123
  </text>
120
124
  );
@@ -127,6 +131,7 @@ export function renderMcpRegistryRow(
127
131
  <strong>{server.name}</strong>
128
132
  </span>
129
133
  <span fg={theme.colors.info}>{version}</span>
134
+ {official ? <span fg={theme.colors.success}>{official}</span> : null}
130
135
  {restart ? <span fg={theme.colors.warning}>{restart}</span> : null}
131
136
  </text>
132
137
  );
@@ -15,9 +15,17 @@ import {
15
15
  addMcpServer,
16
16
  getEnabledMcpServers,
17
17
  getInstalledMcpServers,
18
+ removeMcpServer,
18
19
  setAllowMcp,
19
20
  } from "../../services/claude-settings.js";
20
- import { formatDate, searchMcpServers } from "../../services/mcp-registry.js";
21
+ import {
22
+ createLatestOnlyGuard,
23
+ formatDate,
24
+ isOfficialMcpServer,
25
+ officialFirst,
26
+ registryInstallPlan,
27
+ searchMcpServers,
28
+ } from "../../services/mcp-registry.js";
21
29
  import type { McpRegistryServer, McpServerConfig } from "../../types/index.js";
22
30
  import { ScrollableList } from "../components/ScrollableList.js";
23
31
  import {
@@ -131,24 +139,52 @@ export function McpRegistryScreen() {
131
139
  void loadInstalled();
132
140
  }, [loadInstalled]);
133
141
 
142
+ // Overlapping loads (first load, debounce, R) can resolve out of order: only
143
+ // the newest request's response may touch state. Unmounting takes a token too,
144
+ // so a response that lands afterwards is dropped.
145
+ const guardRef = useRef(createLatestOnlyGuard());
146
+ // The name under the cursor, so a replaced list keeps it on the same server.
147
+ const selectedNameRef = useRef<string | undefined>(undefined);
148
+ selectedNameRef.current = servers[mcpRegistry.selectedIndex]?.name;
149
+ // The query the list on screen was loaded with: the cursor follows a name
150
+ // only across a reload of the same query, never into a different result set.
151
+ const loadedQueryRef = useRef<string | undefined>(undefined);
152
+ // The same query as state, for rendering: badges follow the list on screen,
153
+ // not the text being typed.
154
+ const [loadedQuery, setLoadedQuery] = useState("");
155
+
134
156
  // Load servers
135
- const loadServers = useCallback(async (query: string) => {
136
- setIsLoading(true);
137
- setError(null);
138
- try {
139
- const response = await searchMcpServers({ query, limit: 50 });
140
- if (!response || !Array.isArray(response.servers)) {
141
- throw new Error("Invalid response from MCP Registry API");
157
+ const loadServers = useCallback(
158
+ async (query: string) => {
159
+ const guard = guardRef.current;
160
+ const token = guard.next();
161
+ setIsLoading(true);
162
+ setError(null);
163
+ try {
164
+ const response = await searchMcpServers({ query, limit: 50 });
165
+ if (!response || !Array.isArray(response.servers)) {
166
+ throw new Error("Invalid response from MCP Registry API");
167
+ }
168
+ if (!guard.isLatest(token)) return;
169
+ // Deduplicate to show only latest version of each server, official first
170
+ const next = officialFirst(deduplicateServers(response.servers), query);
171
+ const sameQuery = loadedQueryRef.current === query;
172
+ const keptAt = sameQuery
173
+ ? next.findIndex((server) => server.name === selectedNameRef.current)
174
+ : -1;
175
+ loadedQueryRef.current = query;
176
+ setLoadedQuery(query);
177
+ setServers(next);
178
+ dispatch({ type: "MCPREGISTRY_SELECT", index: Math.max(0, keptAt) });
179
+ } catch (err) {
180
+ if (!guard.isLatest(token)) return;
181
+ setError(err instanceof Error ? err.message : "Failed to load servers");
182
+ setServers([]);
142
183
  }
143
- // Deduplicate to show only latest version of each server
144
- const deduplicated = deduplicateServers(response.servers);
145
- setServers(deduplicated);
146
- } catch (err) {
147
- setError(err instanceof Error ? err.message : "Failed to load servers");
148
- setServers([]);
149
- }
150
- setIsLoading(false);
151
- }, []);
184
+ if (guard.isLatest(token)) setIsLoading(false);
185
+ },
186
+ [dispatch],
187
+ );
152
188
 
153
189
  // Debounced search
154
190
  const debouncedSearch = useCallback(
@@ -168,7 +204,9 @@ export function McpRegistryScreen() {
168
204
  const initialQueryRef = useRef(searchQuery);
169
205
  useEffect(() => {
170
206
  loadServers(initialQueryRef.current);
207
+ const guard = guardRef.current;
171
208
  return () => {
209
+ guard.next();
172
210
  if (searchTimeoutRef.current) {
173
211
  clearTimeout(searchTimeoutRef.current);
174
212
  }
@@ -179,6 +217,12 @@ export function McpRegistryScreen() {
179
217
 
180
218
  /** A new query: the list reloads, debounced, and the cursor starts over. */
181
219
  const setQuery = (query: string) => {
220
+ // The shown list is stale from this keystroke: drop any response still in
221
+ // flight and mark the list pending until the scheduled load settles, so
222
+ // install/remove cannot act on the old rows. The scheduled loadServers
223
+ // always runs (or the screen unmounts) and clears this in every outcome.
224
+ guardRef.current.next();
225
+ setIsLoading(true);
182
226
  setSearchQuery(query);
183
227
  dispatch({ type: "MCPREGISTRY_SEARCH", query });
184
228
  dispatch({ type: "MCPREGISTRY_SELECT", index: 0 });
@@ -201,6 +245,9 @@ export function McpRegistryScreen() {
201
245
  },
202
246
  search: () => dispatch({ type: "SET_SEARCHING", isSearching: true }),
203
247
  local: () => navigateToScreen("mcp"),
248
+ remove: () => {
249
+ void handleRemove();
250
+ },
204
251
  reload: () => {
205
252
  void loadServers(searchQuery);
206
253
  },
@@ -219,14 +266,53 @@ export function McpRegistryScreen() {
219
266
  },
220
267
  });
221
268
 
269
+ const handleRemove = async () => {
270
+ if (isLoading) return;
271
+ const server = servers[mcpRegistry.selectedIndex];
272
+ if (!server || !installedNames.has(server.name)) return;
273
+
274
+ const confirmed = await modal.confirm(
275
+ `Remove ${server.name}?`,
276
+ "This will remove the MCP server configuration.",
277
+ );
278
+ if (!confirmed) return;
279
+
280
+ modal.loading(`Removing ${server.name}...`);
281
+ try {
282
+ await removeMcpServer(server.name, state.projectPath);
283
+ modal.hideModal();
284
+ dispatch({ type: "MCP_SERVER_CHANGED", name: server.name });
285
+ await loadInstalled();
286
+ } catch (error) {
287
+ modal.hideModal();
288
+ await showFailures(
289
+ stepFailureReport(
290
+ `Could not remove ${server.name}`,
291
+ server.name,
292
+ error,
293
+ ),
294
+ );
295
+ }
296
+ };
297
+
222
298
  const handleInstall = async () => {
299
+ // While loading, the visible panel is "Loading..." and `servers` is the
300
+ // previous, hidden list: installing from it would add a row nobody sees.
301
+ if (isLoading) return;
223
302
  const server = servers[mcpRegistry.selectedIndex];
224
303
  if (!server) return;
225
304
 
226
- const config: McpServerConfig = {
227
- type: "http",
228
- url: server.url,
229
- };
305
+ const plan = registryInstallPlan(server);
306
+ if (!plan.ok) {
307
+ // Nothing is written: the popup comes before the modal and the writes.
308
+ await showFailures({
309
+ headline: `${server.name} wasn't installed`,
310
+ rows: [{ subject: server.name, reason: plan.reason }],
311
+ fix: null,
312
+ });
313
+ return;
314
+ }
315
+ const config: McpServerConfig = plan.config;
230
316
 
231
317
  modal.loading(`Installing ${server.name}...`);
232
318
  try {
@@ -250,6 +336,7 @@ export function McpRegistryScreen() {
250
336
  server,
251
337
  isInstalled: installedNames.has(server.name),
252
338
  needsRestart: state.mcp.changedThisSession.includes(server.name),
339
+ isOfficial: isOfficialMcpServer(server, loadedQuery),
253
340
  }));
254
341
 
255
342
  // Get selected server
@@ -279,6 +366,9 @@ export function McpRegistryScreen() {
279
366
  ? `v${selectedServer.version}`
280
367
  : "unknown";
281
368
 
369
+ const plan = registryInstallPlan(selectedServer);
370
+ const packageRuns = plan.ok && plan.kind !== "http" ? plan.config : null;
371
+
282
372
  return (
283
373
  <box flexDirection="column">
284
374
  <text fg={theme.colors.accent}>
@@ -288,6 +378,30 @@ export function McpRegistryScreen() {
288
378
  <text fg={theme.colors.text}>{selectedServer.short_description}</text>
289
379
  </box>
290
380
  <box marginTop={1}>{renderMcpRegistryStatus(selectedItem)}</box>
381
+ {selectedItem.isOfficial ? (
382
+ <box flexDirection="row">
383
+ <text fg={theme.colors.muted}>{"Publisher "}</text>
384
+ <text fg={theme.colors.success}>{"official"}</text>
385
+ </box>
386
+ ) : null}
387
+ {packageRuns ? (
388
+ <box flexDirection="column" marginTop={1}>
389
+ <box flexDirection="row">
390
+ <text fg={theme.colors.muted}>{"Runs "}</text>
391
+ <text fg={theme.colors.text}>
392
+ {[packageRuns.command, ...(packageRuns.args ?? [])].join(" ")}
393
+ </text>
394
+ </box>
395
+ <text fg={theme.colors.warning}>
396
+ It may need API keys or settings to work. magus doesn't add any.
397
+ </text>
398
+ {selectedServer.source_code_url ? (
399
+ <text fg={theme.colors.muted}>
400
+ The source below says what to set.
401
+ </text>
402
+ ) : null}
403
+ </box>
404
+ ) : null}
291
405
  <box flexDirection="row" marginTop={1}>
292
406
  <text fg={theme.colors.text}>
293
407
  <strong>Version: </strong>
@@ -316,11 +430,17 @@ export function McpRegistryScreen() {
316
430
  </text>
317
431
  </box>
318
432
  )}
319
- {selectedItem.isInstalled ? null : (
320
- <box marginTop={1}>
321
- <text fg={theme.colors.muted}>Press Enter to install</text>
322
- </box>
323
- )}
433
+ <box marginTop={1}>
434
+ {!selectedItem.isInstalled && !plan.ok ? (
435
+ <text fg={theme.colors.warning}>{plan.reason}</text>
436
+ ) : (
437
+ <text fg={theme.colors.muted}>
438
+ {selectedItem.isInstalled
439
+ ? "Press d to remove"
440
+ : "Press Enter to install"}
441
+ </text>
442
+ )}
443
+ </box>
324
444
  </box>
325
445
  );
326
446
  };