magus-cli 7.11.4 → 7.11.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.11.4",
3
+ "version": "7.11.5",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.11.4",
69
- "magus-cli-darwin-x64": "7.11.4",
70
- "magus-cli-linux-x64": "7.11.4"
68
+ "magus-cli-darwin-arm64": "7.11.5",
69
+ "magus-cli-darwin-x64": "7.11.5",
70
+ "magus-cli-linux-x64": "7.11.5"
71
71
  }
72
72
  }
@@ -0,0 +1,171 @@
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 type { PluginScope } from "../services/claude-cli.js";
7
+ import { readManifest, writeManifest } from "../services/manifest.js";
8
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
9
+ import { activateProfile } from "../services/profile-activate.js";
10
+ import { removeDeprecatedPlugin } from "../services/update-engine.js";
11
+ import type { ProfileManifest } from "../types/index.js";
12
+
13
+ /**
14
+ * `d` on a deprecated plugin that is installed at no scope in this folder, but
15
+ * shows as a row because the profile render writes `"<id>": false` for it.
16
+ *
17
+ * The render writes `false` for every id in `allPluginIds`, the union of every
18
+ * profile in `.claude/profiles.json` (`renderProfile`), so a plugin a profile
19
+ * that is NOT active still lists keeps its key in the live settings.json. The
20
+ * Plugins screen turns that key into a row. Uninstalling touches nothing:
21
+ * there is no install here, and the CLI answers every scope with "nothing at
22
+ * this scope here", in the wording measured on Claude Code 2.1.312.
23
+ *
24
+ * CONTRACT the fix must provide, in `services/update-engine.ts`, for the
25
+ * Plugins screen's `d` to call when the plan carries no `remove` item:
26
+ *
27
+ * export async function removeDeprecatedPlugin(
28
+ * pluginId: string,
29
+ * projectPath: string,
30
+ * deps?: {
31
+ * // default `uninstallPluginViaCli`
32
+ * uninstall?: (id: string, scope: PluginScope, cwd: string) => Promise<void>;
33
+ * // default `readInheritedRegistryRows`
34
+ * inheritedRows?: (id: string, projectPath: string) => Promise<InstalledPluginEntry[]>;
35
+ * },
36
+ * ): Promise<{ problems: unknown[]; unlistedFrom: string[] }>
37
+ *
38
+ * 1. Uninstalls at user, project and local in `projectPath`; an error
39
+ * `isNotInstalledAnswer` accepts is not a problem.
40
+ * 2. Uninstalls each inherited row in its own folder; errors are problems.
41
+ * 3. Removes the id from EVERY profile in the project's profiles.json that
42
+ * lists it (`removePluginFromProfile` per profile); `unlistedFrom` names
43
+ * them. Profiles that do not list it are not written.
44
+ * 4. Re-applies the active profile, so the live settings.json no longer
45
+ * carries a key for the id.
46
+ *
47
+ * Only for a deprecated (no longer published) plugin: the caller decides that.
48
+ */
49
+
50
+ const ID = "code-analysis@magus";
51
+
52
+ /** Measured, Claude Code 2.1.312, from a folder with no row while others hold PROJECT rows. */
53
+ const wrongScope = (scope: PluginScope) =>
54
+ new Error(
55
+ `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not ${scope}. Use --scope project to uninstall.`,
56
+ );
57
+
58
+ /** The magus-src shape: the active profile does not list it, two others do. */
59
+ const manifest = (): ProfileManifest => ({
60
+ version: 2,
61
+ profiles: {
62
+ default: { name: "Default", plugins: { "dev@magus": "latest" } },
63
+ maintainer: {
64
+ name: "Maintainer",
65
+ plugins: { "dev@magus": "latest", [ID]: "latest" },
66
+ },
67
+ lean: { name: "Lean", plugins: { [ID]: "latest" } },
68
+ minimal: { name: "Minimal", plugins: { "setup@magus": "latest" } },
69
+ },
70
+ });
71
+
72
+ describe("removeDeprecatedPlugin: a deprecated plugin only other profiles list", () => {
73
+ let project: string;
74
+ let log: ReturnType<typeof spyOn>;
75
+ const settingsPath = () => join(project, ".claude", "settings.json");
76
+ const profilesPath = () => join(project, ".claude", "profiles.json");
77
+ const mcpPath = () => join(project, ".mcp.json");
78
+
79
+ beforeEach(async () => {
80
+ project = await mkdtemp(join(tmpdir(), "deprecated-all-profiles-"));
81
+ log = spyOn(console, "log").mockImplementation(() => {});
82
+ await writeManifest(manifest(), project);
83
+ await activateProfile(manifest(), "default", project, {
84
+ onDisplace: () => {},
85
+ });
86
+ });
87
+ afterEach(async () => {
88
+ log.mockRestore();
89
+ await rm(project, { recursive: true, force: true });
90
+ });
91
+
92
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
93
+ test("should report no failure, unlist it from every profile, and leave no settings key for it", async () => {
94
+ // Seeded red: the render wrote the key that makes the row.
95
+ expect((await fs.readJson(settingsPath())).enabledPlugins[ID]).toBe(false);
96
+ const before = (await fs.readJson(profilesPath())).profiles;
97
+
98
+ const calls: Array<{ scope: PluginScope; cwd: string }> = [];
99
+ const result = await removeDeprecatedPlugin(ID, project, {
100
+ uninstall: async (_id, scope, cwd) => {
101
+ calls.push({ scope, cwd });
102
+ if (scope === "project") {
103
+ throw new Error(`Plugin "${ID}" is not installed in project scope.`);
104
+ }
105
+ throw wrongScope(scope);
106
+ },
107
+ inheritedRows: async () => [],
108
+ });
109
+
110
+ expect(result.problems).toEqual([]);
111
+ expect([...result.unlistedFrom].sort()).toEqual(["lean", "maintainer"]);
112
+ expect(calls.map((c) => c.scope).sort()).toEqual([
113
+ "local",
114
+ "project",
115
+ "user",
116
+ ]);
117
+ expect(calls.every((c) => c.cwd === project)).toBe(true);
118
+
119
+ const after = (await readManifest(project)).profiles;
120
+ for (const id of Object.keys(after)) {
121
+ expect(after[id]?.plugins?.[ID]).toBeUndefined();
122
+ }
123
+ expect(after.maintainer?.plugins).toEqual({ "dev@magus": "latest" });
124
+ // Profiles that never listed it are not written at all.
125
+ const raw = (await fs.readJson(profilesPath())).profiles;
126
+ expect(raw.default).toEqual(before.default);
127
+ expect(raw.minimal).toEqual(before.minimal);
128
+
129
+ const live = (await fs.readJson(settingsPath())).enabledPlugins;
130
+ expect(Object.hasOwn(live, ID)).toBe(false);
131
+ expect(live["dev@magus"]).toBe(true);
132
+
133
+ // The row does not come back: the next entry point's reconcile and a
134
+ // fresh activation leave the key out.
135
+ await reconcileActiveProfile(project);
136
+ await activateProfile(await readManifest(project), "default", project, {
137
+ onDisplace: () => {},
138
+ });
139
+ expect(
140
+ Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
141
+ ).toBe(false);
142
+ });
143
+
144
+ // REGRESSION: deprecated plugin `d` re-rendered the live files without absorbing them, losing edits Claude Code made after the TUI started — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
145
+ test("should keep edits made to the live files after the profile was activated", async () => {
146
+ // Written straight into the live files, as Claude Code does mid-session.
147
+ const settings = await fs.readJson(settingsPath());
148
+ settings.permissions = { allow: ["Bash(ls:*)"] };
149
+ settings.enabledPlugins["other@magus"] = true;
150
+ await fs.writeJson(settingsPath(), settings, { spaces: 2 });
151
+ const mcp = await fs.readJson(mcpPath());
152
+ mcp.mcpServers = { ...mcp.mcpServers, local: { command: "local-mcp" } };
153
+ await fs.writeJson(mcpPath(), mcp, { spaces: 2 });
154
+
155
+ const result = await removeDeprecatedPlugin(ID, project, {
156
+ uninstall: async (_id, scope) => {
157
+ throw wrongScope(scope);
158
+ },
159
+ inheritedRows: async () => [],
160
+ });
161
+
162
+ expect(result.problems).toEqual([]);
163
+ const live = await fs.readJson(settingsPath());
164
+ expect(live.permissions?.allow).toEqual(["Bash(ls:*)"]);
165
+ expect(live.enabledPlugins["other@magus"]).toBe(true);
166
+ expect(Object.hasOwn(live.enabledPlugins, ID)).toBe(false);
167
+ expect((await fs.readJson(mcpPath())).mcpServers?.local).toEqual({
168
+ command: "local-mcp",
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isNotInstalledAnswer } from "../services/claude-cli.js";
3
+
4
+ /**
5
+ * `isNotInstalledAnswer` classifies the CLI's answer to an uninstall at a
6
+ * scope that holds nothing for this folder. Claude Code has two wordings for
7
+ * it, and both mean "nothing to do here", never a failure.
8
+ *
9
+ * MEASURED on Claude Code 2.1.312, from a folder with no registry row while
10
+ * other folders hold PROJECT rows for the plugin: `--scope user` and
11
+ * `--scope local` exit 1 with the strings below. The older wording is what the
12
+ * CLI says when no folder holds a row at all.
13
+ */
14
+
15
+ const ID = "code-analysis@magus";
16
+
17
+ /** The exact text `claude plugin uninstall … --scope user` printed. */
18
+ const MEASURED_USER = `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not user. Use --scope project to uninstall.`;
19
+ /** The exact text `claude plugin uninstall … --scope local` printed. */
20
+ const MEASURED_LOCAL = `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not local. Use --scope project to uninstall.`;
21
+
22
+ describe("isNotInstalledAnswer", () => {
23
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
24
+ test("should treat the measured 'installed in project scope, not user' answer as nothing to remove", () => {
25
+ expect(isNotInstalledAnswer(new Error(MEASURED_USER))).toBe(true);
26
+ });
27
+
28
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
29
+ test("should treat the measured 'installed in project scope, not local' answer as nothing to remove", () => {
30
+ expect(isNotInstalledAnswer(new Error(MEASURED_LOCAL))).toBe(true);
31
+ });
32
+
33
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
34
+ test("should still treat the older 'not installed in user scope' answer as nothing to remove", () => {
35
+ expect(
36
+ isNotInstalledAnswer(
37
+ new Error(`Plugin "${ID}" is not installed in user scope.`),
38
+ ),
39
+ ).toBe(true);
40
+ });
41
+
42
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
43
+ test("should still count an unrelated error as a failure", () => {
44
+ expect(isNotInstalledAnswer(new Error("claude timed out"))).toBe(false);
45
+ });
46
+ });
@@ -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 { requirersHere, resolveProfile } from "../services/resolver.js";
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
- plugins: planPluginUpdates(closure.plugins, catalog, requiredBy),
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 { profileId, closure };
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 /not installed in (user|project|local) scope/i.test(message);
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
  /**
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import path from "node:path";
29
- import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
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 { resolveExtends } from "./resolver.js";
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
@@ -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
 
@@ -9,14 +9,8 @@ import {
9
9
  type PluginScope,
10
10
  addMarketplace as cliAddMarketplace,
11
11
  repairPlugin as cliRepairPlugin,
12
- isNotInstalledAnswer,
13
- uninstallPluginViaCli,
14
12
  } from "../../services/claude-cli.js";
15
- import {
16
- readInheritedRegistryRows,
17
- readSettings,
18
- setMcpEnvVar,
19
- } from "../../services/claude-settings.js";
13
+ import { readSettings, setMcpEnvVar } from "../../services/claude-settings.js";
20
14
  import { clearContentDriftCache } from "../../services/content-drift.js";
21
15
  import { missingPluginEnvVars } from "../../services/plugin-env-needs.js";
22
16
  import {
@@ -49,6 +43,7 @@ import {
49
43
  applyPlugins,
50
44
  installPluginInScope,
51
45
  pluginNeedsWork,
46
+ removeDeprecatedPlugin,
52
47
  uninstallPluginInScope,
53
48
  updatePluginInScope,
54
49
  } from "../../services/update-engine.js";
@@ -176,6 +171,14 @@ export function PluginsScreen() {
176
171
  const [requiredBy, setRequiredBy] = useState<
177
172
  Readonly<Record<string, readonly string[]>>
178
173
  >({});
174
+ /**
175
+ * Every plugin any profile lists — the same set `magus update` plans with,
176
+ * so a deprecated plugin only another profile keeps is a `remove` row here
177
+ * and a removal there.
178
+ */
179
+ const [listedByProfiles, setListedByProfiles] = useState<readonly string[]>(
180
+ [],
181
+ );
179
182
 
180
183
  // Fetch data (always fetches all scopes). Only the most recently started
181
184
  // load may touch the screen.
@@ -209,6 +212,7 @@ export function PluginsScreen() {
209
212
  setPins(active?.closure.plugins ?? null);
210
213
  setActiveProfileId(active?.profileId ?? null);
211
214
  setRequiredBy(required);
215
+ setListedByProfiles(active?.listedByProfiles ?? []);
212
216
 
213
217
  // Which catalogs answered, and which only appeared to. Read straight
214
218
  // after the fetch, while the record still describes this attempt.
@@ -300,9 +304,14 @@ export function PluginsScreen() {
300
304
  const statuses = useMemo(
301
305
  (): Map<string, PluginUpdateItem> =>
302
306
  shownPlugins
303
- ? classifyPluginRows(shownPlugins, pins ?? {}, requiredBy)
307
+ ? classifyPluginRows(
308
+ shownPlugins,
309
+ pins ?? {},
310
+ requiredBy,
311
+ listedByProfiles,
312
+ )
304
313
  : new Map(),
305
- [shownPlugins, pins, requiredBy],
314
+ [shownPlugins, pins, requiredBy, listedByProfiles],
306
315
  );
307
316
 
308
317
  /**
@@ -1265,39 +1274,16 @@ export function PluginsScreen() {
1265
1274
  return;
1266
1275
  }
1267
1276
 
1268
- // Errors are collected, not dropped: only the CLI's "is not installed in
1269
- // <scope> scope" answer is expected, and only for a scope with no row.
1270
- const problems: unknown[] = [];
1277
+ // An orphan the plan keeps rather than removes — it moved, another plugin
1278
+ // still requires it, or its marketplace was not read — and the user asked
1279
+ // for it gone. The engine uninstalls everywhere, drops it from every
1280
+ // profile and renders the active one again, so the row does not come back.
1271
1281
  try {
1272
- // Remove from all scopes — try all to clean up stale references
1273
- const scopes: PluginScope[] = ["user", "project", "local"];
1274
- for (const scope of scopes) {
1275
- try {
1276
- modal.loading(
1277
- `Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1278
- );
1279
- await uninstallPluginViaCli(plugin.id, scope, state.projectPath);
1280
- } catch (error) {
1281
- if (!isNotInstalledAnswer(error)) problems.push(error);
1282
- }
1283
- }
1284
- // A folder of the same repository may hold the install this one only
1285
- // shows: the CLI removes it only when run from that folder.
1286
- const inherited = await readInheritedRegistryRows(
1282
+ modal.loading(`Removing ${plugin.name}…`);
1283
+ const { problems } = await removeDeprecatedPlugin(
1287
1284
  plugin.id,
1288
1285
  state.projectPath || process.cwd(),
1289
1286
  );
1290
- for (const row of inherited) {
1291
- if (!row.projectPath) continue;
1292
- try {
1293
- modal.loading(
1294
- `Removing ${plugin.name} from the repository's main folder, which this folder shares…\nclaude plugin uninstall ${plugin.id} --scope ${row.scope}`,
1295
- );
1296
- await uninstallPluginViaCli(plugin.id, row.scope, row.projectPath);
1297
- } catch (error) {
1298
- problems.push(error);
1299
- }
1300
- }
1301
1287
 
1302
1288
  modal.hideModal();
1303
1289
  fetchData();