magus-cli 7.14.0 → 7.14.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/README.md
CHANGED
|
@@ -46,9 +46,10 @@ install [profile] Install a profile's plugins, binaries, skills and
|
|
|
46
46
|
update [profile] Bring the ACTIVE profile up to date: install what it
|
|
47
47
|
declares but this machine lacks, and advance every
|
|
48
48
|
"latest" pin to the newest published version.
|
|
49
|
+
Applies without asking; it only moves forward.
|
|
50
|
+
--dry-run Show the plan and stop; writes nothing, exits 0
|
|
49
51
|
--check Report only, write nothing (exits 1 if anything is
|
|
50
52
|
missing or known to be behind)
|
|
51
|
-
--yes, -y Skip the confirmation prompt
|
|
52
53
|
profile list Show every profile; ● marks the active one
|
|
53
54
|
profile show <name> Print a profile's fully resolved closure
|
|
54
55
|
profile switch <name> Make <name> active: render its settings.json,
|
|
@@ -56,8 +57,6 @@ profile switch <name> Make <name> active: render its settings.json,
|
|
|
56
57
|
profile init Write .claude/profiles.json from this project's setup
|
|
57
58
|
doctor [--fix] Check binary deps, the active profile, conventions
|
|
58
59
|
|
|
59
|
-
claude [args...] Check for plugin updates (1h cache), then run claude
|
|
60
|
-
--force, -f Force the update check
|
|
61
60
|
upgrade Update magus itself
|
|
62
61
|
--version, -v Version + update check
|
|
63
62
|
--help, -h This list
|
|
@@ -171,15 +170,16 @@ walks through it end to end, including conflicts and protected branches.
|
|
|
171
170
|
|
|
172
171
|
| Screen | What it manages |
|
|
173
172
|
|---|---|
|
|
174
|
-
| Plugins | Install,
|
|
173
|
+
| Plugins | Install, uninstall and update at USER, PROJECT or LOCAL scope; shows version changes and newly installed plugins |
|
|
175
174
|
| Skills | Browse and install skills from configured skill repos |
|
|
176
175
|
| MCP | Add MCP servers from a curated catalog |
|
|
177
176
|
| Settings | Claude Code settings, from a catalog of known keys |
|
|
178
|
-
| Profiles |
|
|
177
|
+
| Profiles | Switch between this project's profiles, apply a built-in one, rename, delete, copy or import |
|
|
179
178
|
| CLI Tools | Install and update CLI tools via the right package manager; a tool a plugin installs from a release shows as managed by that plugin |
|
|
180
179
|
| Git State | Gitignore conventions and repo hygiene |
|
|
181
180
|
| Alias | Shell alias + flag management for launching `claude` |
|
|
182
181
|
| Styles | Communication style presets, composed into one native output style |
|
|
182
|
+
| Models | Per-subagent model routing presets (`magus models`) |
|
|
183
183
|
|
|
184
184
|
Navigate with `↑/↓` or `j/k`, `Enter` to select, `r` to refresh, `?` for help,
|
|
185
185
|
`q`/`Escape` to go back. Number keys `1`–`9` jump straight to a screen.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.14.
|
|
3
|
+
"version": "7.14.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.14.
|
|
69
|
-
"magus-cli-darwin-x64": "7.14.
|
|
70
|
-
"magus-cli-linux-x64": "7.14.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.14.2",
|
|
69
|
+
"magus-cli-darwin-x64": "7.14.2",
|
|
70
|
+
"magus-cli-linux-x64": "7.14.2"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scope key on the Plugins screen uninstalls through `uninstallPluginInScope`.
|
|
3
|
+
* When the scope's settings.json turns the plugin on but Claude Code's registry
|
|
4
|
+
* holds its row at ANOTHER scope, `claude plugin uninstall --scope <scope>`
|
|
5
|
+
* refuses with "is installed in <other> scope, not <scope>" and changes
|
|
6
|
+
* nothing. In a profiled project that is the normal state of a PROJECT enable:
|
|
7
|
+
* the profile render wrote it, so the profile is what must drop it. Where no
|
|
8
|
+
* profile owns the enable, the refusal must reach the user in magus's terms,
|
|
9
|
+
* never as Claude Code's raw text.
|
|
10
|
+
*
|
|
11
|
+
* Real files, real reconcile: a temp project, HOME pointed at a temp dir. Only
|
|
12
|
+
* the `claude` CLI is doubled, and its uninstall refuses the way Claude Code
|
|
13
|
+
* does, touching no file.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
17
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import fs from "fs-extra";
|
|
21
|
+
import { readManifest, writeManifest } from "../services/manifest.js";
|
|
22
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
23
|
+
import {
|
|
24
|
+
type PluginApplyDeps,
|
|
25
|
+
REAL_PLUGIN_DEPS,
|
|
26
|
+
REAL_REMOVE_DEPS,
|
|
27
|
+
uninstallPluginInScope,
|
|
28
|
+
} from "../services/update-engine.js";
|
|
29
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
30
|
+
|
|
31
|
+
type Scope = "user" | "project" | "local";
|
|
32
|
+
|
|
33
|
+
let project: string;
|
|
34
|
+
let home: string;
|
|
35
|
+
let log: ReturnType<typeof spyOn>;
|
|
36
|
+
const saved = {
|
|
37
|
+
HOME: process.env.HOME,
|
|
38
|
+
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function manifest(): ProfileManifest {
|
|
42
|
+
return {
|
|
43
|
+
version: 2,
|
|
44
|
+
profiles: {
|
|
45
|
+
team: {
|
|
46
|
+
name: "Team",
|
|
47
|
+
plugins: { "dev@magus": "latest", "multimodel@magus": "latest" },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The settings.json `claude plugin … --scope <scope>` reads and writes. */
|
|
54
|
+
function settingsFor(scope: Scope): string {
|
|
55
|
+
if (scope === "user") return join(home, ".claude", "settings.json");
|
|
56
|
+
if (scope === "local") return join(project, ".claude", "settings.local.json");
|
|
57
|
+
return join(project, ".claude", "settings.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function setEnabled(scope: Scope, pluginId: string): Promise<void> {
|
|
61
|
+
const file = settingsFor(scope);
|
|
62
|
+
const json = (await fs.pathExists(file))
|
|
63
|
+
? ((await fs.readJson(file)) as Record<string, unknown>)
|
|
64
|
+
: {};
|
|
65
|
+
json.enabledPlugins = {
|
|
66
|
+
...((json.enabledPlugins as Record<string, boolean>) ?? {}),
|
|
67
|
+
[pluginId]: true,
|
|
68
|
+
};
|
|
69
|
+
await fs.outputFile(file, JSON.stringify(json));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function enabledIn(scope: Scope): Promise<Record<string, boolean>> {
|
|
73
|
+
const file = settingsFor(scope);
|
|
74
|
+
if (!(await fs.pathExists(file))) return {};
|
|
75
|
+
const json = (await fs.readJson(file)) as {
|
|
76
|
+
enabledPlugins?: Record<string, boolean>;
|
|
77
|
+
};
|
|
78
|
+
return json.enabledPlugins ?? {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function profilePlugins(): Promise<Record<string, unknown>> {
|
|
82
|
+
const { profiles } = await readManifest(project);
|
|
83
|
+
return (profiles.team?.plugins ?? {}) as Record<string, unknown>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Claude Code's refusal when the registry row is at `rowAt`, not `asked`. */
|
|
87
|
+
function wrongScopeRefusal(pluginId: string, rowAt: Scope, asked: Scope) {
|
|
88
|
+
return new Error(
|
|
89
|
+
`✘ Failed to uninstall plugin "${pluginId}": Plugin "${pluginId}" is installed in ${rowAt} scope, not ${asked}. Use --scope ${rowAt} to uninstall.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The real engine deps with only the `claude` CLI and the version read-back
|
|
95
|
+
* doubled. `uninstall` is the given behaviour; it never touches a file.
|
|
96
|
+
*/
|
|
97
|
+
function deps(
|
|
98
|
+
uninstall: (pluginId: string, scope: Scope) => Promise<void>,
|
|
99
|
+
): PluginApplyDeps {
|
|
100
|
+
const {
|
|
101
|
+
loadedVersion: _loaded,
|
|
102
|
+
registryRows: _rows,
|
|
103
|
+
...real
|
|
104
|
+
} = REAL_PLUGIN_DEPS;
|
|
105
|
+
return {
|
|
106
|
+
...real,
|
|
107
|
+
install: async () => {
|
|
108
|
+
throw new Error("install must not run here");
|
|
109
|
+
},
|
|
110
|
+
update: async () => {},
|
|
111
|
+
repair: async () => {},
|
|
112
|
+
readInstalled: async () => "1.0.0",
|
|
113
|
+
saveInstalled: async () => {},
|
|
114
|
+
repairStaleLoad: async () => {
|
|
115
|
+
throw new Error("repairStaleLoad must not run here");
|
|
116
|
+
},
|
|
117
|
+
remove: { ...REAL_REMOVE_DEPS, uninstall },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Claude Code holding the plugin's only registry row at `rowAt`. */
|
|
122
|
+
function rowOnlyAt(rowAt: Scope): PluginApplyDeps {
|
|
123
|
+
return deps(async (pluginId, scope) => {
|
|
124
|
+
if (scope !== rowAt) throw wrongScopeRefusal(pluginId, rowAt, scope);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Claude Code's older refusal, which names no scope the plugin is at. */
|
|
129
|
+
function notInstalledRefusal(pluginId: string, asked: Scope) {
|
|
130
|
+
return new Error(
|
|
131
|
+
`✘ Failed to uninstall plugin "${pluginId}": Plugin "${pluginId}" is not installed in ${asked} scope.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function rejection(promise: Promise<unknown>): Promise<string> {
|
|
136
|
+
try {
|
|
137
|
+
await promise;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return error instanceof Error ? error.message : String(error);
|
|
140
|
+
}
|
|
141
|
+
throw new Error("expected the uninstall to reject, but it resolved");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
beforeEach(async () => {
|
|
145
|
+
project = await mkdtemp(join(tmpdir(), "scope-wrong-scope-"));
|
|
146
|
+
home = await mkdtemp(join(tmpdir(), "scope-wrong-scope-home-"));
|
|
147
|
+
process.env.HOME = home;
|
|
148
|
+
process.env.CLAUDE_CONFIG_DIR = join(home, ".claude");
|
|
149
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterEach(async () => {
|
|
153
|
+
log.mockRestore();
|
|
154
|
+
process.env.HOME = saved.HOME;
|
|
155
|
+
if (saved.CLAUDE_CONFIG_DIR === undefined) {
|
|
156
|
+
// biome-ignore lint/performance/noDelete: an unset env var must be absent, not "undefined"
|
|
157
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
158
|
+
} else process.env.CLAUDE_CONFIG_DIR = saved.CLAUDE_CONFIG_DIR;
|
|
159
|
+
await rm(project, { recursive: true, force: true });
|
|
160
|
+
await rm(home, { recursive: true, force: true });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("a scope uninstall Claude Code refuses as the wrong scope", () => {
|
|
164
|
+
test("PROJECT, enabled by the active profile: the profile drops it and the project no longer enables it", async () => {
|
|
165
|
+
await writeManifest(manifest(), project);
|
|
166
|
+
await activateProfile(manifest(), "team", project, {
|
|
167
|
+
onDisplace: () => {},
|
|
168
|
+
});
|
|
169
|
+
expect(await enabledIn("project")).toHaveProperty("multimodel@magus", true);
|
|
170
|
+
|
|
171
|
+
await uninstallPluginInScope(
|
|
172
|
+
"multimodel@magus",
|
|
173
|
+
"project",
|
|
174
|
+
project,
|
|
175
|
+
rowOnlyAt("user"),
|
|
176
|
+
"team",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect((await enabledIn("project"))["multimodel@magus"]).not.toBe(true);
|
|
180
|
+
expect(await profilePlugins()).not.toHaveProperty("multimodel@magus");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("PROJECT, enabled with no profile to own it: refused in magus's terms, naming USER", async () => {
|
|
184
|
+
await setEnabled("project", "multimodel@magus");
|
|
185
|
+
|
|
186
|
+
const message = await rejection(
|
|
187
|
+
uninstallPluginInScope(
|
|
188
|
+
"multimodel@magus",
|
|
189
|
+
"project",
|
|
190
|
+
project,
|
|
191
|
+
rowOnlyAt("user"),
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
expect(message).toMatch(/USER/);
|
|
196
|
+
expect(message).not.toContain("Failed to uninstall plugin");
|
|
197
|
+
expect(message).not.toContain("--scope");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("USER, enabled while the only row is PROJECT: refused in magus's terms, naming PROJECT", async () => {
|
|
201
|
+
await writeManifest(manifest(), project);
|
|
202
|
+
await activateProfile(manifest(), "team", project, {
|
|
203
|
+
onDisplace: () => {},
|
|
204
|
+
});
|
|
205
|
+
await setEnabled("user", "multimodel@magus");
|
|
206
|
+
const userBefore = await fs.readFile(settingsFor("user"), "utf8");
|
|
207
|
+
|
|
208
|
+
const message = await rejection(
|
|
209
|
+
uninstallPluginInScope(
|
|
210
|
+
"multimodel@magus",
|
|
211
|
+
"user",
|
|
212
|
+
project,
|
|
213
|
+
rowOnlyAt("project"),
|
|
214
|
+
"team",
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
expect(message).toMatch(/PROJECT/);
|
|
219
|
+
expect(message).not.toContain("Failed to uninstall plugin");
|
|
220
|
+
expect(message).not.toContain("--scope");
|
|
221
|
+
expect(await fs.readFile(settingsFor("user"), "utf8")).toBe(userBefore);
|
|
222
|
+
expect(await profilePlugins()).toHaveProperty("multimodel@magus");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("PROJECT, any other CLI failure: rejected with its own text, the profile untouched", async () => {
|
|
226
|
+
await writeManifest(manifest(), project);
|
|
227
|
+
await activateProfile(manifest(), "team", project, {
|
|
228
|
+
onDisplace: () => {},
|
|
229
|
+
});
|
|
230
|
+
const failing = deps(async () => {
|
|
231
|
+
throw new Error("claude timed out");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const message = await rejection(
|
|
235
|
+
uninstallPluginInScope(
|
|
236
|
+
"multimodel@magus",
|
|
237
|
+
"project",
|
|
238
|
+
project,
|
|
239
|
+
failing,
|
|
240
|
+
"team",
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(message).toBe("claude timed out");
|
|
245
|
+
expect(await profilePlugins()).toHaveProperty("multimodel@magus");
|
|
246
|
+
expect(await enabledIn("project")).toHaveProperty("multimodel@magus", true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("PROJECT, the older not-installed wording, no profile to own it: refused in magus's terms, naming PROJECT", async () => {
|
|
250
|
+
await setEnabled("project", "multimodel@magus");
|
|
251
|
+
const olderWording = deps(async (pluginId, scope) => {
|
|
252
|
+
throw notInstalledRefusal(pluginId, scope);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const message = await rejection(
|
|
256
|
+
uninstallPluginInScope(
|
|
257
|
+
"multimodel@magus",
|
|
258
|
+
"project",
|
|
259
|
+
project,
|
|
260
|
+
olderWording,
|
|
261
|
+
),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
expect(message).toMatch(/PROJECT/);
|
|
265
|
+
expect(message).not.toContain("Failed to uninstall plugin");
|
|
266
|
+
expect(message).not.toContain("--scope");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("LOCAL, enabled while the only row is USER: refused naming USER, the local settings untouched", async () => {
|
|
270
|
+
await setEnabled("local", "multimodel@magus");
|
|
271
|
+
const localBefore = await fs.readFile(settingsFor("local"), "utf8");
|
|
272
|
+
|
|
273
|
+
const message = await rejection(
|
|
274
|
+
uninstallPluginInScope(
|
|
275
|
+
"multimodel@magus",
|
|
276
|
+
"local",
|
|
277
|
+
project,
|
|
278
|
+
rowOnlyAt("user"),
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
expect(message).toMatch(/USER/);
|
|
283
|
+
expect(message).not.toContain("Failed to uninstall plugin");
|
|
284
|
+
expect(message).not.toContain("--scope");
|
|
285
|
+
expect(await fs.readFile(settingsFor("local"), "utf8")).toBe(localBefore);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("USER, not enabled there while PROJECT installs it: resolves, the profile still lists it and the project still enables it", async () => {
|
|
289
|
+
await writeManifest(manifest(), project);
|
|
290
|
+
await activateProfile(manifest(), "team", project, {
|
|
291
|
+
onDisplace: () => {},
|
|
292
|
+
});
|
|
293
|
+
expect(await enabledIn("user")).not.toHaveProperty("multimodel@magus");
|
|
294
|
+
|
|
295
|
+
await uninstallPluginInScope(
|
|
296
|
+
"multimodel@magus",
|
|
297
|
+
"user",
|
|
298
|
+
project,
|
|
299
|
+
rowOnlyAt("project"),
|
|
300
|
+
"team",
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(await profilePlugins()).toHaveProperty("multimodel@magus");
|
|
304
|
+
expect(await enabledIn("project")).toHaveProperty("multimodel@magus", true);
|
|
305
|
+
expect(await enabledIn("user")).not.toHaveProperty("multimodel@magus");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("PROJECT, the profile lists it but the enable survives the drop: refused saying the profile no longer lists it, naming USER", async () => {
|
|
309
|
+
// The profile file lists the plugin; the remove double models a drop
|
|
310
|
+
// that succeeds while the PROJECT enable stays on.
|
|
311
|
+
await writeManifest(manifest(), project);
|
|
312
|
+
const base = rowOnlyAt("user");
|
|
313
|
+
const remove = base.remove;
|
|
314
|
+
if (!remove) throw new Error("the harness deps carry remove");
|
|
315
|
+
const surviving: PluginApplyDeps = {
|
|
316
|
+
...base,
|
|
317
|
+
recordInProfile: async () => {},
|
|
318
|
+
remove: {
|
|
319
|
+
...remove,
|
|
320
|
+
dependents: undefined,
|
|
321
|
+
dropFromProfile: async () => true,
|
|
322
|
+
isInstalled: async (_id, scope) => scope === "project",
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const message = await rejection(
|
|
327
|
+
uninstallPluginInScope(
|
|
328
|
+
"multimodel@magus",
|
|
329
|
+
"project",
|
|
330
|
+
project,
|
|
331
|
+
surviving,
|
|
332
|
+
"team",
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
expect(message).toContain("no longer in the profile");
|
|
337
|
+
expect(message).toMatch(/USER/);
|
|
338
|
+
expect(message).not.toContain("Failed to uninstall plugin");
|
|
339
|
+
expect(message).not.toContain("--scope");
|
|
340
|
+
});
|
|
341
|
+
});
|
package/src/cli/router.ts
CHANGED
|
@@ -305,7 +305,8 @@ Usage: magus Open the interactive TUI
|
|
|
305
305
|
Options:
|
|
306
306
|
-v, --version Show version and check for updates
|
|
307
307
|
-h, --help Show this help message
|
|
308
|
-
--no-refresh Skip
|
|
308
|
+
--no-refresh Skip adding, at startup, marketplaces this project uses
|
|
309
|
+
but this machine lacks
|
|
309
310
|
--theme light|dark Force the palette. Otherwise: MAGUS_THEME, then
|
|
310
311
|
TERM_THEME, then a 250 ms terminal query, then COLORFGBG,
|
|
311
312
|
then dark. Only the exact words "light"/"dark" count.
|
|
@@ -373,9 +374,9 @@ Other commands:
|
|
|
373
374
|
upgrade Update magus ITSELF to the latest version
|
|
374
375
|
|
|
375
376
|
Navigation (TUI):
|
|
376
|
-
[1] Plugins [4] Settings [7] Git State
|
|
377
|
+
[1] Plugins [4] Settings [7] Git State [0] Models
|
|
377
378
|
[2] Skills [5] Profiles [8] Alias
|
|
378
|
-
[3] MCP [6] CLI Tools
|
|
379
|
+
[3] MCP [6] CLI Tools [9] Styles
|
|
379
380
|
|
|
380
381
|
Keys:
|
|
381
382
|
↑/↓ or j/k Navigate
|
|
@@ -288,6 +288,19 @@ export function isNotInstalledAnswer(error: unknown): boolean {
|
|
|
288
288
|
);
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The scope a wrong-scope answer says the plugin IS installed at — `user` from
|
|
293
|
+
* `is installed in user scope, not project` — or null when the answer names
|
|
294
|
+
* none, as the `not installed in <scope> scope` wording does.
|
|
295
|
+
*/
|
|
296
|
+
export function installedScopeFromAnswer(error: unknown): PluginScope | null {
|
|
297
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
298
|
+
const match = /is installed in (user|project|local) scope, not /i.exec(
|
|
299
|
+
message,
|
|
300
|
+
);
|
|
301
|
+
return match ? (match[1].toLowerCase() as PluginScope) : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
291
304
|
/**
|
|
292
305
|
* Repair a plugin whose files drifted from the marketplace under an unchanged
|
|
293
306
|
* version — uninstall, then install again at the same scope.
|
|
@@ -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
|
+
installedScopeFromAnswer,
|
|
46
47
|
isNotInstalledAnswer,
|
|
47
48
|
repairPlugin,
|
|
48
49
|
uninstallPluginViaCli,
|
|
@@ -1508,7 +1509,25 @@ export async function uninstallPluginInScope(
|
|
|
1508
1509
|
}
|
|
1509
1510
|
}
|
|
1510
1511
|
|
|
1511
|
-
|
|
1512
|
+
try {
|
|
1513
|
+
await remove.uninstall(pluginId, scope, projectPath);
|
|
1514
|
+
} catch (error) {
|
|
1515
|
+
if (!isNotInstalledAnswer(error)) throw error;
|
|
1516
|
+
await settleScopeWithNoInstall(
|
|
1517
|
+
pluginId,
|
|
1518
|
+
scope,
|
|
1519
|
+
projectPath,
|
|
1520
|
+
deps,
|
|
1521
|
+
error,
|
|
1522
|
+
profile,
|
|
1523
|
+
);
|
|
1524
|
+
if (scope === "project") {
|
|
1525
|
+
await remove
|
|
1526
|
+
.forgetVersion?.(pluginId, scope, projectPath)
|
|
1527
|
+
.catch(() => {});
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1512
1531
|
if (await remove.isInstalled(pluginId, scope, projectPath)) {
|
|
1513
1532
|
throw new Error(`still installed at ${scope.toUpperCase()} scope`);
|
|
1514
1533
|
}
|
|
@@ -1531,6 +1550,83 @@ export async function uninstallPluginInScope(
|
|
|
1531
1550
|
await recordInProfileOrExplain(deps, projectPath, done);
|
|
1532
1551
|
}
|
|
1533
1552
|
|
|
1553
|
+
/**
|
|
1554
|
+
* The half of {@link uninstallPluginInScope} that runs when Claude Code answers
|
|
1555
|
+
* that it holds no install of the plugin at `scope` here, so its uninstall had
|
|
1556
|
+
* nothing to remove.
|
|
1557
|
+
*
|
|
1558
|
+
* At PROJECT the setting that turns the plugin on is the profile render's, so
|
|
1559
|
+
* the profile drops it and the live files are brought back in line with the
|
|
1560
|
+
* profile. USER and LOCAL settings belong to no profile, so nothing is changed
|
|
1561
|
+
* there. Either way the scope is read back afterwards: still installed means
|
|
1562
|
+
* nothing magus owns turns it on, and the refusal names the scope Claude Code
|
|
1563
|
+
* reported, in magus's terms rather than the CLI's text.
|
|
1564
|
+
*/
|
|
1565
|
+
async function settleScopeWithNoInstall(
|
|
1566
|
+
pluginId: string,
|
|
1567
|
+
scope: PluginScope,
|
|
1568
|
+
projectPath: string,
|
|
1569
|
+
deps: PluginApplyDeps,
|
|
1570
|
+
answer: unknown,
|
|
1571
|
+
profile: string | null | undefined,
|
|
1572
|
+
): Promise<void> {
|
|
1573
|
+
const remove = deps.remove;
|
|
1574
|
+
if (!remove) throw new Error("magus cannot remove plugins from here");
|
|
1575
|
+
const name = pluginName(pluginId);
|
|
1576
|
+
const here = scope.toUpperCase();
|
|
1577
|
+
|
|
1578
|
+
let wasListed = false;
|
|
1579
|
+
if (scope === "project") {
|
|
1580
|
+
wasListed = await profileListsPlugin(pluginId, projectPath, profile);
|
|
1581
|
+
if (!(await remove.dropFromProfile(pluginId, projectPath, profile))) {
|
|
1582
|
+
throw new Error(`${name} is still in the profile at ${here} scope`);
|
|
1583
|
+
}
|
|
1584
|
+
await recordInProfileOrExplain(
|
|
1585
|
+
deps,
|
|
1586
|
+
projectPath,
|
|
1587
|
+
`${name} is no longer in the profile`,
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
if (!(await remove.isInstalled(pluginId, scope, projectPath))) return;
|
|
1592
|
+
// The profile was changed above, so the refusal says so: the user must not
|
|
1593
|
+
// find a plugin gone from the profile with nothing telling them why.
|
|
1594
|
+
const lead = wasListed
|
|
1595
|
+
? `${name} is no longer in the profile, but it is`
|
|
1596
|
+
: `${name} is`;
|
|
1597
|
+
const installedAt = installedScopeFromAnswer(answer);
|
|
1598
|
+
if (installedAt && installedAt !== scope) {
|
|
1599
|
+
const there = installedAt.toUpperCase();
|
|
1600
|
+
throw new Error(
|
|
1601
|
+
`${lead}${wasListed ? " still" : ""} installed at ${there} scope, not ${here} — remove it from ${there}`,
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
throw new Error(
|
|
1605
|
+
`${lead} not installed at ${here} scope in this project — remove it from the scope that installed it`,
|
|
1606
|
+
);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Whether `profile` lists the plugin, the profile picked as
|
|
1611
|
+
* `removePluginFromProfile` picks it: undefined means the active one.
|
|
1612
|
+
*/
|
|
1613
|
+
async function profileListsPlugin(
|
|
1614
|
+
pluginId: string,
|
|
1615
|
+
projectPath: string,
|
|
1616
|
+
profile: string | null | undefined,
|
|
1617
|
+
): Promise<boolean> {
|
|
1618
|
+
const manifest = await readManifest(projectPath);
|
|
1619
|
+
const id =
|
|
1620
|
+
profile === undefined
|
|
1621
|
+
? selectProfileId(
|
|
1622
|
+
Object.keys(manifest.profiles),
|
|
1623
|
+
undefined,
|
|
1624
|
+
await activeProfile(projectPath),
|
|
1625
|
+
)
|
|
1626
|
+
: profile;
|
|
1627
|
+
return id ? profileLists(manifest.profiles[id], pluginId) : false;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1534
1630
|
// -- binaries ----------------------------------------------------------------
|
|
1535
1631
|
|
|
1536
1632
|
/**
|
|
@@ -789,28 +789,15 @@ export function PluginsScreen() {
|
|
|
789
789
|
|
|
790
790
|
const handleShowTeamConfigHelp = async () => {
|
|
791
791
|
const helpText =
|
|
792
|
-
"TEAM CONFIGURATION
|
|
793
|
-
"To
|
|
794
|
-
"1.
|
|
795
|
-
"
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
'
|
|
801
|
-
" }\n" +
|
|
802
|
-
" }\n" +
|
|
803
|
-
" },\n" +
|
|
804
|
-
' "enabledPlugins": {\n' +
|
|
805
|
-
' "code-formatter@company-tools": true\n' +
|
|
806
|
-
" }\n" +
|
|
807
|
-
" }\n\n" +
|
|
808
|
-
"2. Team members get prompted to install when they trust the folder\n\n" +
|
|
809
|
-
"3. Marketplaces are GLOBAL ONLY (managed by Claude Code)\n" +
|
|
810
|
-
" - Not project-specific\n" +
|
|
811
|
-
" - All team members share the same marketplace cache\n\n" +
|
|
812
|
-
"NOTE: Individual plugin enablement is still project-specific.\n" +
|
|
813
|
-
'Use "Project" scope in magus to enable for the team.';
|
|
792
|
+
"TEAM CONFIGURATION\n\n" +
|
|
793
|
+
"To give your whole team the same plugins:\n\n" +
|
|
794
|
+
"1. Install each plugin at PROJECT scope (p).\n" +
|
|
795
|
+
" magus records it in this project's profile, together with\n" +
|
|
796
|
+
" where its marketplace lives.\n\n" +
|
|
797
|
+
"2. Commit .claude/profiles.json.\n\n" +
|
|
798
|
+
"3. Teammates run magus install (a fresh clone) or magus update.\n" +
|
|
799
|
+
" magus adds the marketplaces and installs the plugins.\n\n" +
|
|
800
|
+
"Your company's own marketplace: magus edit.";
|
|
814
801
|
|
|
815
802
|
await modal.message("Team Configuration", helpText);
|
|
816
803
|
};
|