magus-cli 7.9.1 → 7.10.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.9.1",
3
+ "version": "7.10.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.9.1",
69
- "magus-cli-darwin-x64": "7.9.1",
70
- "magus-cli-linux-x64": "7.9.1"
68
+ "magus-cli-darwin-arm64": "7.10.1",
69
+ "magus-cli-darwin-x64": "7.10.1",
70
+ "magus-cli-linux-x64": "7.10.1"
71
71
  }
72
72
  }
@@ -361,11 +361,11 @@ describe("C15 (c) — a crush binary that npm does not own", () => {
361
361
  expect(facts.managed).not.toBe(true);
362
362
  });
363
363
 
364
- test("R9.7 classifyTool keeps the not-managed veto for it (unknown / not-managed)", async () => {
364
+ test("R9.7 classifyTool keeps the not-managed veto for it (external / not-managed)", async () => {
365
365
  const { facts } = await probe(CRUSH, elsewhere());
366
366
  const status = classifyTool(CRUSH, facts);
367
367
  expect({ state: status.state, reason: status.reason }).toEqual({
368
- state: "unknown",
368
+ state: "external",
369
369
  reason: "not-managed",
370
370
  });
371
371
  });
@@ -526,7 +526,7 @@ describe("C15 (e) — a claude binary outside the native layout and outside node
526
526
  expect(facts.managed).not.toBe(true);
527
527
  const status = classifyTool(CLAUDE, facts);
528
528
  expect({ state: status.state, reason: status.reason }).toEqual({
529
- state: "unknown",
529
+ state: "external",
530
530
  reason: "not-managed",
531
531
  });
532
532
  });
@@ -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"]),
@@ -197,7 +197,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
197
197
  expect(row.installCommand).toEqual(cliToolInstall(codex));
198
198
  });
199
199
 
200
- test("detected pnpm → the catalogue's via, and a not-managed row naming the real path", () => {
200
+ test("detected pnpm → the catalogue's via, and a not-managed row in the user's terms", () => {
201
201
  const opencode = catalogue("opencode");
202
202
  const install = { primary: "pnpm" as const, all: ["pnpm" as const] };
203
203
  const spec = specForCatalogTool(opencode, null, install);
@@ -210,8 +210,13 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
210
210
  owner: "npm (root /Users/j/.local/lib/node_modules)",
211
211
  });
212
212
  const row = buildToolRow(opencode, spec, f, install);
213
+ expect(row.status?.state).toBe("external");
213
214
  expect(row.status?.reason).toBe("not-managed");
214
- expect(row.status?.note).toContain("/Users/j/.local/share/pnpm/");
215
+ // No path and no installer directory: the user acts on neither.
216
+ expect(row.status?.note).toBe(
217
+ "installed outside npm, so magus leaves it as is",
218
+ );
219
+ expect(row.status?.note).not.toContain("/");
215
220
  // Enter still follows how it IS installed (pnpm).
216
221
  expect(row.updateCommand?.cmd).toBe("pnpm");
217
222
  });
@@ -249,16 +254,28 @@ describe("cliToolStatusText — the row's words", () => {
249
254
  primary: "npm",
250
255
  all: ["npm"],
251
256
  });
252
- const f = facts("crush", { managed: false, owner: "npm (root /l)" });
257
+ const f = facts("crush", { managed: null });
253
258
  const row = buildToolRow(tool, spec, f);
254
259
  const text = cliToolStatusText(row);
255
260
  expect(text.tone).toBe("warning");
256
- expect(text.suffix).toBe(" ? not managed by npm");
257
- // The note (with its path) is for the detail pane's "Why", not the row.
261
+ expect(text.suffix).toBe(" ? owner not known");
262
+ // The note is for the detail pane's "Why", not the row.
258
263
  expect(row.status?.note).toBe(classifyTool(spec, f).note);
259
264
  expect(text.suffix).not.toContain("/");
260
265
  });
261
266
 
267
+ test("external renders muted, naming the installer it is outside of", () => {
268
+ const tool = catalogue("crush");
269
+ const spec = specForCatalogTool(tool, null, {
270
+ primary: "npm",
271
+ all: ["npm"],
272
+ });
273
+ const row = buildToolRow(tool, spec, facts("crush", { managed: false }));
274
+ const text = cliToolStatusText(row);
275
+ expect(text.tone).toBe("muted");
276
+ expect(text.suffix).toBe(" · outside npm");
277
+ });
278
+
262
279
  test("upgrade names the target once, with one v", () => {
263
280
  const tool = catalogue("mnemex");
264
281
  const spec = specForCatalogTool(tool, null, {
@@ -287,12 +304,14 @@ describe("countToolRows — header counts from states", () => {
287
304
  const rows = [
288
305
  buildToolRow(tool, spec, facts("mnemex", { behind: false })),
289
306
  buildToolRow(tool, spec, facts("mnemex", { behind: true })),
307
+ buildToolRow(tool, spec, facts("mnemex", { managed: null })),
308
+ // Another installer's: installed, and not "unverified".
290
309
  buildToolRow(tool, spec, facts("mnemex", { managed: false })),
291
310
  buildToolRow(tool, spec, facts("mnemex", { present: false })),
292
311
  buildToolRow(tool, spec, undefined),
293
312
  ];
294
313
  expect(countToolRows(rows)).toEqual({
295
- installed: 3,
314
+ installed: 4,
296
315
  updates: 1,
297
316
  unverified: 1,
298
317
  });
@@ -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
+ });
@@ -226,7 +226,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
226
226
  );
227
227
  });
228
228
 
229
- test("6. not managed → unknown, naming both paths", () => {
229
+ test("6. not managed → external, in the user's terms", () => {
230
230
  const s = classifyTool(
231
231
  spec({ name: "crush" }),
232
232
  facts({
@@ -237,16 +237,24 @@ describe("classifyTool — every rule of §4.3, in order", () => {
237
237
  owner: "npm (root /Users/j/.local/lib/node_modules)",
238
238
  }),
239
239
  );
240
- expect(s).toMatchObject({ state: "unknown", reason: "not-managed" });
241
- expect(s.note).toContain("/Users/j/.nvm/versions/node/v24.14.0");
242
- expect(s.note).toContain("npm (root /Users/j/.local/lib/node_modules)");
243
- expect(s.note).toContain("second copy");
240
+ expect(s).toMatchObject({ state: "external", reason: "not-managed" });
241
+ expect(s.note).toBe("installed outside npm, so magus leaves it as is");
242
+ expect(s.note).not.toContain("/");
244
243
  });
245
244
 
246
- test("6. managed === null (owner undeterminable) is also a veto", () => {
245
+ test("6. managed === null (owner undeterminable) is also a veto, as unknown", () => {
247
246
  expect(
248
- classifyTool(spec(), facts({ managed: null, behind: true })).reason,
249
- ).toBe("not-managed");
247
+ classifyTool(spec(), facts({ managed: null, behind: true })),
248
+ ).toMatchObject({ state: "unknown", reason: "not-managed" });
249
+ });
250
+
251
+ test("6. brew is named as the user knows it", () => {
252
+ expect(
253
+ classifyTool(
254
+ spec({ via: "brew" }),
255
+ facts({ managed: false, behind: true }),
256
+ ).note,
257
+ ).toBe("installed outside Homebrew, so magus leaves it as is");
250
258
  });
251
259
 
252
260
  test("7. held back → unknown, with the hold as the note", () => {
@@ -364,7 +372,7 @@ describe("classifyTool — every rule of §4.3, in order", () => {
364
372
  ];
365
373
  for (const [s, f] of cases) {
366
374
  const status = classifyTool(spec(s), facts(f));
367
- expect(["unknown", "upgrade"]).toContain(status.state);
375
+ expect(["unknown", "upgrade", "external"]).toContain(status.state);
368
376
  expect(status.note?.length ?? 0).toBeGreaterThan(0);
369
377
  }
370
378
  });
@@ -866,11 +874,11 @@ describe("probeTool — ownership decides managed", () => {
866
874
  const [f] = await probeTools([hostile], io);
867
875
  expect(io.calls).toEqual(["npm root -g"]);
868
876
  expect(f?.installed).toBeNull();
869
- // Cannot tell ⇒ unknown, so it is reported and never applied. The file
870
- // is outside npm's tree, so the reason is ownership (R9.9), not the
871
- // unread version that follows from it.
877
+ // Another installer's file ⇒ external, so it is reported and never
878
+ // applied. The reason is ownership (R9.9), not the unread version that
879
+ // follows from it.
872
880
  expect(classifyTool(hostile, f!)).toMatchObject({
873
- state: "unknown",
881
+ state: "external",
874
882
  reason: "not-managed",
875
883
  });
876
884
 
@@ -502,7 +502,7 @@ describe("planIsBehind — the --check gate", () => {
502
502
  const p = plan({
503
503
  bins: planBinUpdates(
504
504
  [{ name: "crush", via: "npm", sources: [] }],
505
- [toolFacts("crush", { managed: false })],
505
+ [toolFacts("crush", { managed: null })],
506
506
  ),
507
507
  });
508
508
  expect(p.bins[0]!.action).toBe("unknown");
@@ -510,6 +510,19 @@ describe("planIsBehind — the --check gate", () => {
510
510
  expect(planIsBehind(p)).toBe(false);
511
511
  });
512
512
 
513
+ test("an EXTERNAL tool (another installer's) is no work, and not unknown", () => {
514
+ const p = plan({
515
+ bins: planBinUpdates(
516
+ [{ name: "crush", via: "npm", sources: [] }],
517
+ [toolFacts("crush", { managed: false, behind: true })],
518
+ ),
519
+ });
520
+ expect(p.bins[0]!.action).toBe("external");
521
+ expect(planHasWork(p)).toBe(false);
522
+ expect(planIsBehind(p)).toBe(false);
523
+ expect(summarizePlan(p)).toMatchObject({ unknownTools: 0, kept: 1 });
524
+ });
525
+
513
526
  test("a MISSING binary does fail the gate", () => {
514
527
  const p = plan({
515
528
  bins: planBinUpdates(
@@ -946,11 +959,13 @@ describe("planHasWork / summarizePlan", () => {
946
959
  { name: "crush", via: "npm", sources: [] },
947
960
  { name: "claude", via: "npm", sources: [] },
948
961
  { name: "tmux", via: "brew", sources: [] },
962
+ { name: "zsh", via: "brew", sources: [] },
949
963
  ],
950
964
  [
951
- toolFacts("crush", { managed: false }),
952
- toolFacts("claude", { managed: false }),
965
+ toolFacts("crush", { managed: null }),
966
+ toolFacts("claude", { managed: null }),
953
967
  toolFacts("tmux", { behind: false }),
968
+ toolFacts("zsh", { managed: false }),
954
969
  ],
955
970
  ),
956
971
  });
@@ -959,6 +974,7 @@ describe("planHasWork / summarizePlan", () => {
959
974
  unknownTools: 2,
960
975
  unknown: 3,
961
976
  current: 1,
977
+ kept: 1,
962
978
  });
963
979
  });
964
980
  });