magus-cli 7.11.4 → 7.11.6
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__/deprecated-remove-all-profiles.test.ts +171 -0
- package/src/__tests__/mcp-registry-install-screen.test.tsx +732 -0
- package/src/__tests__/mcp-registry-install.test.ts +551 -0
- package/src/__tests__/mcp-registry-screen-debounce.test.tsx +195 -0
- package/src/__tests__/mcp-registry-screen.test.ts +209 -0
- package/src/__tests__/not-installed-answer-wrong-scope.test.ts +46 -0
- package/src/__tests__/update-removes-deprecated-other-profiles.test.ts +401 -0
- 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/mcp-registry.ts +150 -0
- 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/keymap.ts +1 -0
- package/src/ui/renderers/mcpRenderers.tsx +6 -1
- package/src/ui/screens/McpRegistryScreen.tsx +146 -26
- 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.6",
|
|
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.6",
|
|
69
|
+
"magus-cli-darwin-x64": "7.11.6",
|
|
70
|
+
"magus-cli-linux-x64": "7.11.6"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -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
|
+
});
|