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 +4 -4
- package/src/__tests__/blind/requires-min-version/manifests.test.ts +5 -3
- package/src/__tests__/orphan-user-row-repair.test.ts +102 -0
- package/src/__tests__/scope-uninstall-dependents.test.ts +173 -0
- package/src/__tests__/scope-uninstall-profile.test.ts +103 -0
- package/src/services/claude-settings.ts +19 -5
- package/src/services/resolver.ts +32 -0
- package/src/services/update-engine.ts +143 -2
- package/src/ui/screens/PluginsScreen.tsx +18 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.11.
|
|
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.
|
|
69
|
-
"magus-cli-darwin-x64": "7.11.
|
|
70
|
-
"magus-cli-linux-x64": "7.11.
|
|
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
|
-
|
|
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("
|
|
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
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
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(
|
package/src/services/resolver.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}`,
|