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 +4 -4
- package/src/__tests__/scratch-git.ts +48 -0
- package/src/__tests__/worktree-deprecated-remove-reader.test.ts +268 -0
- package/src/__tests__/worktree-deprecated-remove.test.ts +115 -0
- package/src/services/claude-cli.ts +9 -0
- package/src/services/claude-settings.ts +68 -0
- package/src/services/update-engine.ts +57 -1
- package/src/ui/screens/PluginsScreen.tsx +33 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.11.
|
|
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.
|
|
69
|
-
"magus-cli-darwin-x64": "7.11.
|
|
70
|
-
"magus-cli-linux-x64": "7.11.
|
|
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
|
-
|
|
12
|
+
isNotInstalledAnswer,
|
|
13
|
+
uninstallPluginViaCli,
|
|
13
14
|
} from "../../services/claude-cli.js";
|
|
14
|
-
import {
|
|
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
|
|
1275
|
-
} catch {
|
|
1276
|
-
|
|
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);
|