magus-cli 7.11.2 → 7.11.3

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.2",
3
+ "version": "7.11.3",
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.2",
69
- "magus-cli-darwin-x64": "7.11.2",
70
- "magus-cli-linux-x64": "7.11.2"
68
+ "magus-cli-darwin-arm64": "7.11.3",
69
+ "magus-cli-darwin-x64": "7.11.3",
70
+ "magus-cli-linux-x64": "7.11.3"
71
71
  }
72
72
  }
@@ -0,0 +1,48 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import fs from "fs-extra";
5
+
6
+ /**
7
+ * Real scratch git repositories for tests that need `inheritablePaths` to
8
+ * answer for real. Everything lives under os.tmpdir(); nothing touches the
9
+ * operator's home or config.
10
+ */
11
+
12
+ // Isolate from the operator's git config (signing, default branch, excludes).
13
+ const GIT_CFG = [
14
+ "-c",
15
+ "user.name=test",
16
+ "-c",
17
+ "user.email=test@example.com",
18
+ "-c",
19
+ "commit.gpgsign=false",
20
+ "-c",
21
+ "init.defaultBranch=main",
22
+ ];
23
+
24
+ export const git = (cwd: string, ...args: string[]): string =>
25
+ execFileSync("git", [...GIT_CFG, ...args], {
26
+ cwd,
27
+ encoding: "utf8",
28
+ stdio: ["ignore", "pipe", "pipe"],
29
+ }).trim();
30
+
31
+ /** A new scratch directory; realpath'd because git reports real paths. */
32
+ export async function makeScratch(): Promise<string> {
33
+ return fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "magus-remove-")));
34
+ }
35
+
36
+ /** `git init` plus one commit at `dir`. */
37
+ export async function initRepo(dir: string): Promise<void> {
38
+ await fs.ensureDir(dir);
39
+ git(dir, "init", "-q");
40
+ await fs.writeFile(path.join(dir, "README.md"), "hello\n");
41
+ git(dir, "add", "-A");
42
+ git(dir, "commit", "-qm", "initial");
43
+ }
44
+
45
+ /** A linked worktree of `main` at `dir`, on a new branch. */
46
+ export function addWorktree(main: string, dir: string, branch: string): void {
47
+ git(main, "worktree", "add", "-q", "-b", branch, dir);
48
+ }
@@ -0,0 +1,268 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import { isNotInstalledAnswer } from "../services/claude-cli.js";
5
+ import type { PluginScope } from "../services/claude-cli.js";
6
+ import {
7
+ inheritedRegistryRows,
8
+ inheritedRowsOf,
9
+ } from "../services/claude-settings.js";
10
+ import { clearWorktreeCache } from "../services/git-worktree.js";
11
+ import {
12
+ type ApplyReporter,
13
+ type PluginApplyDeps,
14
+ applyPlugins,
15
+ } from "../services/update-engine.js";
16
+ import type { PluginUpdateItem } from "../services/update-plan.js";
17
+ import type { InstalledPluginEntry } from "../types/index.js";
18
+ import { addWorktree, initRepo, makeScratch } from "./scratch-git.js";
19
+
20
+ /**
21
+ * Removing a deprecated plugin from a linked worktree whose only registry row
22
+ * belongs to the main checkout: the reader that finds that row, the folder the
23
+ * uninstall runs in, and the failures that must not be swallowed.
24
+ *
25
+ * Repository membership is git's answer (`inheritablePaths`), so the cases that
26
+ * depend on it run against real scratch repositories.
27
+ */
28
+
29
+ const OTHER = "/elsewhere/unrelated";
30
+ const ID = "code-analysis@magus";
31
+
32
+ let scratch: string;
33
+ let MAIN: string;
34
+ let TREE: string;
35
+ // Two INDEPENDENT repositories, the second kept under the first's
36
+ // `.claude/worktrees/` — a path shape that is not repository membership.
37
+ let PARENT: string;
38
+ let NESTED: string;
39
+
40
+ beforeAll(async () => {
41
+ scratch = await makeScratch();
42
+ MAIN = path.join(scratch, "repo");
43
+ TREE = path.join(scratch, "feature");
44
+ await initRepo(MAIN);
45
+ addWorktree(MAIN, TREE, "feature");
46
+ PARENT = path.join(scratch, "parent");
47
+ NESTED = path.join(PARENT, ".claude", "worktrees", "child");
48
+ await initRepo(PARENT);
49
+ await initRepo(NESTED);
50
+ clearWorktreeCache();
51
+ });
52
+
53
+ afterAll(async () => {
54
+ if (scratch) await fs.remove(scratch);
55
+ });
56
+
57
+ const entry = (
58
+ scope: InstalledPluginEntry["scope"],
59
+ projectPath?: string,
60
+ ): InstalledPluginEntry => ({
61
+ scope,
62
+ version: "7.3.0",
63
+ projectPath,
64
+ installPath: "/cache/7.3.0",
65
+ installedAt: "2026-01-01T00:00:00.000Z",
66
+ lastUpdated: "2026-01-01T00:00:00.000Z",
67
+ });
68
+
69
+ describe("inheritedRegistryRows", () => {
70
+ test("finds the main checkout's project and local rows", () => {
71
+ const rows = inheritedRegistryRows(
72
+ [entry("project", MAIN), entry("local", MAIN)],
73
+ TREE,
74
+ [MAIN],
75
+ );
76
+ expect(rows.map((r) => r.scope).sort()).toEqual(["local", "project"]);
77
+ expect(rows.every((r) => r.projectPath === MAIN)).toBe(true);
78
+ });
79
+
80
+ test("does not return the project's own row", () => {
81
+ const rows = inheritedRegistryRows(
82
+ [entry("project", TREE), entry("project", MAIN)],
83
+ TREE,
84
+ [MAIN, TREE],
85
+ );
86
+ expect(rows.map((r) => r.projectPath)).toEqual([MAIN]);
87
+ });
88
+
89
+ test("never returns rows of an unrelated project or the user row", () => {
90
+ const rows = inheritedRegistryRows(
91
+ [entry("project", OTHER), entry("user")],
92
+ TREE,
93
+ [MAIN],
94
+ );
95
+ expect(rows).toEqual([]);
96
+ });
97
+
98
+ test("returns nothing when no same-repository folder is known", () => {
99
+ expect(inheritedRegistryRows([entry("project", MAIN)], TREE, [])).toEqual(
100
+ [],
101
+ );
102
+ expect(inheritedRegistryRows(undefined, TREE, [MAIN])).toEqual([]);
103
+ });
104
+
105
+ test("a linked worktree inherits its main checkout's row, per git", async () => {
106
+ const rows = await inheritedRowsOf(
107
+ [entry("project", MAIN), entry("project", OTHER)],
108
+ TREE,
109
+ );
110
+ expect(rows.map((r) => r.projectPath)).toEqual([MAIN]);
111
+ });
112
+
113
+ test("an unrelated parent repository's row is NEVER returned for a nested independent repository", async () => {
114
+ const rows = await inheritedRowsOf(
115
+ [entry("project", PARENT), entry("local", PARENT)],
116
+ NESTED,
117
+ );
118
+ expect(rows).toEqual([]);
119
+ });
120
+ });
121
+
122
+ describe("isNotInstalledAnswer", () => {
123
+ test("recognises the CLI's per-scope answer only", () => {
124
+ expect(
125
+ isNotInstalledAnswer(
126
+ new Error(`Plugin "${ID}" is not installed in project scope.`),
127
+ ),
128
+ ).toBe(true);
129
+ expect(isNotInstalledAnswer(new Error("claude timed out"))).toBe(false);
130
+ });
131
+ });
132
+
133
+ function harness(
134
+ rows: InstalledPluginEntry[],
135
+ uninstall: (id: string, scope: PluginScope, cwd: string) => Promise<void>,
136
+ ) {
137
+ const unused = async () => {
138
+ throw new Error("a removal must not install, update or repair");
139
+ };
140
+ const deps: PluginApplyDeps = {
141
+ install: unused,
142
+ update: unused,
143
+ repair: unused,
144
+ readInstalled: unused,
145
+ saveInstalled: unused,
146
+ repairStaleLoad: unused,
147
+ registryRows: async () => rows,
148
+ remove: {
149
+ uninstall,
150
+ isInstalled: async () => false,
151
+ dropFromProfile: async () => true,
152
+ },
153
+ };
154
+ const item: PluginUpdateItem = {
155
+ pluginId: ID,
156
+ pinned: "latest",
157
+ installed: null,
158
+ loaded: null,
159
+ available: null,
160
+ target: null,
161
+ action: "remove",
162
+ reason: "deprecated",
163
+ scopes: [],
164
+ beforeVersions: {},
165
+ note: "no longer published",
166
+ };
167
+ const details: string[] = [];
168
+ const reporter: ApplyReporter = {
169
+ begin: () => {},
170
+ finish: (_ok, _id, detail) => {
171
+ details.push(String(detail));
172
+ },
173
+ detach: (work) => work(),
174
+ };
175
+ return { deps, item, reporter, details };
176
+ }
177
+
178
+ describe("applyPlugins: removing an inherited row", () => {
179
+ test("runs the uninstall in the row's own folder", async () => {
180
+ let rows = [entry("project", MAIN), entry("project", OTHER)];
181
+ const calls: Array<[PluginScope, string]> = [];
182
+ const h = harness(rows, async (_id, scope, cwd) => {
183
+ calls.push([scope, cwd]);
184
+ rows = rows.filter((r) => r.projectPath !== cwd);
185
+ });
186
+ h.deps.registryRows = async () => rows;
187
+
188
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
189
+
190
+ expect(calls).toEqual([["project", MAIN]]);
191
+ expect(result.failed).toEqual([]);
192
+ // The unrelated project's row is untouched.
193
+ expect(rows.map((r) => r.projectPath)).toEqual([OTHER]);
194
+ });
195
+
196
+ test("a real uninstall error surfaces instead of being swallowed", async () => {
197
+ const rows = [entry("project", MAIN)];
198
+ const h = harness(rows, async () => {
199
+ throw new Error("claude plugin uninstall timed out");
200
+ });
201
+
202
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
203
+
204
+ expect(result.failed).toEqual([ID]);
205
+ expect(h.details.join(" ")).toContain("timed out");
206
+ });
207
+
208
+ test("says where it is still installed when the command reported nothing", async () => {
209
+ const rows = [entry("project", MAIN)];
210
+ const h = harness(rows, async () => {});
211
+
212
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
213
+
214
+ expect(result.failed).toEqual([ID]);
215
+ const detail = h.details.join(" ");
216
+ expect(detail).toContain(
217
+ `still installed in ${path.basename(MAIN)}, another folder of this repository`,
218
+ );
219
+ // A folder is named, never an absolute path.
220
+ expect(detail).not.toContain(MAIN);
221
+ });
222
+
223
+ test("an unreadable registry after the uninstalls is a failed removal", async () => {
224
+ const rows = [entry("project", MAIN)];
225
+ let reads = 0;
226
+ const h = harness(rows, async () => {
227
+ throw new Error("claude plugin uninstall timed out");
228
+ });
229
+ h.deps.registryRows = async () => {
230
+ reads++;
231
+ if (reads > 1) throw new Error("registry unreadable");
232
+ return rows;
233
+ };
234
+
235
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
236
+
237
+ expect(result.failed).toEqual([ID]);
238
+ expect(result.removed).toEqual([]);
239
+ });
240
+
241
+ test("an unreadable registry with no error from the CLI says it could not confirm", async () => {
242
+ const rows = [entry("project", MAIN)];
243
+ let reads = 0;
244
+ const h = harness(rows, async () => {});
245
+ h.deps.registryRows = async () => {
246
+ reads++;
247
+ if (reads > 1) throw new Error("registry unreadable");
248
+ return rows;
249
+ };
250
+
251
+ const result = await applyPlugins([h.item], TREE, h.deps, h.reporter);
252
+
253
+ expect(result.failed).toEqual([ID]);
254
+ expect(h.details.join(" ")).toContain("could not confirm it was removed");
255
+ });
256
+
257
+ test("never uninstalls an unrelated parent repository's row from a nested independent repository", async () => {
258
+ const rows = [entry("project", PARENT)];
259
+ const calls: string[] = [];
260
+ const h = harness(rows, async (_id, _scope, cwd) => {
261
+ calls.push(cwd);
262
+ });
263
+
264
+ await applyPlugins([h.item], NESTED, h.deps, h.reporter);
265
+
266
+ expect(calls).toEqual([]);
267
+ });
268
+ });
@@ -0,0 +1,115 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import type { PluginScope } from "../services/claude-cli.js";
5
+ import { clearWorktreeCache } from "../services/git-worktree.js";
6
+ import {
7
+ type ApplyReporter,
8
+ type PluginApplyDeps,
9
+ applyPlugins,
10
+ } from "../services/update-engine.js";
11
+ import type { PluginUpdateItem } from "../services/update-plan.js";
12
+ import type { InstalledPluginEntry } from "../types/index.js";
13
+ import { addWorktree, initRepo, makeScratch } from "./scratch-git.js";
14
+
15
+ /**
16
+ * `d` on a deprecated plugin from a linked worktree, when the only registry
17
+ * row for it belongs to the MAIN checkout.
18
+ *
19
+ * The orphan row exists because the registry row is inherited from the main
20
+ * checkout, but no settings file here turns the plugin on, so the plan carries
21
+ * no scopes. The removal then runs zero uninstalls.
22
+ */
23
+
24
+ const ID = "code-analysis@magus";
25
+
26
+ // A real repository with a real linked worktree, so `inheritablePaths` answers
27
+ // the way git does rather than from a path shape.
28
+ let scratch: string;
29
+ let MAIN: string;
30
+ let TREE: string;
31
+
32
+ beforeAll(async () => {
33
+ scratch = await makeScratch();
34
+ MAIN = path.join(scratch, "repo");
35
+ TREE = path.join(scratch, "feature");
36
+ await initRepo(MAIN);
37
+ addWorktree(MAIN, TREE, "feature");
38
+ clearWorktreeCache();
39
+ });
40
+
41
+ afterAll(async () => {
42
+ if (scratch) await fs.remove(scratch);
43
+ });
44
+
45
+ function row(projectPath: string): InstalledPluginEntry {
46
+ return {
47
+ scope: "project",
48
+ version: "7.3.0",
49
+ projectPath,
50
+ installPath: "/cache/7.3.0",
51
+ installedAt: "2026-01-01T00:00:00.000Z",
52
+ lastUpdated: "2026-01-01T00:00:00.000Z",
53
+ };
54
+ }
55
+
56
+ describe("applyPlugins: remove from a linked worktree", () => {
57
+ // REGRESSION: worktree d on a deprecated plugin removed nothing and showed no error — Fixed in /dev:fix session dev-fix-20261026-155239-490d2c7e
58
+ test("should not report success while the main checkout's registry row remains when removing from a linked worktree", async () => {
59
+ let rows: InstalledPluginEntry[] = [row(MAIN)];
60
+ const unused = async () => {
61
+ throw new Error("a removal must not install, update or repair");
62
+ };
63
+ const deps: PluginApplyDeps = {
64
+ install: unused,
65
+ update: unused,
66
+ repair: unused,
67
+ readInstalled: unused,
68
+ saveInstalled: unused,
69
+ repairStaleLoad: unused,
70
+ registryRows: async () => rows,
71
+ remove: {
72
+ // Like `claude plugin uninstall --scope project`: only the row
73
+ // pinned to exactly the cwd it runs in can be removed.
74
+ uninstall: async (_id, scope: PluginScope, cwd) => {
75
+ const hit = rows.some(
76
+ (r) => r.scope === scope && r.projectPath === cwd,
77
+ );
78
+ if (!hit) {
79
+ throw new Error(
80
+ `Plugin "${ID}" is not installed in ${scope} scope.`,
81
+ );
82
+ }
83
+ rows = rows.filter((r) => r.projectPath !== cwd);
84
+ },
85
+ // Settings-derived: no settings turn the plugin on here.
86
+ isInstalled: async () => false,
87
+ dropFromProfile: async () => true,
88
+ },
89
+ };
90
+ const item: PluginUpdateItem = {
91
+ pluginId: ID,
92
+ pinned: "latest",
93
+ installed: null,
94
+ loaded: null,
95
+ available: null,
96
+ target: null,
97
+ action: "remove",
98
+ reason: "deprecated",
99
+ scopes: [],
100
+ beforeVersions: {},
101
+ note: "no longer published",
102
+ };
103
+ const reporter: ApplyReporter = {
104
+ begin: () => {},
105
+ finish: () => {},
106
+ detach: (work) => work(),
107
+ };
108
+
109
+ const result = await applyPlugins([item], TREE, deps, reporter);
110
+
111
+ // Either the row is really gone (uninstall ran in the main checkout), or
112
+ // the removal says it failed. Success with the row still there is the bug.
113
+ expect(rows.length === 0 || result.failed.includes(ID)).toBe(true);
114
+ });
115
+ });
@@ -268,6 +268,15 @@ export async function uninstallPluginViaCli(
268
268
  );
269
269
  }
270
270
 
271
+ /**
272
+ * Is `error` the CLI's answer that the plugin is not installed at the scope it
273
+ * was asked about — the one failure of an uninstall that means "nothing to do".
274
+ */
275
+ export function isNotInstalledAnswer(error: unknown): boolean {
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ return /not installed in (user|project|local) scope/i.test(message);
278
+ }
279
+
271
280
  /**
272
281
  * Repair a plugin whose files drifted from the marketplace under an unchanged
273
282
  * version — uninstall, then install again at the same scope.
@@ -434,6 +434,74 @@ export function ownRegistryEntry(
434
434
  );
435
435
  }
436
436
 
437
+ /**
438
+ * The project and local rows of one plugin that `projectPath` INHERITS: rows
439
+ * pinned to a same-repository folder (`fallbackPaths`, as
440
+ * {@link pickRegistryEntry} reads them) rather than to `projectPath` itself.
441
+ *
442
+ * These are the rows that make a plugin show up in a linked worktree that never
443
+ * installed it, and `claude plugin uninstall` removes them only when run from the
444
+ * row's own folder. Rows of any other project are never returned, and neither is
445
+ * the project's own row ({@link ownRegistryEntry} answers that one).
446
+ */
447
+ export function inheritedRegistryRows(
448
+ entries: readonly InstalledPluginEntry[] | undefined,
449
+ projectPath: string,
450
+ fallbackPaths: readonly string[],
451
+ ): InstalledPluginEntry[] {
452
+ if (!Array.isArray(entries)) return [];
453
+ const own = path.resolve(projectPath);
454
+ const sources = new Set(fallbackPaths.map((p) => path.resolve(p)));
455
+ sources.delete(own);
456
+ return entries.filter(
457
+ (e) =>
458
+ (e.scope === "project" || e.scope === "local") &&
459
+ e.projectPath !== undefined &&
460
+ sources.has(path.resolve(e.projectPath)),
461
+ );
462
+ }
463
+
464
+ /**
465
+ * Same-repository folders whose rows `projectPath` inherits: git's
466
+ * `inheritablePaths`, the one definition the read side ({@link pickRegistryEntry})
467
+ * uses too. `[]` when git cannot answer.
468
+ */
469
+ async function inheritableFolders(projectPath: string): Promise<string[]> {
470
+ return inheritablePaths(projectPath);
471
+ }
472
+
473
+ /**
474
+ * Read-only: the inherited rows of `pluginId` for `projectPath`, read from the
475
+ * registry. `[]` when the registry cannot be read or there are none.
476
+ */
477
+ export async function readInheritedRegistryRows(
478
+ pluginId: string,
479
+ projectPath: string,
480
+ ): Promise<InstalledPluginEntry[]> {
481
+ try {
482
+ const registry = await readInstalledPluginsRegistry();
483
+ return inheritedRegistryRows(
484
+ registry.plugins?.[pluginId],
485
+ projectPath,
486
+ await inheritableFolders(projectPath),
487
+ );
488
+ } catch {
489
+ return [];
490
+ }
491
+ }
492
+
493
+ /** The inherited rows within `rows` already read, for a caller holding them. */
494
+ export async function inheritedRowsOf(
495
+ rows: readonly InstalledPluginEntry[],
496
+ projectPath: string,
497
+ ): Promise<InstalledPluginEntry[]> {
498
+ return inheritedRegistryRows(
499
+ rows,
500
+ projectPath,
501
+ await inheritableFolders(projectPath),
502
+ );
503
+ }
504
+
437
505
  async function overlayRegistryVersions(
438
506
  base: Record<string, string>,
439
507
  scope: "user" | "project" | "local",
@@ -43,6 +43,7 @@ import { selectProfileId } from "./active-closure.js";
43
43
  import type { PluginScope } from "./claude-cli.js";
44
44
  import {
45
45
  installPlugin,
46
+ isNotInstalledAnswer,
46
47
  repairPlugin,
47
48
  uninstallPluginViaCli,
48
49
  updatePlugin,
@@ -53,6 +54,7 @@ import {
53
54
  getEnabledPlugins,
54
55
  getGlobalEnabledPlugins,
55
56
  getLocalEnabledPlugins,
57
+ inheritedRowsOf,
56
58
  loadedVersionHere,
57
59
  notDownloadedReason,
58
60
  readInstalledPluginsRegistry,
@@ -883,11 +885,33 @@ async function applyRemove(
883
885
  }
884
886
  try {
885
887
  let uninstallError: unknown = null;
888
+ // Errors of the inherited-row uninstalls, apart from the scope loop's: the
889
+ // CLI's "not installed in <scope> scope" is the expected answer there for a
890
+ // scope this folder only inherits, and is not a failure.
891
+ let inheritedError: unknown = null;
886
892
  for (const scope of item.scopes) {
887
893
  try {
888
894
  await remove.uninstall(item.pluginId, scope, projectPath);
889
895
  } catch (error) {
890
- uninstallError ??= error;
896
+ if (!isNotInstalledAnswer(error)) uninstallError ??= error;
897
+ }
898
+ }
899
+ // A row this folder only INHERITS (a linked worktree reading its main
900
+ // checkout's install) is removed from the row's own folder; the CLI finds
901
+ // nothing to remove from anywhere else.
902
+ const inherited = await inheritedRowsOf(
903
+ (await readRows(deps, item.pluginId)) ?? [],
904
+ projectPath,
905
+ );
906
+ for (const row of inherited) {
907
+ try {
908
+ await remove.uninstall(
909
+ item.pluginId,
910
+ row.scope,
911
+ row.projectPath ?? projectPath,
912
+ );
913
+ } catch (error) {
914
+ inheritedError ??= error;
891
915
  }
892
916
  }
893
917
  const unlisted = await remove.dropFromProfile(
@@ -913,6 +937,38 @@ async function applyRemove(
913
937
  .catch(() => {});
914
938
  }
915
939
  }
940
+ // Success needs the rows READ and none remaining: a read-back that fails
941
+ // is a removal nobody confirmed, never "no rows remain".
942
+ const rowsNow = await readRows(deps, item.pluginId);
943
+ if (rowsNow === null && deps.registryRows) {
944
+ if (inheritedError instanceof Error) {
945
+ fail(inheritedError.message, "exception");
946
+ } else {
947
+ fail("could not confirm it was removed");
948
+ }
949
+ return;
950
+ }
951
+ const remaining = await inheritedRowsOf(rowsNow ?? [], projectPath);
952
+ if (remaining.length > 0) {
953
+ if (inheritedError instanceof Error) {
954
+ fail(inheritedError.message, "exception");
955
+ return;
956
+ }
957
+ const names = [
958
+ ...new Set(
959
+ remaining
960
+ .map((r) => r.projectPath)
961
+ .filter((p): p is string => p !== undefined)
962
+ .map((p) => path.basename(p)),
963
+ ),
964
+ ];
965
+ fail(
966
+ names.length > 0
967
+ ? `still installed in ${names.join(", ")}, another folder of this repository`
968
+ : "still installed in another folder of this repository",
969
+ );
970
+ return;
971
+ }
916
972
  if (still.length > 0) {
917
973
  // The CLI's own words when it said anything; ours otherwise.
918
974
  if (uninstallError !== null) {
@@ -9,9 +9,14 @@ import {
9
9
  type PluginScope,
10
10
  addMarketplace as cliAddMarketplace,
11
11
  repairPlugin as cliRepairPlugin,
12
- uninstallPlugin as cliUninstallPlugin,
12
+ isNotInstalledAnswer,
13
+ uninstallPluginViaCli,
13
14
  } from "../../services/claude-cli.js";
14
- import { readSettings, setMcpEnvVar } from "../../services/claude-settings.js";
15
+ import {
16
+ readInheritedRegistryRows,
17
+ readSettings,
18
+ setMcpEnvVar,
19
+ } from "../../services/claude-settings.js";
15
20
  import { clearContentDriftCache } from "../../services/content-drift.js";
16
21
  import { missingPluginEnvVars } from "../../services/plugin-env-needs.js";
17
22
  import {
@@ -1260,25 +1265,45 @@ export function PluginsScreen() {
1260
1265
  return;
1261
1266
  }
1262
1267
 
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[] = [];
1263
1271
  try {
1264
1272
  // Remove from all scopes — try all to clean up stale references
1265
1273
  const scopes: PluginScope[] = ["user", "project", "local"];
1266
- // Nothing tallies which scopes actually held it. The migrate dialog
1267
- // that consumed that tally is gone with the `moved` inference, and
1268
- // collecting it for no reader is how a value goes stale unnoticed.
1269
1274
  for (const scope of scopes) {
1270
1275
  try {
1271
1276
  modal.loading(
1272
1277
  `Removing ${plugin.name} from ${scope}…\nclaude plugin uninstall ${plugin.id} --scope ${scope}`,
1273
1278
  );
1274
- await cliUninstallPlugin(plugin.id, scope, state.projectPath);
1275
- } catch {
1276
- // Ignore errors for scopes where it doesn't exist
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(
1287
+ plugin.id,
1288
+ state.projectPath || process.cwd(),
1289
+ );
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);
1277
1299
  }
1278
1300
  }
1279
1301
 
1280
1302
  modal.hideModal();
1281
1303
  fetchData();
1304
+ if (problems.length > 0) {
1305
+ await reportPluginFailure(plugin.id, "removed", problems[0]);
1306
+ }
1282
1307
  } catch (error) {
1283
1308
  modal.hideModal();
1284
1309
  await reportPluginFailure(plugin.id, "removed", error);