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.
- package/package.json +4 -4
- package/src/__tests__/deprecated-remove-all-profiles.test.ts +171 -0
- package/src/__tests__/mcp-registry-install-screen.test.tsx +732 -0
- package/src/__tests__/mcp-registry-install.test.ts +551 -0
- package/src/__tests__/mcp-registry-screen-debounce.test.tsx +195 -0
- package/src/__tests__/mcp-registry-screen.test.ts +209 -0
- package/src/__tests__/not-installed-answer-wrong-scope.test.ts +46 -0
- package/src/__tests__/update-removes-deprecated-other-profiles.test.ts +401 -0
- package/src/cli/update.ts +14 -2
- package/src/services/active-closure.ts +12 -2
- package/src/services/claude-cli.ts +12 -1
- package/src/services/mcp-registry.ts +150 -0
- package/src/services/profile-state.ts +2 -12
- package/src/services/resolver.ts +31 -0
- package/src/services/update-engine.ts +146 -12
- package/src/services/update-plan.ts +42 -2
- package/src/ui/keymap.ts +1 -0
- package/src/ui/renderers/mcpRenderers.tsx +6 -1
- package/src/ui/screens/McpRegistryScreen.tsx +146 -26
- package/src/ui/screens/PluginsScreen.tsx +24 -38
package/src/services/resolver.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
860
|
-
* even when the first fails part-way: a
|
|
861
|
-
* marketplace publishes only ever asks for an
|
|
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
|
|
865
|
-
* profile
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
)
|
|
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) => [
|
|
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 {
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
{
|
|
320
|
-
|
|
321
|
-
<text fg={theme.colors.
|
|
322
|
-
|
|
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
|
};
|