magus-cli 7.14.0 → 7.14.1

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.14.0",
3
+ "version": "7.14.1",
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.0",
69
- "magus-cli-darwin-x64": "7.14.0",
70
- "magus-cli-linux-x64": "7.14.0"
68
+ "magus-cli-darwin-arm64": "7.14.1",
69
+ "magus-cli-darwin-x64": "7.14.1",
70
+ "magus-cli-linux-x64": "7.14.1"
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
+ });
@@ -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
- await remove.uninstall(pluginId, scope, projectPath);
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
  /**