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 +4 -4
- package/src/__tests__/cli-profile-prompts.test.ts +9 -9
- package/src/__tests__/deprecated-remove-all-profiles.test.ts +171 -0
- package/src/__tests__/not-installed-answer-wrong-scope.test.ts +46 -0
- package/src/__tests__/profile-adopt.test.ts +63 -1
- package/src/__tests__/update-removes-deprecated-other-profiles.test.ts +401 -0
- package/src/cli/bootstrap.ts +4 -20
- package/src/cli/update.ts +14 -2
- package/src/services/active-closure.ts +12 -2
- package/src/services/claude-cli.ts +12 -1
- package/src/services/profile-adopt.ts +13 -28
- package/src/services/profile-state.ts +2 -12
- package/src/services/resolver.ts +31 -0
- package/src/services/update-engine.ts +146 -12
- package/src/services/update-plan.ts +42 -2
- package/src/ui/screens/PluginsScreen.tsx +24 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.11.
|
|
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.
|
|
69
|
-
"magus-cli-darwin-x64": "7.11.
|
|
70
|
-
"magus-cli-linux-x64": "7.11.
|
|
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,
|
|
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
|
-
|
|
936
|
-
|
|
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
|
+
});
|