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
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import { setColorEnabled, stripAnsi } from "../cli/ansi.js";
|
|
7
|
+
import { planReport } from "../cli/update-view.js";
|
|
8
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
9
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
10
|
+
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
11
|
+
import { reconcileActiveProfile } from "../services/profile-absorb.js";
|
|
12
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
13
|
+
import { withCliBookkeeping } from "../services/profile-state.js";
|
|
14
|
+
import {
|
|
15
|
+
pluginsAnyProfileLists,
|
|
16
|
+
resolveProfile,
|
|
17
|
+
} from "../services/resolver.js";
|
|
18
|
+
import {
|
|
19
|
+
type ApplyReporter,
|
|
20
|
+
type PluginApplyDeps,
|
|
21
|
+
REAL_REMOVE_DEPS,
|
|
22
|
+
applyPlugins,
|
|
23
|
+
describeRemoved,
|
|
24
|
+
} from "../services/update-engine.js";
|
|
25
|
+
import {
|
|
26
|
+
type PluginUpdateItem,
|
|
27
|
+
type UpdatePlan,
|
|
28
|
+
classifyPluginRows,
|
|
29
|
+
planIsBehind,
|
|
30
|
+
planPluginUpdates,
|
|
31
|
+
} from "../services/update-plan.js";
|
|
32
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `magus update` and the Plugins screen plan a deprecated plugin that only a
|
|
36
|
+
* profile which is NOT active lists.
|
|
37
|
+
*
|
|
38
|
+
* The profile render writes `"<id>": false` into `.claude/settings.json` for
|
|
39
|
+
* every id ANY profile lists, and the Plugins screen shows that key as a
|
|
40
|
+
* deprecated row. The planner used to see only the active profile's pins and
|
|
41
|
+
* the plugins installed somewhere, so `magus update --check` answered "Nothing
|
|
42
|
+
* missing and nothing behind." while the row stayed, and `magus update` never
|
|
43
|
+
* removed it. When the active profile did list it, the removal dropped it from
|
|
44
|
+
* that profile only, so another profile kept it and the `false` came back on
|
|
45
|
+
* the next render.
|
|
46
|
+
*
|
|
47
|
+
* CONTRACT:
|
|
48
|
+
* - `pluginsAnyProfileLists(manifest)` (resolver.ts): every plugin id some
|
|
49
|
+
* profile turns on, `extends` resolved.
|
|
50
|
+
* - `planPluginUpdates(pins, catalog, requiredBy, listedByProfiles)` and
|
|
51
|
+
* `classifyPluginRows(plugins, pins, requiredBy, listedByProfiles)`: an id
|
|
52
|
+
* in `listedByProfiles` that is neither pinned nor installed gets a
|
|
53
|
+
* `remove` item with `scopes: []` when it is deprecated, and NO item
|
|
54
|
+
* otherwise (published, moved, still required, marketplace unread).
|
|
55
|
+
* - applying a `remove` drops the id from EVERY profile listing it, absorbing
|
|
56
|
+
* the live files first, so the user's live edits survive and the key is
|
|
57
|
+
* gone from settings.json afterwards.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
const ID = "code-analysis@magus";
|
|
61
|
+
|
|
62
|
+
/** The reporter's shape: the active profile does not list it, two others do. */
|
|
63
|
+
const manifest = (activeListsIt = false): ProfileManifest => ({
|
|
64
|
+
version: 2,
|
|
65
|
+
profiles: {
|
|
66
|
+
default: {
|
|
67
|
+
name: "Default",
|
|
68
|
+
plugins: {
|
|
69
|
+
"dev@magus": "latest",
|
|
70
|
+
...(activeListsIt ? { [ID]: "latest" } : {}),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
maintainer: {
|
|
74
|
+
name: "Maintainer",
|
|
75
|
+
plugins: { "dev@magus": "latest", [ID]: "latest" },
|
|
76
|
+
},
|
|
77
|
+
lean: { name: "Lean", plugins: { [ID]: "latest" } },
|
|
78
|
+
minimal: { name: "Minimal", plugins: { "setup@magus": "latest" } },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
|
|
83
|
+
return {
|
|
84
|
+
name: over.id.split("@")[0] as string,
|
|
85
|
+
version: null,
|
|
86
|
+
description: "",
|
|
87
|
+
marketplace: over.id.split("@")[1] ?? "magus",
|
|
88
|
+
marketplaceDisplay: "",
|
|
89
|
+
...over,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Published and installed here — what makes "magus" a marketplace we read. */
|
|
94
|
+
const DEV = info({
|
|
95
|
+
id: "dev@magus",
|
|
96
|
+
version: "1.0.0",
|
|
97
|
+
projectScope: { installed: true, version: "1.0.0" },
|
|
98
|
+
});
|
|
99
|
+
/** Published, installed nowhere. */
|
|
100
|
+
const SETUP = info({ id: "setup@magus", version: "2.0.0" });
|
|
101
|
+
|
|
102
|
+
/** The id's catalog entry as the screen sees it: a `false` key, an orphan. */
|
|
103
|
+
const ORPHAN = info({ id: ID, isOrphaned: true });
|
|
104
|
+
|
|
105
|
+
const CATALOG: PluginInfo[] = [DEV, SETUP, ORPHAN];
|
|
106
|
+
|
|
107
|
+
function itemsFor(items: PluginUpdateItem[], id: string): PluginUpdateItem[] {
|
|
108
|
+
return items.filter((i) => i.pluginId === id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
afterEach(() => setColorEnabled(null));
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// 1. The planner
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe("planner: a plugin only an inactive profile lists", () => {
|
|
118
|
+
// REGRESSION: `magus update --check` said "Nothing missing and nothing behind." while a deprecated plugin only other profiles list stayed a row — MTL-332
|
|
119
|
+
test("should plan one remove with no scopes for a deprecated id", () => {
|
|
120
|
+
const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
|
|
121
|
+
ID,
|
|
122
|
+
"dev@magus",
|
|
123
|
+
"setup@magus",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const found = itemsFor(items, ID);
|
|
127
|
+
expect(found).toHaveLength(1);
|
|
128
|
+
expect({
|
|
129
|
+
action: found[0]?.action,
|
|
130
|
+
reason: found[0]?.reason,
|
|
131
|
+
scopes: found[0]?.scopes,
|
|
132
|
+
}).toEqual({ action: "remove", reason: "deprecated", scopes: [] });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("should plan a deprecated id with no catalog entry at all", () => {
|
|
136
|
+
const items = planPluginUpdates({ "dev@magus": "latest" }, [DEV], {}, [ID]);
|
|
137
|
+
expect(itemsFor(items, ID).map((i) => i.action)).toEqual(["remove"]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("should plan nothing for a PUBLISHED id only an inactive profile lists", () => {
|
|
141
|
+
const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
|
|
142
|
+
ID,
|
|
143
|
+
"setup@magus",
|
|
144
|
+
]);
|
|
145
|
+
expect(itemsFor(items, "setup@magus")).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("should not plan a remove for one that moved to another marketplace", () => {
|
|
149
|
+
const moved = info({ id: "code-analysis@other", version: "3.0.0" });
|
|
150
|
+
const items = planPluginUpdates(
|
|
151
|
+
{ "dev@magus": "latest" },
|
|
152
|
+
[...CATALOG, moved],
|
|
153
|
+
{},
|
|
154
|
+
[ID],
|
|
155
|
+
);
|
|
156
|
+
expect(itemsFor(items, ID).filter((i) => i.action === "remove")).toEqual(
|
|
157
|
+
[],
|
|
158
|
+
);
|
|
159
|
+
expect(itemsFor(items, ID).filter((i) => i.action === "install")).toEqual(
|
|
160
|
+
[],
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("should not plan a remove for one a staying plugin still requires", () => {
|
|
165
|
+
const items = planPluginUpdates(
|
|
166
|
+
{ "dev@magus": "latest" },
|
|
167
|
+
CATALOG,
|
|
168
|
+
{ [ID]: ["dev@magus"] },
|
|
169
|
+
[ID],
|
|
170
|
+
);
|
|
171
|
+
expect(itemsFor(items, ID).filter((i) => i.action === "remove")).toEqual(
|
|
172
|
+
[],
|
|
173
|
+
);
|
|
174
|
+
expect(itemsFor(items, ID).filter((i) => i.action === "install")).toEqual(
|
|
175
|
+
[],
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("should plan nothing when the id's marketplace was not read", () => {
|
|
180
|
+
const unread = "thing@unread-market";
|
|
181
|
+
const items = planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [
|
|
182
|
+
unread,
|
|
183
|
+
]);
|
|
184
|
+
expect(itemsFor(items, unread)).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("should make --check behind and report it under the removed wording", () => {
|
|
188
|
+
setColorEnabled(false);
|
|
189
|
+
const plan: UpdatePlan = {
|
|
190
|
+
profileId: "default",
|
|
191
|
+
plugins: planPluginUpdates({ "dev@magus": "latest" }, CATALOG, {}, [ID]),
|
|
192
|
+
bins: [],
|
|
193
|
+
skills: [],
|
|
194
|
+
binSpecs: new Map(),
|
|
195
|
+
};
|
|
196
|
+
expect(planIsBehind(plan)).toBe(true);
|
|
197
|
+
const report = planReport(plan).map(stripAnsi).join("\n");
|
|
198
|
+
expect(report).toContain("code-analysis");
|
|
199
|
+
expect(describeRemoved([ID])).toBe(
|
|
200
|
+
"Removed code-analysis — no longer published",
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("pluginsAnyProfileLists", () => {
|
|
206
|
+
test("should list every id some profile turns on, and none turned off", () => {
|
|
207
|
+
const m = manifest();
|
|
208
|
+
const entry = m.profiles.minimal;
|
|
209
|
+
if (entry) entry.plugins = { ...entry.plugins, "off@magus": false };
|
|
210
|
+
expect([...pluginsAnyProfileLists(m)].sort()).toEqual([
|
|
211
|
+
ID,
|
|
212
|
+
"dev@magus",
|
|
213
|
+
"setup@magus",
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// 2. Parity: the CLI's plan and the screen's rows
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe("parity: magus update and the Plugins screen", () => {
|
|
223
|
+
test("should agree on a deprecated id only an inactive profile lists", () => {
|
|
224
|
+
const pins = { "dev@magus": "latest" };
|
|
225
|
+
const listed = pluginsAnyProfileLists(manifest());
|
|
226
|
+
const cli = planPluginUpdates(pins, CATALOG, {}, listed);
|
|
227
|
+
const rows = classifyPluginRows(CATALOG, pins, {}, listed);
|
|
228
|
+
|
|
229
|
+
expect(rows.get(ID)).toEqual(itemsFor(cli, ID)[0]);
|
|
230
|
+
expect(rows.get(ID)?.action).toBe("remove");
|
|
231
|
+
expect(rows.has("setup@magus")).toBe(
|
|
232
|
+
itemsFor(cli, "setup@magus").length > 0,
|
|
233
|
+
);
|
|
234
|
+
expect(rows.has("setup@magus")).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// 3 + 4. Applying the plan on a real project
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/** Measured, Claude Code 2.1.312, from a folder with no row while others hold PROJECT rows. */
|
|
243
|
+
const wrongScope = (scope: PluginScope) =>
|
|
244
|
+
new Error(
|
|
245
|
+
`✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not ${scope}. Use --scope project to uninstall.`,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const SILENT: ApplyReporter = {
|
|
249
|
+
begin: () => {},
|
|
250
|
+
finish: () => {},
|
|
251
|
+
detach: (work) => work(),
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** The real profile half; Claude Code's commands are doubles. */
|
|
255
|
+
function deps(): PluginApplyDeps {
|
|
256
|
+
const unused = async () => {
|
|
257
|
+
throw new Error("a removal must not install, update or repair anything");
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
install: unused,
|
|
261
|
+
update: unused,
|
|
262
|
+
repair: unused,
|
|
263
|
+
readInstalled: unused,
|
|
264
|
+
saveInstalled: unused,
|
|
265
|
+
repairStaleLoad: unused,
|
|
266
|
+
registryRows: async () => [],
|
|
267
|
+
remove: {
|
|
268
|
+
...REAL_REMOVE_DEPS,
|
|
269
|
+
uninstall: async (_id, scope) => {
|
|
270
|
+
throw wrongScope(scope);
|
|
271
|
+
},
|
|
272
|
+
isInstalled: async () => false,
|
|
273
|
+
forgetVersion: async () => {},
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Both callers: `magus update` (under bookkeeping) and the Plugins screen. */
|
|
279
|
+
const SURFACES = [
|
|
280
|
+
["magus update", true],
|
|
281
|
+
["the Plugins screen", false],
|
|
282
|
+
] as const;
|
|
283
|
+
|
|
284
|
+
describe("applying the plan removes it from every profile", () => {
|
|
285
|
+
let project: string;
|
|
286
|
+
let log: ReturnType<typeof spyOn>;
|
|
287
|
+
const settingsPath = () => join(project, ".claude", "settings.json");
|
|
288
|
+
const profilesPath = () => join(project, ".claude", "profiles.json");
|
|
289
|
+
|
|
290
|
+
async function setUp(activeListsIt: boolean) {
|
|
291
|
+
await writeManifest(manifest(activeListsIt), project);
|
|
292
|
+
await activateProfile(manifest(activeListsIt), "default", project, {
|
|
293
|
+
onDisplace: () => {},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The plan exactly as `magus update` makes it. */
|
|
298
|
+
async function plan(): Promise<PluginUpdateItem[]> {
|
|
299
|
+
const m = await readManifest(project);
|
|
300
|
+
const closure = await resolveProfile(m, "default");
|
|
301
|
+
return planPluginUpdates(
|
|
302
|
+
closure.plugins,
|
|
303
|
+
CATALOG,
|
|
304
|
+
{},
|
|
305
|
+
pluginsAnyProfileLists(m),
|
|
306
|
+
).filter((i) => i.action === "remove");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function apply(items: PluginUpdateItem[], bookkept: boolean) {
|
|
310
|
+
const run = () =>
|
|
311
|
+
applyPlugins(items, project, deps(), SILENT, { profile: "default" });
|
|
312
|
+
return bookkept ? withCliBookkeeping(project, run) : run();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
beforeEach(async () => {
|
|
316
|
+
project = await mkdtemp(join(tmpdir(), "update-deprecated-others-"));
|
|
317
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
318
|
+
});
|
|
319
|
+
afterEach(async () => {
|
|
320
|
+
log.mockRestore();
|
|
321
|
+
await rm(project, { recursive: true, force: true });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
for (const [surface, bookkept] of SURFACES) {
|
|
325
|
+
// REGRESSION: `magus update` never removed a deprecated plugin only inactive profiles list; its `false` key stayed in settings.json — MTL-332
|
|
326
|
+
test(`${surface}: no profile lists it, untouched profiles are byte-equal, no settings key`, async () => {
|
|
327
|
+
await setUp(false);
|
|
328
|
+
// Seeded red: the render wrote the key that makes the row.
|
|
329
|
+
expect((await fs.readJson(settingsPath())).enabledPlugins[ID]).toBe(
|
|
330
|
+
false,
|
|
331
|
+
);
|
|
332
|
+
const before = (await fs.readJson(profilesPath())).profiles;
|
|
333
|
+
|
|
334
|
+
const items = await plan();
|
|
335
|
+
expect(items.map((i) => i.pluginId)).toEqual([ID]);
|
|
336
|
+
const result = await apply(items, bookkept);
|
|
337
|
+
|
|
338
|
+
expect(result.failed).toEqual([]);
|
|
339
|
+
expect(result.removed).toEqual([ID]);
|
|
340
|
+
const after = (await readManifest(project)).profiles;
|
|
341
|
+
for (const id of Object.keys(after)) {
|
|
342
|
+
expect(after[id]?.plugins?.[ID]).toBeUndefined();
|
|
343
|
+
}
|
|
344
|
+
expect(after.maintainer?.plugins).toEqual({ "dev@magus": "latest" });
|
|
345
|
+
const raw = (await fs.readJson(profilesPath())).profiles;
|
|
346
|
+
expect(raw.default).toEqual(before.default);
|
|
347
|
+
expect(raw.minimal).toEqual(before.minimal);
|
|
348
|
+
|
|
349
|
+
const live = (await fs.readJson(settingsPath())).enabledPlugins;
|
|
350
|
+
expect(Object.hasOwn(live, ID)).toBe(false);
|
|
351
|
+
expect(live["dev@magus"]).toBe(true);
|
|
352
|
+
|
|
353
|
+
// It does not come back on the next entry point.
|
|
354
|
+
await reconcileActiveProfile(project);
|
|
355
|
+
expect(
|
|
356
|
+
Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
|
|
357
|
+
).toBe(false);
|
|
358
|
+
expect(await plan()).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// REGRESSION: removing a deprecated plugin must not lose live edits made after the profile was activated — MTL-332
|
|
362
|
+
test(`${surface}: a live edit survives the removal`, async () => {
|
|
363
|
+
await setUp(false);
|
|
364
|
+
const settings = await fs.readJson(settingsPath());
|
|
365
|
+
settings.permissions = { allow: ["Bash(ls:*)"] };
|
|
366
|
+
settings.enabledPlugins["other@magus"] = true;
|
|
367
|
+
await fs.writeJson(settingsPath(), settings, { spaces: 2 });
|
|
368
|
+
|
|
369
|
+
const result = await apply(await plan(), bookkept);
|
|
370
|
+
|
|
371
|
+
expect(result.failed).toEqual([]);
|
|
372
|
+
const live = await fs.readJson(settingsPath());
|
|
373
|
+
expect(live.permissions?.allow).toEqual(["Bash(ls:*)"]);
|
|
374
|
+
expect(live.enabledPlugins["other@magus"]).toBe(true);
|
|
375
|
+
expect(Object.hasOwn(live.enabledPlugins, ID)).toBe(false);
|
|
376
|
+
for (const entry of Object.values(
|
|
377
|
+
(await readManifest(project)).profiles,
|
|
378
|
+
)) {
|
|
379
|
+
expect(entry.plugins?.[ID]).toBeUndefined();
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// REGRESSION: removing a deprecated plugin the active profile lists dropped it from that profile only, so another profile kept it — MTL-332
|
|
384
|
+
test(`${surface}: the active profile and another both list it, neither does afterwards`, async () => {
|
|
385
|
+
await setUp(true);
|
|
386
|
+
const items = await plan();
|
|
387
|
+
expect(items.map((i) => i.pluginId)).toEqual([ID]);
|
|
388
|
+
|
|
389
|
+
const result = await apply(items, bookkept);
|
|
390
|
+
|
|
391
|
+
expect(result.failed).toEqual([]);
|
|
392
|
+
const after = (await readManifest(project)).profiles;
|
|
393
|
+
expect(after.default?.plugins?.[ID]).toBeUndefined();
|
|
394
|
+
expect(after.maintainer?.plugins?.[ID]).toBeUndefined();
|
|
395
|
+
expect(after.lean?.plugins?.[ID]).toBeUndefined();
|
|
396
|
+
expect(
|
|
397
|
+
Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
|
|
398
|
+
).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
});
|
package/src/cli/update.ts
CHANGED
|
@@ -63,7 +63,11 @@ import {
|
|
|
63
63
|
switchHint,
|
|
64
64
|
withCliBookkeeping,
|
|
65
65
|
} from "../services/profile-state.js";
|
|
66
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
pluginsAnyProfileLists,
|
|
68
|
+
requirersHere,
|
|
69
|
+
resolveProfile,
|
|
70
|
+
} from "../services/resolver.js";
|
|
67
71
|
import { getInstalledSkillNames } from "../services/skills-manager.js";
|
|
68
72
|
import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
|
|
69
73
|
import { managedBins } from "../services/toolchain.js";
|
|
@@ -503,7 +507,15 @@ async function updateProfile(
|
|
|
503
507
|
);
|
|
504
508
|
const plan: UpdatePlan = {
|
|
505
509
|
profileId: targetId,
|
|
506
|
-
|
|
510
|
+
// Every profile's plugins, not only this one's: a deprecated plugin a
|
|
511
|
+
// profile that is not active lists is still a row the render keeps, and
|
|
512
|
+
// is planned for removal.
|
|
513
|
+
plugins: planPluginUpdates(
|
|
514
|
+
closure.plugins,
|
|
515
|
+
catalog,
|
|
516
|
+
requiredBy,
|
|
517
|
+
pluginsAnyProfileLists(current),
|
|
518
|
+
),
|
|
507
519
|
bins: planBinUpdates(tools, toolFacts),
|
|
508
520
|
skills: planSkillUpdates(skillRefs, installedSkills),
|
|
509
521
|
binSpecs: new Map(tools.map((b) => [b.name, b])),
|
|
@@ -21,7 +21,7 @@ import type {
|
|
|
21
21
|
} from "../types/index.js";
|
|
22
22
|
import { readManifest, validateManifest } from "./manifest.js";
|
|
23
23
|
import { activeProfile } from "./profile-live.js";
|
|
24
|
-
import { resolveProfile } from "./resolver.js";
|
|
24
|
+
import { pluginsAnyProfileLists, resolveProfile } from "./resolver.js";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The profile to use: the explicit one, else the active one, else the only one.
|
|
@@ -45,6 +45,12 @@ export function selectProfileId(
|
|
|
45
45
|
export interface ActiveClosure {
|
|
46
46
|
profileId: string;
|
|
47
47
|
closure: ResolvedClosure;
|
|
48
|
+
/**
|
|
49
|
+
* Every plugin id ANY profile in the manifest lists
|
|
50
|
+
* (`pluginsAnyProfileLists`) — the planner's `listedByProfiles`, the same
|
|
51
|
+
* set `magus update` passes it.
|
|
52
|
+
*/
|
|
53
|
+
listedByProfiles: string[];
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export interface ActiveClosureDeps {
|
|
@@ -110,7 +116,11 @@ export async function loadActiveClosure(
|
|
|
110
116
|
profileId,
|
|
111
117
|
opts.resolveBins === false ? { binResolver: NO_BINS } : {},
|
|
112
118
|
);
|
|
113
|
-
return {
|
|
119
|
+
return {
|
|
120
|
+
profileId,
|
|
121
|
+
closure,
|
|
122
|
+
listedByProfiles: pluginsAnyProfileLists(manifest),
|
|
123
|
+
};
|
|
114
124
|
} catch {
|
|
115
125
|
return null;
|
|
116
126
|
}
|
|
@@ -271,10 +271,21 @@ export async function uninstallPluginViaCli(
|
|
|
271
271
|
/**
|
|
272
272
|
* Is `error` the CLI's answer that the plugin is not installed at the scope it
|
|
273
273
|
* was asked about — the one failure of an uninstall that means "nothing to do".
|
|
274
|
+
*
|
|
275
|
+
* Claude Code has two wordings for it. With no row anywhere: `Plugin "<id>" is
|
|
276
|
+
* not installed in <scope> scope`. With rows only at another scope or in other
|
|
277
|
+
* folders (measured, Claude Code 2.1.312): `Plugin "<id>" is installed in
|
|
278
|
+
* <other> scope, not <scope>. Use --scope <other> to uninstall.` Both mean the
|
|
279
|
+
* asked scope holds nothing here; anything else is a real failure.
|
|
274
280
|
*/
|
|
275
281
|
export function isNotInstalledAnswer(error: unknown): boolean {
|
|
276
282
|
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
-
return
|
|
283
|
+
return (
|
|
284
|
+
/not installed in (user|project|local) scope/i.test(message) ||
|
|
285
|
+
/is installed in (user|project|local) scope, not (user|project|local)\b/i.test(
|
|
286
|
+
message,
|
|
287
|
+
)
|
|
288
|
+
);
|
|
278
289
|
}
|
|
279
290
|
|
|
280
291
|
/**
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
McpRegistryResponse,
|
|
3
3
|
McpRegistryServer,
|
|
4
4
|
McpServer,
|
|
5
|
+
McpServerConfig,
|
|
5
6
|
} from "../types/index.js";
|
|
6
7
|
|
|
7
8
|
const DEFAULT_MCP_REGISTRY_API =
|
|
@@ -155,6 +156,155 @@ export async function fetchCuratedMcpServers(
|
|
|
155
156
|
return servers;
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Whether the server is run by the owner of the domain its name claims.
|
|
161
|
+
* The name is `<reverse-dns namespace>/<slug>`; the server is official when the
|
|
162
|
+
* namespace labels are a label-boundary prefix of the URL host's reversed labels
|
|
163
|
+
* (`com.notion` for `mcp.notion.com`). Code-host namespaces (`io.github.*`) name
|
|
164
|
+
* an account, not an owner, and npm:/pypi:/unparsable sources have no host.
|
|
165
|
+
* It is official FOR A SEARCH: the brand (the namespace's second label, `notion`
|
|
166
|
+
* in `com.notion`) must equal, as a whole word, one of the search words.
|
|
167
|
+
*/
|
|
168
|
+
export function isOfficialMcpServer(
|
|
169
|
+
server: Pick<McpRegistryServer, "name" | "url">,
|
|
170
|
+
query: string,
|
|
171
|
+
): boolean {
|
|
172
|
+
const words = query
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
175
|
+
.filter(Boolean);
|
|
176
|
+
if (words.length === 0) return false;
|
|
177
|
+
const namespace = server.name.split("/")[0]?.toLowerCase() ?? "";
|
|
178
|
+
const labels = namespace.split(".");
|
|
179
|
+
if (labels.length < 2 || labels.some((label) => label === "")) return false;
|
|
180
|
+
if (labels[0] === "io" && labels[1] === "github") return false;
|
|
181
|
+
if (!words.includes(labels[1] as string)) return false;
|
|
182
|
+
|
|
183
|
+
let host: string;
|
|
184
|
+
try {
|
|
185
|
+
const url = new URL(server.url);
|
|
186
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
187
|
+
host = url.hostname.toLowerCase();
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (!host) return false;
|
|
192
|
+
|
|
193
|
+
const hostLabels = host.split(".").reverse();
|
|
194
|
+
return (
|
|
195
|
+
labels.length <= hostLabels.length &&
|
|
196
|
+
labels.every((label, i) => label === hostLabels[i])
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type RegistryInstallKind = "http" | "pypi" | "npm";
|
|
201
|
+
export type RegistryInstallPlan =
|
|
202
|
+
| { ok: true; kind: RegistryInstallKind; config: McpServerConfig }
|
|
203
|
+
| { ok: false; kind: string; reason: string };
|
|
204
|
+
|
|
205
|
+
// Strict single tokens that cannot start with `-`: registry rows are
|
|
206
|
+
// third-party data and the name becomes an argv element, so `npm:-y` or
|
|
207
|
+
// `npm:--package=evil` must never turn into an option.
|
|
208
|
+
const NPM_PACKAGE =
|
|
209
|
+
/^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*(?:@[a-z0-9][a-z0-9._+-]*)?$/i;
|
|
210
|
+
const PYPI_PACKAGE =
|
|
211
|
+
/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?:@[a-z0-9][a-z0-9._+-]*)?$/i;
|
|
212
|
+
|
|
213
|
+
function refuse(kind: string, reason: string): RegistryInstallPlan {
|
|
214
|
+
return { ok: false, kind, reason };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function unreadableAddress(): RegistryInstallPlan {
|
|
218
|
+
return refuse("unknown", "magus can't read this server's address.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What installing a registry row writes, decided by the row's `url` alone
|
|
223
|
+
* (`package_registry` is ignored: an `https:` row stays hosted). Expected
|
|
224
|
+
* refusals are returned, never thrown. No shell: the entry is command + args.
|
|
225
|
+
*/
|
|
226
|
+
export function registryInstallPlan(
|
|
227
|
+
server: Pick<McpRegistryServer, "url">,
|
|
228
|
+
): RegistryInstallPlan {
|
|
229
|
+
const raw = (server.url ?? "").trim();
|
|
230
|
+
const match = /^([a-z][a-z0-9+.-]*):(.*)$/is.exec(raw);
|
|
231
|
+
if (!match) return unreadableAddress();
|
|
232
|
+
const scheme = (match[1] as string).toLowerCase();
|
|
233
|
+
const rest = (match[2] as string).trim();
|
|
234
|
+
// `localhost:8080` is host:port, not a scheme.
|
|
235
|
+
if (/^\d+(?:[/?#]|$)/.test(rest)) return unreadableAddress();
|
|
236
|
+
|
|
237
|
+
if (scheme === "http" || scheme === "https") {
|
|
238
|
+
try {
|
|
239
|
+
if (new URL(raw).hostname) {
|
|
240
|
+
return {
|
|
241
|
+
ok: true,
|
|
242
|
+
kind: "http",
|
|
243
|
+
config: { type: "http", url: raw },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
// falls through to the refusal below
|
|
248
|
+
}
|
|
249
|
+
return unreadableAddress();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (scheme === "pypi" || scheme === "npm") {
|
|
253
|
+
if (rest === "") {
|
|
254
|
+
return refuse(
|
|
255
|
+
scheme,
|
|
256
|
+
`This ${scheme} server has no package name, so there's nothing to run.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const pattern = scheme === "pypi" ? PYPI_PACKAGE : NPM_PACKAGE;
|
|
260
|
+
if (!pattern.test(rest)) {
|
|
261
|
+
return refuse(
|
|
262
|
+
scheme,
|
|
263
|
+
`The package name of this ${scheme} server looks wrong, so magus won't run it.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
kind: scheme,
|
|
269
|
+
config:
|
|
270
|
+
scheme === "pypi"
|
|
271
|
+
? { command: "uvx", args: [rest] }
|
|
272
|
+
: { command: "npx", args: ["-y", rest] },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return refuse(
|
|
277
|
+
scheme,
|
|
278
|
+
`magus can't install ${scheme} servers. It can install https, pypi and npm ones.`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Official servers first; endpoint order is kept inside each group. */
|
|
283
|
+
export function officialFirst<
|
|
284
|
+
T extends Pick<McpRegistryServer, "name" | "url">,
|
|
285
|
+
>(servers: readonly T[], query: string): T[] {
|
|
286
|
+
const official = servers.filter((server) =>
|
|
287
|
+
isOfficialMcpServer(server, query),
|
|
288
|
+
);
|
|
289
|
+
const rest = servers.filter((server) => !isOfficialMcpServer(server, query));
|
|
290
|
+
return [...official, ...rest];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Last-to-start wins: take a token before each request and apply its response
|
|
295
|
+
* only while `isLatest(token)` still holds.
|
|
296
|
+
*/
|
|
297
|
+
export function createLatestOnlyGuard(): {
|
|
298
|
+
next(): number;
|
|
299
|
+
isLatest(token: number): boolean;
|
|
300
|
+
} {
|
|
301
|
+
let latest = 0;
|
|
302
|
+
return {
|
|
303
|
+
next: () => ++latest,
|
|
304
|
+
isLatest: (token) => token === latest,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
158
308
|
export function formatDate(dateStr?: string): string {
|
|
159
309
|
if (!dateStr) return "";
|
|
160
310
|
const date = new Date(dateStr);
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import type { ProfileManifest
|
|
29
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
30
30
|
import { selectProfileId } from "./active-closure.js";
|
|
31
31
|
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
32
32
|
import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
|
|
@@ -74,7 +74,7 @@ import {
|
|
|
74
74
|
skillFolder,
|
|
75
75
|
skillIgnoreLine,
|
|
76
76
|
} from "./profile-skills.js";
|
|
77
|
-
import {
|
|
77
|
+
import { profileLists } from "./resolver.js";
|
|
78
78
|
import type { SettingsParseError } from "./settings-file.js";
|
|
79
79
|
|
|
80
80
|
export type ActiveProfileCheck =
|
|
@@ -674,16 +674,6 @@ export async function withCliBookkeeping<T>(
|
|
|
674
674
|
}
|
|
675
675
|
}
|
|
676
676
|
|
|
677
|
-
/** Whether a profile entry, `extends` included, turns the plugin on. */
|
|
678
|
-
function profileLists(
|
|
679
|
-
entry: ProfileManifestEntry | undefined,
|
|
680
|
-
pluginId: string,
|
|
681
|
-
): boolean {
|
|
682
|
-
if (!entry) return false;
|
|
683
|
-
const value = resolveExtends(entry).plugins?.[pluginId];
|
|
684
|
-
return value !== undefined && value !== false;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
677
|
/**
|
|
688
678
|
* Stop a profile listing `pluginId`, so its next activation does not turn the
|
|
689
679
|
* plugin back on. Answers whether the profile no longer lists it — read back
|