magus-cli 7.9.1 → 7.10.0
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__/blind/plugin-deps/doctor.test.ts +1 -0
- package/src/__tests__/plugin-mcp-source-shapes.test.ts +250 -0
- package/src/__tests__/worktree-profiles.test.ts +583 -0
- package/src/cli/doctor.ts +24 -2
- package/src/cli/update-view.ts +73 -0
- package/src/cli/update.ts +21 -0
- package/src/cli/worktrees.ts +32 -0
- package/src/services/git-worktree.ts +41 -0
- package/src/services/plugin-mcp-config.ts +3 -1
- package/src/services/worktree-profiles.ts +181 -0
- package/src/ui/App.tsx +32 -1
- package/src/ui/adapters/startupReports.ts +22 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.10.0",
|
|
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.
|
|
69
|
-
"magus-cli-darwin-x64": "7.
|
|
70
|
-
"magus-cli-linux-x64": "7.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.10.0",
|
|
69
|
+
"magus-cli-darwin-x64": "7.10.0",
|
|
70
|
+
"magus-cli-linux-x64": "7.10.0"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -174,6 +174,7 @@ describe("magus doctor black-box contract", () => {
|
|
|
174
174
|
"plugins_needing_update",
|
|
175
175
|
"schema",
|
|
176
176
|
"unfixable",
|
|
177
|
+
"worktreesWithoutProfile",
|
|
177
178
|
]);
|
|
178
179
|
expect(new Set(report.plugins)).toEqual(
|
|
179
180
|
new Set(["user-tool@test", "project-tool@test", "local-tool@test"]),
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace plugin `source` comes in two shapes: a string path
|
|
3
|
+
* ("./plugins/foo") or an object (git-subdir / github / url). Only a string
|
|
4
|
+
* source has an on-disk MCP config under the marketplace clone.
|
|
5
|
+
*
|
|
6
|
+
* Regression guarded: the Plugins screen does
|
|
7
|
+
* src = await getPluginSourcePath(mp, name); if (!src) skip;
|
|
8
|
+
* await getPluginEnvRequirements(mp, src)
|
|
9
|
+
* and an object source used to throw `pluginSource.replace is not a function`.
|
|
10
|
+
*
|
|
11
|
+
* The module resolves the home directory at import time, and Bun's
|
|
12
|
+
* os.homedir() ignores a HOME changed at runtime, so every scenario runs in a
|
|
13
|
+
* child bun process with HOME pointed at a temp directory.
|
|
14
|
+
*/
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
const MODULE_PATH = path.resolve(
|
|
22
|
+
import.meta.dir,
|
|
23
|
+
"../services/plugin-mcp-config.ts",
|
|
24
|
+
);
|
|
25
|
+
const MP = "testmp";
|
|
26
|
+
|
|
27
|
+
const CHILD_SCRIPT = `
|
|
28
|
+
import { getPluginSourcePath, getPluginEnvRequirements } from ${JSON.stringify(MODULE_PATH)};
|
|
29
|
+
|
|
30
|
+
const input = JSON.parse(process.env.MCP_SHAPES_INPUT ?? "{}");
|
|
31
|
+
const out = {};
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const sp = await getPluginSourcePath(input.mp, input.plugin);
|
|
35
|
+
out.sourcePathType = typeof sp;
|
|
36
|
+
out.sourcePath = sp === undefined ? null : sp;
|
|
37
|
+
out.sourcePathOk = true;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
out.sourcePathOk = false;
|
|
40
|
+
out.sourcePathError = String(e && e.message ? e.message : e);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The exact Plugins-screen sequence.
|
|
44
|
+
try {
|
|
45
|
+
const src = await getPluginSourcePath(input.mp, input.plugin);
|
|
46
|
+
if (!src) {
|
|
47
|
+
out.sequence = { ok: true, skipped: true };
|
|
48
|
+
} else {
|
|
49
|
+
const reqs = await getPluginEnvRequirements(input.mp, src);
|
|
50
|
+
out.sequence = { ok: true, skipped: false, names: reqs.map((r) => r.name) };
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
out.sequence = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (typeof input.direct === "string") {
|
|
57
|
+
try {
|
|
58
|
+
const reqs = await getPluginEnvRequirements(input.mp, input.direct);
|
|
59
|
+
out.direct = {
|
|
60
|
+
ok: true,
|
|
61
|
+
reqs: reqs.map((r) => ({ name: r.name, serverName: r.serverName })),
|
|
62
|
+
};
|
|
63
|
+
} catch (e) {
|
|
64
|
+
out.direct = { ok: false, error: String(e && e.message ? e.message : e) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.stdout.write("__RESULT__" + JSON.stringify(out) + "\\n");
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
interface ChildResult {
|
|
72
|
+
sourcePathOk: boolean;
|
|
73
|
+
sourcePathError?: string;
|
|
74
|
+
sourcePathType?: string;
|
|
75
|
+
sourcePath?: unknown;
|
|
76
|
+
sequence: {
|
|
77
|
+
ok: boolean;
|
|
78
|
+
skipped?: boolean;
|
|
79
|
+
names?: string[];
|
|
80
|
+
error?: string;
|
|
81
|
+
};
|
|
82
|
+
direct?: {
|
|
83
|
+
ok: boolean;
|
|
84
|
+
reqs?: Array<{ name: string; serverName: string }>;
|
|
85
|
+
error?: string;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let tmpHome: string;
|
|
90
|
+
let scriptPath: string;
|
|
91
|
+
|
|
92
|
+
async function writeMarketplace(
|
|
93
|
+
plugins: Array<{ name: string; source: unknown }>,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const dir = path.join(
|
|
96
|
+
tmpHome,
|
|
97
|
+
".claude",
|
|
98
|
+
"plugins",
|
|
99
|
+
"marketplaces",
|
|
100
|
+
MP,
|
|
101
|
+
".claude-plugin",
|
|
102
|
+
);
|
|
103
|
+
await fs.mkdir(dir, { recursive: true });
|
|
104
|
+
await fs.writeFile(
|
|
105
|
+
path.join(dir, "marketplace.json"),
|
|
106
|
+
JSON.stringify({ name: MP, plugins }, null, 2),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function writeMcpConfig(
|
|
111
|
+
relSource: string,
|
|
112
|
+
config: Record<string, unknown>,
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
const dir = path.join(
|
|
115
|
+
tmpHome,
|
|
116
|
+
".claude",
|
|
117
|
+
"plugins",
|
|
118
|
+
"marketplaces",
|
|
119
|
+
MP,
|
|
120
|
+
relSource.replace(/^\.\//, ""),
|
|
121
|
+
"mcp-servers",
|
|
122
|
+
);
|
|
123
|
+
await fs.mkdir(dir, { recursive: true });
|
|
124
|
+
await fs.writeFile(
|
|
125
|
+
path.join(dir, "mcp-config.json"),
|
|
126
|
+
JSON.stringify(config, null, 2),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runChild(input: {
|
|
131
|
+
mp: string;
|
|
132
|
+
plugin: string;
|
|
133
|
+
direct?: string;
|
|
134
|
+
}): ChildResult {
|
|
135
|
+
const res = spawnSync("bun", ["--no-env-file", scriptPath], {
|
|
136
|
+
cwd: tmpHome,
|
|
137
|
+
env: {
|
|
138
|
+
...process.env,
|
|
139
|
+
HOME: tmpHome,
|
|
140
|
+
MCP_SHAPES_INPUT: JSON.stringify(input),
|
|
141
|
+
},
|
|
142
|
+
encoding: "utf-8",
|
|
143
|
+
timeout: 30_000,
|
|
144
|
+
});
|
|
145
|
+
const stdout = res.stdout ?? "";
|
|
146
|
+
const line = stdout.split("\n").find((l) => l.startsWith("__RESULT__"));
|
|
147
|
+
if (!line) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`child produced no result (status ${res.status})\nstdout:\n${stdout}\nstderr:\n${res.stderr ?? ""}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return JSON.parse(line.slice("__RESULT__".length)) as ChildResult;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
beforeEach(async () => {
|
|
156
|
+
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-source-shapes-"));
|
|
157
|
+
scriptPath = path.join(tmpHome, "child.ts");
|
|
158
|
+
await fs.writeFile(scriptPath, CHILD_SCRIPT);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(async () => {
|
|
162
|
+
await fs.rm(tmpHome, { recursive: true, force: true });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("string source", () => {
|
|
166
|
+
test("getPluginSourcePath returns the string and env requirements resolve from its mcp-config", async () => {
|
|
167
|
+
await writeMarketplace([{ name: "foo", source: "./plugins/foo" }]);
|
|
168
|
+
await writeMcpConfig("./plugins/foo", {
|
|
169
|
+
"foo-server": {
|
|
170
|
+
command: "node",
|
|
171
|
+
args: ["server.js"],
|
|
172
|
+
env: { FOO_TOKEN: "${FOO_TOKEN}" },
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const r = runChild({ mp: MP, plugin: "foo", direct: "./plugins/foo" });
|
|
177
|
+
|
|
178
|
+
expect(r.sourcePathOk).toBe(true);
|
|
179
|
+
expect(r.sourcePathType).toBe("string");
|
|
180
|
+
expect(r.sourcePath).toBe("./plugins/foo");
|
|
181
|
+
|
|
182
|
+
expect(r.direct?.ok).toBe(true);
|
|
183
|
+
const names = (r.direct?.reqs ?? []).map((q) => q.name);
|
|
184
|
+
expect(names).toContain("FOO_TOKEN");
|
|
185
|
+
const foo = r.direct?.reqs?.find((q) => q.name === "FOO_TOKEN");
|
|
186
|
+
expect(foo?.serverName).toBe("foo-server");
|
|
187
|
+
|
|
188
|
+
// The Plugins-screen sequence reaches the same requirement.
|
|
189
|
+
expect(r.sequence.ok).toBe(true);
|
|
190
|
+
expect(r.sequence.skipped).toBe(false);
|
|
191
|
+
expect(r.sequence.names).toContain("FOO_TOKEN");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("object source", () => {
|
|
196
|
+
const shapes: Array<[string, Record<string, unknown>]> = [
|
|
197
|
+
[
|
|
198
|
+
"git-subdir",
|
|
199
|
+
{
|
|
200
|
+
source: "git-subdir",
|
|
201
|
+
url: "https://github.com/expo/skills.git",
|
|
202
|
+
path: "plugins/expo",
|
|
203
|
+
ref: "main",
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
["github", { source: "github", repo: "owner/name" }],
|
|
207
|
+
["url", { source: "url", url: "https://example.com/x.git" }],
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
for (const [label, source] of shapes) {
|
|
211
|
+
test(`${label}: getPluginSourcePath returns undefined, not the object`, async () => {
|
|
212
|
+
await writeMarketplace([
|
|
213
|
+
{ name: "foo", source: "./plugins/foo" },
|
|
214
|
+
{ name: "remote", source },
|
|
215
|
+
]);
|
|
216
|
+
await writeMcpConfig("./plugins/foo", {
|
|
217
|
+
"foo-server": { env: { FOO_TOKEN: "${FOO_TOKEN}" } },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const r = runChild({ mp: MP, plugin: "remote" });
|
|
221
|
+
|
|
222
|
+
expect(r.sourcePathError).toBeUndefined();
|
|
223
|
+
expect(r.sourcePathOk).toBe(true);
|
|
224
|
+
expect(r.sourcePathType).toBe("undefined");
|
|
225
|
+
expect(r.sourcePathType).not.toBe("object");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test(`${label}: the Plugins-screen sequence resolves without throwing`, async () => {
|
|
229
|
+
await writeMarketplace([{ name: "remote", source }]);
|
|
230
|
+
|
|
231
|
+
const r = runChild({ mp: MP, plugin: "remote" });
|
|
232
|
+
|
|
233
|
+
expect(r.sequence.error).toBeUndefined();
|
|
234
|
+
expect(r.sequence.ok).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("unknown plugin", () => {
|
|
240
|
+
test("getPluginSourcePath returns undefined for a name not in the marketplace", async () => {
|
|
241
|
+
await writeMarketplace([{ name: "foo", source: "./plugins/foo" }]);
|
|
242
|
+
|
|
243
|
+
const r = runChild({ mp: MP, plugin: "does-not-exist" });
|
|
244
|
+
|
|
245
|
+
expect(r.sourcePathOk).toBe(true);
|
|
246
|
+
expect(r.sourcePathType).toBe("undefined");
|
|
247
|
+
expect(r.sequence.ok).toBe(true);
|
|
248
|
+
expect(r.sequence.skipped).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
});
|