magus-cli 7.11.1 → 7.11.2

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.1",
3
+ "version": "7.11.2",
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.1",
69
- "magus-cli-darwin-x64": "7.11.1",
70
- "magus-cli-linux-x64": "7.11.1"
68
+ "magus-cli-darwin-arm64": "7.11.2",
69
+ "magus-cli-darwin-x64": "7.11.2",
70
+ "magus-cli-linux-x64": "7.11.2"
71
71
  }
72
72
  }
@@ -40,8 +40,10 @@ describe("shipped plugin manifests under schema 2", () => {
40
40
  }
41
41
 
42
42
  // REQ-26: claudish gets a minVersion floor; no other requirement gets one. The floor
43
- // moved from 10.7.0 to 11.1.0 with claudish plugin 2.5.0, which needs SlotRow.recent.
44
- test("REQ-26 claudish's claudish requirement has minVersion 11.1.0", () => {
43
+ // moved from 10.7.0 to 11.1.0 with claudish plugin 2.5.0, which needs SlotRow.recent, and
44
+ // to 12.1.0 with claudish plugin 2.6.2, for resize_session, and to 12.3.0 with 2.8.1, whose
45
+ // resize goes down to 40 columns.
46
+ test("REQ-26 claudish's claudish requirement has minVersion 12.3.0", () => {
45
47
  const claudish = declaring.find(({ name }) => name === "claudish");
46
48
  if (!claudish) throw new Error("plugins/claudish declares no requires");
47
49
  const result = parseRequires(claudish.json);
@@ -51,7 +53,7 @@ describe("shipped plugin manifests under schema 2", () => {
51
53
  | (Record<string, unknown> & { name: string })
52
54
  | undefined;
53
55
  expect(req).toBeDefined();
54
- expect(req?.minVersion).toBe("11.1.0");
56
+ expect(req?.minVersion).toBe("12.3.0");
55
57
  });
56
58
 
57
59
  test("REQ-26 no other shipped requirement declares a floor", () => {
@@ -0,0 +1,102 @@
1
+ // REGRESSION: MTL-221 orphan USER registry row blocks PROJECT install/repair; scope uninstall must drop from profile — Fixed in /dev:fix session dev-fix-20261022-magus-scope-7c3e
2
+
3
+ /**
4
+ * An orphan USER registry row — a `user` row in installed_plugins.json for a
5
+ * plugin USER settings do not turn on — wins Claude Code's load resolution.
6
+ * The stale-load repair refused on it ("update it at USER scope") without
7
+ * asking whether USER actually installs the plugin, so every PROJECT install
8
+ * failed. The engine must remove the orphan through `claude plugin uninstall
9
+ * --scope user` and retry, and keep refusing when USER really installs it.
10
+ *
11
+ * Doubles only: nothing here reads or writes the real ~/.claude.
12
+ */
13
+
14
+ import { describe, expect, mock, test } from "bun:test";
15
+ import type { StaleLoadRepairResult } from "../services/claude-settings.js";
16
+ import {
17
+ type PluginApplyDeps,
18
+ type PluginRemoveDeps,
19
+ installPluginInScope,
20
+ } from "../services/update-engine.js";
21
+
22
+ const PROJECT = "/tmp/mtl-221-project";
23
+ const ID = "dev@magus";
24
+ const OLD = "9.2.0";
25
+ const NEW = "9.4.0";
26
+
27
+ /** The refusal `repairStaleLoad` gives when the winning row is USER's. */
28
+ function userRefusal(): StaleLoadRepairResult & { blockedBy: "user" } {
29
+ return {
30
+ pluginId: ID,
31
+ target: NEW,
32
+ rewritten: 0,
33
+ backup: null,
34
+ loaded: null,
35
+ ok: false,
36
+ error:
37
+ "Claude Code loads the USER install of it here — update it at USER scope",
38
+ blockedBy: "user",
39
+ };
40
+ }
41
+
42
+ function repaired(): StaleLoadRepairResult {
43
+ return {
44
+ pluginId: ID,
45
+ target: NEW,
46
+ rewritten: 0,
47
+ backup: null,
48
+ loaded: NEW,
49
+ ok: true,
50
+ };
51
+ }
52
+
53
+ function setup(userReallyInstalls: boolean) {
54
+ let repairCalls = 0;
55
+ const remove: PluginRemoveDeps = {
56
+ uninstall: mock(async () => {}),
57
+ isInstalled: mock(async (_id: string, scope: string) =>
58
+ scope === "user" ? userReallyInstalls : scope === "project",
59
+ ),
60
+ dropFromProfile: mock(async () => true),
61
+ forgetVersion: mock(async () => {}),
62
+ };
63
+ const deps: PluginApplyDeps = {
64
+ install: mock(async () => {}),
65
+ update: mock(async () => {}),
66
+ repair: mock(async () => {}),
67
+ readInstalled: mock(async () => NEW),
68
+ saveInstalled: mock(async () => {}),
69
+ // The orphan USER row still wins until something removes it.
70
+ loadedVersion: mock(async () => OLD),
71
+ repairStaleLoad: mock(async () => {
72
+ repairCalls += 1;
73
+ return repairCalls === 1 ? userRefusal() : repaired();
74
+ }),
75
+ remove,
76
+ };
77
+ return { deps, remove, repairCalls: () => repairCalls };
78
+ }
79
+
80
+ describe("MTL-221: PROJECT install behind an orphan USER registry row", () => {
81
+ test("a. orphan USER row: uninstalled at USER once, repair retried, install succeeds", async () => {
82
+ const { deps, remove, repairCalls } = setup(false);
83
+
84
+ await expect(
85
+ installPluginInScope(ID, "project", PROJECT, deps),
86
+ ).resolves.toBe(NEW);
87
+
88
+ expect(remove.uninstall).toHaveBeenCalledTimes(1);
89
+ expect(remove.uninstall).toHaveBeenCalledWith(ID, "user", PROJECT);
90
+ expect(repairCalls()).toBe(2);
91
+ });
92
+
93
+ test("b. real USER install: never uninstalled, the install still refuses naming USER", async () => {
94
+ const { deps, remove } = setup(true);
95
+
96
+ await expect(
97
+ installPluginInScope(ID, "project", PROJECT, deps),
98
+ ).rejects.toThrow(/USER/);
99
+
100
+ expect(remove.uninstall).not.toHaveBeenCalled();
101
+ });
102
+ });
@@ -0,0 +1,173 @@
1
+ // REGRESSION: MTL-221 uninstalling a plugin a declared plugin depends on must refuse, not leave it declared — Fixed in /dev:fix session dev-fix-20261022-magus-scope-7c3e
2
+
3
+ /**
4
+ * A profile's resolved closure re-adds every dependency of a plugin it
5
+ * declares (`resolveProfile` → `resolveDependencies` → `dependencies.derived`).
6
+ * `dev@magus` depends on `claudish@magus`, so dropping claudish from a profile
7
+ * that declares dev cannot take effect: claudish comes straight back.
8
+ *
9
+ * When an uninstall would need the profile to stop declaring the plugin
10
+ * (scope PROJECT, or no OTHER scope installs it) and a plugin the profile
11
+ * declares depends on it, `uninstallPluginInScope` refuses before doing
12
+ * anything, naming both plugins by name. A USER/LOCAL uninstall while PROJECT
13
+ * still installs it needs no profile change, so it is never refused.
14
+ *
15
+ * Doubles only: nothing here reads or writes the real ~/.claude.
16
+ */
17
+
18
+ import { describe, expect, mock, test } from "bun:test";
19
+ import * as resolver from "../services/resolver.js";
20
+ import * as engine from "../services/update-engine.js";
21
+ import type {
22
+ PluginApplyDeps,
23
+ PluginRemoveDeps,
24
+ } from "../services/update-engine.js";
25
+ import type { ProfileManifest } from "../types/index.js";
26
+
27
+ const PROJECT = "/tmp/mtl-221-project";
28
+ const ID = "claudish@magus";
29
+ const PROFILE = "team";
30
+
31
+ type Scope = "user" | "project" | "local";
32
+
33
+ /**
34
+ * Remove deps over an in-memory install set, plus the assumed new optional
35
+ * `dependents` seam answering which declared plugins need `pluginId`.
36
+ */
37
+ function setup(installedAt: Scope[], dependentIds: string[]) {
38
+ const installed = new Set<Scope>(installedAt);
39
+ const calls: string[] = [];
40
+ const dependents = mock(
41
+ async (
42
+ _pluginId: string,
43
+ _projectPath: string,
44
+ _profile: string | null | undefined,
45
+ ) => dependentIds,
46
+ );
47
+ const remove = {
48
+ uninstall: mock(async (id: string, scope: Scope, path: string) => {
49
+ calls.push(`uninstall:${id}:${scope}:${path}`);
50
+ installed.delete(scope);
51
+ }),
52
+ isInstalled: mock(async (_id: string, scope: Scope) =>
53
+ installed.has(scope),
54
+ ),
55
+ dropFromProfile: mock(async () => {
56
+ calls.push("dropFromProfile");
57
+ return true;
58
+ }),
59
+ forgetVersion: mock(async () => {
60
+ calls.push("forgetVersion");
61
+ }),
62
+ dependents,
63
+ } as PluginRemoveDeps & { dependents: typeof dependents };
64
+ const deps: PluginApplyDeps = {
65
+ install: mock(async () => {}),
66
+ update: mock(async () => {}),
67
+ repair: mock(async () => {}),
68
+ readInstalled: mock(async () => null),
69
+ saveInstalled: mock(async () => {}),
70
+ repairStaleLoad: mock(async () => {
71
+ throw new Error("repairStaleLoad must not run on an uninstall");
72
+ }),
73
+ remove,
74
+ };
75
+ return { deps, remove, calls };
76
+ }
77
+
78
+ const uninstallPluginInScope = engine.uninstallPluginInScope;
79
+
80
+ describe("MTL-221: uninstalling a plugin a declared plugin depends on", () => {
81
+ test("f. PROJECT uninstall of a dependency of a declared plugin: refuses before doing anything", async () => {
82
+ const { deps, remove, calls } = setup(["project"], ["dev@magus"]);
83
+
84
+ let message: string | null = null;
85
+ try {
86
+ await uninstallPluginInScope(ID, "project", PROJECT, deps, PROFILE);
87
+ } catch (err) {
88
+ message = err instanceof Error ? err.message : String(err);
89
+ }
90
+
91
+ expect(message).not.toBeNull();
92
+ expect(message).toContain("dev needs claudish");
93
+ expect(message).toContain("uninstall dev first");
94
+ // Plugin names, not ids.
95
+ expect(message).not.toContain("@magus");
96
+ expect(remove.uninstall).not.toHaveBeenCalled();
97
+ expect(remove.forgetVersion).not.toHaveBeenCalled();
98
+ expect(remove.dropFromProfile).not.toHaveBeenCalled();
99
+ expect(calls).toEqual([]);
100
+ });
101
+
102
+ test("g. PROJECT uninstall with no declared dependent: uninstalls, then drops from the profile once", async () => {
103
+ const { deps, remove, calls } = setup(["project"], []);
104
+
105
+ await uninstallPluginInScope(ID, "project", PROJECT, deps, PROFILE);
106
+
107
+ expect(remove.uninstall).toHaveBeenCalledWith(ID, "project", PROJECT);
108
+ expect(remove.dropFromProfile).toHaveBeenCalledTimes(1);
109
+ expect(calls).toEqual([
110
+ `uninstall:${ID}:project:${PROJECT}`,
111
+ "forgetVersion",
112
+ "dropFromProfile",
113
+ ]);
114
+ });
115
+
116
+ test("h. USER uninstall while PROJECT still installs it: not refused, profile untouched", async () => {
117
+ const { deps, remove } = setup(["user", "project"], ["dev@magus"]);
118
+
119
+ await uninstallPluginInScope(ID, "user", PROJECT, deps, PROFILE);
120
+
121
+ expect(remove.uninstall).toHaveBeenCalledWith(ID, "user", PROJECT);
122
+ expect(remove.dropFromProfile).not.toHaveBeenCalled();
123
+ });
124
+ });
125
+
126
+ /**
127
+ * Assumed signature, consistent with `resolveProfile` /
128
+ * `ResolveOptions.dependencyResolver` (a `PluginDependencyReader`):
129
+ *
130
+ * profileDependentsOf(
131
+ * manifest: ProfileManifest,
132
+ * profileId: string,
133
+ * pluginId: string,
134
+ * dependencyResolver: PluginDependencyReader,
135
+ * ): Promise<string[]>
136
+ *
137
+ * Answers the ids of plugins the profile declares that need `pluginId`.
138
+ */
139
+ const profileDependentsOf = (resolver as Record<string, unknown>)
140
+ .profileDependentsOf as (
141
+ manifest: ProfileManifest,
142
+ profileId: string,
143
+ pluginId: string,
144
+ dependencyResolver: (
145
+ pluginId: string,
146
+ projectPath?: string,
147
+ ) => Promise<string[] | null>,
148
+ ) => Promise<string[]>;
149
+
150
+ describe("MTL-221: profileDependentsOf", () => {
151
+ test("i. names the declared plugin that depends on the given one", async () => {
152
+ const manifest: ProfileManifest = {
153
+ version: 2,
154
+ profiles: {
155
+ [PROFILE]: {
156
+ name: PROFILE,
157
+ plugins: { "dev@magus": "latest", "claudish@magus": "latest" },
158
+ },
159
+ },
160
+ };
161
+ const graph: Record<string, string[]> = {
162
+ "dev@magus": ["claudish@magus", "mnemex@magus"],
163
+ "claudish@magus": [],
164
+ "mnemex@magus": [],
165
+ };
166
+ const dependencyResolver = async (id: string) => graph[id] ?? [];
167
+
168
+ expect(typeof profileDependentsOf).toBe("function");
169
+ expect(
170
+ await profileDependentsOf(manifest, PROFILE, ID, dependencyResolver),
171
+ ).toEqual(["dev@magus"]);
172
+ });
173
+ });
@@ -0,0 +1,103 @@
1
+ // REGRESSION: MTL-221 orphan USER registry row blocks PROJECT install/repair; scope uninstall must drop from profile — Fixed in /dev:fix session dev-fix-20261022-magus-scope-7c3e
2
+
3
+ /**
4
+ * The Plugins screen's scope uninstall never dropped the plugin from the
5
+ * active profile, so a profile-declared plugin came straight back as "not
6
+ * installed — profile declares it". `uninstallPluginInScope` is the one engine
7
+ * function both screen call sites will use: uninstall at the scope, read back,
8
+ * and drop from the profile when the scope was PROJECT or nothing installs the
9
+ * plugin any more — never while PROJECT still installs it.
10
+ *
11
+ * Doubles only: nothing here reads or writes the real ~/.claude.
12
+ */
13
+
14
+ import { describe, expect, mock, test } from "bun:test";
15
+ import * as engine from "../services/update-engine.js";
16
+ import type {
17
+ PluginApplyDeps,
18
+ PluginRemoveDeps,
19
+ } from "../services/update-engine.js";
20
+
21
+ const PROJECT = "/tmp/mtl-221-project";
22
+ const ID = "dev@magus";
23
+ const PROFILE = "team";
24
+
25
+ type Scope = "user" | "project" | "local";
26
+
27
+ /**
28
+ * Remove deps over an in-memory install set: `uninstall` takes the scope out,
29
+ * `isInstalled` reads the set back.
30
+ */
31
+ function setup(installedAt: Scope[]) {
32
+ const installed = new Set<Scope>(installedAt);
33
+ const calls: string[] = [];
34
+ const remove: PluginRemoveDeps = {
35
+ uninstall: mock(async (id: string, scope: Scope, path: string) => {
36
+ calls.push(`uninstall:${id}:${scope}:${path}`);
37
+ installed.delete(scope);
38
+ }),
39
+ isInstalled: mock(async (_id: string, scope: Scope) =>
40
+ installed.has(scope),
41
+ ),
42
+ dropFromProfile: mock(async () => {
43
+ calls.push("dropFromProfile");
44
+ return true;
45
+ }),
46
+ forgetVersion: mock(async () => {}),
47
+ };
48
+ const deps: PluginApplyDeps = {
49
+ install: mock(async () => {}),
50
+ update: mock(async () => {}),
51
+ repair: mock(async () => {}),
52
+ readInstalled: mock(async () => null),
53
+ saveInstalled: mock(async () => {}),
54
+ repairStaleLoad: mock(async () => {
55
+ throw new Error("repairStaleLoad must not run on an uninstall");
56
+ }),
57
+ remove,
58
+ };
59
+ return { deps, remove, calls };
60
+ }
61
+
62
+ // Looked up off the namespace so the file loads before the export exists (RED).
63
+ const uninstallPluginInScope = (engine as Record<string, unknown>)
64
+ .uninstallPluginInScope as (
65
+ pluginId: string,
66
+ scope: Scope,
67
+ projectPath: string,
68
+ deps?: PluginApplyDeps,
69
+ profile?: string | null,
70
+ ) => Promise<unknown>;
71
+
72
+ describe("MTL-221: uninstallPluginInScope drops the plugin from the profile", () => {
73
+ test("c. PROJECT uninstall: uninstalls at PROJECT, then drops from the profile once", async () => {
74
+ const { deps, remove, calls } = setup(["project"]);
75
+
76
+ await uninstallPluginInScope(ID, "project", PROJECT, deps, PROFILE);
77
+
78
+ expect(remove.uninstall).toHaveBeenCalledWith(ID, "project", PROJECT);
79
+ expect(remove.dropFromProfile).toHaveBeenCalledTimes(1);
80
+ expect(calls).toEqual([
81
+ `uninstall:${ID}:project:${PROJECT}`,
82
+ "dropFromProfile",
83
+ ]);
84
+ });
85
+
86
+ test("d. USER uninstall while PROJECT still installs it: the profile is untouched", async () => {
87
+ const { deps, remove } = setup(["user", "project"]);
88
+
89
+ await uninstallPluginInScope(ID, "user", PROJECT, deps, PROFILE);
90
+
91
+ expect(remove.uninstall).toHaveBeenCalledWith(ID, "user", PROJECT);
92
+ expect(remove.dropFromProfile).not.toHaveBeenCalled();
93
+ });
94
+
95
+ test("e. LOCAL uninstall leaving it installed nowhere: drops from the profile once", async () => {
96
+ const { deps, remove } = setup(["local"]);
97
+
98
+ await uninstallPluginInScope(ID, "local", PROJECT, deps, PROFILE);
99
+
100
+ expect(remove.uninstall).toHaveBeenCalledWith(ID, "local", PROJECT);
101
+ expect(remove.dropFromProfile).toHaveBeenCalledTimes(1);
102
+ });
103
+ });
@@ -1947,6 +1947,12 @@ export interface StaleLoadRepairResult {
1947
1947
  ok: boolean;
1948
1948
  /** Why it failed. Absent when `ok`. */
1949
1949
  error?: string;
1950
+ /**
1951
+ * Set when it refused because a machine-wide row wins here, naming that
1952
+ * scope — so a caller can tell a real USER install from an orphan USER row
1953
+ * without matching on `error`.
1954
+ */
1955
+ blockedBy?: "user" | "managed";
1950
1956
  }
1951
1957
 
1952
1958
  /**
@@ -2087,11 +2093,19 @@ export async function repairStaleLoad(
2087
2093
  // plugins, which the registry type in types/index.ts does not name — see
2088
2094
  // `LoaderScope` in plugin-resolution.ts.
2089
2095
  const scope: string = winner.scope;
2090
- return fail(
2091
- scope === "managed"
2092
- ? "Claude Code loads the install your administrator manages here — ask them to update it"
2093
- : "Claude Code loads the USER install of it here — update it at USER scope",
2094
- );
2096
+ return scope === "managed"
2097
+ ? {
2098
+ ...fail(
2099
+ "Claude Code loads the install your administrator manages here — ask them to update it",
2100
+ ),
2101
+ blockedBy: "managed",
2102
+ }
2103
+ : {
2104
+ ...fail(
2105
+ "Claude Code loads the USER install of it here — update it at USER scope",
2106
+ ),
2107
+ blockedBy: "user",
2108
+ };
2095
2109
  }
2096
2110
 
2097
2111
  const stale = rows.filter(
@@ -195,6 +195,38 @@ export async function requirersHere(
195
195
  return edges;
196
196
  }
197
197
 
198
+ /**
199
+ * The plugins `profileId` declares that need `pluginId` to load, directly or
200
+ * transitively — sorted ids, none when the profile does not exist.
201
+ *
202
+ * `resolveProfile` re-adds every dependency of a declared plugin, so while one
203
+ * of these is declared, the profile cannot stop enabling `pluginId`. Each
204
+ * declared plugin's closure is walked with the same `resolveDependencies` the
205
+ * resolve uses, so the two cannot disagree about what comes back.
206
+ */
207
+ export async function profileDependentsOf(
208
+ manifest: ProfileManifest,
209
+ profileId: string,
210
+ pluginId: string,
211
+ dependencyResolver: PluginDependencyReader,
212
+ ): Promise<string[]> {
213
+ const raw = manifest.profiles[profileId];
214
+ if (!raw) return [];
215
+ const entry = resolveExtends(raw);
216
+ const off = new Set(
217
+ Object.entries(entry.plugins ?? {})
218
+ .filter(([, ver]) => ver === false)
219
+ .map(([id]) => id),
220
+ );
221
+ const dependents: string[] = [];
222
+ for (const id of Object.keys(normalizePlugins(entry.plugins))) {
223
+ if (id === pluginId) continue;
224
+ const report = await resolveDependencies([id], off, dependencyResolver);
225
+ if (report.derived.includes(pluginId)) dependents.push(id);
226
+ }
227
+ return dependents.sort();
228
+ }
229
+
198
230
  function hasDependencies(report: DependencyReport): boolean {
199
231
  return (
200
232
  report.derived.length > 0 ||
@@ -39,6 +39,7 @@ import type {
39
39
  import type { Command } from "../utils/command-utils.js";
40
40
  import { formatCommand } from "../utils/command-utils.js";
41
41
  import { runArgv, succeeded } from "../utils/run.js";
42
+ import { selectProfileId } from "./active-closure.js";
42
43
  import type { PluginScope } from "./claude-cli.js";
43
44
  import {
44
45
  installPlugin,
@@ -58,13 +59,17 @@ import {
58
59
  repairStaleLoad,
59
60
  rowBelongsToRepository,
60
61
  } from "./claude-settings.js";
62
+ import { readManifest } from "./manifest.js";
63
+ import { readPluginDependencies } from "./plugin-dependencies.js";
61
64
  import {
62
65
  compareVersions,
63
66
  isRepairablyBehind,
64
67
  readInstalledVersionForScope,
65
68
  saveInstalledPluginVersionForScope,
66
69
  } from "./plugin-manager.js";
70
+ import { activeProfile } from "./profile-live.js";
67
71
  import { removePluginFromProfile } from "./profile-state.js";
72
+ import { profileDependentsOf } from "./resolver.js";
68
73
  import { installSkill } from "./skills-manager.js";
69
74
  import {
70
75
  type ToolFacts,
@@ -302,6 +307,17 @@ export interface PluginRemoveDeps {
302
307
  scope: PluginScope,
303
308
  projectPath: string,
304
309
  ) => Promise<void>;
310
+ /**
311
+ * The plugins `profile` declares that need `pluginId` (`profileDependentsOf`).
312
+ * While any is declared, the profile re-adds `pluginId` as their dependency,
313
+ * so dropping it cannot take effect. `profile` undefined means the active
314
+ * one. Optional so a double that does not model dependencies skips the check.
315
+ */
316
+ dependents?: (
317
+ pluginId: string,
318
+ projectPath: string,
319
+ profile: string | null | undefined,
320
+ ) => Promise<string[]>;
305
321
  }
306
322
 
307
323
  /** The settings a scope's install lives in — the classifier's definition. */
@@ -321,6 +337,23 @@ export const REAL_REMOVE_DEPS: PluginRemoveDeps = {
321
337
  dropFromProfile: (pluginId, projectPath, profile) =>
322
338
  removePluginFromProfile(projectPath, pluginId, profile),
323
339
  forgetVersion: forgetInstalledPluginVersion,
340
+ // The profile picked as `removePluginFromProfile` picks it, so the check and
341
+ // the drop judge the same profile. A parse error throws, as it does there.
342
+ dependents: async (pluginId, projectPath, profile) => {
343
+ const manifest = await readManifest(projectPath);
344
+ const id =
345
+ profile === undefined
346
+ ? selectProfileId(
347
+ Object.keys(manifest.profiles),
348
+ undefined,
349
+ await activeProfile(projectPath),
350
+ )
351
+ : profile;
352
+ if (!id) return [];
353
+ return profileDependentsOf(manifest, id, pluginId, (dep) =>
354
+ readPluginDependencies(dep, projectPath),
355
+ );
356
+ },
324
357
  };
325
358
 
326
359
  export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
@@ -455,7 +488,7 @@ async function followMisdirectedUpdate(
455
488
  notFollowed: `it went to v${version}, not the v${target} this update asked for`,
456
489
  };
457
490
  }
458
- const repair = await deps.repairStaleLoad(pluginId, version, projectPath);
491
+ const repair = await repairLoadHere(deps, pluginId, version, projectPath);
459
492
  return {
460
493
  elsewhere,
461
494
  version,
@@ -506,6 +539,39 @@ export async function bringLoadForward(
506
539
  if (!deps.loadedVersion) return null;
507
540
  const loaded = await deps.loadedVersion(pluginId, projectPath);
508
541
  if (!isRepairablyBehind(target, loaded)) return null;
542
+ return repairLoadHere(deps, pluginId, target, projectPath);
543
+ }
544
+
545
+ /**
546
+ * `deps.repairStaleLoad`, clearing an ORPHAN USER row first when that is what
547
+ * blocks it.
548
+ *
549
+ * A USER registry row for a plugin USER settings do not turn on still wins the
550
+ * loader's resolution in every project, so the repair refuses on it — yet the
551
+ * user installs nothing at USER. Claude Code's startup sync adds such rows and
552
+ * never removes them. `claude plugin uninstall --scope user` removes just that
553
+ * row (measured, Claude Code 2.1.308), so it runs once and the repair is
554
+ * retried. A REAL USER install keeps the refusal: moving it is the user's call.
555
+ */
556
+ async function repairLoadHere(
557
+ deps: PluginApplyDeps,
558
+ pluginId: string,
559
+ target: string,
560
+ projectPath: string,
561
+ ): Promise<StaleLoadRepairResult> {
562
+ const first = await deps.repairStaleLoad(pluginId, target, projectPath);
563
+ const remove = deps.remove;
564
+ if (first.ok || first.blockedBy !== "user" || !remove) return first;
565
+ if (await remove.isInstalled(pluginId, "user", projectPath)) return first;
566
+ try {
567
+ await remove.uninstall(pluginId, "user", projectPath);
568
+ } catch (error) {
569
+ // The CLI's own words: no fallback writes the registry in its place.
570
+ return {
571
+ ...first,
572
+ error: error instanceof Error ? error.message : String(error),
573
+ };
574
+ }
509
575
  return deps.repairStaleLoad(pluginId, target, projectPath);
510
576
  }
511
577
 
@@ -747,7 +813,8 @@ async function applyStaleLoad(
747
813
  return;
748
814
  }
749
815
  try {
750
- const outcome = await deps.repairStaleLoad(
816
+ const outcome = await repairLoadHere(
817
+ deps,
751
818
  item.pluginId,
752
819
  item.target,
753
820
  projectPath,
@@ -1131,6 +1198,80 @@ export async function updatePluginInScope(
1131
1198
  );
1132
1199
  }
1133
1200
 
1201
+ /**
1202
+ * Uninstall a plugin at ONE explicitly chosen scope, and stop the profile
1203
+ * declaring it when that leaves nothing for the profile to keep.
1204
+ *
1205
+ * Without the profile step, a profile-declared plugin came straight back as
1206
+ * "not installed — profile declares it". The profile is dropped when the scope
1207
+ * was PROJECT — the settings the profile itself renders — or when the plugin is
1208
+ * now installed at no scope. Never for a USER or LOCAL uninstall while PROJECT
1209
+ * still installs it: the profile still describes that install.
1210
+ *
1211
+ * Success is read back, as in {@link applyRemove}: still installed at `scope`
1212
+ * afterwards throws, whatever the command said.
1213
+ *
1214
+ * @param profile the profile to drop it from; undefined means the active one.
1215
+ */
1216
+ export async function uninstallPluginInScope(
1217
+ pluginId: string,
1218
+ scope: PluginScope,
1219
+ projectPath: string,
1220
+ deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
1221
+ profile?: string | null,
1222
+ ): Promise<void> {
1223
+ const remove = deps.remove;
1224
+ if (!remove) throw new Error("magus cannot remove plugins from here");
1225
+
1226
+ // Refuse BEFORE uninstalling when the profile drop below would be needed but
1227
+ // cannot hold: a declared plugin that depends on this one re-adds it.
1228
+ if (remove.dependents) {
1229
+ let installedElsewhere = false;
1230
+ for (const other of REMOVE_SCOPES) {
1231
+ if (other === scope) continue;
1232
+ if (await remove.isInstalled(pluginId, other, projectPath)) {
1233
+ installedElsewhere = true;
1234
+ break;
1235
+ }
1236
+ }
1237
+ if (scope === "project" || !installedElsewhere) {
1238
+ const dependents = await remove.dependents(
1239
+ pluginId,
1240
+ projectPath,
1241
+ profile,
1242
+ );
1243
+ if (dependents.length > 0) {
1244
+ const names = dependents.map(pluginName).join(", ");
1245
+ const needs = dependents.length === 1 ? "needs" : "need";
1246
+ throw new Error(
1247
+ `${names} ${needs} ${pluginName(pluginId)} — uninstall ${names} first`,
1248
+ );
1249
+ }
1250
+ }
1251
+ }
1252
+
1253
+ await remove.uninstall(pluginId, scope, projectPath);
1254
+ if (await remove.isInstalled(pluginId, scope, projectPath)) {
1255
+ throw new Error(`still installed at ${scope.toUpperCase()} scope`);
1256
+ }
1257
+ // Best-effort, as in applyRemove: the read-back above is the judge.
1258
+ await remove.forgetVersion?.(pluginId, scope, projectPath).catch(() => {});
1259
+
1260
+ let installedNowhere = true;
1261
+ for (const other of REMOVE_SCOPES) {
1262
+ if (await remove.isInstalled(pluginId, other, projectPath)) {
1263
+ installedNowhere = false;
1264
+ break;
1265
+ }
1266
+ }
1267
+ if (scope !== "project" && !installedNowhere) return;
1268
+ if (!(await remove.dropFromProfile(pluginId, projectPath, profile))) {
1269
+ throw new Error(
1270
+ `uninstalled at ${scope.toUpperCase()} scope, but the profile still lists it`,
1271
+ );
1272
+ }
1273
+ }
1274
+
1134
1275
  // -- binaries ----------------------------------------------------------------
1135
1276
 
1136
1277
  /**
@@ -44,6 +44,7 @@ import {
44
44
  applyPlugins,
45
45
  installPluginInScope,
46
46
  pluginNeedsWork,
47
+ uninstallPluginInScope,
47
48
  updatePluginInScope,
48
49
  } from "../../services/update-engine.js";
49
50
  // The ONE plugin classifier, shared with `magus update`. Every state this
@@ -1019,7 +1020,15 @@ export function PluginsScreen() {
1019
1020
  modal.loading(
1020
1021
  `Uninstalling ${plugin.name} from ${scopeLabel}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1021
1022
  );
1022
- await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1023
+ // Through the engine, so the profile stops declaring what was
1024
+ // just uninstalled and the result is read back.
1025
+ await uninstallPluginInScope(
1026
+ plugin.id,
1027
+ scope,
1028
+ state.projectPath || process.cwd(),
1029
+ REAL_PLUGIN_DEPS,
1030
+ activeProfileId,
1031
+ );
1023
1032
  } else if (action === "update") {
1024
1033
  modal.loading(
1025
1034
  `Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
@@ -1185,7 +1194,14 @@ export function PluginsScreen() {
1185
1194
  modal.loading(
1186
1195
  `Uninstalling ${plugin.name} from ${scopeLabel}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1187
1196
  );
1188
- await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1197
+ // Engine uninstall: read back, and dropped from the profile — see above.
1198
+ await uninstallPluginInScope(
1199
+ plugin.id,
1200
+ scope,
1201
+ state.projectPath || process.cwd(),
1202
+ REAL_PLUGIN_DEPS,
1203
+ activeProfileId,
1204
+ );
1189
1205
  } else if (action === "update") {
1190
1206
  modal.loading(
1191
1207
  `Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,