magus-cli 7.11.3 → 7.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.11.3",
3
+ "version": "7.11.5",
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.3",
69
- "magus-cli-darwin-x64": "7.11.3",
70
- "magus-cli-linux-x64": "7.11.3"
68
+ "magus-cli-darwin-arm64": "7.11.5",
69
+ "magus-cli-darwin-x64": "7.11.5",
70
+ "magus-cli-linux-x64": "7.11.5"
71
71
  }
72
72
  }
@@ -919,7 +919,7 @@ describe("REQ-3 ensureManifest", () => {
919
919
  );
920
920
 
921
921
  test(
922
- "REQ-3d2 with no project plugins and a USER plugin, the plugins line says USER and the project has none of its own",
922
+ "REQ-3d2 with no project plugins and a USER plugin, nothing is adopted and no profile is written",
923
923
  async () => {
924
924
  const sb = await sandbox();
925
925
  const project = await standaloneProject(sb, {
@@ -932,17 +932,17 @@ describe("REQ-3 ensureManifest", () => {
932
932
  const run = await callBootstrap(sb, "ensureManifest", project, {});
933
933
 
934
934
  expect(run.error).toBeNull();
935
- const pluginLines = run.lines.filter((l) =>
936
- l.trim().startsWith("plugins"),
935
+ expect(run.result).toBeNull();
936
+ expect(run.lines.filter((l) => l.trim().startsWith("plugins"))).toEqual(
937
+ [],
937
938
  );
938
- expect(pluginLines.length).toBeGreaterThan(0);
939
- expect(pluginLines.some((l) => l.includes("USER"))).toBe(true);
940
- expect(pluginLines.filter((l) => l.includes("PROJECT"))).toEqual([]);
941
939
  expect(
942
- run.lines.some((l) =>
943
- l.includes("This project has no plugins of its own"),
944
- ),
940
+ run.lines.some((l) => l.includes("no plugins to save as one")),
945
941
  ).toBe(true);
942
+ expect(run.lines.filter((l) => l.includes("USER"))).toEqual([]);
943
+ expect(
944
+ await Bun.file(join(project, ".claude", "profiles.json")).exists(),
945
+ ).toBe(false);
946
946
  },
947
947
  CHILD_TEST_TIMEOUT_MS,
948
948
  );
@@ -0,0 +1,171 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import type { PluginScope } from "../services/claude-cli.js";
7
+ import { readManifest, writeManifest } from "../services/manifest.js";
8
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
9
+ import { activateProfile } from "../services/profile-activate.js";
10
+ import { removeDeprecatedPlugin } from "../services/update-engine.js";
11
+ import type { ProfileManifest } from "../types/index.js";
12
+
13
+ /**
14
+ * `d` on a deprecated plugin that is installed at no scope in this folder, but
15
+ * shows as a row because the profile render writes `"<id>": false` for it.
16
+ *
17
+ * The render writes `false` for every id in `allPluginIds`, the union of every
18
+ * profile in `.claude/profiles.json` (`renderProfile`), so a plugin a profile
19
+ * that is NOT active still lists keeps its key in the live settings.json. The
20
+ * Plugins screen turns that key into a row. Uninstalling touches nothing:
21
+ * there is no install here, and the CLI answers every scope with "nothing at
22
+ * this scope here", in the wording measured on Claude Code 2.1.312.
23
+ *
24
+ * CONTRACT the fix must provide, in `services/update-engine.ts`, for the
25
+ * Plugins screen's `d` to call when the plan carries no `remove` item:
26
+ *
27
+ * export async function removeDeprecatedPlugin(
28
+ * pluginId: string,
29
+ * projectPath: string,
30
+ * deps?: {
31
+ * // default `uninstallPluginViaCli`
32
+ * uninstall?: (id: string, scope: PluginScope, cwd: string) => Promise<void>;
33
+ * // default `readInheritedRegistryRows`
34
+ * inheritedRows?: (id: string, projectPath: string) => Promise<InstalledPluginEntry[]>;
35
+ * },
36
+ * ): Promise<{ problems: unknown[]; unlistedFrom: string[] }>
37
+ *
38
+ * 1. Uninstalls at user, project and local in `projectPath`; an error
39
+ * `isNotInstalledAnswer` accepts is not a problem.
40
+ * 2. Uninstalls each inherited row in its own folder; errors are problems.
41
+ * 3. Removes the id from EVERY profile in the project's profiles.json that
42
+ * lists it (`removePluginFromProfile` per profile); `unlistedFrom` names
43
+ * them. Profiles that do not list it are not written.
44
+ * 4. Re-applies the active profile, so the live settings.json no longer
45
+ * carries a key for the id.
46
+ *
47
+ * Only for a deprecated (no longer published) plugin: the caller decides that.
48
+ */
49
+
50
+ const ID = "code-analysis@magus";
51
+
52
+ /** Measured, Claude Code 2.1.312, from a folder with no row while others hold PROJECT rows. */
53
+ const wrongScope = (scope: PluginScope) =>
54
+ new Error(
55
+ `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not ${scope}. Use --scope project to uninstall.`,
56
+ );
57
+
58
+ /** The magus-src shape: the active profile does not list it, two others do. */
59
+ const manifest = (): ProfileManifest => ({
60
+ version: 2,
61
+ profiles: {
62
+ default: { name: "Default", plugins: { "dev@magus": "latest" } },
63
+ maintainer: {
64
+ name: "Maintainer",
65
+ plugins: { "dev@magus": "latest", [ID]: "latest" },
66
+ },
67
+ lean: { name: "Lean", plugins: { [ID]: "latest" } },
68
+ minimal: { name: "Minimal", plugins: { "setup@magus": "latest" } },
69
+ },
70
+ });
71
+
72
+ describe("removeDeprecatedPlugin: a deprecated plugin only other profiles list", () => {
73
+ let project: string;
74
+ let log: ReturnType<typeof spyOn>;
75
+ const settingsPath = () => join(project, ".claude", "settings.json");
76
+ const profilesPath = () => join(project, ".claude", "profiles.json");
77
+ const mcpPath = () => join(project, ".mcp.json");
78
+
79
+ beforeEach(async () => {
80
+ project = await mkdtemp(join(tmpdir(), "deprecated-all-profiles-"));
81
+ log = spyOn(console, "log").mockImplementation(() => {});
82
+ await writeManifest(manifest(), project);
83
+ await activateProfile(manifest(), "default", project, {
84
+ onDisplace: () => {},
85
+ });
86
+ });
87
+ afterEach(async () => {
88
+ log.mockRestore();
89
+ await rm(project, { recursive: true, force: true });
90
+ });
91
+
92
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
93
+ test("should report no failure, unlist it from every profile, and leave no settings key for it", async () => {
94
+ // Seeded red: the render wrote the key that makes the row.
95
+ expect((await fs.readJson(settingsPath())).enabledPlugins[ID]).toBe(false);
96
+ const before = (await fs.readJson(profilesPath())).profiles;
97
+
98
+ const calls: Array<{ scope: PluginScope; cwd: string }> = [];
99
+ const result = await removeDeprecatedPlugin(ID, project, {
100
+ uninstall: async (_id, scope, cwd) => {
101
+ calls.push({ scope, cwd });
102
+ if (scope === "project") {
103
+ throw new Error(`Plugin "${ID}" is not installed in project scope.`);
104
+ }
105
+ throw wrongScope(scope);
106
+ },
107
+ inheritedRows: async () => [],
108
+ });
109
+
110
+ expect(result.problems).toEqual([]);
111
+ expect([...result.unlistedFrom].sort()).toEqual(["lean", "maintainer"]);
112
+ expect(calls.map((c) => c.scope).sort()).toEqual([
113
+ "local",
114
+ "project",
115
+ "user",
116
+ ]);
117
+ expect(calls.every((c) => c.cwd === project)).toBe(true);
118
+
119
+ const after = (await readManifest(project)).profiles;
120
+ for (const id of Object.keys(after)) {
121
+ expect(after[id]?.plugins?.[ID]).toBeUndefined();
122
+ }
123
+ expect(after.maintainer?.plugins).toEqual({ "dev@magus": "latest" });
124
+ // Profiles that never listed it are not written at all.
125
+ const raw = (await fs.readJson(profilesPath())).profiles;
126
+ expect(raw.default).toEqual(before.default);
127
+ expect(raw.minimal).toEqual(before.minimal);
128
+
129
+ const live = (await fs.readJson(settingsPath())).enabledPlugins;
130
+ expect(Object.hasOwn(live, ID)).toBe(false);
131
+ expect(live["dev@magus"]).toBe(true);
132
+
133
+ // The row does not come back: the next entry point's reconcile and a
134
+ // fresh activation leave the key out.
135
+ await reconcileActiveProfile(project);
136
+ await activateProfile(await readManifest(project), "default", project, {
137
+ onDisplace: () => {},
138
+ });
139
+ expect(
140
+ Object.hasOwn((await fs.readJson(settingsPath())).enabledPlugins, ID),
141
+ ).toBe(false);
142
+ });
143
+
144
+ // REGRESSION: deprecated plugin `d` re-rendered the live files without absorbing them, losing edits Claude Code made after the TUI started — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
145
+ test("should keep edits made to the live files after the profile was activated", async () => {
146
+ // Written straight into the live files, as Claude Code does mid-session.
147
+ const settings = await fs.readJson(settingsPath());
148
+ settings.permissions = { allow: ["Bash(ls:*)"] };
149
+ settings.enabledPlugins["other@magus"] = true;
150
+ await fs.writeJson(settingsPath(), settings, { spaces: 2 });
151
+ const mcp = await fs.readJson(mcpPath());
152
+ mcp.mcpServers = { ...mcp.mcpServers, local: { command: "local-mcp" } };
153
+ await fs.writeJson(mcpPath(), mcp, { spaces: 2 });
154
+
155
+ const result = await removeDeprecatedPlugin(ID, project, {
156
+ uninstall: async (_id, scope) => {
157
+ throw wrongScope(scope);
158
+ },
159
+ inheritedRows: async () => [],
160
+ });
161
+
162
+ expect(result.problems).toEqual([]);
163
+ const live = await fs.readJson(settingsPath());
164
+ expect(live.permissions?.allow).toEqual(["Bash(ls:*)"]);
165
+ expect(live.enabledPlugins["other@magus"]).toBe(true);
166
+ expect(Object.hasOwn(live.enabledPlugins, ID)).toBe(false);
167
+ expect((await fs.readJson(mcpPath())).mcpServers?.local).toEqual({
168
+ command: "local-mcp",
169
+ });
170
+ });
171
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isNotInstalledAnswer } from "../services/claude-cli.js";
3
+
4
+ /**
5
+ * `isNotInstalledAnswer` classifies the CLI's answer to an uninstall at a
6
+ * scope that holds nothing for this folder. Claude Code has two wordings for
7
+ * it, and both mean "nothing to do here", never a failure.
8
+ *
9
+ * MEASURED on Claude Code 2.1.312, from a folder with no registry row while
10
+ * other folders hold PROJECT rows for the plugin: `--scope user` and
11
+ * `--scope local` exit 1 with the strings below. The older wording is what the
12
+ * CLI says when no folder holds a row at all.
13
+ */
14
+
15
+ const ID = "code-analysis@magus";
16
+
17
+ /** The exact text `claude plugin uninstall … --scope user` printed. */
18
+ const MEASURED_USER = `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not user. Use --scope project to uninstall.`;
19
+ /** The exact text `claude plugin uninstall … --scope local` printed. */
20
+ const MEASURED_LOCAL = `✘ Failed to uninstall plugin "${ID}": Plugin "${ID}" is installed in project scope, not local. Use --scope project to uninstall.`;
21
+
22
+ describe("isNotInstalledAnswer", () => {
23
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
24
+ test("should treat the measured 'installed in project scope, not user' answer as nothing to remove", () => {
25
+ expect(isNotInstalledAnswer(new Error(MEASURED_USER))).toBe(true);
26
+ });
27
+
28
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
29
+ test("should treat the measured 'installed in project scope, not local' answer as nothing to remove", () => {
30
+ expect(isNotInstalledAnswer(new Error(MEASURED_LOCAL))).toBe(true);
31
+ });
32
+
33
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
34
+ test("should still treat the older 'not installed in user scope' answer as nothing to remove", () => {
35
+ expect(
36
+ isNotInstalledAnswer(
37
+ new Error(`Plugin "${ID}" is not installed in user scope.`),
38
+ ),
39
+ ).toBe(true);
40
+ });
41
+
42
+ // REGRESSION: deprecated plugin `d` failed with "installed in project scope, not user" and the row came back — Fixed in /dev:fix session dev-fix-20261029-2140-uninstall
43
+ test("should still count an unrelated error as a failure", () => {
44
+ expect(isNotInstalledAnswer(new Error("claude timed out"))).toBe(false);
45
+ });
46
+ });
@@ -1,5 +1,9 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import {
6
+ adoptCurrentProject,
3
7
  buildAdoptedProfile,
4
8
  findMachineSpecificPaths,
5
9
  } from "../services/profile-adopt.js";
@@ -175,3 +179,61 @@ describe("findMachineSpecificPaths", () => {
175
179
  expect(findMachineSpecificPaths({ a: `${HOME}/x` }, "")).toEqual([]);
176
180
  });
177
181
  });
182
+
183
+ describe("adoptCurrentProject — USER plugins never enter a PROJECT profile", () => {
184
+ // REGRESSION: first magus run in a fresh checkout adopted USER plugins into the PROJECT profile (MTL-323)
185
+ let root: string;
186
+ let project: string;
187
+ let savedHome: string | undefined;
188
+
189
+ function writeSettings(dir: string, settings: Record<string, unknown>) {
190
+ mkdirSync(join(dir, ".claude"), { recursive: true });
191
+ writeFileSync(
192
+ join(dir, ".claude", "settings.json"),
193
+ JSON.stringify(settings, null, 2),
194
+ );
195
+ }
196
+
197
+ beforeEach(() => {
198
+ root = mkdtempSync(join(tmpdir(), "magus-adopt-scope-"));
199
+ project = join(root, "project");
200
+ const home = join(root, "home");
201
+ mkdirSync(project, { recursive: true });
202
+ // The USER settings reader resolves process.env.HOME at call time.
203
+ savedHome = process.env.HOME;
204
+ process.env.HOME = home;
205
+ writeSettings(home, {
206
+ enabledPlugins: {
207
+ "setup@magus": true,
208
+ "agent-colours@magus-alpha": true,
209
+ },
210
+ });
211
+ });
212
+
213
+ afterEach(() => {
214
+ // biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"
215
+ if (savedHome === undefined) delete process.env.HOME;
216
+ else process.env.HOME = savedHome;
217
+ rmSync(root, { recursive: true, force: true });
218
+ });
219
+
220
+ test("a project that enables no plugins adopts none", async () => {
221
+ writeSettings(project, { enableAllProjectMcpServers: true });
222
+ const result = await adoptCurrentProject(project);
223
+ expect(result.entry.plugins).toEqual({});
224
+ });
225
+
226
+ test("a project whose every plugin entry is false adopts none", async () => {
227
+ writeSettings(project, {
228
+ enabledPlugins: { "dev@magus": false, "designer@magus": false },
229
+ });
230
+ const result = await adoptCurrentProject(project);
231
+ expect(result.entry.plugins).toEqual({});
232
+ });
233
+
234
+ test("a project that enables its own plugin adopts exactly that one", async () => {
235
+ writeSettings(project, { enabledPlugins: { "dev@magus": true } });
236
+ const result = await adoptCurrentProject(project);
237
+ expect(result.entry.plugins).toEqual({ "dev@magus": "latest" });
238
+ });
239
+ });